// Pure look-ahead scheduling math for TechnoEngine. // // The engine runs a 25ms timer; on each wake it asks this module which beats fall // inside the next `lookaheadMs` of audio time and schedules oscillators for them. // Keeping the arithmetic here means the beat grid is unit-testable without an // AudioContext (tests run in node — there is no WebAudio there). export interface BeatGrid { bpm: number; /** AudioContext time (ms) at which beat 0 sounds. */ originMs: number; } export interface ScheduledBeat { beatIndex: number; /** Absolute AudioContext time (ms) the beat sounds at. */ timeMs: number; } export const beatIntervalMs = (bpm: number): number => 60_000 / bpm; /** Absolute audio time of a given beat index. */ export const beatTimeMs = (grid: BeatGrid, beatIndex: number): number => grid.originMs + beatIndex * beatIntervalMs(grid.bpm); /** * Every beat with index >= fromBeat whose time falls at or before * `nowMs + lookaheadMs`. Returned in ascending order; empty when nothing is due. * * The engine advances its own cursor past the last returned index, so a beat is * never scheduled twice however ragged the timer wakeups are. */ export function dueBeats( grid: BeatGrid, fromBeat: number, nowMs: number, lookaheadMs: number, ): ScheduledBeat[] { const horizon = nowMs + lookaheadMs; const out: ScheduledBeat[] = []; // Guard against a pathological horizon (tab suspended for minutes) producing a // scheduling storm — we cap the burst and let the cursor catch up next wake. const MAX_BURST = 64; for (let i = fromBeat; out.length < MAX_BURST; i++) { const timeMs = beatTimeMs(grid, i); if (timeMs > horizon) break; out.push({ beatIndex: i, timeMs }); } return out; } /** 16th-note subdivision times within a beat, for hats and the bassline. */ export function subdivisions(grid: BeatGrid, beatIndex: number, n: number): number[] { const step = beatIntervalMs(grid.bpm) / n; const base = beatTimeMs(grid, beatIndex); return Array.from({ length: n }, (_, i) => base + i * step); } /** Bar/beat position of a beat index in 4/4. */ export const barOf = (beatIndex: number): number => Math.floor(beatIndex / 4); export const beatInBar = (beatIndex: number): number => ((beatIndex % 4) + 4) % 4; export const isDownbeat = (beatIndex: number): boolean => beatInBar(beatIndex) === 0; /** * Where a 16th-note step sits in the 2-bar (32-step) bassline pattern. * Exposed so tests can assert the pattern wraps rather than drifting. */ export const patternStep = (beatIndex: number, sixteenth: number, steps = 32): number => (((beatIndex * 4 + sixteenth) % steps) + steps) % steps;