Play notes in time on the audio clock with a scheduler #332
game-maker / gate (push) Successful in 3m34s
game-maker / suite (push) Failing after 16m7s

Patterns and songs (#333, #334) have to play sample-accurate in time,
whatever the frame rate does. What is counted in game time runs away
from the sound. This lays the ground: musical time, a scheduler that
plans notes ahead on the audio clock, and a player that starts and ends
a note at a given second. Nothing in the editor plays a sequence yet,
the check scripts drive it through `__gm.music`.

- Ticks, 480 to the quarter as in MIDI files (decided in #332).
  Triplets and quintuplets land on whole ticks. Any meter, a beat being
  the note value under the line. The tempo always counts quarters.
- `core/music/sequence.ts` is a pure rule: the notes starting in a
  window of playing time. Across the end of a loop it looks into the
  next round, so the seam has no gap. A note past the loop end ends
  there. Windows are whole ticks, and the seam is stepped exactly, so
  no rounding can drop the first note of a round.
- `core/music/Scheduler.ts` plans the next 100 ms every 25 ms through a
  host, so it is tested with a clock of its own. The first note sounds
  50 ms after the start. Changes to the sequence are heard from the
  next window on. Stop and seek fade what it started. A new tempo or
  loop counts from what is planned, and the playhead keeps its old
  pace until the clock gets there.
- The position is the audio clock less `outputLatency`, as bar, beat
  and tick.
- The beat comes from a Web Worker (`viewport/musicTimer.ts`), which
  keeps its pace in a hidden tab.
- The sound library starts a note at `timing.at`: a buffer source by
  `start(when)`, the pitch worklet by counting down inside the render
  block it falls in. A note's end is set on the clock at once, its
  fade to the sample. Leaving a loop still waits for the page.
- Delay synced to beats (decided in #332, noted from #327): a setting
  `Sync` (Seconds / Beats), `time` then in quarter notes, held to the
  2 s of the line. Play sound and Play note have an input `tempo`,
  120 BPM where nothing is connected. A scheduled note carries the
  tempo of its sequence.
- Swing is left for #333/#334, as decided.

Also fixes the red suite of 6f1f315 (task 526). Three checks in e2e 34
waited on the page's clock for things that run on the audio clock. In
CI the voice was already 0.7 s old when first seen, and the 0.3 s note
of Play note was over. `stats().last` now says when a voice started on
the audio clock, and the Quad, Step and Play note checks hold every
reading against it.

Tests:
- music.test.ts (new): 17 tests, time, windows, loop seam, scheduler
  with a jittery timer, stop, seek, tempo change
- sound-library +4, soundfx +1, node-catalog +2 (tempo inputs)
- Counter-checks: an unclamped note length, a window that never moves
  on, a buffer source ignoring its start time and a Delay ignoring
  Beats each turn 1, 6, 1 and 2 tests red
- e2e 34 on melektaus-atx: ALL PASSED, 47 checks (+2). Nine notes of a
  looped bar at 150 BPM exactly 0.4 s apart, each planned 89 to 91 ms
  ahead. The position matched the clock less 32 ms latency to the
  tick. Offline the worklet started on samples 590, 12000 and 15200.
- Gate on melektaus-atx: CHECK EXIT 0, 244 test files, 3786 tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-06 12:29:31 +02:00
1 parent 6f1f31516f
commit 23bdc3f775
20 files changed
+1201 -86

No files matched your search

+5 -1
View File
@@ -240,6 +240,7 @@ export interface NodeContext {
* the one case where the rule is in the way: the death sound of an object that destroys itself.
* `fx` names a sound FX asset the voice is played through (#311); `''` plays it as it is.
* `fxParams` are values for its parameters; one not given is the default the FX declares.
* `tempo` is what a Delay synced to beats counts by (#332).
* Returns the number of the voice for `tuneSound`, or 0 where nothing is played.
*/
playSound(
@@ -249,12 +250,14 @@ export interface NodeContext {
detached: boolean,
fx: string,
fxParams: FxParams,
tempo: number,
): number;
/**
* Plays one note of an instrument asset (#331): `note` as MIDI counts it (60 is C4), struck with
* `velocity` (0 to 1, how loud it is next to `volume`) and released after `duration` seconds, or
* held until it is stopped where that is 0. Owned like a `playSound` voice, `instrumentParams` are
* values for the instrument's parameters. Returns the number of the voice, 0 where nothing plays.
* values for the instrument's parameters, `tempo` as for `playSound`. Returns the number of the
* voice, 0 where nothing plays.
*/
playNote(
instrumentId: string,
@@ -264,6 +267,7 @@ export interface NodeContext {
duration: number,
detached: boolean,
instrumentParams: FxParams,
tempo: number,
): number;
/**
* How many seconds one run of that sound asset lasts, so a change can span it; 0 where it does not
+5 -2
View File
@@ -1,15 +1,16 @@
import { MIDDLE_C } from '../../../instrument/notes';
import { heldTempo } from '../../../music/time';
import type { NodeDef } from '../../NodeRegistry';
import { asBoolean, asNumber, asString } from '../../values';
import { exec, num, withDefault } from '../shared';
import { fxParamsOf } from './PlaySound';
import { fxParamsOf, tempoPort } from './PlaySound';
export const PlayNote: NodeDef = {
type: 'PlayNote',
label: 'Play note',
category: 'action',
description:
'Plays one note of an instrument asset. "note" is its number as MIDI counts it: 60 is C4, 61 C#4, 72 C5. "velocity" is how hard it is struck, 0 to 1 – it makes the note quieter and is handed to what the instrument does with it. "duration" is how many seconds until the note is released; at 0 it is held until a Stop sound ends it. A note belongs to this object like a sound and stops when the object is destroyed, unless "detached" is on. "params" takes values for the parameters of the instrument from an Instrument parameters node. "voice" is the number of this note, for Set sound FX parameters and Stop sound; 0 where the instrument plays nothing for it.',
'Plays one note of an instrument asset. "note" is its number as MIDI counts it: 60 is C4, 61 C#4, 72 C5. "velocity" is how hard it is struck, 0 to 1 – it makes the note quieter and is handed to what the instrument does with it. "duration" is how many seconds until the note is released; at 0 it is held until a Stop sound ends it. A note belongs to this object like a sound and stops when the object is destroyed, unless "detached" is on. "params" takes values for the parameters of the instrument from an Instrument parameters node. "voice" is the number of this note, for Set sound FX parameters and Stop sound; 0 where the instrument plays nothing for it. "tempo" is what a Delay in the instrument synced to beats counts by, in quarter notes a minute.',
inputs: [
exec(),
{
@@ -42,6 +43,7 @@ export const PlayNote: NodeDef = {
field: { key: 'detached', label: 'Detached (outlives this object)', kind: 'boolean', default: false },
},
{ key: 'fxParams', label: 'params', type: 'fxparams' },
tempoPort(),
],
outputs: [exec(), num('voice')],
params: [],
@@ -56,6 +58,7 @@ export const PlayNote: NodeDef = {
asNumber(inputs.duration),
asBoolean(inputs.detached),
fxParamsOf(inputs.fxParams),
heldTempo(asNumber(inputs.tempo)),
)
: 0;
return { next: 'exec', outputs: { voice } };
+24 -1
View File
@@ -1,4 +1,5 @@
import { isStruct } from '../../../model/Properties';
import { DEFAULT_TEMPO, heldTempo, MAX_TEMPO, MIN_TEMPO } from '../../../music/time';
import type { FxParams } from '../../../soundfx/nodes';
import type { NodeDef, PortDef } from '../../NodeRegistry';
import { asBoolean, asNumber, asString, type Value } from '../../values';
@@ -64,7 +65,8 @@ export const PlaySound: NodeDef = {
'nothing was played. "length" is how many seconds one run of the sound lasts, to hand to the ' +
'"time" of Set sound FX parameters so a change spans the sound; 0 where nothing was played or ' +
'the sound has not been loaded yet. A pitch never changes it. "tail" is that length and the ' +
'echoes of its FX after it, as they are when it starts – how long until the voice is quiet.',
'echoes of its FX after it, as they are when it starts – how long until the voice is quiet. ' +
'"tempo" is what a Delay of its FX synced to beats counts by, in quarter notes a minute.',
inputs: [
exec(),
soundPort(),
@@ -73,6 +75,7 @@ export const PlaySound: NodeDef = {
detachedPort(),
soundFxPort(),
{ key: 'fxParams', label: 'fx params', type: 'fxparams' },
tempoPort(),
],
outputs: [exec(), num('voice'), num('length'), num('tail')],
params: [],
@@ -86,6 +89,7 @@ export const PlaySound: NodeDef = {
asBoolean(inputs.detached),
asString(inputs.fx),
fxParamsOf(inputs.fxParams),
heldTempo(asNumber(inputs.tempo)),
)
: 0;
const length = voice ? ctx.soundLength(sound) : 0;
@@ -94,6 +98,25 @@ export const PlaySound: NodeDef = {
},
};
/** The tempo of a voice outside a song (#332), for a Delay synced to beats. */
export function tempoPort(): PortDef {
return {
key: 'tempo',
label: 'tempo',
type: 'number',
default: DEFAULT_TEMPO,
field: {
key: 'tempo',
label: 'Tempo (BPM)',
kind: 'number',
default: DEFAULT_TEMPO,
min: MIN_TEMPO,
max: MAX_TEMPO,
step: 1,
},
};
}
/** The numbers of a parameter bundle; anything else hands in none. */
export function fxParamsOf(value: Value | undefined): FxParams {
if (!isStruct(value)) return {};
+180
View File
@@ -0,0 +1,180 @@
import { heldLoop, type LoopRange, notesIn, playheadAt, type Sequence } from './sequence';
import {
COMMON_TIME,
DEFAULT_TEMPO,
heldMeter,
heldTempo,
type Meter,
type Position,
positionOf,
secondsOfTicks,
ticksOfSeconds,
} from './time';
/** Seconds ahead of the audio clock the notes are planned, and how often that is done. */
export const LOOKAHEAD = 0.1;
export const INTERVAL = 0.025;
/** Seconds between a start and its first note: the page has to get the note to the audio thread. */
export const LEAD = 0.05;
/** One note handed to the player: when it starts on the audio clock and how long it is held. */
export interface ScheduledNote {
at: number;
duration: number;
note: number;
velocity: number;
instrument: string;
/** What a Delay synced to beats counts by. */
tempo: number;
}
/** What the scheduler needs of the audio side; a test hands in a clock of its own. */
export interface SchedulerHost {
/** Seconds on the audio clock. */
now(): number;
/** Seconds between the audio clock and what is heard. */
latency(): number;
/** Starts the note, gives back the number of its voice; 0 where nothing plays. */
play(note: ScheduledNote): number;
playing(voice: number): boolean;
stop(voices: readonly number[]): void;
/** Calls `pump` every `INTERVAL` seconds, also in a hidden tab, until the returned function is called. */
wake(pump: () => void): () => void;
}
export interface SchedulerOptions {
tempo?: number;
meter?: Meter;
loop?: LoopRange | null;
}
/**
* Plays a sequence on the audio clock (#332): every `INTERVAL` it plans the notes of the next
* `LOOKAHEAD` seconds with their exact start, so a slow frame or a busy page never moves a note.
* Playing time is counted in whole ticks from an anchor on the audio clock. The sequence is read
* afresh for every window, so a change is heard from the next one on; what is planned stays.
*/
export class Scheduler {
private tempo: number;
private meter: Meter;
private loop: LoopRange | null;
/** Where the playhead started, in ticks of the sequence. */
private from = 0;
/** Where on the audio clock playing time 0 is; null while stopped. */
private anchor: number | null = null;
/** Playing time planned so far, in whole ticks. */
private until = 0;
/** How it played before the last change of tempo or loop, for the time until that is reached. */
private before: { anchor: number; from: number; tempo: number; loop: LoopRange | null } | null = null;
private voices: number[] = [];
private sleep: (() => void) | null = null;
constructor(
private readonly host: SchedulerHost,
private readonly sequence: () => Sequence,
options: SchedulerOptions = {},
) {
this.tempo = heldTempo(options.tempo ?? DEFAULT_TEMPO);
this.meter = heldMeter(options.meter ?? COMMON_TIME);
this.loop = heldLoop(options.loop ?? null);
}
get playing(): boolean {
return this.anchor !== null;
}
/** Plays from `from` ticks of the sequence, or from where it was last started or sent. */
start(from = this.from): void {
this.stop();
this.from = Math.max(0, Math.round(from));
this.anchor = this.host.now() + LEAD;
this.until = 0;
this.before = null;
this.sleep = this.host.wake(() => this.pump());
this.pump();
}
/** Every note it started fades out, those still to come with them. */
stop(): void {
this.sleep?.();
this.sleep = null;
if (this.anchor !== null) this.from = Math.floor(this.headAt(this.host.now()));
this.anchor = null;
this.before = null;
if (this.voices.length > 0) this.host.stop(this.voices);
this.voices = [];
}
/** A jump: playing on from there, cut off where it was, or only where the next start begins. */
seek(tick: number): void {
if (this.playing) this.start(tick);
else this.from = Math.max(0, Math.round(tick));
}
/** Counted from what is planned already: a note in the next window keeps the tempo it had. */
setTempo(tempo: number): void {
this.rebase();
this.tempo = heldTempo(tempo);
}
setLoop(loop: LoopRange | null): void {
this.rebase();
this.loop = heldLoop(loop);
}
setMeter(meter: Meter): void {
this.meter = heldMeter(meter);
}
/**
* Where what is heard right now stands: the audio clock less the output latency, so a playhead
* drawn from it is not ahead of the sound. Before the first note it is where it starts.
*/
position(): Position & { ticks: number } {
const ticks = this.anchor === null ? this.from : this.headAt(this.host.now() - this.host.latency());
return { ...positionOf(ticks, this.meter), ticks };
}
/** Plans the notes of the window ahead that are not planned yet. */
pump(): void {
if (this.anchor === null) return;
const to = Math.floor(ticksOfSeconds(this.host.now() + LOOKAHEAD - this.anchor, this.tempo));
if (to <= this.until) return;
this.voices = this.voices.filter((voice) => this.host.playing(voice));
for (const due of notesIn(this.sequence(), this.from, this.loop, this.until, to)) {
const voice = this.host.play({
at: this.anchor + secondsOfTicks(due.at, this.tempo),
duration: secondsOfTicks(due.length, this.tempo),
note: due.note,
velocity: due.velocity,
instrument: due.instrument,
tempo: this.tempo,
});
if (voice !== 0) this.voices.push(voice);
}
this.until = to;
}
/** The playhead at a second of the audio clock; before the anchor, where it starts. */
private headAt(time: number): number {
if (this.anchor === null) return this.from;
const span =
this.before && time < this.anchor
? this.before
: { anchor: this.anchor, from: this.from, tempo: this.tempo, loop: this.loop };
const elapsed = ticksOfSeconds(time - span.anchor, span.tempo);
return playheadAt(Math.max(0, elapsed), span.from, span.loop);
}
/**
* Moves the anchor to the end of what is planned, so a new tempo or loop starts there. Until the
* clock gets there, the playhead goes on as it did.
*/
private rebase(): void {
if (this.anchor === null) return;
this.before = { anchor: this.anchor, from: this.from, tempo: this.tempo, loop: this.loop };
this.anchor += secondsOfTicks(this.until, this.tempo);
this.from = playheadAt(this.until, this.from, this.loop);
this.until = 0;
}
}
+84
View File
@@ -0,0 +1,84 @@
/**
* What the scheduler plays (#332), before there are patterns and songs to make it of (#333, #334):
* notes of instruments at ticks, and a loop the playhead runs round. Everything here is counted in
* ticks of playing time – `elapsed` since the start, which only ever grows – and of the sequence –
* where the playhead stands, which jumps back at the end of a loop.
*/
export interface SequenceNote {
/** Where it starts in the sequence, and how long it is held, in ticks. */
tick: number;
length: number;
/** As MIDI counts it: 60 is C4. */
note: number;
/** 0 to 1. */
velocity: number;
/** The instrument asset that plays it. */
instrument: string;
}
export interface Sequence {
notes: readonly SequenceNote[];
}
/** From `start` up to `end`, in ticks of the sequence; `end` is where the playhead jumps back. */
export interface LoopRange {
start: number;
end: number;
}
/** A loop with nothing in it is none. */
export function heldLoop(loop: LoopRange | null): LoopRange | null {
if (!loop || !(loop.end > loop.start) || !(loop.start >= 0)) return null;
return loop;
}
/**
* Where the playhead stands after `elapsed` ticks of playing from `from`. Started before the end of
* the loop, it runs round it once it gets there; started behind it, it plays on.
*/
export function playheadAt(elapsed: number, from: number, loop: LoopRange | null): number {
if (!loop || from >= loop.end) return from + elapsed;
const toEnd = loop.end - from;
if (elapsed < toEnd) return from + elapsed;
return loop.start + ((elapsed - toEnd) % (loop.end - loop.start));
}
/** A note of the sequence where it falls in playing time, held no longer than the loop lets it. */
export interface DueNote extends SequenceNote {
/** Ticks of playing time from the start. */
at: number;
}
/**
* The notes starting in the playing time from `start` up to `end`, both whole ticks. Across the end
* of a loop the window goes on at its start, so the next round is planned ahead like any other
* stretch and the seam has no gap. A note reaching past the end of a loop ends there: the playhead
* has jumped back.
*/
export function notesIn(
sequence: Sequence,
from: number,
loop: LoopRange | null,
start: number,
end: number,
): DueNote[] {
const due: DueNote[] = [];
const stretchEnd = loop !== null && from < loop.end ? loop.end : Infinity;
let elapsed = start;
let head = playheadAt(start, from, loop);
while (elapsed < end) {
const span = Math.min(end - elapsed, stretchEnd - head);
if (!(span > 0)) break;
for (const note of sequence.notes)
if (note.tick >= head && note.tick < head + span)
due.push({
...note,
at: elapsed + (note.tick - head),
length: Math.min(note.length, stretchEnd - note.tick),
});
elapsed += span;
head = head + span >= stretchEnd ? loop!.start : head + span;
}
return due.sort((a, b) => a.at - b.at || a.note - b.note);
}
+71
View File
@@ -0,0 +1,71 @@
/**
* Musical time (#332): ticks, tempo and meter, and the seconds they come to. Ticks are whole numbers
* of a quarter note's share, so a grid, a triplet and a quintuplet all land on one exactly.
*/
/** As MIDI files and most DAWs count: a 1/32 triplet is 40, a 1/16 quintuplet 96. */
export const TICKS_PER_QUARTER = 480;
/** A tempo always counts quarter notes per minute, in 6/8 as in 4/4. */
export const DEFAULT_TEMPO = 120;
export const MIN_TEMPO = 20;
export const MAX_TEMPO = 400;
/** A time signature: `beats` notes of the value `unit` to the bar – 3/4, 6/8, 7/8. */
export interface Meter {
beats: number;
unit: number;
}
export const COMMON_TIME: Meter = { beats: 4, unit: 4 };
const UNITS = [1, 2, 4, 8, 16, 32];
const MAX_BEATS = 32;
/** A meter as written, or 4/4 where it is not one: a beat is a power of two, a bar 1 to 32 of them. */
export function heldMeter(meter: Meter): Meter {
const { beats, unit } = meter;
if (!Number.isInteger(beats) || beats < 1 || beats > MAX_BEATS || !UNITS.includes(unit)) return COMMON_TIME;
return { beats, unit };
}
/** A tempo between `MIN_TEMPO` and `MAX_TEMPO`; 0 and anything that is not a number is the default. */
export function heldTempo(tempo: number): number {
if (!Number.isFinite(tempo) || tempo <= 0) return DEFAULT_TEMPO;
return Math.max(MIN_TEMPO, Math.min(MAX_TEMPO, tempo));
}
export function ticksPerBeat(meter: Meter): number {
return (TICKS_PER_QUARTER * 4) / meter.unit;
}
export function ticksPerBar(meter: Meter): number {
return meter.beats * ticksPerBeat(meter);
}
export function secondsOfTicks(ticks: number, tempo: number): number {
return (ticks * 60) / (tempo * TICKS_PER_QUARTER);
}
export function ticksOfSeconds(seconds: number, tempo: number): number {
return (seconds * tempo * TICKS_PER_QUARTER) / 60;
}
/** Seconds of a number of quarter notes: 1 is a quarter, 0.5 an eighth, 0.75 a dotted eighth. */
export function secondsOfQuarters(quarters: number, tempo: number): number {
return (quarters * 60) / tempo;
}
/** Where a tick stands as a musician counts it: bar and beat from 1, the tick inside the beat from 0. */
export interface Position {
bar: number;
beat: number;
tick: number;
}
export function positionOf(ticks: number, meter: Meter): Position {
const at = Math.max(0, Math.floor(ticks));
const bar = ticksPerBar(meter);
const beat = ticksPerBeat(meter);
const inBar = at % bar;
return { bar: Math.floor(at / bar) + 1, beat: Math.floor(inBar / beat) + 1, tick: inBar % beat };
}
+35 -10
View File
@@ -2,6 +2,7 @@ import type { DeclaredPort } from '../graph/Graph';
import { SOUND_FX_INPUT, SOUND_FX_OUTPUT } from '../graph/nested';
import type { NodeDef, PortDef } from '../graph/NodeRegistry';
import type { PropertyBag } from '../model/Properties';
import { DEFAULT_TEMPO, heldTempo, secondsOfQuarters } from '../music/time';
import type { FlatDef } from '../properties/PropertySchema';
export { SOUND_FX_INPUT, SOUND_FX_OUTPUT };
@@ -72,7 +73,9 @@ export type FxStagePlan =
| { kind: 'filter'; filter: FxFilter; frequency: number; gain: FxAmount; q: number }
| {
kind: 'delay';
/** Seconds, or quarter notes where `beats` (#332). */
time: FxAmount;
beats?: boolean;
feedback: FxAmount;
damping: FxAmount;
mix: FxAmount;
@@ -89,10 +92,14 @@ export const EMPTY_PLAN: FxPlan = { pitch: [], stages: [] };
/** Values for the parameters, by name; a name the plan does not have is left out. */
export type FxParams = Readonly<Record<string, number>>;
/** How a voice goes through its sound FX: the plan and the values it starts with, kept so they can change while it plays (#315). */
/**
* How a voice goes through its sound FX: the plan and the values it starts with, kept so they can
* change while it plays (#315), and the tempo a Delay synced to beats counts by (#332).
*/
export interface FxVoice {
plan: FxPlan;
params: FxParams;
tempo?: number;
}
export const DRY_VOICE: FxVoice = { plan: EMPTY_PLAN, params: {} };
@@ -276,7 +283,18 @@ export const SoundFxMix: SoundFxNodeDef = {
/** As long as a delay line is ever made: every voice holds one this long, whatever its time. */
export const DELAY_MAX = 2;
const DELAY_TIME = { ...numberRow('time', 'Time (s)', 0.01, DELAY_MAX, 0.01), default: 0.25 };
/** Up to two bars of 4/4 in beats; whatever it comes to is held to `DELAY_MAX` seconds. */
const DELAY_TIME = { ...numberRow('time', 'Time', 0.01, 8, 0.01), default: 0.25 };
const DELAY_SYNC = {
key: 'sync',
label: 'Sync',
kind: 'enum' as const,
default: 'seconds',
options: [
{ value: 'seconds', label: 'Seconds' },
{ value: 'beats', label: 'Beats' },
],
};
/** Never 1: the echoes would never fade, and above it they would grow. */
const DELAY_FEEDBACK = { ...numberRow('feedback', 'Feedback', 0, 0.95, 0.01), default: 0.4 };
const DELAY_DAMPING = { ...numberRow('damping', 'Damping', 0, 1, 0.01), default: 0.3 };
@@ -288,10 +306,10 @@ export const SoundFxDelay: SoundFxNodeDef = {
label: 'Delay',
category: 'sound',
description:
'Echoes the sound: it comes back after "time" seconds, again and again, each echo "feedback" times as loud as the one before. "damping" makes every echo duller than the last, like tape. "mix" is how loud the echoes are next to the sound, which itself stays as it is. With "ping-pong" the echoes take turns left and right. The echoes ring on after the sound ends; a voice lasts until they have faded.',
'Echoes the sound: it comes back after "time", again and again, each echo "feedback" times as loud as the one before. "time" is in seconds, or with "sync" on beats in quarter notes at the tempo of the voice: 1 is a quarter, 0.5 an eighth, 0.75 a dotted eighth, 0.333 an eighth triplet. A Play sound or Play note says that tempo, a song its own. Either way an echo comes back after 2 s at most. "damping" makes every echo duller than the last, like tape. "mix" is how loud the echoes are next to the sound, which itself stays as it is. With "ping-pong" the echoes take turns left and right. The echoes ring on after the sound ends; a voice lasts until they have faded.',
inputs: [audioIn, ...[DELAY_TIME, DELAY_FEEDBACK, DELAY_DAMPING, DELAY_MIX].map(numberIn)],
outputs: [audioOut],
params: [PING_PONG],
params: [DELAY_SYNC, PING_PONG],
emit: (sounds, amounts, settings) => {
const mix = held(amounts.mix, DELAY_MIX);
if (isNone(mix)) return arriving(sounds);
@@ -299,6 +317,7 @@ export const SoundFxDelay: SoundFxNodeDef = {
{
kind: 'delay',
time: held(amounts.time, DELAY_TIME),
beats: settings[DELAY_SYNC.key] === 'beats',
feedback: held(amounts.feedback, DELAY_FEEDBACK),
damping: held(amounts.damping, DELAY_DAMPING),
mix,
@@ -333,8 +352,13 @@ function amountOf(amount: FxAmount, params: FxParams): number {
return Math.max(amount.min ?? -Infinity, Math.min(amount.max ?? Infinity, given));
}
/** Seconds of a delay's time, from beats at the voice's tempo where it is synced, never past the line. */
function delaySeconds(time: number, beats: boolean, tempo: number): number {
return Math.min(DELAY_MAX, beats ? secondsOfQuarters(time, heldTempo(tempo)) : time);
}
/** A stage fed by a parameter stays at 0 as well: a value handed in later has to find it there. */
function stagesOf(plan: FxPlan, params: FxParams): FxStage[] {
function stagesOf(plan: FxPlan, params: FxParams, tempo: number): FxStage[] {
const stages: FxStage[] = [];
for (const stage of plan.stages) {
if (stage.kind === 'gain') {
@@ -345,7 +369,7 @@ function stagesOf(plan: FxPlan, params: FxParams): FxStage[] {
if (stage.kind === 'delay') {
stages.push({
kind: 'delay',
time: amountOf(stage.time, params),
time: delaySeconds(amountOf(stage.time, params), stage.beats === true, tempo),
feedback: amountOf(stage.feedback, params),
damping: dampingCutoff(amountOf(stage.damping, params)),
mix: amountOf(stage.mix, params),
@@ -362,9 +386,10 @@ function stagesOf(plan: FxPlan, params: FxParams): FxStage[] {
/**
* The chain one voice is sent along: the plan with every parameter filled in. Its shape depends on
* the plan alone, never on the values, so the values of a playing voice can change in place (#315).
* `tempo` is what a Delay synced to beats counts by (#332).
*/
export function fxChainOf(plan: FxPlan, params: FxParams = {}): FxChain {
const stages = stagesOf(plan, params);
export function fxChainOf(plan: FxPlan, params: FxParams = {}, tempo = DEFAULT_TEMPO): FxChain {
const stages = stagesOf(plan, params, tempo);
if (!('mix' in plan)) {
const pitch = plan.pitch.reduce((sum, amount) => sum + amountOf(amount, params), 0);
const chain = { pitch, shifted: plan.pitch.length > 0, stages };
@@ -372,8 +397,8 @@ export function fxChainOf(plan: FxPlan, params: FxParams = {}): FxChain {
}
return {
mix: amountOf(plan.mix, params),
a: fxChainOf(plan.a, params),
b: fxChainOf(plan.b, params),
a: fxChainOf(plan.a, params, tempo),
b: fxChainOf(plan.b, params, tempo),
stages,
};
}