not-tonight/src/audio/scheduling.ts
type-two c262ac8261 LANE-JUICE: pure foundation — beat scheduling, filter curve, UI style kit, drunk typo renderer
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>
2026-07-19 18:04:33 +10:00

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;