@@ -80,6 +80,41 @@ const EDGE_RAMP_SECONDS = 0.008;
8080const STOP_FADE_TIME_CONSTANT = 0.004 ;
8181const STOP_FADE_TAIL_SECONDS = 0.02 ;
8282
83+ /**
84+ * Decoding a source through WebAudio requires fetching the ENTIRE file into an
85+ * ArrayBuffer first — for audio that rides inside a large camera/screen-capture
86+ * video container, that is hundreds of MB per source and can OOM the tab
87+ * before a single decoded sample exists. Sources whose bytes exceed this cap
88+ * are permanently skipped for the session (they keep playing through the
89+ * streamed HTMLMediaElement path instead of the sample-accurate transport).
90+ */
91+ const MAX_DECODE_SOURCE_BYTES = 96 * 1024 * 1024 ;
92+
93+ /** Read a response body while enforcing MAX_DECODE_SOURCE_BYTES; null = over cap. */
94+ async function readBodyCapped ( body : ReadableStream < Uint8Array > ) : Promise < ArrayBuffer | null > {
95+ const reader = body . getReader ( ) ;
96+ const chunks : Uint8Array [ ] = [ ] ;
97+ let total = 0 ;
98+ for ( ; ; ) {
99+ const { done, value } = await reader . read ( ) ;
100+ if ( done ) break ;
101+ if ( ! value ) continue ;
102+ total += value . byteLength ;
103+ if ( total > MAX_DECODE_SOURCE_BYTES ) {
104+ void reader . cancel ( ) . catch ( ( ) => undefined ) ;
105+ return null ;
106+ }
107+ chunks . push ( value ) ;
108+ }
109+ const out = new Uint8Array ( total ) ;
110+ let offset = 0 ;
111+ for ( const chunk of chunks ) {
112+ out . set ( chunk , offset ) ;
113+ offset += chunk . byteLength ;
114+ }
115+ return out . buffer ;
116+ }
117+
83118/**
84119 * Anti-pop gain automation: a hard cut starts/stops mid-waveform, which is an
85120 * audible click. Ramp gain over EDGE_RAMP_SECONDS at the source's effective
@@ -122,6 +157,13 @@ export class WebAudioTransport {
122157 private _bufferCache = new Map < string , AudioBuffer > ( ) ;
123158 private _failedSrcs = new Set < string > ( ) ;
124159 private _fetchAttemptedSrcs = new Set < string > ( ) ;
160+ // In-flight decode per src. Decoding a long source takes SECONDS (full-file
161+ // fetch + decodeAudioData) while callers — the periodic warm pass, play(),
162+ // rate changes — may re-request the same src many times a second. Without
163+ // this map each re-request launched ANOTHER whole-file fetch: dozens of
164+ // concurrent copies of the same source stacked up in memory and could OOM
165+ // the tab before the first decode ever landed in `_bufferCache`.
166+ private _pendingDecodes = new Map < string , Promise < AudioBuffer | null > > ( ) ;
125167 private _activeSources : ScheduledSource [ ] = [ ] ;
126168 private _masterGain : GainNode | null = null ;
127169 // Composition-time reference frame: at AudioContext time `_rateAnchorCtx`,
@@ -160,8 +202,21 @@ export class WebAudioTransport {
160202 if ( this . _failedSrcs . has ( src ) ) return null ;
161203 if ( ! this . _ctx ) return null ;
162204
205+ // Coalesce concurrent requests for the same src onto one fetch+decode.
206+ const pending = this . _pendingDecodes . get ( src ) ;
207+ if ( pending ) return pending ;
208+ const task = this . _decodeSrc ( src ) ;
209+ this . _pendingDecodes . set ( src , task ) ;
210+ try {
211+ return await task ;
212+ } finally {
213+ this . _pendingDecodes . delete ( src ) ;
214+ }
215+ }
216+
217+ private async _decodeSrc ( src : string ) : Promise < AudioBuffer | null > {
163218 const arrayBuffer = await this . _fetchAudioBytes ( src ) ;
164- if ( ! arrayBuffer ) return null ;
219+ if ( ! arrayBuffer || ! this . _ctx ) return null ;
165220
166221 // A decode failure means the bytes themselves are unusable (corrupt or an
167222 // unsupported codec) — that IS permanent, so blacklist to avoid re-decoding
@@ -199,13 +254,44 @@ export class WebAudioTransport {
199254 swallow ( "webAudioTransport.fetch" , new Error ( `${ response . status } ${ src } ` ) ) ;
200255 return null ;
201256 }
202- return await response . arrayBuffer ( ) ;
257+ return await this . _readResponseCapped ( response , src ) ;
203258 } catch ( err ) {
204259 swallow ( "webAudioTransport.fetch" , err ) ;
205260 return null ;
206261 }
207262 }
208263
264+ /** Permanent per-session skip for a source too large to decode; the element
265+ * keeps playing through the streamed HTMLMediaElement path. */
266+ private _markOversized ( src : string , size : string ) : null {
267+ this . _failedSrcs . add ( src ) ;
268+ swallow ( "webAudioTransport.oversize" , new Error ( `${ size } ${ src } ` ) ) ;
269+ return null ;
270+ }
271+
272+ /**
273+ * Enforce MAX_DECODE_SOURCE_BYTES while reading a response. Declared length
274+ * short-circuits before any bytes buffer; otherwise the capped stream read
275+ * bounds the damage. Optional-chained so environments whose fetch shim lacks
276+ * headers/body (tests, exotic embedders) use the plain arrayBuffer path.
277+ */
278+ private async _readResponseCapped ( response : Response , src : string ) : Promise < ArrayBuffer | null > {
279+ const declared = Number ( response . headers ?. get ?.( "content-length" ) ?? "" ) ;
280+ if ( Number . isFinite ( declared ) && declared > MAX_DECODE_SOURCE_BYTES ) {
281+ void response . body ?. cancel ?.( ) . catch ( ( ) => undefined ) ;
282+ return this . _markOversized ( src , `${ declared } B` ) ;
283+ }
284+ if ( response . body ?. getReader ) {
285+ const capped = await readBodyCapped ( response . body ) ;
286+ return capped ?? this . _markOversized ( src , `>${ MAX_DECODE_SOURCE_BYTES } B` ) ;
287+ }
288+ const buf = await response . arrayBuffer ( ) ;
289+ if ( buf . byteLength > MAX_DECODE_SOURCE_BYTES ) {
290+ return this . _markOversized ( src , `${ buf . byteLength } B` ) ;
291+ }
292+ return buf ;
293+ }
294+
209295 startGeneration ( ) : number {
210296 this . _playGeneration += 1 ;
211297 return this . _playGeneration ;
@@ -407,6 +493,7 @@ export class WebAudioTransport {
407493 this . stopAll ( ) ;
408494 this . _bufferCache . clear ( ) ;
409495 this . _failedSrcs . clear ( ) ;
496+ this . _pendingDecodes . clear ( ) ;
410497 if ( this . _ctx ) {
411498 try {
412499 void this . _ctx . close ( ) ;
0 commit comments