import { describe, expect, it } from 'vitest'; import { createAdaptiveMixer } from '@/audio/adaptive-mixer'; import { MUSIC_BEAT_SECONDS } from '@/audio/soundtrack-transport'; import { createFakeAudioHarness, type FakeAudioHarness, type FakeAudioParamRecord, } from '../helpers/fake-audio-context'; function recordFor( harness: FakeAudioHarness, parameter: AudioParam, ): FakeAudioParamRecord { const record = harness.params.find((candidate) => candidate.param === parameter); if (record === undefined) throw new Error('Missing fake AudioParam record.'); return record; } describe('adaptive mixer', () => { it('sums the dawn input after the mood controls, untouched by chapter moods', () => { const harness = createFakeAudioHarness(); const connections = new Map(); const createGain = harness.context.createGain.bind(harness.context); harness.context.createGain = () => { const node = createGain(); const connect = node.connect.bind(node) as (target: AudioNode) => AudioNode; node.connect = ((target: AudioNode) => { connections.set(node, [...(connections.get(node) ?? []), target]); return connect(target); }) as GainNode['connect']; return node; }; const mixer = createAdaptiveMixer(harness.context, harness.destination); const sum = connections.get(mixer.dawnInput)?.[0]; expect(sum).toBeDefined(); // The same summing node feeds the destination (mute, volume, pulse tap). expect(connections.get(sum)).toEqual([harness.destination]); const dawnRecord = recordFor(harness, mixer.dawnInput.gain); harness.setCurrentTime(2); mixer.setChapterIntent({ sectionId: 'contact', mood: 'resolve', atStart: false, entryPhrase: 6, generation: 1, }); expect(dawnRecord.events).toEqual([]); mixer.dispose(); expect((mixer.dawnInput as GainNode & { disconnectCount: number }).disconnectCount).toBe(1); }); it('ramps only the four approved mood controls over 1.4 seconds', () => { const harness = createFakeAudioHarness(); const mixer = createAdaptiveMixer(harness.context, harness.destination); harness.setCurrentTime(2); mixer.setChapterIntent({ sectionId: 'toolkit', mood: 'resolve', atStart: false, entryPhrase: 6, generation: 1, }); const events = harness.params.flatMap((record) => record.events); expect(events.filter((event) => event.kind === 'linear' && event.at === 3.4)) .toHaveLength(4); }); it('arms lift after sustained physical input but never for navigation', () => { const harness = createFakeAudioHarness(); const mixer = createAdaptiveMixer(harness.context, harness.destination); const liftGate = harness.gainNodes[3]!.gain; mixer.setTransportAnchor(0); mixer.setDriveLiftAvailableAt(0); harness.setCurrentTime(1); mixer.setDriveEnergy({ energy: 1, source: 'navigation' }); expect(recordFor(harness, liftGate).events) .not.toContainEqual(expect.objectContaining({ kind: 'linear', value: 1 })); mixer.setDriveEnergy({ energy: 0.7, source: 'physical' }); expect(recordFor(harness, liftGate).events).toContainEqual({ kind: 'linear', value: 1, at: 4 * MUSIC_BEAT_SECONDS, }); }); it('cancels an unsustained entry and schedules hysteretic exit', () => { const harness = createFakeAudioHarness(); const mixer = createAdaptiveMixer(harness.context, harness.destination); const liftGate = harness.gainNodes[3]!.gain; mixer.setTransportAnchor(0); mixer.setDriveLiftAvailableAt(0); harness.setCurrentTime(1); mixer.setDriveEnergy({ energy: 0.7, source: 'physical' }); harness.setCurrentTime(1.2); mixer.setDriveEnergy({ energy: 0.5, source: 'physical' }); expect(recordFor(harness, liftGate).events) .toContainEqual({ kind: 'hold', at: 1.2 }); harness.setCurrentTime(2); mixer.setDriveEnergy({ energy: 0.7, source: 'physical' }); harness.setCurrentTime(5); mixer.setDriveEnergy({ energy: 0.2, source: 'physical' }); expect(recordFor(harness, liftGate).events) .toContainEqual(expect.objectContaining({ kind: 'linear', value: 0 })); }); it('finishes an audible lift entry smoothly before scheduling its exit', () => { const harness = createFakeAudioHarness(); const mixer = createAdaptiveMixer(harness.context, harness.destination); const liftGate = harness.gainNodes[3]!.gain; const liftEvents = recordFor(harness, liftGate).events; mixer.setTransportAnchor(0); mixer.setDriveLiftAvailableAt(0); harness.setCurrentTime(1); mixer.setDriveEnergy({ energy: 0.7, source: 'physical' }); const eventsBeforeDrop = liftEvents.length; const entryMidpoint = 3.5 * MUSIC_BEAT_SECONDS; harness.setCurrentTime(entryMidpoint); mixer.setDriveEnergy({ energy: 0.2, source: 'physical' }); expect(liftEvents).toContainEqual({ kind: 'linear', value: 1, at: 4 * MUSIC_BEAT_SECONDS, }); expect(liftEvents.slice(eventsBeforeDrop)).toEqual([ { kind: 'set', value: 1, at: 5 * MUSIC_BEAT_SECONDS }, { kind: 'linear', value: 0, at: 6 * MUSIC_BEAT_SECONDS }, ]); }); it('replays cached strong drive only after a late loop source is ready', () => { const harness = createFakeAudioHarness(); const mixer = createAdaptiveMixer(harness.context, harness.destination); const liftGate = harness.gainNodes[3]!.gain; mixer.setDriveEnergy({ energy: 0.8, source: 'physical' }); mixer.setTransportAnchor(0); expect(recordFor(harness, liftGate).events) .not.toContainEqual(expect.objectContaining({ kind: 'linear', value: 1 })); harness.setCurrentTime(5); mixer.setDriveLiftAvailableAt(5.08); const entryEnd = recordFor(harness, liftGate).events.findLast( (event) => event.kind === 'linear' && event.value === 1, ); expect(entryEnd).toBeDefined(); expect(entryEnd!.at).toBeGreaterThan(5.08 + 0.45); expect(entryEnd!.at / MUSIC_BEAT_SECONDS).toBeCloseTo( Math.round(entryEnd!.at / MUSIC_BEAT_SECONDS), 8, ); }); it('disposes each owned mixer node once without disconnecting the destination', () => { const harness = createFakeAudioHarness(); const mixer = createAdaptiveMixer(harness.context, harness.destination); const destination = harness.destination as AudioNode & { disconnectCount: number }; mixer.dispose(); mixer.dispose(); // Nine with the END OF SHIFT dawn input. expect(harness.gainNodes).toHaveLength(9); for (const node of harness.gainNodes) { expect((node as GainNode & { disconnectCount: number }).disconnectCount).toBe(1); } expect(destination.disconnectCount).toBe(0); }); });