Bound decoded forward fill per chunk in streaming read loop

The inter-segment back-pressure gate matched WAV byte density but let a 4MB Opus segment (~100s at 320kbps) decode eagerly into main-process RAM, OOMing the tab with HW accel off. Drain per chunk past high-water, gated on playback start. Adds load-generation diagnostics for the double-load hypothesis.
This commit is contained in:
daniel-c-harvey
2026-06-24 19:50:33 -04:00
parent 036ee1f78e
commit aeec582957
2 changed files with 118 additions and 16 deletions
+49 -1
View File
@@ -61,6 +61,16 @@ export class StreamDecoder {
// at 4 GB by the 32-bit RIFF size field, so overflow is not a practical concern.
private totalRawBytes: number = 0;
private processedBytes: number = 0;
// Absolute count of raw bytes already DROPPED off the front of rawChunks (the memory bound).
// processedBytes is an absolute cursor into the whole logical byte stream; rawChunks no longer
// begins at stream byte 0 once consumed chunks are compacted away, so extractAlignedData walks
// from discardedBytes (the absolute position of rawChunks[0]) rather than 0. totalRawBytes and
// every offset stay absolute and unchanged — only the array's front moves. Without this, a long
// WAV (e.g. a 92-min mix ≈ 970 MB raw) accumulates its ENTIRE decoded-from body in rawChunks
// because consumed chunks were never released; Phase 21.2 bounds only the DECODED scheduler
// queue, not this raw queue — so software (HW-accel-off) playback crashed the tab on memory.
private discardedBytes: number = 0;
private totalStreamLength: number = 0;
private streamComplete: boolean = false;
private headerError: string | null = null;
@@ -94,6 +104,7 @@ export class StreamDecoder {
this.rawChunks = [];
this.totalRawBytes = 0;
this.processedBytes = 0;
this.discardedBytes = 0;
this.totalStreamLength = totalStreamLength;
this.streamComplete = false;
this.headerBytesReceived = 0;
@@ -228,6 +239,36 @@ export class StreamDecoder {
this.totalRawBytes += data.length;
}
/**
* Drop fully-consumed raw chunks off the front of rawChunks, reclaiming their bytes. A chunk is
* droppable only when its ENTIRE span lies at or before processedBytes (the decode cursor); a
* chunk that straddles the cursor still has unconsumed tail bytes a later segment will read, so
* the walk stops there. discardedBytes tracks the absolute start of rawChunks[0] so
* extractAlignedData keeps reading the correct bytes after compaction. Splicing once at the end
* (not per chunk) keeps this O(n) in the dropped count.
*
* This is the raw-side analogue of PlaybackScheduler.evictPlayedBuffers (the decoded side): both
* keep their queue bounded to roughly the live window, so a long stream never balloons memory.
*/
private releaseConsumedChunks(): void {
let dropCount = 0;
let frontPos = this.discardedBytes;
for (const chunk of this.rawChunks) {
// Drop only when the whole chunk is behind the cursor (end <= processedBytes). A chunk
// ending exactly at processedBytes has every byte consumed and is safe to drop.
if (frontPos + chunk.length <= this.processedBytes) {
frontPos += chunk.length;
dropCount++;
} else {
break; // this chunk straddles the cursor (or is ahead) — stop.
}
}
if (dropCount > 0) {
this.rawChunks.splice(0, dropCount);
this.discardedBytes = frontPos;
}
}
/**
* Try to decode the next segment of audio.
*
@@ -276,6 +317,9 @@ export class StreamDecoder {
// Advance only after a successful decode so a thrown timeout/decode
// failure does not silently drop the segment.
this.processedBytes += alignedSize;
// Release fully-consumed raw chunks now that the cursor has moved past them. This is the
// memory bound: without it rawChunks retains the whole stream body (the OOM on long WAVs).
this.releaseConsumedChunks();
return { buffer, duration: buffer.duration };
} catch (error) {
// Re-throw typed errors so the outer drain loop in processChunk /
@@ -339,7 +383,9 @@ export class StreamDecoder {
let extractedOffset = 0;
let remaining = size;
let streamPosition = this.processedBytes;
let currentPos = 0;
// rawChunks[0] now begins at absolute stream byte `discardedBytes` (front-compaction has
// dropped everything before it), so the walk starts there, not at 0.
let currentPos = this.discardedBytes;
for (const chunk of this.rawChunks) {
if (remaining <= 0) break;
@@ -473,6 +519,7 @@ export class StreamDecoder {
this.rawChunks = [];
this.totalRawBytes = 0;
this.processedBytes = 0;
this.discardedBytes = 0;
this.totalStreamLength = 0;
this.streamComplete = false;
this.headerBytesReceived = 0;
@@ -501,6 +548,7 @@ export class StreamDecoder {
this.rawChunks = [];
this.totalRawBytes = 0;
this.processedBytes = 0;
this.discardedBytes = 0;
this.streamComplete = false;
this.headerBytesReceived = 0;
this.headerSearchChunks = [];