Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions packages/core/src/runtime/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> }
)._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();
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
12 changes: 11 additions & 1 deletion packages/core/src/runtime/webAudioTransport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<AudioBuffer | null> {
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;
Expand Down
Loading