not-tonight/tests/arc.test.ts
type-two 1a66c602fd LANE-JUICE2: ear-pass mix desk, ambience beds, night arc in the engine
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>
2026-07-19 21:47:53 +10:00

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