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:
@@ -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.1–0.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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user