fix: harden adaptive audio resume and release

This commit is contained in:
2026-09-04 17:34:58 +03:00
parent 1431e65768
commit 906e9a0617
4 changed files with 43 additions and 2 deletions

View File

@@ -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';

View File

@@ -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;
},

View File

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

View File

@@ -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;