From bc1811f81e4ba0d242f12238eccb78a401d9d052 Mon Sep 17 00:00:00 2001 From: Pavel Date: Sun, 13 Sep 2026 13:29:32 +0300 Subject: [PATCH] fix: keep scheduled audio when a clip ends during an export During an export the OfflineAudioContext renders behind the frame loop, by up to a second of samples. When a clip ended, forwardAudioDecoder called AudioDecoder.reset(), which stops every scheduled AudioBufferSourceNode at the context's current time. That cut the part of the clip the audio thread had not rendered yet: 40-1000 ms per clip in audio-only exports, 10-60 ms in MP4 exports. In offline modes the decoder now only releases its decoding state when the clip ends. The scheduled buffers already end at the clip's trimEnd, so they stop at the clip's out point on their own. Realtime playback still stops immediately. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AybThRmRHP14dmvwixan1h --- packages/runtime/src/media/audio.ts | 19 ++++++++++++++----- packages/runtime/src/systems/playback.ts | 7 +++++++ 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/packages/runtime/src/media/audio.ts b/packages/runtime/src/media/audio.ts index df05a8dd..b4a56112 100644 --- a/packages/runtime/src/media/audio.ts +++ b/packages/runtime/src/media/audio.ts @@ -228,17 +228,26 @@ export class AudioDecoder { } public reset() { + this.release(); + + for (const node of this.audioNodes) { + node.stop(); + } + this.audioNodes.clear(); + } + + /** + * Drops the decoding state but lets buffers that are already scheduled play out. + * `renderData` truncates every scheduled buffer at the clip's `trimEnd`, so the + * nodes stop on their own at the clip's out point and `onended` removes them. + */ + public release() { if (!this.iterator) return; this.iterator?.return(); this.iterator = null; this.firstBuffer = null; this.lastBuffer = null; - - for (const node of this.audioNodes) { - node.stop(); - } - this.audioNodes.clear(); } } diff --git a/packages/runtime/src/systems/playback.ts b/packages/runtime/src/systems/playback.ts index 8e963368..3cc0c0e3 100644 --- a/packages/runtime/src/systems/playback.ts +++ b/packages/runtime/src/systems/playback.ts @@ -196,6 +196,13 @@ function forwardAudioDecoder(world: World, scene: Entity, entity: Entity, audioS relativeDelay: (origin / fps) + audioDelay, }); framePromises(world)?.push(playPromise); + } else if (world.get(Mode)?.value !== 'realtime') { + // Offline rendering: the OfflineAudioContext runs behind the frame loop (the + // encoder lets it lag by up to a second of samples). Stopping the nodes here would + // cut the part of the clip the audio thread has not rendered yet, so every clip + // that ends before the scene does would lose its tail in the export. The scheduled + // buffers already end at the clip's out point, so let them finish. + decoder.release(); } else { decoder.reset(); }