not-tonight/tests/scheduling.test.ts
type-two 2eb3c94ccd LANE-JUICE: audio engine, SFX set, five UI widgets, JuiceDemoScene
Audio (src/audio/), raw WebAudio, no samples or libraries:
- TechnoEngine: 128 BPM four-on-the-floor kick, off-beat hats, 2-bar 16th
  bassline, 8-bar pad. Look-ahead scheduler (25ms timer, 100ms horizon) is the
  beat authority — emits beat:tick with the SCHEDULED audio time, so consumers
  can compare honestly. Replaces core/StubBeatClock at integration; same event.
  Stale beats are resynced to the next downbeat rather than caught up: a
  throttled/hidden tab otherwise dumps a 64-beat backlog onto one sample.
  Music runs through the location lowpass; SFX bypass it and stay crisp.
- Sfx: 10 procedural one-shots + rain bed, one shared noise buffer, voices
  self-disconnect on ended so a six-hour night doesn't accumulate nodes.

UI (src/ui/), reusable Phaser containers, no door/floor imports:
- Stamp (the signature interaction), Phone (Dazza thread + phoneTheatre),
  DialogueBox (typed-out, drunk-typo render), Clicker (digit roll),
  MeterHud (neon vibe / crowd-temp aggro / hype badge / 3 heat slots).
- JuiceDemoScene exercises every widget and SFX, with a log-mapped cutoff
  slider and a DOOR/FLOOR toggle that routes through the bus.

Verified in-browser: the door->floor sweep ramps 250Hz->18kHz over 600ms,
monotonic and exponential (halfway lands ~2.1kHz, not the ~9kHz a linear ramp
would give) — that late bloom is what sells the door opening. Under a real
hidden tab, 52 consecutive beats scheduled with zero in the past, 75-98ms lead,
max one beat per wake.

main.ts touched to route J / #juice to the demo — outside lane ownership,
flagged for sign-off in LANEHANDOVER.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:52:43 +10:00

213 lines
7.5 KiB
TypeScript

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