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:
@@ -36,6 +36,32 @@ import { AudioContextManager } from './AudioContextManager.js';
|
||||
*/
|
||||
const DEFAULT_BACK_RETAIN_SECONDS = 10;
|
||||
|
||||
/**
|
||||
* Forward back-pressure water-marks (Phase 21.2 — the bound on the *unplayed* region).
|
||||
*
|
||||
* The single back-pressure signal is the scheduler's decoded forward lookahead: how many
|
||||
* seconds of decoded audio sit AHEAD of the playhead (OQ7). Production (the C# read loop and,
|
||||
* for Opus, the demux/decode feed) pauses above the high-water mark and resumes below the
|
||||
* low-water mark — classic hysteresis so the two producers do not chatter on/off per chunk.
|
||||
*
|
||||
* Provisional time-based defaults (OQ1 — 21.4 tunes them):
|
||||
* - HIGH (30 s): the most decoded lookahead we hold ahead of the playhead before throttling.
|
||||
* Comfortably above the playback-start minimum (6 buffers ≈ a second or two), so C2 holds —
|
||||
* first audio never waits on a throttle (the high-water is reached only well after playback
|
||||
* is already running).
|
||||
* - LOW (15 s): resume producing here. Kept generous so the forward fill never drains to the
|
||||
* ~500 ms scheduler lookahead under normal network jitter (AC3 — no starvation).
|
||||
*
|
||||
* OQ3 hard memory ceiling: an absolute byte cap on total decoded float held, independent of the
|
||||
* time window. This is the guard-rail that makes "1 GB never OOMs" a guarantee rather than a
|
||||
* tuning hope — if a pathological stream packs an unusual amount of decoded audio into the time
|
||||
* window, the byte cap still pauses production. Estimated as channels × frames × 4 bytes (f32).
|
||||
*/
|
||||
const DEFAULT_FORWARD_HIGH_WATER_SECONDS = 30;
|
||||
const DEFAULT_FORWARD_LOW_WATER_SECONDS = 15;
|
||||
const DEFAULT_MAX_DECODED_BYTES = 96 * 1024 * 1024; // ~96 MB of decoded float PCM
|
||||
const BYTES_PER_FLOAT_SAMPLE = 4;
|
||||
|
||||
interface ScheduledSource {
|
||||
source: AudioBufferSourceNode;
|
||||
bufferIndex: number;
|
||||
@@ -66,6 +92,19 @@ export class PlaybackScheduler {
|
||||
// 21.2 will drive this from the window policy. Not a hardcoded eviction decision.
|
||||
private backRetainSeconds: number = DEFAULT_BACK_RETAIN_SECONDS;
|
||||
|
||||
// Forward back-pressure water-marks + the OQ3 hard byte ceiling (Phase 21.2). This is the
|
||||
// single shared window policy (OQ6): both producers read isProductionPaused() and honor it
|
||||
// in their own way — the C# read loop stops ReadAsync, the Opus feed stops demux/decode.
|
||||
private forwardHighWaterSeconds: number = DEFAULT_FORWARD_HIGH_WATER_SECONDS;
|
||||
private forwardLowWaterSeconds: number = DEFAULT_FORWARD_LOW_WATER_SECONDS;
|
||||
private maxDecodedBytes: number = DEFAULT_MAX_DECODED_BYTES;
|
||||
|
||||
// Hysteresis latch for the production pause. Once forward fill crosses the high-water mark we
|
||||
// stay paused until it drains below the low-water mark, so the two producers do not flap
|
||||
// on/off around a single threshold (and a paused producer does not resume for one chunk only
|
||||
// to re-pause immediately). False until first crossing; flips on the band edges.
|
||||
private productionPaused_: boolean = false;
|
||||
|
||||
// Callbacks
|
||||
public onPlaybackEnded: (() => void) | null = null;
|
||||
|
||||
@@ -132,6 +171,68 @@ export class PlaybackScheduler {
|
||||
this.backRetainSeconds = Math.max(0, seconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the forward back-pressure water-marks (seconds of decoded lookahead) and the OQ3
|
||||
* hard byte ceiling. Provisional config seam — 21.4 tunes the numbers. Low is clamped below
|
||||
* high so the hysteresis band is always valid; non-positive byte cap disables the OQ3 guard.
|
||||
*/
|
||||
setForwardWindow(highWaterSeconds: number, lowWaterSeconds: number, maxDecodedBytes: number): void {
|
||||
this.forwardHighWaterSeconds = Math.max(0, highWaterSeconds);
|
||||
this.forwardLowWaterSeconds = Math.max(0, Math.min(lowWaterSeconds, this.forwardHighWaterSeconds));
|
||||
this.maxDecodedBytes = maxDecodedBytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seconds of decoded audio sitting AHEAD of the current playhead — the forward fill. This is
|
||||
* the single back-pressure signal (OQ7): the absolute end time of the last decoded buffer
|
||||
* minus the current playback position. Never negative (clamped at 0 when the playhead has
|
||||
* caught up to or passed the decoded tail).
|
||||
*/
|
||||
getForwardLookaheadSeconds(): number {
|
||||
const decodedEnd = this.getTotalDuration() + this.playbackOffset;
|
||||
return Math.max(0, decodedEnd - this.getCurrentPosition());
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimated bytes of decoded float PCM currently retained (OQ3 input). Web Audio AudioBuffers
|
||||
* are 32-bit float per sample per channel; frames = duration × sampleRate. Summed across the
|
||||
* retained buffers only — evicted buffers are already reclaimed, so this tracks the live
|
||||
* footprint, not the whole track.
|
||||
*/
|
||||
getDecodedByteEstimate(): number {
|
||||
let bytes = 0;
|
||||
for (const b of this.buffers) {
|
||||
bytes += b.length * b.numberOfChannels * BYTES_PER_FLOAT_SAMPLE;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* The single shared production-pause decision (Phase 21.2, OQ6/OQ7). Both producers — the C#
|
||||
* read loop (21.2a) and the Opus demux/decode feed (21.2b) — call this and stop producing
|
||||
* while it returns true. Hysteresis: pause when forward lookahead exceeds the high-water mark
|
||||
* OR the decoded byte estimate exceeds the OQ3 ceiling; resume only once forward lookahead has
|
||||
* drained below the low-water mark AND the byte estimate is back under the ceiling. The
|
||||
* byte-ceiling test has no separate low-water band — it is the hard guard rail, so it releases
|
||||
* as soon as eviction brings the footprint back under the cap.
|
||||
*/
|
||||
isProductionPaused(): boolean {
|
||||
const lookahead = this.getForwardLookaheadSeconds();
|
||||
const overByteCeiling = this.maxDecodedBytes > 0 && this.getDecodedByteEstimate() > this.maxDecodedBytes;
|
||||
|
||||
if (this.productionPaused_) {
|
||||
// Stay paused until BOTH the time window has drained below low-water AND the byte
|
||||
// footprint is back under the ceiling.
|
||||
if (lookahead <= this.forwardLowWaterSeconds && !overByteCeiling) {
|
||||
this.productionPaused_ = false;
|
||||
}
|
||||
} else if (lookahead >= this.forwardHighWaterSeconds || overByteCeiling) {
|
||||
this.productionPaused_ = true;
|
||||
}
|
||||
|
||||
return this.productionPaused_;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop already-played buffers from the front of the array, reclaiming their decoded float
|
||||
* memory, and advance the time anchor so all position/index bookkeeping stays exact.
|
||||
@@ -418,6 +519,9 @@ export class PlaybackScheduler {
|
||||
this.nextBufferIndex = 0;
|
||||
this.nextScheduleTime = 0;
|
||||
this.playbackOffset = 0;
|
||||
// Release the back-pressure latch — a fresh stream must start unthrottled so its first
|
||||
// chunks decode immediately (C2: no throttle-induced first-audio stall).
|
||||
this.productionPaused_ = false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -432,6 +536,9 @@ export class PlaybackScheduler {
|
||||
this.nextBufferIndex = 0;
|
||||
this.nextScheduleTime = 0;
|
||||
// Note: playbackOffset is NOT reset - it will be set by the caller
|
||||
// Release the back-pressure latch — the post-seek continuation must refill from the new
|
||||
// offset without inheriting the pre-seek paused state.
|
||||
this.productionPaused_ = false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user