Fix complete-without-start hang for ultra-short tracks; add Opus rebuffer hysteresis

Tracks whose total audio falls below the playback-start threshold (Opus <1s lead, WAV <6 buffers) silently hung loaded-but-not-playing. After MarkStreamCompleteAsync, call TryStartPlaybackAsync when _streamingPlaybackStarted is still false so the scheduler drains its buffers and fires onPlaybackEnded exactly once.
This commit is contained in:
daniel-c-harvey
2026-06-25 15:54:53 -04:00
parent 48e58c266d
commit 4ab430d232
4 changed files with 275 additions and 44 deletions
@@ -63,6 +63,21 @@ 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;
/**
* Rebuffer hysteresis lead — the minimum SECONDS of decoded-but-unscheduled audio that must
* accumulate ahead of the schedule cursor before playback may (re)start after a mid-stream underrun.
*
* Why seconds, not a buffer count: the per-buffer duration differs wildly by format. A WAV/lossless
* segment is a sizeable slab (~0.10.4 s); a single Opus WebCodecs packet is ~20 ms. The old resume
* path re-anchored on the FIRST arriving buffer, so for Opus it scheduled ~20 ms, drained it, parked,
* resumed on the next ~20 ms, and so on — the audible start/stop thrash during the WebCodecs decode
* ramp. Gating on a fixed LEAD in seconds gives a resume the same cushion a fresh start has,
* independent of format. 1 s is the same order as the lossless playback-start lead (~6 segments) and
* sits far below the 30 s forward high-water, so back-pressure never throttles production while the
* scheduler is still re-accumulating this lead. Tunable; not magic.
*/
const DEFAULT_MIN_PLAYBACK_LEAD_SECONDS = 1.0;
interface ScheduledSource {
source: AudioBufferSourceNode;
bufferIndex: number;
@@ -100,6 +115,12 @@ export class PlaybackScheduler {
private forwardLowWaterSeconds: number = DEFAULT_FORWARD_LOW_WATER_SECONDS;
private maxDecodedBytes: number = DEFAULT_MAX_DECODED_BYTES;
// Rebuffer hysteresis lead (seconds). The minimum decoded-but-unscheduled audio that must sit
// ahead of the schedule cursor before playback may (re)start — at a fresh start AND after a
// mid-stream underrun. Without it the underrun resume re-anchored on the first arriving buffer
// and thrashed on the Opus decode ramp. See DEFAULT_MIN_PLAYBACK_LEAD_SECONDS.
private minPlaybackLeadSeconds: number = DEFAULT_MIN_PLAYBACK_LEAD_SECONDS;
// 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
@@ -148,13 +169,23 @@ export class PlaybackScheduler {
*/
setStreamComplete(complete: boolean): void {
this.streamComplete = complete;
// If the queue already drained mid-stream (we are parked in underrun) when the genuine-end
// signal arrives, finalise now — the tail produced no more buffers, so this drained state is
// the real end. Gated on underrun_ (logically-playing-but-starved), not isActive_, which is
// false during a parked underrun. A drained queue with no playback in flight (never started,
// or already finished) is left untouched.
if (complete && this.underrun_ &&
this.scheduledSources.length === 0 && this.nextBufferIndex >= this.buffers.length) {
// Only act when the genuine-end signal lands while we are parked in underrun (logically
// playing but starved); a drained queue with no playback in flight — never started, or
// already finished — is left untouched. Gated on underrun_, not isActive_, which is false
// during a parked underrun.
if (!complete || !this.underrun_) {
return;
}
// The rebuffer threshold no longer applies — a complete stream yields no further buffers:
// - tail buffers accumulated below the threshold while we were parked (the new hysteresis
// kept us parked) → schedule them out; scheduleNewBuffers' underrun branch now resumes
// because streamComplete overrides the lead gate, and handleSourceEnded fires the genuine
// end when they drain. Without this the buffers would never schedule and we would park
// forever (queue drained, isActive_ false, threshold never met).
// - no tail at all (cursor already at the decoded end) → this drained state IS the end.
if (this.nextBufferIndex < this.buffers.length) {
this.scheduleNewBuffers();
} else if (this.scheduledSources.length === 0) {
this.finishPlayback();
}
}
@@ -432,6 +463,19 @@ export class PlaybackScheduler {
// captured before the gap) so the resumed audio is contiguous from "now" — a stale anchor
// would schedule the next source in the past and the browser would drop or rush it.
if (this.underrun_) {
// Rebuffer hysteresis: do NOT resume on the first arriving buffer. With an empty scheduled
// tail, resuming on a single buffer plays it (~20 ms for Opus) and immediately re-drains,
// re-parking — the audible start/stop thrash on the Opus WebCodecs decode ramp. Stay parked
// and keep accumulating until a healthy lead has rebuilt, so the resumed playback has the
// same cushion a fresh start does. While parked the playhead is frozen, so each arriving
// buffer grows the lead monotonically toward the threshold (no starvation/deadlock).
//
// streamComplete overrides the gate: a finished stream produces no further buffers, so a
// tail shorter than the lead MUST still play out (here and via setStreamComplete) rather
// than park forever. handleSourceEnded fires the genuine end once that tail drains.
if (!this.streamComplete && !this.hasMinimumPlaybackLead()) {
return; // still re-accumulating the rebuffer lead — remain parked
}
this.underrun_ = false;
this.isActive_ = true;
this.playbackAnchorTime = this.contextManager.currentTime;
@@ -663,6 +707,24 @@ export class PlaybackScheduler {
return this.buffers.length >= minCount;
}
/**
* True once at least `minPlaybackLeadSeconds` of decoded-but-unscheduled audio sits ahead of the
* schedule cursor — the rebuffer-hysteresis gate for both a fresh playback start (cursor at 0, so
* this measures the whole decoded head) and an underrun resume (cursor at the drained tail, so this
* measures only the freshly-accumulated lead). Sums only up to the threshold and short-circuits, so
* it is bounded (~one threshold's worth of buffers) regardless of how much is buffered ahead.
*/
hasMinimumPlaybackLead(): boolean {
let lead = 0;
for (let i = this.nextBufferIndex; i < this.buffers.length; i++) {
lead += this.buffers[i].duration;
if (lead >= this.minPlaybackLeadSeconds) {
return true;
}
}
return false;
}
/**
* Check if playback is active
*/