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>
213 lines
7.5 KiB
TypeScript
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);
|
|
});
|
|
});
|