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

+299
View File
@@ -0,0 +1,299 @@
import { describe, expect, it } from 'vitest';
import {
INTERVAL,
LEAD,
LOOKAHEAD,
type ScheduledNote,
Scheduler,
type SchedulerHost,
} from '../../src/core/music/Scheduler';
import {
heldLoop,
notesIn,
playheadAt,
type Sequence,
type SequenceNote,
} from '../../src/core/music/sequence';
import {
heldMeter,
heldTempo,
positionOf,
secondsOfQuarters,
secondsOfTicks,
TICKS_PER_QUARTER,
ticksOfSeconds,
ticksPerBar,
ticksPerBeat,
} from '../../src/core/music/time';
const Q = TICKS_PER_QUARTER;
function note(tick: number, length = Q / 2, pitch = 60): SequenceNote {
return { tick, length, note: pitch, velocity: 1, instrument: 'piano' };
}
describe('musical time (#332)', () => {
it('counts 480 ticks to the quarter, so triplets and quintuplets land on whole ticks', () => {
expect([Q / 3, Q / 5, Q / 6, Q / 8, Q / 12].every(Number.isInteger)).toBe(true);
expect(secondsOfTicks(Q, 120)).toBe(0.5);
expect(secondsOfTicks(Q / 3, 120)).toBeCloseTo(1 / 6);
expect(ticksOfSeconds(0.5, 120)).toBe(Q);
expect(secondsOfQuarters(0.75, 120)).toBe(0.375);
});
it('takes any meter: a beat is the note value under the line, a bar so many of them', () => {
expect([ticksPerBeat({ beats: 4, unit: 4 }), ticksPerBar({ beats: 4, unit: 4 })]).toEqual([Q, 4 * Q]);
expect([ticksPerBeat({ beats: 6, unit: 8 }), ticksPerBar({ beats: 6, unit: 8 })]).toEqual([Q / 2, 3 * Q]);
expect(ticksPerBar({ beats: 7, unit: 8 })).toBe(3.5 * Q);
expect(heldMeter({ beats: 3, unit: 4 })).toEqual({ beats: 3, unit: 4 });
for (const odd of [
{ beats: 0, unit: 4 },
{ beats: 3.5, unit: 4 },
{ beats: 4, unit: 3 },
{ beats: 33, unit: 4 },
])
expect(heldMeter(odd)).toEqual({ beats: 4, unit: 4 });
});
it('holds a tempo to 20 to 400 BPM, 120 where it is none', () => {
expect([heldTempo(90), heldTempo(5), heldTempo(1000), heldTempo(0), heldTempo(Number.NaN)]).toEqual([
90, 20, 400, 120, 120,
]);
});
it('names a tick as a musician counts it: bar and beat from 1, the tick in the beat from 0', () => {
expect(positionOf(0, { beats: 4, unit: 4 })).toEqual({ bar: 1, beat: 1, tick: 0 });
expect(positionOf(5 * Q + 120, { beats: 4, unit: 4 })).toEqual({ bar: 2, beat: 2, tick: 120 });
// 7/8: seven eighths of 240 ticks to the bar.
expect(positionOf(7 * 240 + 6 * 240 + 10, { beats: 7, unit: 8 })).toEqual({ bar: 2, beat: 7, tick: 10 });
expect(positionOf(-30, { beats: 4, unit: 4 })).toEqual({ bar: 1, beat: 1, tick: 0 });
});
});
describe('the notes of a window of playing time (#332)', () => {
const sequence: Sequence = { notes: [note(0), note(Q), note(2 * Q), note(3 * Q)] };
it('runs the playhead round a loop it started in front of, and on past one it started behind', () => {
const loop = { start: Q, end: 3 * Q };
expect([0, Q, 3 * Q - 1, 3 * Q, 4 * Q, 5 * Q].map((e) => playheadAt(e, 0, loop))).toEqual([
0,
Q,
3 * Q - 1,
Q,
2 * Q,
Q,
]);
expect(playheadAt(Q, 4 * Q, loop)).toBe(5 * Q);
expect(playheadAt(Q, 0, null)).toBe(Q);
expect([heldLoop({ start: Q, end: Q }), heldLoop({ start: -1, end: Q })]).toEqual([null, null]);
});
it('gives the notes starting in it, by their start in playing time, and none twice across windows', () => {
expect(notesIn(sequence, 0, null, 0, Q + 1).map((n) => n.at)).toEqual([0, Q]);
const pieces = [0, 100, Q, Q + 1, 2 * Q + 37, 4 * Q]
.slice(1)
.map((end, i, ends) => [ends[i - 1] ?? 0, end]);
const joined = pieces.flatMap(([start, end]) =>
notesIn(sequence, 0, null, start!, end!).map((n) => n.at),
);
expect(joined).toEqual([0, Q, 2 * Q, 3 * Q]);
});
it('looks across the end of a loop into the next round, so the seam has no gap', () => {
const loop = { start: Q, end: 3 * Q };
// Playing time 2.5 to 4.5 quarters: the end of the first round and the start of the second.
const due = notesIn(sequence, 0, loop, 2.5 * Q, 4.5 * Q);
expect(due.map((n) => [n.at, n.tick])).toEqual([
[3 * Q, Q],
[4 * Q, 2 * Q],
]);
// Round after round, every note once, two quarters apart.
const rounds = notesIn(sequence, 0, loop, 0, 11 * Q).map((n) => n.at);
expect(rounds).toEqual([0, Q, 2 * Q, 3 * Q, 4 * Q, 5 * Q, 6 * Q, 7 * Q, 8 * Q, 9 * Q, 10 * Q]);
const split = [0, 3 * Q - 7, 3 * Q, 3 * Q + 1, 7 * Q, 11 * Q];
const pieces = split
.slice(1)
.flatMap((end, i) => notesIn(sequence, 0, loop, split[i]!, end).map((n) => n.at));
expect(pieces).toEqual(rounds);
});
it('ends a note at the end of the loop, where the playhead jumps back', () => {
const long: Sequence = { notes: [note(2 * Q, 4 * Q)] };
expect(notesIn(long, 0, { start: 0, end: 3 * Q }, 0, 3 * Q)[0]!.length).toBe(Q);
expect(notesIn(long, 0, null, 0, 3 * Q)[0]!.length).toBe(4 * Q);
});
it('keeps notes struck together in the order of their pitch', () => {
const chord: Sequence = { notes: [note(0, Q, 67), note(0, Q, 60), note(0, Q, 64)] };
expect(notesIn(chord, 0, null, 0, 1).map((n) => n.note)).toEqual([60, 64, 67]);
});
});
/** An audio clock the test moves by hand, and a player that only writes down what it is given. */
function fakeHost() {
const host = {
time: 10,
latency: 0,
played: [] as ScheduledNote[],
stopped: [] as number[][],
sounding: new Set<number>(),
pump: null as (() => void) | null,
asleep: 0,
};
const sink: SchedulerHost = {
now: () => host.time,
latency: () => host.latency,
play: (scheduled) => {
host.played.push(scheduled);
host.sounding.add(host.played.length);
return host.played.length;
},
playing: (voice) => host.sounding.has(voice),
stop: (voices) => void host.stopped.push([...voices]),
wake: (pump) => {
host.pump = pump;
return () => {
host.pump = null;
host.asleep += 1;
};
},
};
/** Lets the clock run, the timer firing every `INTERVAL` on the way, as late as `jitter` says. */
const run = (seconds: number, jitter = () => 0) => {
const end = host.time + seconds;
while (host.time + INTERVAL <= end + 1e-9) {
host.time += INTERVAL + jitter();
host.pump?.();
}
};
return { host, sink, run };
}
describe('the scheduler on the audio clock (#332)', () => {
const quarters: Sequence = { notes: [0, 1, 2, 3, 4, 5, 6, 7].map((i) => note(i * Q)) };
it('plans each note at its exact second, a lookahead before it, the first a moment after the start', () => {
const { host, sink, run } = fakeHost();
const scheduler = new Scheduler(sink, () => quarters, { tempo: 120 });
scheduler.start();
expect(host.played.map((n) => n.at)).toEqual([10 + LEAD]);
run(2);
// The fifth is due at 2.05 s, inside the 100 ms ahead of 2 s.
const offsets = host.played.map((n) => n.at - 10 - LEAD);
expect(offsets).toHaveLength(5);
offsets.forEach((offset, i) => expect(offset).toBeCloseTo(i * 0.5, 9));
expect(host.played.every((n) => n.duration === 0.25 && n.tempo === 120 && n.instrument === 'piano')).toBe(
true,
);
});
it('plans every note before it is due and never twice, however late the timer fires', () => {
const { host, sink, run } = fakeHost();
const scheduler = new Scheduler(sink, () => quarters, { tempo: 150 });
let planned = 0;
const late: number[] = [];
const watch = () => {
for (const n of host.played.slice(planned)) late.push(n.at - host.time);
planned = host.played.length;
};
scheduler.start();
watch();
// Up to 60 ms late every time – a busy page – which is still inside the 100 ms ahead.
let k = 0;
const jitter = () => [0.06, 0, 0.03, 0.045][k++ % 4]!;
for (let i = 0; i < 160; i++) {
run(INTERVAL, jitter);
watch();
}
expect(host.played).toHaveLength(8);
expect(Math.min(...late)).toBeGreaterThan(0);
host.played.forEach((n, i) => expect(n.at - 10 - LEAD).toBeCloseTo(i * 0.4, 9));
});
it('runs a loop round and round without a gap at the seam', () => {
const { host, sink, run } = fakeHost();
const twoNotes: Sequence = { notes: [note(0), note(Q)] };
const scheduler = new Scheduler(sink, () => twoNotes, { tempo: 120, loop: { start: 0, end: 2 * Q } });
scheduler.start();
run(3);
const gaps = host.played.slice(1).map((n, i) => n.at - host.played[i]!.at);
expect(host.played.length).toBeGreaterThanOrEqual(6);
expect(gaps.every((gap) => Math.abs(gap - 0.5) < 1e-9)).toBe(true);
});
it('plays a change from the next window on and leaves what is planned', () => {
const { host, sink, run } = fakeHost();
let sequence: Sequence = quarters;
const scheduler = new Scheduler(sink, () => sequence, { tempo: 120 });
scheduler.start();
run(0.6);
const before = host.played.length;
sequence = { notes: quarters.notes.map((n) => ({ ...n, note: 72 })) };
run(2);
expect(host.played.slice(0, before).every((n) => n.note === 60)).toBe(true);
expect(host.played.slice(before).every((n) => n.note === 72)).toBe(true);
expect(host.played.length).toBeGreaterThan(before);
});
it('says where what is heard stands: the clock less the latency, in bars, beats and ticks', () => {
const { host, sink, run } = fakeHost();
const scheduler = new Scheduler(sink, () => quarters, { tempo: 120, meter: { beats: 3, unit: 4 } });
expect(scheduler.position()).toEqual({ bar: 1, beat: 1, tick: 0, ticks: 0 });
scheduler.start();
host.latency = 0.05;
run(1.75);
// 1.75 s on, less the lead and the latency: 1.65 s, 3.3 quarters – the second bar's first beat.
const heard = scheduler.position();
expect([heard.bar, heard.beat]).toEqual([2, 1]);
expect(Math.abs(heard.tick - 0.3 * Q)).toBeLessThanOrEqual(1);
expect(heard.ticks).toBeCloseTo(3.3 * Q, 6);
});
it('stops every note it started, those still to come too, and plays on from there next time', () => {
const { host, sink, run } = fakeHost();
const scheduler = new Scheduler(sink, () => quarters, { tempo: 120 });
scheduler.start();
run(0.6);
// The first note has ended by itself: only the second is left to stop.
host.sounding.delete(1);
run(0.025);
scheduler.stop();
expect(host.stopped).toEqual([[2]]);
expect([scheduler.playing, host.pump, host.asleep]).toEqual([false, null, 1]);
expect(scheduler.position().ticks).toBe(Math.floor(ticksOfSeconds(0.625 - LEAD, 120)));
host.played = [];
scheduler.start();
expect(host.played[0]?.note).toBeUndefined();
run(0.5);
expect(host.played[0]!.at - host.time).toBeLessThan(LOOKAHEAD + 1e-9);
});
it('jumps: cuts what plays and goes on from the new place at once', () => {
const { host, sink, run } = fakeHost();
const scheduler = new Scheduler(sink, () => quarters, { tempo: 120 });
scheduler.start();
run(0.3);
const started = host.time;
scheduler.seek(6 * Q);
expect(host.stopped).toEqual([[1]]);
expect(host.played.at(-1)).toMatchObject({ at: started + LEAD });
expect(scheduler.position().ticks).toBe(6 * Q);
});
it('a new tempo counts from what is planned, and the playhead does not jump meanwhile', () => {
const { host, sink, run } = fakeHost();
const scheduler = new Scheduler(sink, () => quarters, { tempo: 120 });
scheduler.start();
run(1.2);
const heard = scheduler.position().ticks;
const planned = host.played.length;
scheduler.setTempo(60);
expect(scheduler.position().ticks).toBeCloseTo(heard, 6);
run(3);
const after = host.played.slice(planned - 1).map((n) => n.at);
const gaps = after.slice(1).map((at, i) => at - after[i]!);
// The last note at the old tempo, then a second to the quarter.
expect(gaps.slice(1).every((gap) => Math.abs(gap - 1) < 1e-9)).toBe(true);
expect(host.played.at(-1)!.tempo).toBe(60);
});
});
+14 -1
View File
@@ -1994,9 +1994,15 @@ const CASES: Record<string, Case[]> = {
// `sounds.test.ts`).
after: (ctx) =>
expect(ctx.played).toEqual([
{ assetId: 'snd-1', volume: 0.5, loop: true, detached: false, fx: '', fxParams: {} },
{ assetId: 'snd-1', volume: 0.5, loop: true, detached: false, fx: '', fxParams: {}, tempo: 120 },
]),
},
{
name: 'hands on its tempo for a Delay synced to beats, held to 20 to 400 BPM (#332)',
inputs: { sound: 'snd-1', volume: 1, loop: '', tempo: 1000 },
next: 'exec',
after: (ctx) => expect(ctx.played[0]?.tempo).toBe(400),
},
{
name: 'detached says the voice is nobody’s, so destroying this object leaves it playing',
inputs: { sound: 'snd-1', volume: 1, loop: '', detached: true },
@@ -2058,9 +2064,16 @@ const CASES: Record<string, Case[]> = {
duration: 0.25,
detached: false,
instrumentParams: { cut: 2 },
tempo: 120,
},
]),
},
{
name: 'hands on its tempo for a Delay synced to beats (#332)',
inputs: { instrument: 'ins-1', note: 60, tempo: 90 },
next: 'exec',
after: (ctx) => expect(ctx.notes[0]?.tempo).toBe(90),
},
],
InstrumentParameterValues: [
{
+18 -1
View File
@@ -18,6 +18,7 @@ import {
EMPTY_PLAN,
type FxChain,
fxChainOf,
type FxDelay,
type FxPlan,
fxRings,
type FxStage,
@@ -54,7 +55,7 @@ function makeFx(f: ProjectFixture = makeProject()) {
}
/** Input, the given effects in a row, Output. */
function line(effects: { type: string; params?: Record<string, number | boolean> }[]) {
function line(effects: { type: string; params?: Record<string, number | boolean | string> }[]) {
const f = makeFx();
const input = f.edit.addNode('SoundFxInput');
let tail = input;
@@ -306,6 +307,22 @@ describe('a Delay in a sound FX graph (#327)', () => {
expect(fxChainOf(plan, { echo: 1 }).stages).toMatchObject([{ time: 1 }]);
expect(fxChainOf(plan, { echo: 5 }).stages).toMatchObject([{ time: 2 }]);
});
it('synced to beats counts its time in quarter notes at the tempo of the voice (#332)', () => {
const synced = (time: number) =>
compileSoundFx(line([{ type: 'SoundFxDelay', params: { sync: 'beats', time } }]).fx.graph).compiled!;
expect(synced(0.75)).toMatchObject({ stages: [{ beats: true, time: { value: 0.75 } }] });
// A dotted eighth at 120 BPM, an eighth triplet at 100, a quarter where no tempo is said.
expect(fxChainOf(synced(0.75), {}, 120).stages).toMatchObject([{ time: 0.375 }]);
expect((fxChainOf(synced(1 / 3), {}, 100).stages[0] as FxDelay).time).toBeCloseTo(0.2);
expect(fxChainOf(synced(1)).stages).toMatchObject([{ time: 0.5 }]);
// A whole note at 60 BPM is 4 s: held to the 2 s of the line, as is a tempo out of its range.
expect(fxChainOf(synced(4), {}, 60).stages).toMatchObject([{ time: 2 }]);
expect(fxChainOf(synced(1), {}, 1000).stages).toMatchObject([{ time: 0.15 }]);
// In seconds the tempo changes nothing.
const seconds = compileSoundFx(line([{ type: 'SoundFxDelay', params: { time: 0.75 } }]).fx.graph);
expect(fxChainOf(seconds.compiled!, {}, 60).stages).toMatchObject([{ time: 0.75 }]);
});
});
describe('the tail of a voice (#324)', () => {
+6 -4
View File
@@ -213,6 +213,7 @@ export type FakeContext = NodeContext & {
detached: boolean;
fx: string;
fxParams: Readonly<Record<string, number>>;
tempo: number;
}[];
/** The notes the context was asked to play (#331); a voice's number is how many have been played. */
notes: {
@@ -223,6 +224,7 @@ export type FakeContext = NodeContext & {
duration: number;
detached: boolean;
instrumentParams: Readonly<Record<string, number>>;
tempo: number;
}[];
silenced: string[];
tuned: { voice: number; fxParams: Readonly<Record<string, number>>; time: number; ease: EaseShare }[];
@@ -278,11 +280,11 @@ export function fakeContext(overrides: Partial<NodeContext> = {}): FakeContext {
silenced: [],
loaded: [],
// The number of a voice is how many have been played: unique, and never 0.
playSound: (assetId, volume, loop, detached, fx, fxParams) =>
ctx.played.push({ assetId, volume, loop, detached, fx, fxParams }),
playSound: (assetId, volume, loop, detached, fx, fxParams, tempo) =>
ctx.played.push({ assetId, volume, loop, detached, fx, fxParams, tempo }),
notes: [],
playNote: (instrumentId, note, velocity, volume, duration, detached, instrumentParams) =>
ctx.notes.push({ instrumentId, note, velocity, volume, duration, detached, instrumentParams }),
playNote: (instrumentId, note, velocity, volume, duration, detached, instrumentParams, tempo) =>
ctx.notes.push({ instrumentId, note, velocity, volume, duration, detached, instrumentParams, tempo }),
// Every sound lasts a quarter of a second, as if it had been loaded.
soundLength: () => 0.25,
// The tail of a voice is its number in tenths of a second, so a test can tell one from another.
+74 -6
View File
@@ -60,9 +60,11 @@ interface FakeSource {
playbackRate: FakeParam;
onended: (() => void) | null;
started: boolean;
/** The second of the audio clock it was started at. */
startedAt: number;
stopped: boolean;
connect: (target: unknown) => unknown;
start: () => void;
start: (at?: number) => void;
stop: () => void;
}
@@ -171,9 +173,11 @@ function fakeContext() {
playbackRate: param(1),
onended: null,
started: false,
startedAt: -1,
stopped: false,
start: () => {
start: (at?: number) => {
source.started = true;
source.startedAt = at ?? 0;
},
stop: () => {
source.stopped = true;
@@ -387,6 +391,7 @@ describe('the sound library', () => {
pitches: [0],
stages: 0,
duration: 1.5,
at: 0,
});
});
@@ -817,6 +822,30 @@ describe('the sound library', () => {
expect(library.stats().playing).toBe(0);
});
it('counts an echo synced to beats by the tempo of the voice, never past the line (#332)', async () => {
const library = new SoundLibrary();
const synced = (quarters: number): FxPlan => ({
pitch: [],
stages: [
{
kind: 'delay',
time: { value: quarters },
beats: true,
feedback: { value: 0 },
damping: { value: 0 },
mix: { value: 1 },
pingPong: false,
},
],
});
const voice = await play(library, FILE, {}, { plan: synced(1), params: {}, tempo: 90 });
expect(delays[0]!.delayTime.value).toBeCloseTo(60 / 90);
expect(library.tail(voice)).toBeCloseTo(60 / 90);
// Four quarters at 120 BPM are 2 s, which is as long as the line is; eight would be 4 s.
await play(library, FILE, {}, { plan: synced(8), params: {} });
expect(delays[1]!.delayTime.value).toBe(2);
});
it('lets the echoes ring when asked to: only the sound fades, the voice stays until they are gone', async () => {
const library = new SoundLibrary();
await play(library, FILE, {}, { pitch: 0, stages: [echo()] });
@@ -943,17 +972,56 @@ describe('notes of an instrument (#331)', () => {
expect(library.stats().playing).toBe(0);
});
it('a note with a duration is released that long after it starts, on the audio clock', async () => {
it('a note with a duration is released that long after it starts, its fade set on the audio clock at once (#332)', async () => {
const library = new SoundLibrary();
await note(library, [sample(FILE)], 0.5);
expect(clocks.map((clock) => clock.stopAt)).toEqual([0.5]);
clockTo(0.5);
expect(gains[1]!.ramps).toEqual([['linear', 0, 0.01]]);
expect(gains[1]!.ramps).toEqual([
['set', 0.5, 0.5],
['linear', 0, 0.51],
]);
expect(clocks.map((clock) => clock.stopAt)).toEqual([0.51]);
expect(sources[0]!.stopped).toBe(false);
clockTo();
expect(sources[0]!.stopped).toBe(true);
expect(library.stats().playing).toBe(0);
});
it('a note planned ahead starts on that second of the audio clock and is released from there (#332)', async () => {
const library = new SoundLibrary();
library.playNote('instrument:i', [sample(FILE)], {}, 1, '', 0.5, { at: 2 });
await settle();
await arrive();
expect(sources[0]!.startedAt).toBe(2);
expect(library.stats().last?.at).toBe(2);
expect(gains[1]!.ramps).toEqual([
['set', 0.5, 2.5],
['linear', 0, 2.51],
]);
expect(clocks.map((clock) => clock.stopAt)).toEqual([2.51]);
// One whose second has passed by the time its bytes are there starts now.
library.playNote('instrument:i', [sample(FILE)], {}, 1, '', 0, { at: -1 });
expect(sources[1]!.startedAt).toBe(0);
});
it('tells a pitched note planned ahead when to start, the worklet having no start of its own (#332)', async () => {
const library = new SoundLibrary();
const pitched = { ...sample(FILE), pitch: [{ value: 7 }] } as FxPlan;
library.playNote('instrument:i', [pitched], {}, 1, '', 0, { at: 1.25 });
await settle();
await arrive();
expect(worklets[0]!.options.processorOptions.startAt).toBe(1.25);
});
it('a note planned to end later leaves its loop then, not now (#332)', async () => {
const library = new SoundLibrary();
await note(library, [sample(FILE, { loop: true, gate: false })], 1);
expect(sources[0]!.loop).toBe(true);
clockTo(0.99);
expect(sources[0]!.loop).toBe(true);
clockTo(1);
expect(sources[0]!.loop).toBe(false);
});
it('a note released before its bytes arrived still starts, and is released at once', async () => {
const library = new SoundLibrary();
const voice = library.playNote(