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:
@@ -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 = [];
|
||||
|
||||
Reference in New Issue
Block a user