From 9d4cd7bdd2774d3ef742de107dd6f4890c7574d7 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 23 Oct 2026 23:33:05 -0400 Subject: [PATCH 1/2] fix(core): release decoded preview audio once no clip uses its source The preview transport cached every decoded audio buffer by source until the player was destroyed. Each scheduling pass now drops cached buffers whose source no clip in the document still uses (scene edits, swapped sources), and keeps the rest so playback never re-decodes a clip still in the film. --- packages/core/src/runtime/init.test.ts | 55 +++++++++++++++++++ packages/core/src/runtime/init.ts | 5 +- .../core/src/runtime/webAudioTransport.ts | 12 +++- 3 files changed, 70 insertions(+), 2 deletions(-) diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index ff1177d1a48..9bfc7993194 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -5947,6 +5947,61 @@ describe("initSandboxRuntimeModular", () => { }); }); + describe("decoded audio across edits", () => { + useMockAudioContext(); + afterEach(() => vi.unstubAllGlobals()); + + it("releases buffers for sources the film no longer uses and keeps the rest without re-decoding", async () => { + const first = mountAudio("https://cdn.example.com/clip-0.mp3"); + const audios = [first]; + for (let i = 1; i < 8; i++) { + const audio = document.createElement("audio"); + audio.setAttribute("data-start", String(i)); + audio.setAttribute("data-duration", "1"); + audio.setAttribute("src", `https://cdn.example.com/clip-${i}.mp3`); + audio.load = () => {}; + audio.play = vi.fn(() => Promise.resolve()); + first.parentElement!.appendChild(audio); + audios.push(audio); + } + (AudioContext.prototype as unknown as { decodeAudioData: unknown }).decodeAudioData = + async () => ({}) as AudioBuffer; + const fetchMock = vi.fn(async () => ({ + ok: true, + arrayBuffer: async () => new ArrayBuffer(8), + })); + vi.stubGlobal("fetch", fetchMock); + vi.spyOn(console, "info").mockImplementation(() => {}); + vi.spyOn(WebAudioTransport.prototype, "schedulePlayback").mockResolvedValue(null); + const decode = vi.spyOn(WebAudioTransport.prototype, "decodeAudioElement"); + const settled = async (calls: number) => { + await vi.waitFor(() => expect(decode).toHaveBeenCalledTimes(calls)); + await Promise.all(decode.mock.results.map((r) => r.value)); + }; + const cached = () => + [ + ...( + decode.mock.contexts[0] as unknown as { _bufferCache: Map } + )._bufferCache.keys(), + ].sort(); + + await startPlayback(); + await settled(8); + expect(cached()).toHaveLength(8); + + // An edit swaps the second half of the clips to new sources. + window.__player?.pause(); + audios + .slice(4) + .forEach((a, i) => a.setAttribute("src", `https://cdn.example.com/edit-${i}.mp3`)); + window.__player?.play(); + await settled(16); + + expect(cached()).toEqual(audios.map((a) => a.getAttribute("src")).sort()); + expect(fetchMock).toHaveBeenCalledTimes(12); + }); + }); + // `el.volume` stops at 1, so a video's authored boost can only be carried by a Web Audio gain. describe("a video's sound above unity", () => { const ctx = useMockAudioContext(); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index e4042fe6fa9..c86780fba2d 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -5351,7 +5351,10 @@ export function initSandboxRuntimeModular(): void { const scheduleWebAudioForActiveClips = () => { if (state.nativeMediaSyncDisabled || state.webAudioMediaDisabled) return; const gen = webAudio.startGeneration(); - for (const rawEl of webAudioMediaIn(document)) { + const clips = webAudioMediaIn(document); + // This pass is the only decoder, so a source it no longer visits (edited, swapped out) is done. + webAudio.releaseDecodedExcept(clips); + for (const rawEl of clips) { if (isSilencedByHidden(rawEl)) continue; const compStart = resolveAbsoluteMediaStartSeconds(rawEl); if (!Number.isFinite(compStart)) continue; diff --git a/packages/core/src/runtime/webAudioTransport.ts b/packages/core/src/runtime/webAudioTransport.ts index 77b17d880ba..d95ed3134d1 100644 --- a/packages/core/src/runtime/webAudioTransport.ts +++ b/packages/core/src/runtime/webAudioTransport.ts @@ -145,6 +145,8 @@ function isBufferSource( return source.sourceKind === "buffer"; } +const decodeKey = (el: HTMLMediaElement): string | null => el.currentSrc || el.getAttribute("src"); + // A pause to scrub or a reschedule mid-play keeps the output awake; a real idle lets it sleep. const IDLE_SUSPEND_MS = 10000; @@ -260,8 +262,16 @@ export class WebAudioTransport { return this._rateAnchorComp + (this._ctx.currentTime - this._rateAnchorCtx) * this._rate; } + /** Drops decoded audio no listed element still points at. A playing source holds its own buffer. */ + releaseDecodedExcept(els: readonly HTMLMediaElement[]): void { + const live = new Set(els.map(decodeKey)); + for (const src of [...this._bufferCache.keys()]) { + if (!live.has(src)) this._bufferCache.delete(src); + } + } + async decodeAudioElement(el: HTMLMediaElement): Promise { - const src = el.currentSrc || el.getAttribute("src"); + const src = decodeKey(el); if (!src) return null; if (this._bufferCache.has(src)) return this._bufferCache.get(src)!; if (this._failedSrcs.has(src)) return null; From bb4b8df97f6d031ac5b715ad80a39b29af45ce99 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 23 Oct 2026 23:54:11 -0400 Subject: [PATCH 2/2] fix(core): drop the comment the comment-share ratchet rejects --- packages/core/src/runtime/init.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index c86780fba2d..a72bf32b513 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -5352,7 +5352,6 @@ export function initSandboxRuntimeModular(): void { if (state.nativeMediaSyncDisabled || state.webAudioMediaDisabled) return; const gen = webAudio.startGeneration(); const clips = webAudioMediaIn(document); - // This pass is the only decoder, so a source it no longer visits (edited, swapped out) is done. webAudio.releaseDecodedExcept(clips); for (const rawEl of clips) { if (isSilencedByHidden(rawEl)) continue;