From 906e9a0617013ea21048170905efb487421e5c7d Mon Sep 17 00:00:00 2001 From: LucaP Date: Fri, 4 Sep 2026 17:34:58 +0300 Subject: [PATCH] fix: harden adaptive audio resume and release --- src/audio/adaptive-mixer.ts | 3 ++- src/audio/soundtrack-engine.ts | 2 +- tests/unit/adaptive-mixer.test.ts | 26 ++++++++++++++++++++++++++ tests/unit/soundtrack-engine.test.ts | 14 ++++++++++++++ 4 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/audio/adaptive-mixer.ts b/src/audio/adaptive-mixer.ts index 5c5c243..e933374 100644 --- a/src/audio/adaptive-mixer.ts +++ b/src/audio/adaptive-mixer.ts @@ -141,12 +141,13 @@ export const createAdaptiveMixer: AdaptiveMixerFactory = (context, destination) liftPhase === 'off' || liftPhase === 'exiting' ) return; + const entryWillComplete = liftPhase === 'entering'; scheduledStart = nextBeatTime( now + LIFT_EXIT_DWELL_SECONDS, transportAnchor, ); scheduledEnd = scheduledStart + MUSIC_BEAT_SECONDS; - holdParameter(driveLiftGate.gain, now); + if (!entryWillComplete) holdParameter(driveLiftGate.gain, now); driveLiftGate.gain.setValueAtTime(1, scheduledStart); driveLiftGate.gain.linearRampToValueAtTime(0, scheduledEnd); liftPhase = 'exiting'; diff --git a/src/audio/soundtrack-engine.ts b/src/audio/soundtrack-engine.ts index f708f5c..594f7dd 100644 --- a/src/audio/soundtrack-engine.ts +++ b/src/audio/soundtrack-engine.ts @@ -499,8 +499,8 @@ export function createSoundtrackEngine( async resume() { if (closed) throw new DOMException('Audio graph is closed.', 'InvalidStateError'); await context.resume(); - ensureDriveLiftLoad(); if (transportStarted) return; + ensureDriveLiftLoad(); loadPromise ??= loadAndStart(); await loadPromise; }, diff --git a/tests/unit/adaptive-mixer.test.ts b/tests/unit/adaptive-mixer.test.ts index af76820..301bbbf 100644 --- a/tests/unit/adaptive-mixer.test.ts +++ b/tests/unit/adaptive-mixer.test.ts @@ -74,6 +74,32 @@ describe('adaptive mixer', () => { .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); diff --git a/tests/unit/soundtrack-engine.test.ts b/tests/unit/soundtrack-engine.test.ts index 40426b6..5d95da1 100644 --- a/tests/unit/soundtrack-engine.test.ts +++ b/tests/unit/soundtrack-engine.test.ts @@ -298,6 +298,20 @@ describe('soundtrack engine', () => { expect(test.mixer.setDriveLiftAvailableAt).toHaveBeenCalledWith(drive.starts[0]!.at); }); + it('does not reload the attached drive lift after resuming established main playback', async () => { + const test = createEngineHarness({ + chapter: resolveChapterIntent('experience'), + drive: { energy: 0.8, source: 'physical' }, + }); + await test.graph.resume(); + await test.flushOptionalLoads(); + + await test.graph.suspend(); + await test.graph.resume(); + + expect(test.loadCalls.filter((url) => url === '/drive-lift.opus')).toHaveLength(1); + }); + it('routes drive commands immediately to texture and mixer', () => { const test = createEngineHarness(); const sample = { energy: 0.72, source: 'physical' } as const;