Let a looped sample play out past its loop when the note ends #331

With "Mode" and "Loop" as two fields, One-shot plus Loop ran until
Stop was pressed: letting go of the key did nothing. The two fields
are now one, "Play", with four entries that each say what happens
while the note is held and when it is let go.

- Gate and One-shot play the sound once, as before. Loop repeats it
  and fades out with the note.
- "Loop, then release" is new: the sampler's sustain loop. Let go,
  it finishes the round it is in and plays what comes after "Loop
  end" to the end of the sound.
- A buffer source stops looping and plays on by itself. The pitch
  worklet gets a "leave" message. The phase vocoder then lets the
  round finish that its newest frame has read into, so nothing read
  already changes under the frames still sounding.
- Samples saved with "Mode" and "Loop" are turned into "Play" on
  load. Gate and One-shot stay, either with Loop becomes Loop, since
  playing until stopped is gone.
- The keys of the Instrument tab stay half lit while a note let go
  is still heard. Esc on the keys stops everything, like Stop.
- Striking a ringing key again plays a new note beside it rather
  than stopping the old one, so a drum can be hit quickly. This
  differs from what was proposed in the issue discussion.

Tests:
- instrument + pitch-player + sound-library + node-catalog:
  477 tests, green (12 new).
- e2e 34 (melektaus-atx): ALL PASSED, 45 checks (+1). Let go after
  a loop of 0.05 s, the note played out for 0.31 s on the audio
  clock, the key half lit meanwhile.
- Counter-check: a release that does not leave the loop fails the
  2 new player tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-06 11:34:30 +02:00
1 parent 8a4e4a6762
commit 144dee0a97
14 files changed
+285 -29

No files matched your search

+36 -1
View File
@@ -139,6 +139,41 @@ describe('compiling a note', () => {
expect(compile(f, 71).layers).toHaveLength(1);
});
it.each([
['gate', { loop: false, gate: true }],
['oneshot', { loop: false, gate: false }],
['loop', { loop: true, gate: true, loopStart: 0.25, loopEnd: 1 }],
// Looping, not ending with the note: the player leaves the loop on a release.
['release', { loop: true, gate: false, loopStart: 0.25, loopEnd: 1 }],
])('plays a Sample set to %s as such', (play, source) => {
const f = makeInstrument();
Object.assign(nodeOf(f, 'InstrumentSample').params, {
sound: f.sound('Pad', KICK).id,
play,
loopStart: 0.25,
loopEnd: 1,
});
expect(way(compile(f, 60).layers[0])?.source).toMatchObject(source);
});
it.each([
[{ mode: 'gate' }, 'gate'],
[{ mode: 'oneshot' }, 'oneshot'],
[{ mode: 'gate', loop: true }, 'loop'],
// It ran until it was stopped; that is gone, and Loop is the nearest.
[{ mode: 'oneshot', loop: true }, 'loop'],
])('loads a Sample saved with %o before "Play" as %s', (old, play) => {
const f = makeInstrument();
const sample = nodeOf(f, 'InstrumentSample');
delete sample.params.play;
Object.assign(sample.params, old);
const back = roundTrip(f.project).assets.find((a) => a.id === f.instrument.id) as InstrumentAsset;
const params = back.graph.nodes.find((n) => n.type === 'InstrumentSample')!.params;
expect(params.play).toBe(play);
expect(params).not.toHaveProperty('mode');
expect(params).not.toHaveProperty('loop');
});
it('plays a Sample that does not follow the note the same for every note', () => {
const f = makeInstrument();
Object.assign(nodeOf(f, 'InstrumentSample').params, {
@@ -157,7 +192,7 @@ describe('compiling a note', () => {
name36: 'Kick',
});
const kick = f.edit.addNode('InstrumentSample', { sound: f.sound('Kick', KICK).id, followNote: false });
const snare = f.edit.addNode('InstrumentSample', { sound: f.sound('Snare', SNARE).id, mode: 'oneshot' });
const snare = f.edit.addNode('InstrumentSample', { sound: f.sound('Snare', SNARE).id, play: 'oneshot' });
const output = f.edit.addNode('InstrumentOutput');
f.edit.connect(notes, 'n36', kick, 'trigger');
f.edit.connect(notes, 'n38', snare, 'trigger');
+32
View File
@@ -155,6 +155,38 @@ describe('a loop between two points (#331)', () => {
expect(out[40000]).toBeCloseTo(16000 / 48000, 3);
});
/** Plays `before` blocks of a loop, leaves it and plays on to the end; says what came out. */
function leaving(sound: Float32Array, loop: { start: number; end: number }, before: number): number[] {
const player = new PitchPlayer([sound], 1, RATE, loop);
const out: number[] = [];
const block = new Float32Array(BLOCK);
for (let b = 0; b < 2000; b++) {
if (b === before) player.leaveLoop();
const going = player.render([block], [0]);
out.push(...block);
if (!going) break;
}
return out;
}
it('leaves the loop at the end of the round it is in and plays what comes after it', () => {
const ramp = Float32Array.from({ length: 48000 }, (_, i) => i / 48000);
// Let go at 38400, in the second round of the loop: it runs to 48000, then 24000 on comes.
const out = leaving(ramp, { start: 12000, end: 24000 }, 300);
expect(out[44000]).toBeCloseTo(20000 / 48000, 3);
expect(out[47999]).toBeCloseTo(23999 / 48000, 3);
expect(out[50000]).toBeCloseTo(26000 / 48000, 3);
expect(out[71999]).toBeCloseTo(47999 / 48000, 3);
expect(heard(Float32Array.from(out))).toBe(72000);
});
it('plays straight through when the loop is left before it was ever reached', () => {
const ramp = Float32Array.from({ length: 48000 }, (_, i) => (i + 1) / 48000);
const out = leaving(ramp, { start: 12000, end: 24000 }, 50);
expect(out[30000]).toBeCloseTo(30001 / 48000, 3);
expect(heard(Float32Array.from(out))).toBe(48000);
});
it('takes a loop that does not fit the sound as the whole sound', () => {
const ramp = Float32Array.from({ length: 4800 }, (_, i) => i / 4800);
const out = render(ramp, 0, { loop: { start: 3000, end: 9000 }, blocks: 60 });
+26
View File
@@ -990,6 +990,32 @@ describe('notes of an instrument (#331)', () => {
});
});
it('a release lets a loop that does not end with the note leave its loop and play out', async () => {
const library = new SoundLibrary();
const voice = await note(library, [
sample(FILE, { loop: true, loopEnd: 1, gate: false }),
sample(OTHER, { loop: true, loopEnd: 1 }),
]);
library.release(voice);
// The first leaves its loop and is not faded; the second, a plain Loop, fades out as before.
expect(sources.map((source) => source.loop)).toEqual([false, true]);
expect(gains[1]!.ramps).toEqual([]);
expect(gains[2]!.ramps).toEqual([['linear', 0, 0.01]]);
clockTo();
expect(sources.map((source) => source.stopped)).toEqual([false, true]);
expect(library.playing(voice)).toBe(true);
sources[0]!.onended?.();
expect(library.playing(voice)).toBe(false);
});
it('tells the pitch worklet to leave its loop on a release', async () => {
const library = new SoundLibrary();
const pitched = { ...sample(FILE, { loop: true, loopEnd: 1, gate: false }), pitch: [{ value: 3 }] };
const voice = await note(library, [pitched]);
library.release(voice);
expect(worklets[0]!.posted).toEqual(['leave']);
});
it('plays nothing where no way has a file', () => {
const library = new SoundLibrary();
expect(library.playNote('instrument:i', [], {}, 1, '', 0)).toBe(0);