A sound FX could only be put on a single sound. Now a track, a pattern item, a Play pattern and a Play song send everything they play through one chain of it, as an insert in a DAW. A Play note gets one of its own. - Track: an FX box in the head next to M and S opens a menu of the project's sound FX, and the track's inspector has the row too. Order: items, track FX, volume, pan. - Pattern item: Sound FX in its inspector. Its notes play on a bus of their own into the track's bus. - Play pattern and Play song take fx and fx params. The chain sits on the master of the playing, after the tracks and before the level and the fades. Set sound FX parameters reaches it by the playback. - Play note takes fx and fx params next to the instrument's params. The FX parameters go by "fx:" so the two sets stay apart. Set sound FX parameters on a note changes a name both have in both. - A Pitch on a bus is a new PitchShifter: the phase vocoder of the PitchPlayer, fed while it plays. It cannot look ahead and is exactly one frame late at every pitch, 2048 samples. - That latency is made up. The other way of a Mix is held back by a delay of the same length. Every note and clip starts as much earlier as the buses it passes are late. The scheduler plans that much further ahead, and the first note waits that much after Play. - The Song tab waits for the chains before it plays and lets its buses go a moment after Stop. A playing in a game lets them go after its echoes and what the shifters still hold. - songBuses moved to its own file. Kept in song.ts it pulled the FX compiler into the node registry through an import cycle. Decisions of the user: one chain for the whole (1a), FX everywhere asked for (2a), Set sound FX parameters on playbacks (3a), and a pitch shifter of its own with exact latency compensation for a Pitch on a bus. Tests: - Unit pitch-shifter (new): 11 green. At 0 semitones the output is the input 2048 samples later, off by at most 1.2e-7. - Unit sound-library: 84 green, 8 new. Counter-checks (no early start, no Mix delay, chain made every time, tune misses the master) each turned their test red. - Unit bus-fx (new): 6 green, 4 counter-checks red. Scheduler ahead: 1 new, its counter-check red. - e2e 65 (melektaus-atx): ALL PASSED, 35 checks, 4 new, first run. Chrome at 44.1 kHz: the bus was 0.046440 s late, the note started exactly that much early. Offline the shifter moved 1 kHz to 2000 Hz and passed a click 2048 samples later. - Gate (melektaus-atx): CHECK EXIT 0, 259 files, 4021 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
327 lines
13 KiB
TypeScript
327 lines
13 KiB
TypeScript
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('plans as much further ahead as a note may be started early, its playhead on what is heard (#346)', () => {
|
||
const { host, sink, run } = fakeHost();
|
||
const early = 3 * (2048 / 48000);
|
||
const scheduler = new Scheduler({ ...sink, ahead: () => early }, () => quarters, { tempo: 150 });
|
||
let planned = 0;
|
||
const room: number[] = [];
|
||
const watch = () => {
|
||
// What the player has left once it starts the note that much earlier.
|
||
for (const n of host.played.slice(planned)) room.push(n.at - early - host.time);
|
||
planned = host.played.length;
|
||
};
|
||
scheduler.start();
|
||
watch();
|
||
expect(host.played.map((n) => n.at)).toEqual([10 + LEAD + early]);
|
||
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(...room)).toBeGreaterThan(0);
|
||
host.played.forEach((n, i) => expect(n.at - 10 - LEAD - early).toBeCloseTo(i * 0.4, 9));
|
||
host.time = 10 + LEAD + early + 0.4;
|
||
expect(scheduler.position().ticks).toBeCloseTo(ticksOfSeconds(0.4, 150), 6);
|
||
});
|
||
|
||
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);
|
||
});
|
||
});
|