Skip to content

Commit b38f902

Browse files
committed
fix(core): coalesce in-flight audio decodes; cap decodable source size
Two independent OOM paths in the WebAudio decode pipeline, both hit by a composition whose audio rides inside large camera-video containers: - No in-flight dedup: decoding a long source takes seconds, while the warm pass / play() / rate changes may re-request the same src many times a second. Each re-request launched ANOTHER whole-file fetch — dozens of concurrent copies of the same source could stack up before the first decode ever reached the buffer cache. decodeAudioElement now coalesces concurrent requests per src onto one fetch+decode promise. - No size bound: WebAudio decode requires the ENTIRE file in an ArrayBuffer. Sources beyond 96MB are now permanently skipped for the session (content-length short-circuit before any bytes buffer, plus a capped stream read when length is undeclared) and keep playing through the streamed HTMLMediaElement path instead.
1 parent 3ad5af3 commit b38f902

2 files changed

Lines changed: 156 additions & 2 deletions

File tree

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

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,6 +471,73 @@ describe("WebAudioTransport", () => {
471471
vi.unstubAllGlobals();
472472
});
473473

474+
it("coalesces concurrent decodes of the same src onto ONE fetch", async () => {
475+
const transport = transportWithDecode(async () => ({}) as AudioBuffer);
476+
let release: (() => void) | null = null;
477+
const gate = new Promise<void>((r) => {
478+
release = r;
479+
});
480+
const fetchMock = vi.fn(async () => {
481+
await gate;
482+
return { ok: true, arrayBuffer: async () => new ArrayBuffer(8) };
483+
});
484+
vi.stubGlobal("fetch", fetchMock);
485+
486+
// The periodic warm pass / play / rate changes can all request the same
487+
// src while a long fetch+decode is still in flight — without coalescing,
488+
// each launched ANOTHER whole-file fetch (the OOM amplifier).
489+
const first = transport.decodeAudioElement(el("long.mp4"));
490+
const second = transport.decodeAudioElement(el("long.mp4"));
491+
release!();
492+
const [a, b] = await Promise.all([first, second]);
493+
expect(a).not.toBeNull();
494+
expect(b).toBe(a);
495+
expect(fetchMock).toHaveBeenCalledTimes(1);
496+
vi.unstubAllGlobals();
497+
});
498+
499+
it("skips oversized sources via content-length and never re-fetches them", async () => {
500+
const decode = vi.fn(async () => ({}) as AudioBuffer);
501+
const transport = transportWithDecode(decode);
502+
const fetchMock = vi.fn(async () => ({
503+
ok: true,
504+
headers: { get: () => String(200 * 1024 * 1024) },
505+
body: { cancel: async () => undefined },
506+
arrayBuffer: async () => new ArrayBuffer(8),
507+
}));
508+
vi.stubGlobal("fetch", fetchMock);
509+
510+
expect(await transport.decodeAudioElement(el("huge-camera.mp4"))).toBeNull();
511+
expect(decode).not.toHaveBeenCalled();
512+
// Permanent per-session skip — the element keeps its streamed fallback.
513+
expect(await transport.decodeAudioElement(el("huge-camera.mp4"))).toBeNull();
514+
expect(fetchMock).toHaveBeenCalledTimes(1);
515+
vi.unstubAllGlobals();
516+
});
517+
518+
it("caps an unbounded streamed body instead of buffering it all", async () => {
519+
const transport = transportWithDecode(async () => ({}) as AudioBuffer);
520+
// Chunks report huge byteLengths without allocating them — the cap logic
521+
// only inspects sizes until it aborts.
522+
const chunk = { byteLength: 60 * 1024 * 1024 } as Uint8Array;
523+
let reads = 0;
524+
const fetchMock = vi.fn(async () => ({
525+
ok: true,
526+
headers: { get: () => null },
527+
body: {
528+
getReader: () => ({
529+
read: async () => (reads++ < 3 ? { done: false, value: chunk } : { done: true }),
530+
cancel: async () => undefined,
531+
}),
532+
},
533+
}));
534+
vi.stubGlobal("fetch", fetchMock);
535+
536+
expect(await transport.decodeAudioElement(el("no-length.mp4"))).toBeNull();
537+
expect(reads).toBeLessThanOrEqual(2); // aborted once past the cap
538+
vi.unstubAllGlobals();
539+
});
540+
474541
it("first fetch per src uses the HTTP cache; only a RETRY bypasses it with no-store", async () => {
475542
const transport = transportWithDecode(async () => ({}) as AudioBuffer);
476543
const fetchMock = vi

packages/core/src/runtime/webAudioTransport.ts

Lines changed: 89 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,41 @@ const EDGE_RAMP_SECONDS = 0.008;
8080
const STOP_FADE_TIME_CONSTANT = 0.004;
8181
const 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

Comments
 (0)