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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user