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('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('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(); expect(harness.gainNodes).toHaveLength(8); for (const node of harness.gainNodes) { expect((node as GainNode & { disconnectCount: number }).disconnectCount).toBe(1); } expect(destination.disconnectCount).toBe(0); }); });