diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index ff1177d1a4..9bfc799319 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 e4042fe6fa..a72bf32b51 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -5351,7 +5351,9 @@ 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); + 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 77b17d880b..d95ed3134d 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;