Skip to content

Commit 3ad5af3

Browse files
committed
perf(core): bound audio pre-decode to the playhead window; tear down on pagehide
The mount-time warm pass fetched every timed audio source's WHOLE file and held its decoded PCM (~23MB per stereo minute) — on every document load. In an editor that reloads the preview iframe per edit (and keeps a standby iframe), a multi-clip composition multiplied that into gigabytes and could OOM the tab. Pre-decode now warms only clips near the current time (the periodic re-run slides the window with the playhead; play() decodes exactly what it needs as before), embedders can opt a never-played document out via window.__HF_AUDIO_PREDECODE_DISABLED, and teardown (which closes the AudioContext and drops the buffer cache) also runs on pagehide — the dependable unload signal for iframes whose document is replaced.
1 parent f75042a commit 3ad5af3

2 files changed

Lines changed: 113 additions & 13 deletions

File tree

packages/core/src/runtime/init.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2739,5 +2739,72 @@ describe("initSandboxRuntimeModular", () => {
27392739
vi.unstubAllGlobals();
27402740
}
27412741
});
2742+
2743+
it("only warms clips near the playhead, and honors the embedder opt-out", async () => {
2744+
const decodeAudioData = vi.fn(async () => ({}) as AudioBuffer);
2745+
class MockAudioContext {
2746+
currentTime = 0;
2747+
state = "running";
2748+
destination = {};
2749+
decodeAudioData = decodeAudioData;
2750+
createGain() {
2751+
return { gain: { value: 1 }, connect: vi.fn(), disconnect: vi.fn() };
2752+
}
2753+
resume = vi.fn();
2754+
close = vi.fn();
2755+
}
2756+
vi.stubGlobal("AudioContext", MockAudioContext);
2757+
const fetchMock = vi.fn(async () => ({
2758+
ok: true,
2759+
arrayBuffer: async () => new ArrayBuffer(8),
2760+
}));
2761+
vi.stubGlobal("fetch", fetchMock);
2762+
2763+
try {
2764+
const root = document.createElement("div");
2765+
root.setAttribute("data-composition-id", "main");
2766+
root.setAttribute("data-root", "true");
2767+
root.setAttribute("data-start", "0");
2768+
root.setAttribute("data-duration", "600");
2769+
root.setAttribute("data-width", "1920");
2770+
root.setAttribute("data-height", "1080");
2771+
const near = document.createElement("audio");
2772+
near.setAttribute("data-start", "0");
2773+
near.setAttribute("data-duration", "5");
2774+
near.setAttribute("src", "https://media.example/near.mp3");
2775+
const far = document.createElement("audio");
2776+
far.setAttribute("data-start", "300");
2777+
far.setAttribute("data-duration", "5");
2778+
far.setAttribute("src", "https://media.example/far.mp3");
2779+
root.append(near, far);
2780+
document.body.appendChild(root);
2781+
window.__timelines = { main: createMockTimeline(600) };
2782+
2783+
initSandboxRuntimeModular();
2784+
2785+
// Warming a whole long composition costs a full-file fetch + ~23MB of
2786+
// PCM per stereo minute PER SOURCE — only playhead-proximate clips may
2787+
// pre-decode; the rest stay lazy until the playhead approaches.
2788+
await vi.waitFor(() => {
2789+
expect(fetchMock).toHaveBeenCalledTimes(1);
2790+
});
2791+
expect(String(fetchMock.mock.calls[0]?.[0])).toContain("near.mp3");
2792+
2793+
window.__hfRuntimeTeardown?.();
2794+
fetchMock.mockClear();
2795+
(
2796+
window as Window & { __HF_AUDIO_PREDECODE_DISABLED?: boolean }
2797+
).__HF_AUDIO_PREDECODE_DISABLED = true;
2798+
initSandboxRuntimeModular();
2799+
// A document that exists but is never played (e.g. a double-buffered
2800+
// preview's standby iframe) must not warm anything.
2801+
await new Promise((r) => setTimeout(r, 20));
2802+
expect(fetchMock).not.toHaveBeenCalled();
2803+
} finally {
2804+
delete (window as Window & { __HF_AUDIO_PREDECODE_DISABLED?: boolean })
2805+
.__HF_AUDIO_PREDECODE_DISABLED;
2806+
vi.unstubAllGlobals();
2807+
}
2808+
});
27422809
});
27432810
});

packages/core/src/runtime/init.ts

Lines changed: 46 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1920,25 +1920,49 @@ export function initSandboxRuntimeModular(): void {
19201920
}
19211921
};
19221922

1923-
// Decode-only warm pass over every timed audio source so WebAudio buffers are
1924-
// ready BEFORE the first play() instead of being decoded lazily inside it.
1925-
// The lazy path costs a full-file fetch+decodeAudioData per source at the
1926-
// moment the user hits play — on a freshly (re)loaded document (every editor
1927-
// edit reloads the preview iframe) that gap plays through the HTMLMedia
1928-
// fallback, heard as an audio dropout. Pre-decoding overlaps that work with
1929-
// document load. `decodeAudioElement` dedupes via its buffer cache and
1930-
// failure blacklist, so repeated calls per element are cheap map hits.
1931-
// Skipped during render capture: the producer mixes audio with ffmpeg from
1932-
// source files and never plays through WebAudio.
1923+
// Decode-only warm pass over timed audio sources NEAR THE PLAYHEAD so
1924+
// WebAudio buffers are ready BEFORE the first play() instead of being decoded
1925+
// lazily inside it. The lazy path costs a full-file fetch+decodeAudioData per
1926+
// source at the moment the user hits play — on a freshly (re)loaded document
1927+
// (every editor edit reloads the preview iframe) that gap plays through the
1928+
// HTMLMedia fallback, heard as an audio dropout. Pre-decoding overlaps that
1929+
// work with document load.
1930+
//
1931+
// BOUNDED, not exhaustive: a source's fetch pulls the WHOLE file into memory
1932+
// and the decoded PCM is ~23MB per stereo minute — warming EVERY clip of a
1933+
// long multi-clip composition on EVERY document load multiplies to gigabytes
1934+
// (an editor with a standby iframe doubles it again). So each pass only warms
1935+
// clips whose window is near the current time; the periodic re-run (every 30
1936+
// transport ticks) slides the window as the playhead moves, and play() still
1937+
// decodes whatever it needs exactly as before. `decodeAudioElement` dedupes
1938+
// via its buffer cache and failure blacklist, so repeated calls per element
1939+
// are cheap map hits.
1940+
//
1941+
// Skipped during render capture (the producer mixes audio with ffmpeg) and
1942+
// when the embedder set `__HF_AUDIO_PREDECODE_DISABLED` — the opt-out for
1943+
// documents that exist but are never played (e.g. a double-buffered editor
1944+
// preview's hidden standby iframe).
1945+
const PREDECODE_BEHIND_SECONDS = 10;
1946+
const PREDECODE_AHEAD_SECONDS = 45;
19331947
const predecodeAudioClipBuffers = () => {
19341948
if (state.tornDown || !webAudioReady) return;
19351949
if (state.nativeMediaSyncDisabled || state.webAudioMediaDisabled) return;
1936-
if ((window as Window & { __HF_RENDER_CAPTURE_MODE?: boolean }).__HF_RENDER_CAPTURE_MODE) {
1937-
return;
1938-
}
1950+
const w = window as Window & {
1951+
__HF_RENDER_CAPTURE_MODE?: boolean;
1952+
__HF_AUDIO_PREDECODE_DISABLED?: boolean;
1953+
};
1954+
if (w.__HF_RENDER_CAPTURE_MODE || w.__HF_AUDIO_PREDECODE_DISABLED) return;
1955+
const now = Math.max(0, state.currentTime || 0);
1956+
const windowStart = now - PREDECODE_BEHIND_SECONDS;
1957+
const windowEnd = now + PREDECODE_AHEAD_SECONDS;
19391958
const audioEls = document.querySelectorAll("audio[data-start]");
19401959
for (const el of audioEls) {
19411960
if (!(el instanceof HTMLMediaElement) || !el.isConnected) continue;
1961+
const start = Number.parseFloat(el.dataset.start ?? "");
1962+
if (!Number.isFinite(start)) continue;
1963+
const durAttr = Number.parseFloat(el.dataset.duration ?? "");
1964+
const end = Number.isFinite(durAttr) && durAttr > 0 ? start + durAttr : Infinity;
1965+
if (end < windowStart || start > windowEnd) continue;
19421966
void webAudio.decodeAudioElement(el);
19431967
}
19441968
};
@@ -3357,6 +3381,7 @@ export function initSandboxRuntimeModular(): void {
33573381
window.removeEventListener("beforeunload", state.beforeUnloadHandler);
33583382
state.beforeUnloadHandler = null;
33593383
}
3384+
window.removeEventListener("pagehide", teardown);
33603385
picker.disablePickMode();
33613386
for (const adapter of state.deterministicAdapters) {
33623387
if (!adapter || typeof adapter.revert !== "function") continue;
@@ -3411,4 +3436,12 @@ export function initSandboxRuntimeModular(): void {
34113436
window.__hfRuntimeTeardown = teardown;
34123437
state.beforeUnloadHandler = teardown;
34133438
window.addEventListener("beforeunload", state.beforeUnloadHandler);
3439+
// `pagehide` too: in an iframe whose document is replaced (an editor writing
3440+
// a new `srcdoc` on every edit), pagehide is the dependable unload signal.
3441+
// Without a teardown there, each discarded document keeps a live
3442+
// AudioContext + decoded-buffer cache until GC gets around to it — under
3443+
// rapid edits that transiently stacks hundreds of MB of PCM per reload.
3444+
// teardown() is idempotent (state.tornDown), so double-firing with
3445+
// beforeunload is harmless.
3446+
window.addEventListener("pagehide", teardown);
34143447
}

0 commit comments

Comments
 (0)