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