Phase 21.2: back-pressure to bound the unplayed decoded region
Shared scheduler fill signal (forward water-marks + hard byte cap) pauses the C# read loop above high-water and, for Opus, stops the demux/decode feed so WebCodecs queues stay near-empty. Routes through the existing cancellation discipline; releases the latch on clear/seek.
This commit is contained in:
@@ -39,6 +39,17 @@ const MAX_PACKET_FRAMES = 5760;
|
||||
export class OpusStreamDecoder implements IStreamingDecoder {
|
||||
private readonly contextManager: AudioContextManager;
|
||||
private readonly sidecar: OpusSeekData;
|
||||
// Phase 21.2b back-pressure hook: returns true when the shared scheduler is full (forward fill
|
||||
// over high-water). While full, push() stashes raw bytes WITHOUT demuxing/decoding so the
|
||||
// WebCodecs decode queue and decodedQueue stay near-empty behind a throttled socket (OQ7).
|
||||
// Null = no back-pressure (e.g. unit tests), in which case the decoder feeds eagerly as before.
|
||||
private readonly isSchedulerFull: (() => boolean) | null;
|
||||
|
||||
// Raw bytes received while the scheduler was full, held undemuxed until it drains. The C# read
|
||||
// loop also pauses above high-water, so this stash is bounded to at most the in-flight chunks
|
||||
// between the loop reading the productionPaused flag and actually stopping — a handful of KB,
|
||||
// not a decode-ahead. Drained (demuxed + decoded) on the next push once below high-water.
|
||||
private pendingBytes: Uint8Array[] = [];
|
||||
|
||||
private demuxer = new OggDemuxer();
|
||||
private decoder: AudioDecoder | null = null;
|
||||
@@ -66,9 +77,13 @@ export class OpusStreamDecoder implements IStreamingDecoder {
|
||||
private emittedFrames = 0;
|
||||
private readonly totalFrames: number;
|
||||
|
||||
constructor(contextManager: AudioContextManager, sidecar: OpusSeekData) {
|
||||
constructor(
|
||||
contextManager: AudioContextManager,
|
||||
sidecar: OpusSeekData,
|
||||
isSchedulerFull: (() => boolean) | null = null) {
|
||||
this.contextManager = contextManager;
|
||||
this.sidecar = sidecar;
|
||||
this.isSchedulerFull = isSchedulerFull;
|
||||
this.totalFrames = sidecar.totalDurationSeconds > 0
|
||||
? Math.round(sidecar.totalDurationSeconds * OPUS_SAMPLE_RATE)
|
||||
: Number.POSITIVE_INFINITY;
|
||||
@@ -136,17 +151,59 @@ export class OpusStreamDecoder implements IStreamingDecoder {
|
||||
|
||||
async push(chunk: Uint8Array): Promise<AudioBuffer[]> {
|
||||
if (this.fatalError) return [];
|
||||
|
||||
// 21.2b back-pressure: while the scheduler is full, do NOT demux/decode ahead. Stash the
|
||||
// raw bytes in arrival order and return nothing — the WebCodecs decode queue and
|
||||
// decodedQueue stay near-empty (OQ7). The bytes are demuxed/decoded on a later push once
|
||||
// the scheduler has drained below low-water, in exactly the order received (Ogg demux is
|
||||
// order-sensitive). configure() is deferred too — no need to spin up the decoder while
|
||||
// throttled. The C# loop also stops reading above high-water, so the stash stays small.
|
||||
if (this.isSchedulerFull?.()) {
|
||||
this.pendingBytes.push(chunk);
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!(await this.ensureConfigured())) return [];
|
||||
|
||||
const packets = this.demuxer.push(chunk);
|
||||
this.decodePackets(packets);
|
||||
// Drained below high-water: replay any stashed bytes first (preserving stream order), then
|
||||
// the new chunk, through the demuxer as one contiguous feed.
|
||||
const out: AudioBuffer[] = [];
|
||||
if (this.pendingBytes.length > 0) {
|
||||
const stashed = this.pendingBytes;
|
||||
this.pendingBytes = [];
|
||||
for (const bytes of stashed) {
|
||||
this.decodePackets(this.demuxer.push(bytes));
|
||||
}
|
||||
}
|
||||
|
||||
this.decodePackets(this.demuxer.push(chunk));
|
||||
// Wait until the WebCodecs decoder has processed the queued packets before draining.
|
||||
await this.yieldToDecoder();
|
||||
return this.drainDecoded();
|
||||
out.push(...this.drainDecoded());
|
||||
return out;
|
||||
}
|
||||
|
||||
async complete(): Promise<AudioBuffer[]> {
|
||||
if (this.fatalError || !this.decoder || this.decoder.state !== 'configured') {
|
||||
if (this.fatalError) {
|
||||
return this.drainDecoded();
|
||||
}
|
||||
|
||||
// End-of-stream may arrive while still throttled with bytes stashed (e.g. a short track
|
||||
// that finished sending before the scheduler drained). Configure if needed and replay the
|
||||
// stash so the tail is decoded before flush — otherwise the final seconds would be lost.
|
||||
if (this.pendingBytes.length > 0) {
|
||||
if (await this.ensureConfigured()) {
|
||||
const stashed = this.pendingBytes;
|
||||
this.pendingBytes = [];
|
||||
for (const bytes of stashed) {
|
||||
this.decodePackets(this.demuxer.push(bytes));
|
||||
}
|
||||
} else {
|
||||
this.pendingBytes = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.decoder || this.decoder.state !== 'configured') {
|
||||
return this.drainDecoded();
|
||||
}
|
||||
try {
|
||||
@@ -184,6 +241,10 @@ export class OpusStreamDecoder implements IStreamingDecoder {
|
||||
// continuation mode (treat the first page's packets as audio — no setup pages in a 206 body).
|
||||
this.demuxer.reset(true);
|
||||
this.decodedQueue = [];
|
||||
// Drop any bytes stashed by back-pressure: they belong to the PRE-seek stream position and
|
||||
// must never be replayed against the post-seek (range-continuation) demux state (C6 — no
|
||||
// stale feed racing the reset).
|
||||
this.pendingBytes = [];
|
||||
this.emittedFrames = 0; // post-seek buffers are positioned by the scheduler's playbackOffset
|
||||
// Arm the lead trim: skip enough decoded frames to land at targetTimeSeconds, not at
|
||||
// landingTimeSeconds (the page start). Clamp to ≥0 to guard against floating-point rounding.
|
||||
@@ -199,6 +260,7 @@ export class OpusStreamDecoder implements IStreamingDecoder {
|
||||
try { d.close(); } catch { /* already closed */ }
|
||||
}
|
||||
this.decodedQueue = [];
|
||||
this.pendingBytes = [];
|
||||
if (this.decoder && this.decoder.state !== 'closed') {
|
||||
try { this.decoder.close(); } catch { /* already closed */ }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user