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(), 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); }); });