fix(audio): guard underrun/stream-complete against false end-of-playback

pause() clears underrun_ so setStreamComplete can't fire TrackEnded while paused; resetToStart() resets streamComplete. Prior fix: underrun_ park + streamComplete discriminator prevent the Opus-startup false-end. Tests: 18 PlaybackScheduler cases including pause-during-underrun and underrun->resume->genuine-end-once.
This commit is contained in:
daniel-c-harvey
2026-06-25 15:16:22 -04:00
parent 3aed5c129f
commit 67422e922d
5 changed files with 340 additions and 14 deletions
+114 -13
View File
@@ -107,6 +107,22 @@ export class PlaybackScheduler {
// Mutated by evaluateProductionPause() — named to signal the state-advance on each call.
private productionPaused_: boolean = false;
// True once the producer (C# read loop / Opus feed) has signalled that ALL bytes are in and
// every decodable buffer has been added. This is the discriminator between a genuine
// end-of-track and a transient gap. End-of-playback fires ONLY when this is true AND the
// scheduled queue has drained — a drained queue while this is false is a startup/underrun gap,
// not the end (Opus decodes via WebCodecs asynchronously, so the first AudioBuffer can lag the
// playback-start minimum, briefly leaving zero scheduled sources before real playback). Reset
// by clear/clearForSeek/resetToStart; set by setStreamComplete.
private streamComplete: boolean = false;
// True while playback is logically running but the decoded queue ran dry mid-stream (underrun).
// We stop the scheduler (isActive_ = false) so no source schedules against a stale anchor, but
// remember we must re-anchor and resume the moment new buffers arrive — distinct from a paused/
// stopped player, which clears this. Without it, scheduleNewBuffers would silently no-op on the
// !isActive_ guard and playback would never recover from a starvation gap.
private underrun_: boolean = false;
// Callbacks
public onPlaybackEnded: (() => void) | null = null;
@@ -121,6 +137,28 @@ export class PlaybackScheduler {
this.buffers.push(buffer);
}
/**
* Mark whether the byte stream is complete (all bytes received and all decodable buffers added).
* The end-of-playback callback fires only when this is true AND the scheduled queue has drained —
* so a drained queue while the stream is still in flight (startup/underrun) is never mistaken for
* end-of-track. Set true by AudioPlayer on markStreamComplete / decoder isComplete; set false on a
* fresh stream or a range-continuation reinit. Setting it true while playback has already drained
* mid-stream finalises the track immediately (the genuine-end signal arrived after the queue
* emptied — e.g. the very last buffers were the tail).
*/
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) {
this.finishPlayback();
}
}
/**
* Get total duration of all stored buffers
*/
@@ -350,18 +388,25 @@ export class PlaybackScheduler {
}
if (startBufferIndex >= this.buffers.length) {
// Position landed at or past the end of all buffers. Previously this
// returned silently, leaving the player stuck "playing" with no source
// scheduled — a pause near the end followed by play never recovered.
// Treat this as end-of-track so listeners (UI / end callback) fire.
this.isActive_ = false;
this.playbackAnchorTime = 0;
this.playbackAnchorPosition = 0;
this.onPlaybackEnded?.();
// Position landed at or past the end of all currently-decoded buffers. This is
// end-of-track ONLY if the stream is complete; otherwise it is a startup/underrun
// gap (decode hasn't caught up to the playhead yet) and firing onPlaybackEnded here
// would be a FALSE end — exactly the Opus-startup misfire. When complete, finish;
// when still streaming, park in underrun so scheduleNewBuffers resumes on the next
// decoded buffer rather than the player being stuck "playing" with nothing scheduled.
if (this.streamComplete) {
this.finishPlayback();
} else {
this.underrun_ = true;
this.playbackAnchorPosition = position;
this.nextBufferIndex = startBufferIndex;
this.isActive_ = false; // no source to schedule yet; resume() re-anchors on refill
}
return;
}
// Set timing anchors
this.underrun_ = false;
this.playbackAnchorPosition = position;
this.playbackAnchorTime = this.contextManager.currentTime;
this.nextScheduleTime = this.contextManager.currentTime + 0.01; // Small lookahead
@@ -380,6 +425,21 @@ export class PlaybackScheduler {
return; // No new buffers
}
// Resume from a mid-stream underrun: the queue had drained ahead of decode and we parked
// (isActive_ = false, underrun_ = true) instead of firing a false end. Newly decoded
// buffers are now available at nextBufferIndex, so re-anchor the clock at the resume point
// and re-enable scheduling. We re-anchor (rather than reusing the stale nextScheduleTime
// 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_) {
this.underrun_ = false;
this.isActive_ = true;
this.playbackAnchorTime = this.contextManager.currentTime;
this.nextScheduleTime = this.contextManager.currentTime + 0.01;
this.scheduleBuffersFrom(this.nextBufferIndex, 0);
return;
}
// Use isActive_ as the sentinel for "playback is running", not nextScheduleTime === 0.
// AudioContext.currentTime can legitimately be 0 at context creation, which would cause
// nextScheduleTime === 0 to incorrectly reset a value already set by playFromPosition.
@@ -464,21 +524,53 @@ export class PlaybackScheduler {
this.scheduleBuffersFrom(this.nextBufferIndex, 0);
}
// Check if all playback has finished
// The scheduled queue drained AND the cursor caught up to every decoded buffer. Whether
// this is the end depends on the stream:
// - streamComplete: genuine end-of-track — finish and fire onPlaybackEnded.
// - still streaming: a mid-stream UNDERRUN (decode fell behind the playhead — the Opus
// WebCodecs startup gap, or a network stall). Firing onPlaybackEnded here is the false
// end this guards against. Park in underrun; scheduleNewBuffers resumes on the next
// decoded buffer.
if (this.scheduledSources.length === 0 && this.nextBufferIndex >= this.buffers.length) {
this.isActive_ = false;
this.playbackAnchorTime = 0;
this.playbackAnchorPosition = 0;
this.onPlaybackEnded?.();
if (this.streamComplete) {
this.finishPlayback();
} else {
this.underrun_ = true;
// Hold the playhead at the decoded tail so getCurrentPosition stays exact during
// the gap. isActive_ goes false so no stale-anchor scheduling occurs; resume
// re-anchors at currentTime when buffers arrive.
this.playbackAnchorPosition = this.getCurrentPosition() - this.playbackOffset;
this.playbackAnchorTime = 0;
this.isActive_ = false;
}
}
}
/**
* Finalise playback: stop the clock, reset anchors, and fire the end-of-playback callback. The
* single genuine-end path, reached only when the stream is complete AND the queue has fully
* drained (handleSourceEnded / setStreamComplete) or playback resumed past a complete stream's
* end (playFromPosition). Never called for a transient startup/underrun gap.
*/
private finishPlayback(): void {
this.isActive_ = false;
this.underrun_ = false;
this.playbackAnchorTime = 0;
this.playbackAnchorPosition = 0;
this.onPlaybackEnded?.();
}
/**
* Pause playback - saves position and stops sources
*/
pause(): number {
const position = this.getCurrentPosition();
this.isActive_ = false; // Prevent handleSourceEnded from scheduling more
// Clear the underrun flag: if the queue drained mid-stream and the user pauses before new
// buffers arrive, a subsequent setStreamComplete must not fire finishPlayback while still
// paused. On resume, playFromPosition re-parks underrun if the decoded tail still hasn't
// caught up, so no genuine end is lost by clearing it here.
this.underrun_ = false;
this.stopAllSources();
// getCurrentPosition() returns absolute time (anchor + playbackOffset); the anchor
// is buffer-relative, so strip the offset back out before storing it.
@@ -507,6 +599,8 @@ export class PlaybackScheduler {
*/
resetToStart(): void {
this.isActive_ = false;
this.underrun_ = false;
this.streamComplete = false;
this.stopAllSources();
this.playbackAnchorPosition = 0;
this.playbackAnchorTime = 0;
@@ -519,6 +613,8 @@ export class PlaybackScheduler {
*/
clear(): void {
this.isActive_ = false;
this.underrun_ = false;
this.streamComplete = false;
this.stopAllSources();
this.buffers = [];
this.playbackAnchorPosition = 0;
@@ -536,6 +632,11 @@ export class PlaybackScheduler {
*/
clearForSeek(): void {
this.isActive_ = false;
this.underrun_ = false;
// The range continuation is a fresh byte stream — it is NOT complete until its own
// markStreamComplete. Reset so a stale "complete" from the pre-seek stream cannot make the
// post-seek refill fire a premature end before its bytes arrive.
this.streamComplete = false;
this.stopAllSources();
this.buffers = [];
this.playbackAnchorPosition = 0;