The Phase-1 debt was 'verified by ear: NOTHING'. I still can't hear, so this lane builds the instrument that lets someone who can tell me what's wrong, and makes their answer a diff instead of a conversation. - TechnoEngine drives every voice from the live TrackMix and the arc, instead of module consts. Behaviour-identical at DEFAULT_MIX (verified constant by constant). Voices whose effective gain rounds to silence are skipped, not clamped — at 9PM the bass builds zero nodes rather than ~8/sec of silence. setMixValue quantises integer paths, so what formatMix dumps is what played. - MixDeskScene: solo/mute per voice with a per-voice listening prompt, every mix param on log-mapped sliders, an arc scrubber (audition 1AM at 9PM), and DUMP MIX -> a pasteable TrackMix literal. Restores the arc pin and solo state it found on close, rather than forcing a default — the demo deliberately pins PEAK and the desk was silently un-tuning it. - Ambience: rain outside, crowd murmur inside, kebab-shop fluoro hum. On the SFX bus, not the music lowpass — that filter models the PA being behind a wall, and rain is not behind anything. Headcount comes off door:verdict admits and floor:ejection, NOT door:clicker: the clicker is the player's claimed count and its divergence from reality is the mechanic, so the room would sound like the lie. Verified live: 120 + 2 admits - 1 ejection = 121, clicker ignored. - JuiceDemoScene pins the arc at PEAK on entry. GameClock runs 360 game-min in 13 real min, so an unpinned demo opens on kick+hat and anyone judging the bass would conclude it's broken. - Phone thread scrolls with visible-window culling and a binary-searched range, a history cap, and a 'new below' cue when a text lands off-screen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
278 lines
9.6 KiB
TypeScript
278 lines
9.6 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { ARC, arcAt, arcIntensity, arcLabel, NIGHT_END_MIN, type ArcScales } from '../src/audio/arc';
|
|
import { VOICE_NAMES } from '../src/audio/mix';
|
|
|
|
const first = ARC[0]!;
|
|
const last = ARC[ARC.length - 1]!;
|
|
|
|
const mean = (a: number, b: number): number => (a + b) / 2;
|
|
|
|
const PLATEAU_MIN = 180;
|
|
const KICK_FULL_MIN = 120;
|
|
|
|
// The plateau is read straight off the keyframe, never sampled out of arcAt —
|
|
// a bound derived from the function under test cannot catch that function
|
|
// drifting. Kick is full by 11 PM by design; these three climb to midnight.
|
|
const plateau: ArcScales = ARC.find((k) => k.at === PLATEAU_MIN)!.scales;
|
|
const LATE_BUILDERS = ['hat', 'bass', 'pad'] as const;
|
|
|
|
describe('ARC keyframes', () => {
|
|
// Every loop below iterates one of these two; a zero-length either would turn
|
|
// the whole suite green by iterating nothing.
|
|
it('has the keyframes and voices the rest of this suite loops over', () => {
|
|
expect(ARC.length).toBe(7);
|
|
expect(VOICE_NAMES.length).toBe(4);
|
|
expect([...VOICE_NAMES].sort()).toEqual(['bass', 'hat', 'kick', 'pad']);
|
|
});
|
|
|
|
it('runs strictly ascending from minute 0 to the end of the night', () => {
|
|
expect(first.at).toBe(0);
|
|
expect(last.at).toBe(NIGHT_END_MIN);
|
|
expect(NIGHT_END_MIN).toBe(360);
|
|
for (let i = 1; i < ARC.length; i++) {
|
|
expect(ARC[i]!.at).toBeGreaterThan(ARC[i - 1]!.at);
|
|
}
|
|
});
|
|
|
|
it('keeps every voice scale inside 0..1 and carries a label', () => {
|
|
for (const k of ARC) {
|
|
expect(k.label.length).toBeGreaterThan(0);
|
|
for (const v of VOICE_NAMES) {
|
|
expect(k.scales[v]).toBeGreaterThanOrEqual(0);
|
|
expect(k.scales[v]).toBeLessThanOrEqual(1);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('arcAt', () => {
|
|
it('returns the exact keyframe scales at each keyframe minute', () => {
|
|
for (const k of ARC) {
|
|
expect(arcAt(k.at)).toEqual(k.scales);
|
|
}
|
|
});
|
|
|
|
// Every branch has to copy. Handing back a live keyframe means one caller
|
|
// doing `arcAt(-1).bass = 0` silently rewrites ARC for the whole session, and
|
|
// every subsequent 9 PM opens wrong.
|
|
it('returns a fresh object on every branch, including both clamps', () => {
|
|
// Clamp low.
|
|
const low = arcAt(-1);
|
|
expect(low).not.toBe(first.scales);
|
|
low.bass = 0.123;
|
|
expect(first.scales.bass).toBe(0);
|
|
expect(arcAt(-1).bass).toBe(0);
|
|
|
|
// Clamp high.
|
|
const high = arcAt(NIGHT_END_MIN + 1);
|
|
expect(high).not.toBe(last.scales);
|
|
high.pad = 0.123;
|
|
expect(last.scales.pad).toBe(0.6);
|
|
expect(arcAt(NIGHT_END_MIN + 1).pad).toBe(0.6);
|
|
|
|
// The edge minutes themselves take the clamp branches, not interpolation.
|
|
const atFirst = arcAt(first.at);
|
|
expect(atFirst).not.toBe(first.scales);
|
|
atFirst.kick = 0.111;
|
|
expect(arcAt(first.at).kick).toBe(0.8);
|
|
|
|
const atLast = arcAt(last.at);
|
|
expect(atLast).not.toBe(last.scales);
|
|
atLast.kick = 0.111;
|
|
expect(arcAt(last.at).kick).toBe(0.45);
|
|
|
|
// A non-finite minute funnels into the clamp-low branch.
|
|
const stalled = arcAt(NaN);
|
|
expect(stalled).not.toBe(first.scales);
|
|
stalled.bass = 0.9;
|
|
expect(arcAt(NaN).bass).toBe(0);
|
|
|
|
// Interpolation branch.
|
|
const k = ARC[3]!;
|
|
const s = arcAt(k.at);
|
|
expect(s).not.toBe(k.scales);
|
|
s.kick = 0;
|
|
expect(k.scales.kick).toBe(1);
|
|
});
|
|
|
|
it('clamps outside the night instead of extrapolating', () => {
|
|
expect(arcAt(-1)).toEqual(first.scales);
|
|
expect(arcAt(-10_000)).toEqual(first.scales);
|
|
expect(arcAt(NIGHT_END_MIN + 1)).toEqual(last.scales);
|
|
expect(arcAt(10_000)).toEqual(last.scales);
|
|
});
|
|
|
|
it('never produces a negative or >1 gain at any minute, including past the end', () => {
|
|
for (let t = -120; t <= NIGHT_END_MIN + 120; t++) {
|
|
const s = arcAt(t);
|
|
for (const v of VOICE_NAMES) {
|
|
expect(s[v]).toBeGreaterThanOrEqual(0);
|
|
expect(s[v]).toBeLessThanOrEqual(1);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('interpolates linearly between keyframes', () => {
|
|
// 30 is the midpoint of the 0 -> 60 span.
|
|
const a = ARC[0]!.scales;
|
|
const b = ARC[1]!.scales;
|
|
const mid = arcAt(30);
|
|
for (const v of VOICE_NAMES) expect(mid[v]).toBeCloseTo(mean(a[v], b[v]), 10);
|
|
|
|
// 90 is the midpoint of 60 -> 120.
|
|
const c = ARC[2]!.scales;
|
|
const mid2 = arcAt(90);
|
|
for (const v of VOICE_NAMES) expect(mid2[v]).toBeCloseTo(mean(b[v], c[v]), 10);
|
|
|
|
// A quarter of the way across the 330 -> 360 close-down.
|
|
const d = ARC[5]!.scales;
|
|
const e = ARC[6]!.scales;
|
|
const q = arcAt(337.5);
|
|
for (const v of VOICE_NAMES) expect(q[v]).toBeCloseTo(d[v] + (e[v] - d[v]) * 0.25, 10);
|
|
});
|
|
|
|
it('survives a non-finite clock minute without emitting NaN', () => {
|
|
for (const bad of [NaN, Infinity, -Infinity]) {
|
|
const s: ArcScales = arcAt(bad);
|
|
for (const v of VOICE_NAMES) expect(Number.isFinite(s[v])).toBe(true);
|
|
}
|
|
// A stalled clock reads as the start of the night, not as silence.
|
|
expect(arcAt(NaN)).toEqual(first.scales);
|
|
});
|
|
});
|
|
|
|
describe('the shape of the night', () => {
|
|
it('opens with no bassline — the room is empty', () => {
|
|
expect(arcAt(0).bass).toBe(0);
|
|
expect(arcAt(0).pad).toBe(0);
|
|
expect(arcAt(0).kick).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('brings the bass up once there is a crowd to carry it', () => {
|
|
expect(arcAt(60).bass).toBeGreaterThan(arcAt(0).bass);
|
|
expect(arcAt(120).bass).toBeGreaterThan(arcAt(60).bass);
|
|
});
|
|
|
|
it('builds every voice through the first half of the night', () => {
|
|
for (const v of VOICE_NAMES) {
|
|
let prev = -Infinity;
|
|
for (let t = 0; t <= 180; t += 5) {
|
|
const s = arcAt(t)[v];
|
|
expect(s).toBeGreaterThanOrEqual(prev);
|
|
prev = s;
|
|
}
|
|
expect(arcAt(180)[v]).toBeGreaterThan(arcAt(0)[v]);
|
|
}
|
|
});
|
|
|
|
// The build has to be a real build: strictly quieter than the plateau on the
|
|
// way up, so a keyframe that arrives at full volume early — flattening the
|
|
// climb the whole arc exists for — is a failure, not a pass.
|
|
it('stays strictly under the midnight plateau on the way up', () => {
|
|
for (const k of ARC) {
|
|
if (k.at >= KICK_FULL_MIN) continue;
|
|
for (const v of VOICE_NAMES) expect(k.scales[v]).toBeLessThan(plateau[v]);
|
|
}
|
|
|
|
const eleven = ARC.find((k) => k.at === KICK_FULL_MIN)!;
|
|
expect(eleven.scales.kick).toBe(plateau.kick);
|
|
for (const v of LATE_BUILDERS) expect(eleven.scales[v]).toBeLessThan(plateau[v]);
|
|
|
|
for (let t = 0; t < KICK_FULL_MIN; t++) {
|
|
for (const v of VOICE_NAMES) expect(arcAt(t)[v]).toBeLessThan(plateau[v]);
|
|
}
|
|
for (let t = KICK_FULL_MIN; t < PLATEAU_MIN; t++) {
|
|
for (const v of LATE_BUILDERS) expect(arcAt(t)[v]).toBeLessThan(plateau[v]);
|
|
}
|
|
});
|
|
|
|
it('holds every voice at its loudest across the midnight..2 AM window', () => {
|
|
for (const v of VOICE_NAMES) {
|
|
for (let t = PLATEAU_MIN; t <= 300; t += 10) expect(arcAt(t)[v]).toBeCloseTo(plateau[v], 10);
|
|
for (let t = 301; t <= NIGHT_END_MIN; t++) expect(arcAt(t)[v]).toBeLessThan(plateau[v]);
|
|
}
|
|
});
|
|
|
|
it('strips every voice back by lights up', () => {
|
|
for (const v of VOICE_NAMES) {
|
|
expect(arcAt(NIGHT_END_MIN)[v]).toBeLessThan(plateau[v]);
|
|
expect(arcAt(NIGHT_END_MIN)[v]).toBeLessThan(arcAt(330)[v]);
|
|
}
|
|
// Low end goes first — that is what actually empties a floor.
|
|
expect(arcAt(330).bass).toBeLessThan(arcAt(330).pad);
|
|
});
|
|
});
|
|
|
|
describe('arcLabel', () => {
|
|
it('names each keyframe at its minute and holds it until the next', () => {
|
|
for (let i = 0; i < ARC.length; i++) {
|
|
const k = ARC[i]!;
|
|
expect(arcLabel(k.at)).toBe(k.label);
|
|
const next = ARC[i + 1];
|
|
if (next && next.at > k.at + 1) expect(arcLabel(k.at + 1)).toBe(k.label);
|
|
}
|
|
});
|
|
|
|
it('reads the expected labels across the night', () => {
|
|
expect(arcLabel(0)).toBe('EMPTY');
|
|
expect(arcLabel(59)).toBe('EMPTY');
|
|
expect(arcLabel(60)).toBe('FILLING');
|
|
expect(arcLabel(119)).toBe('FILLING');
|
|
expect(arcLabel(120)).toBe('BUILDING');
|
|
expect(arcLabel(180)).toBe('PEAK');
|
|
expect(arcLabel(299)).toBe('PEAK');
|
|
expect(arcLabel(300)).toBe('PEAK');
|
|
expect(arcLabel(330)).toBe('LAST DRINKS');
|
|
expect(arcLabel(359)).toBe('LAST DRINKS');
|
|
expect(arcLabel(NIGHT_END_MIN)).toBe('LIGHTS UP');
|
|
});
|
|
|
|
it('holds the edges before doors and after close', () => {
|
|
expect(arcLabel(-30)).toBe(first.label);
|
|
expect(arcLabel(NIGHT_END_MIN + 30)).toBe(last.label);
|
|
expect(arcLabel(NaN)).toBe(first.label);
|
|
});
|
|
});
|
|
|
|
describe('arcIntensity', () => {
|
|
it('stays within 0..1 across and beyond the night', () => {
|
|
for (let t = -60; t <= NIGHT_END_MIN + 60; t++) {
|
|
const i = arcIntensity(t);
|
|
expect(i).toBeGreaterThanOrEqual(0);
|
|
expect(i).toBeLessThanOrEqual(1);
|
|
}
|
|
});
|
|
|
|
it('is the mean of the four voice scalars', () => {
|
|
for (const t of [0, 45, 120, 240, 345, 360]) {
|
|
const s = arcAt(t);
|
|
expect(arcIntensity(t)).toBeCloseTo((s.kick + s.hat + s.bass + s.pad) / 4, 10);
|
|
}
|
|
});
|
|
|
|
it('rises from doors to peak', () => {
|
|
let prev = -Infinity;
|
|
for (let t = 0; t <= 180; t += 10) {
|
|
const i = arcIntensity(t);
|
|
expect(i).toBeGreaterThan(prev);
|
|
prev = i;
|
|
}
|
|
expect(arcIntensity(180)).toBe(1);
|
|
expect(arcIntensity(0)).toBeLessThan(0.5);
|
|
});
|
|
|
|
it('holds flat across the peak plateau', () => {
|
|
for (let t = 180; t <= 300; t += 20) expect(arcIntensity(t)).toBeCloseTo(1, 10);
|
|
});
|
|
|
|
it('falls from the plateau to lights up', () => {
|
|
let prev = Infinity;
|
|
for (let t = 300; t <= NIGHT_END_MIN; t += 10) {
|
|
const i = arcIntensity(t);
|
|
expect(i).toBeLessThan(prev);
|
|
prev = i;
|
|
}
|
|
expect(arcIntensity(NIGHT_END_MIN)).toBeLessThan(arcIntensity(300));
|
|
});
|
|
});
|