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:
1 parent
8a4e4a6762
commit
144dee0a97
14 files changed
+285
-29
No files matched your search
@@ -30,9 +30,11 @@ import {
|
||||
INSTRUMENT_SAMPLE,
|
||||
INSTRUMENT_SOUND_FX,
|
||||
isInstrumentNodeDef,
|
||||
loops,
|
||||
noteOutputKey,
|
||||
noteParam,
|
||||
notesRange,
|
||||
samplePlay,
|
||||
singleNotes,
|
||||
VELOCITY_PARAM,
|
||||
} from './nodes';
|
||||
@@ -194,7 +196,8 @@ export function compileInstrument(graph: Graph, note: number, sources: Instrumen
|
||||
if (!sound?.file) return null;
|
||||
const follows = node.params.followNote !== false;
|
||||
const shift = follows ? played - noteParam(node.params, 'root', MIDDLE_C) : 0;
|
||||
const loop = node.params.loop === true;
|
||||
const play = samplePlay(node.params);
|
||||
const loop = loops(play);
|
||||
return {
|
||||
pitch: shift === 0 ? [] : [{ value: shift }],
|
||||
stages: [],
|
||||
@@ -204,7 +207,7 @@ export function compileInstrument(graph: Graph, note: number, sources: Instrumen
|
||||
loop,
|
||||
loopStart: loop ? Math.max(0, asNumber(node.params.loopStart ?? 0)) : 0,
|
||||
loopEnd: loop ? Math.max(0, asNumber(node.params.loopEnd ?? 0)) : 0,
|
||||
gate: textOf(node.params.mode ?? 'gate') !== 'oneshot',
|
||||
gate: play === 'gate' || play === 'loop',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -116,18 +116,42 @@ export const InstrumentNotes: InstrumentNodeDef = {
|
||||
const SOUND: PropertyDef = { key: 'sound', label: 'Sound', kind: 'sound', default: '' };
|
||||
const ROOT = noteRow('root', 'Root', MIDDLE_C);
|
||||
const FOLLOW = { key: 'followNote', label: 'Follow note', kind: 'boolean' as const, default: true };
|
||||
export const SAMPLE_MODES = [
|
||||
/** How a Sample plays while its note is held and once it is let go. */
|
||||
export type SamplePlay = 'gate' | 'oneshot' | 'loop' | 'release';
|
||||
export const SAMPLE_PLAYS: { value: SamplePlay; label: string }[] = [
|
||||
{ value: 'gate', label: 'Gate' },
|
||||
{ value: 'oneshot', label: 'One-shot' },
|
||||
{ value: 'loop', label: 'Loop' },
|
||||
{ value: 'release', label: 'Loop, then release' },
|
||||
];
|
||||
const MODE: PropertyDef = {
|
||||
key: 'mode',
|
||||
label: 'Mode',
|
||||
const PLAY: PropertyDef = {
|
||||
key: 'play',
|
||||
label: 'Play',
|
||||
kind: 'enum',
|
||||
default: 'gate',
|
||||
options: SAMPLE_MODES,
|
||||
options: SAMPLE_PLAYS,
|
||||
};
|
||||
const LOOP = { key: 'loop', label: 'Loop', kind: 'boolean' as const, default: false };
|
||||
|
||||
/**
|
||||
* What a Sample plays as. Before "Play" it had "Mode" and "Loop": a looping one ended with the note
|
||||
* either way then, or ran until it was stopped, which is gone – both are "Loop" now.
|
||||
*/
|
||||
export function samplePlay(params: PropertyBag): SamplePlay {
|
||||
const play = SAMPLE_PLAYS.find((option) => option.value === params[PLAY.key]);
|
||||
if (play) return play.value;
|
||||
if (params.loop === true) return 'loop';
|
||||
return params.mode === 'oneshot' ? 'oneshot' : 'gate';
|
||||
}
|
||||
|
||||
export function loops(play: SamplePlay): boolean {
|
||||
return play === 'loop' || play === 'release';
|
||||
}
|
||||
|
||||
/** A Sample as it was saved before "Play", with its "Mode" and "Loop" turned into one. */
|
||||
export function withSamplePlay(params: PropertyBag): PropertyBag {
|
||||
const { mode: _mode, loop: _loop, ...rest } = params;
|
||||
return { ...rest, [PLAY.key]: samplePlay(params) };
|
||||
}
|
||||
const LOOP_START: PropertyDef = {
|
||||
key: 'loopStart',
|
||||
label: 'Loop start (s)',
|
||||
@@ -151,13 +175,13 @@ export const InstrumentSample: InstrumentNodeDef = {
|
||||
category: 'sound',
|
||||
instrument: true,
|
||||
description:
|
||||
'Plays a sound asset when a note triggers it. With "follow note" it is played higher or lower by the distance from "root" – the note it sounds as it is – and keeps its length. Off, every note plays it as it is, as for a drum. "Gate" ends it with the note, faded out in a few milliseconds; "One-shot" plays it through whatever the note does. "Loop" repeats it, between "loop start" and "loop end" in seconds where they are set (0 is the end of the sound), until the note ends – or, as a one-shot, until it is stopped. The volume of the sound asset counts.',
|
||||
'Plays a sound asset when a note triggers it. With "follow note" it is played higher or lower by the distance from "root" – the note it sounds as it is – and keeps its length. Off, every note plays it as it is, as for a drum. "Play" says what it does: "Gate" plays it once and ends it with the note, faded out in a few milliseconds; "One-shot" plays it once through whatever the note does. "Loop" repeats it between "loop start" and "loop end" in seconds (0 is the end of the sound) until the note ends; "Loop, then release" leaves the loop when the note ends and plays what comes after "loop end" – with "loop end" at 0 there is nothing after it, and the note ends with the loop. The volume of the sound asset counts.',
|
||||
inputs: [triggerIn],
|
||||
outputs: [audioOut],
|
||||
params: [SOUND, ROOT, FOLLOW, MODE, LOOP, LOOP_START, LOOP_END],
|
||||
params: [SOUND, ROOT, FOLLOW, PLAY, LOOP_START, LOOP_END],
|
||||
idleParams: (params) => [
|
||||
...(params[FOLLOW.key] === false ? [ROOT.key] : []),
|
||||
...(params[LOOP.key] === true ? [] : [LOOP_START.key, LOOP_END.key]),
|
||||
...(loops(samplePlay(params)) ? [] : [LOOP_START.key, LOOP_END.key]),
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -29,8 +29,9 @@ export interface FxDelay {
|
||||
|
||||
/**
|
||||
* Where a way of an instrument's note gets its sound (#331): a file of its own, how loud and how it
|
||||
* loops, and whether it ends with the note (`gate`) or plays through. A way of a sound FX has none –
|
||||
* it plays the sound the voice was started with. `loopEnd` 0 is the end of the sound.
|
||||
* loops, and whether it ends with the note (`gate`) or plays through – one that loops leaves its
|
||||
* loop then and plays what comes after it. A way of a sound FX has none: it plays the sound the
|
||||
* voice was started with. `loopEnd` 0 is the end of the sound.
|
||||
*/
|
||||
export interface FxSource {
|
||||
file: string;
|
||||
|
||||
@@ -55,10 +55,15 @@ export class PitchPlayer {
|
||||
/** Whether `lastPhase` holds the frame before: a frame at 0 semitones does not need its phases. */
|
||||
private phased = false;
|
||||
private primed = false;
|
||||
/** How many samples of the output the sound lasts; a looping one never ends. */
|
||||
private readonly end: number;
|
||||
/** How many samples of the output the sound lasts; a looping one never ends until it leaves its loop. */
|
||||
private end: number;
|
||||
/** Where a loop runs in the samples of the sound; null for a sound that does not loop. */
|
||||
private readonly loop: { start: number; end: number } | null;
|
||||
/**
|
||||
* Once the loop is left, how far the sound's samples lag behind the output from the end of the
|
||||
* last round on: every round played before then. Infinity while it loops on.
|
||||
*/
|
||||
private unwound = Infinity;
|
||||
|
||||
/**
|
||||
* `channels` are the samples of the sound, `step` how many of them pass for one sample of the
|
||||
@@ -129,6 +134,21 @@ export class PitchPlayer {
|
||||
return this.frameAt + this.given < this.end;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lets the loop finish the round it is in and then plays on into what comes after it, to the end
|
||||
* of the sound (#331). The round is the one the newest frame has read up to, so nothing read
|
||||
* already changes under the frames still sounding.
|
||||
*/
|
||||
leaveLoop(): void {
|
||||
const { loop } = this;
|
||||
if (!loop || this.unwound !== Infinity) return;
|
||||
const span = loop.end - loop.start;
|
||||
const read = (this.frameAt + this.size) * this.step;
|
||||
const rounds = read < loop.end ? 0 : Math.floor((read - loop.end) / span) + 1;
|
||||
this.unwound = rounds * span;
|
||||
this.end = Math.ceil(((this.channels[0]?.length ?? 0) + this.unwound) / this.step);
|
||||
}
|
||||
|
||||
/** Adds the next frame, a hop on from the last, and hands out the hop it finishes. */
|
||||
private frame(semitones: number): void {
|
||||
this.frameAt += this.hop;
|
||||
@@ -259,14 +279,22 @@ export class PitchPlayer {
|
||||
|
||||
/**
|
||||
* Channel `c` for the frame starting at output sample `from`, each sample read between two of the
|
||||
* sound's; outside the sound it is silence, or the loop again.
|
||||
* sound's; outside the sound it is silence, or the loop again – or past the loop once it is left.
|
||||
*/
|
||||
private readFrame(c: number, from: number): Float64Array {
|
||||
const { reading, size, step, loop } = this;
|
||||
const { reading, size, step, loop, unwound } = this;
|
||||
const samples = this.channels[c]!;
|
||||
const end = loop ? loop.end : samples.length;
|
||||
for (let j = 0; j < size; j++) {
|
||||
let position = (from + j) * step;
|
||||
if (loop && position >= loop.end + unwound) {
|
||||
position -= unwound;
|
||||
const below = Math.floor(position);
|
||||
const fraction = position - below;
|
||||
const next = below + 1 < samples.length ? samples[below + 1]! : 0;
|
||||
reading[j] = below < samples.length ? samples[below]! * (1 - fraction) + next * fraction : 0;
|
||||
continue;
|
||||
}
|
||||
if (position < 0 || position >= end) {
|
||||
if (!loop || position < 0) {
|
||||
reading[j] = 0;
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
import { isParticleNodeDef } from '../particles/nodes';
|
||||
import { isShaderNodeDef } from '../shader/nodes';
|
||||
import { isSoundFxNodeDef } from '../soundfx/nodes';
|
||||
import { isInstrumentNodeDef } from '../instrument/nodes';
|
||||
import { INSTRUMENT_SAMPLE, isInstrumentNodeDef, withSamplePlay } from '../instrument/nodes';
|
||||
import { isKeyInterpolation, normalizeKeys } from '../model/animation';
|
||||
import type {
|
||||
AnimationKey,
|
||||
@@ -240,7 +240,10 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
|
||||
assets.push({ id, type, name, props, graph: normalizeGraph(a.graph, isSoundFxNodeDef) });
|
||||
} else if (type === 'instrument') {
|
||||
const keep = (def: NodeDef) => isInstrumentNodeDef(def) || isSoundFxNodeDef(def);
|
||||
assets.push({ id, type, name, props, graph: normalizeGraph(a.graph, keep) });
|
||||
const graph = normalizeGraph(a.graph, keep);
|
||||
for (const node of graph.nodes)
|
||||
if (node.type === INSTRUMENT_SAMPLE) node.params = withSamplePlay(node.params);
|
||||
assets.push({ id, type, name, props, graph });
|
||||
} else if (type === 'texture') {
|
||||
assets.push({ id, type, name, props, ...normalizeTexture(a, takenIds) });
|
||||
} else if (type === 'font') {
|
||||
|
||||
Reference in new issue
Block a user