import { describe, expect, it } from 'vitest'; import { barOf, beatInBar, beatIntervalMs, beatTimeMs, dueBeats, isDownbeat, patternStep, subdivisions, type BeatGrid, } from '../src/audio/scheduling'; const grid = (bpm: number, originMs = 0): BeatGrid => ({ bpm, originMs }); describe('beatIntervalMs', () => { it('converts BPM to milliseconds per beat', () => { expect(beatIntervalMs(120)).toBe(500); expect(beatIntervalMs(128)).toBe(468.75); }); }); describe('beatTimeMs', () => { it('places beat 0 at the origin and steps by one interval', () => { const g = grid(120); expect(beatTimeMs(g, 0)).toBe(0); expect(beatTimeMs(g, 1)).toBe(500); expect(beatTimeMs(g, 8)).toBe(4000); }); it('offsets every beat by a non-zero origin', () => { const g = grid(128, 1234.5); expect(beatTimeMs(g, 0)).toBe(1234.5); for (let i = 0; i < 16; i++) { expect(beatTimeMs(g, i)).toBeCloseTo(1234.5 + i * 468.75, 6); } }); it('handles negative indices (beats before the origin)', () => { expect(beatTimeMs(grid(120, 1000), -2)).toBe(0); }); }); describe('dueBeats', () => { it('returns only beats inside the lookahead horizon, ascending from fromBeat', () => { // 500ms grid, now 0, 1200ms horizon -> beats 0,1,2 (2 sounds at 1000) const due = dueBeats(grid(120), 0, 0, 1200); expect(due.map((b) => b.beatIndex)).toEqual([0, 1, 2]); expect(due.map((b) => b.timeMs)).toEqual([0, 500, 1000]); }); it('includes a beat landing exactly on the horizon', () => { expect(dueBeats(grid(120), 0, 0, 1000).map((b) => b.beatIndex)).toEqual([0, 1, 2]); }); it('returns nothing when the next beat is beyond the horizon', () => { // beat 4 sounds at 2000; waking at 1600 with a 100ms lookahead is too early expect(dueBeats(grid(120), 4, 1600, 100)).toEqual([]); }); it('respects fromBeat rather than replaying earlier beats', () => { const due = dueBeats(grid(120), 3, 0, 2500); expect(due.map((b) => b.beatIndex)).toEqual([3, 4, 5]); }); it('respects the grid origin', () => { // origin 10s: nothing is due while audio time is still near zero expect(dueBeats(grid(120, 10_000), 0, 0, 100)).toEqual([]); expect(dueBeats(grid(120, 10_000), 0, 9_950, 100).map((b) => b.beatIndex)).toEqual([0]); }); it('yields every beat exactly once, in order, across ragged wakeups', () => { const g = grid(128, 40); const lookahead = 100; // Irregular timer wakeups: some shorter than the lookahead, some longer than a beat. const wakes = [25, 31, 18, 40, 25, 7, 63, 25, 12, 90, 25, 33]; const seen: number[] = []; let cursor = 0; let now = 0; // Bounded by wake count, not by beats collected: a regression that stops // returning beats must go red, not spin forever and hang `npm test`. for (let w = 0; w < 2000; w++) { now += wakes[w % wakes.length] ?? 25; const due = dueBeats(g, cursor, now, lookahead); for (const b of due) { expect(b.timeMs).toBeCloseTo(beatTimeMs(g, b.beatIndex), 6); expect(b.timeMs).toBeLessThanOrEqual(now + lookahead); seen.push(b.beatIndex); } const last = due.at(-1); if (last) cursor = last.beatIndex + 1; } // Every beat whose time fell inside the final horizon should have sounded. const expected = Math.floor((now + lookahead - 40) / beatIntervalMs(128)) + 1; expect(expected).toBeGreaterThan(100); // sanity: the walk really is long expect(seen.length).toBe(expected); // No gaps, no duplicates, strictly ascending from 0. expect(seen).toEqual(seen.map((_, i) => i)); }); it('never schedules a beat more than a lookahead ahead of the wake time', () => { const g = grid(120); let cursor = 0; let scheduled = 0; let lastNow = 0; for (let now = 0; now < 20_000; now += 25) { const due = dueBeats(g, cursor, now, 120); for (const b of due) expect(b.timeMs).toBeLessThanOrEqual(now + 120); scheduled += due.length; const last = due.at(-1); if (last) cursor = last.beatIndex + 1; lastNow = now; } // Scheduling nothing would satisfy the bound above vacuously; silence is the // failure mode this test exists to catch. expect(scheduled).toBe(Math.floor((lastNow + 120) / beatIntervalMs(120)) + 1); }); it('caps a suspended-tab backlog at MAX_BURST and drains it without loss', () => { const g = grid(128); const stalledNow = 5 * 60_000; // tab was hidden for five minutes const lookahead = 100; const expectedTotal = Math.floor((stalledNow + lookahead) / beatIntervalMs(128)) + 1; expect(expectedTotal).toBeGreaterThan(600); // sanity: the burst really is huge const first = dueBeats(g, 0, stalledNow, lookahead); expect(first.length).toBe(64); expect(first.map((b) => b.beatIndex)).toEqual(first.map((_, i) => i)); // Subsequent wakes at the same audio time drain the rest, 64 at a time. const seen = first.map((b) => b.beatIndex); let cursor = 64; for (let guard = 0; guard < 100; guard++) { const due = dueBeats(g, cursor, stalledNow, lookahead); if (due.length === 0) break; expect(due.length).toBeLessThanOrEqual(64); for (const b of due) seen.push(b.beatIndex); cursor = due[due.length - 1]!.beatIndex + 1; } expect(seen).toEqual(seen.map((_, i) => i)); expect(seen.length).toBe(expectedTotal); }); }); describe('subdivisions', () => { it('returns n evenly spaced times starting on the beat', () => { const g = grid(120, 250); const subs = subdivisions(g, 2, 4); // beat 2 sounds at 1250 expect(subs.length).toBe(4); expect(subs[0]).toBe(beatTimeMs(g, 2)); expect(subs).toEqual([1250, 1375, 1500, 1625]); }); it('lands the next beat exactly one step past the last subdivision', () => { const g = grid(128, 12); const subs = subdivisions(g, 7, 16); const step = beatIntervalMs(128) / 16; expect(subs.length).toBe(16); expect(subs[15]! + step).toBeCloseTo(beatTimeMs(g, 8), 6); }); }); describe('bar position (4/4)', () => { it('maps beat indices to bar and position', () => { expect(barOf(0)).toBe(0); expect(beatInBar(0)).toBe(0); expect(isDownbeat(0)).toBe(true); for (let i = 0; i < 32; i++) { expect(barOf(i)).toBe(Math.floor(i / 4)); expect(beatInBar(i)).toBe(i % 4); expect(isDownbeat(i)).toBe(i % 4 === 0); } }); it('keeps beatInBar non-negative before the origin', () => { expect(beatInBar(-1)).toBe(3); expect(beatInBar(-4)).toBe(0); expect(isDownbeat(-4)).toBe(true); // barOf must floor, not truncate, or beat -1 lands in bar 0 alongside beat 3. expect(barOf(-1)).toBe(-1); expect(barOf(-4)).toBe(-1); expect(barOf(-5)).toBe(-2); }); }); describe('patternStep', () => { it('walks 0..31 across two bars then wraps', () => { const walk: number[] = []; for (let beat = 0; beat < 8; beat++) { for (let s = 0; s < 4; s++) walk.push(patternStep(beat, s)); } expect(walk.length).toBe(32); expect(walk).toEqual(walk.map((_, i) => i)); expect(patternStep(8, 0)).toBe(0); }); it('repeats with period 32 rather than drifting', () => { for (let beat = 0; beat < 64; beat++) { for (let s = 0; s < 4; s++) { expect(patternStep(beat, s)).toBe(patternStep(beat + 8, s)); expect(patternStep(beat, s)).toBe((beat * 4 + s) % 32); } } }); it('honours a custom step count and negative indices', () => { expect(patternStep(4, 0, 16)).toBe(0); expect(patternStep(3, 2, 16)).toBe(14); expect(patternStep(-1, 0)).toBe(28); }); });