@@ -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