Testable-in-node math extracted ahead of the WebAudio/Phaser layers: - audio/scheduling.ts: look-ahead beat grid (dueBeats never repeats or skips a beat however ragged the timer wakes; burst-capped for suspended tabs) - audio/filterCurve.ts: the location filter's exponential cutoff ramp — the door-opening sweep, in frequency-space so it blooms late like a real door - ui/style.ts: chunky pixel widget kit sharing the doll PALETTE - ui/typo.ts: deterministic drunk-typo/typing/wobble renderer (seeded, no Math.random) - ui/juiceEvents.ts: EventMap extensions via declaration merging, so door:phoneTheatre types correctly without editing frozen data/types.ts. TODO(contract) — CONTRACT CHANGE REQUEST filed in LANEHANDOVER.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
70 lines
2.6 KiB
TypeScript
70 lines
2.6 KiB
TypeScript
// 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;
|