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
@@ -441,6 +441,197 @@ test('clear and clearForSeek release the back-pressure latch (C2 latency parity)
assertEqual(s.evaluateProductionPause(), false, 'clearForSeek resets the latch');
});
// === False end-of-playback guard (Opus-startup misfire) ======================================
//
// The scheduler must distinguish a GENUINE end-of-track (stream complete AND queue drained) from a
// transient startup/underrun gap (queue drained while bytes are still streaming — Opus decodes via
// WebCodecs asynchronously, so the first buffers can lag the playback-start minimum). The end
// callback fires only in the first case. These tests drive the real handleSourceEnded cascade via
// FakeSource.stop() and assert onPlaybackEnded fires exactly when it should.
/** Drive the schedule cursor + live sources to a fully-drained queue at the buffer tail. */
function drainAllSources(s: PlaybackScheduler, cm: FakeContextManager): void {
const priv = s as unknown as { scheduledSources: Array<{ source: FakeSource }> };
let guard = 0;
while (priv.scheduledSources.length > 0 && guard++ < 10000) {
// Stop the head source; its onended → handleSourceEnded removes it and schedules the next.
priv.scheduledSources[0].source.stop();
}
}
// A drained queue MID-STREAM (streamComplete false) must NOT fire onPlaybackEnded — it parks in
// underrun instead. This is the exact Opus-startup false-end.
test('drained queue while still streaming does not fire onPlaybackEnded (no false end)', () => {
const cm = new FakeContextManager();
const s = makeScheduler(cm);
let ended = 0;
s.onPlaybackEnded = () => { ended++; };
// A short run of buffers, playback started, but the stream is NOT marked complete.
for (let i = 0; i < 3; i++) s.addBuffer(buf(0.3));
cm.now = 0;
s.playFromPosition(0);
// Advance the clock past the buffered tail and drain every scheduled source.
cm.now = 1.0;
drainAllSources(s, cm);
assertEqual(ended, 0, 'no end callback fired mid-stream');
assertEqual(s.isActive(), false, 'scheduler parked (inactive) on underrun');
});
// After a mid-stream underrun, newly decoded buffers must RESUME playback (scheduleNewBuffers
// re-anchors and re-activates) — not stay stuck, and still not fire a false end.
test('underrun resumes when new buffers arrive', () => {
const cm = new FakeContextManager();
const s = makeScheduler(cm);
let ended = 0;
s.onPlaybackEnded = () => { ended++; };
for (let i = 0; i < 3; i++) s.addBuffer(buf(0.3));
cm.now = 0;
s.playFromPosition(0);
cm.now = 1.0;
drainAllSources(s, cm); // underrun
assertEqual(s.isActive(), false, 'inactive after underrun');
// Decode catches up: more buffers arrive and the producer schedules them.
for (let i = 0; i < 3; i++) s.addBuffer(buf(0.3));
s.scheduleNewBuffers();
assertEqual(s.isActive(), true, 'resumed active after refill');
assertEqual(ended, 0, 'still no false end after resume');
const priv = s as unknown as { scheduledSources: unknown[] };
if (priv.scheduledSources.length === 0) {
throw new Error('expected new sources scheduled on resume');
}
});
// GENUINE end: stream complete AND queue drains → onPlaybackEnded fires exactly once.
test('genuine end (streamComplete + drained) fires onPlaybackEnded exactly once', () => {
const cm = new FakeContextManager();
const s = makeScheduler(cm);
let ended = 0;
s.onPlaybackEnded = () => { ended++; };
for (let i = 0; i < 3; i++) s.addBuffer(buf(0.3));
cm.now = 0;
s.playFromPosition(0);
s.setStreamComplete(true); // all bytes in, no more buffers coming
cm.now = 1.0;
drainAllSources(s, cm);
assertEqual(ended, 1, 'end fired once on genuine completion');
assertEqual(s.isActive(), false, 'inactive after genuine end');
});
// setStreamComplete arriving AFTER the queue has already drained mid-stream (the tail produced no
// new buffers) must finalise immediately — the genuine-end signal that landed late.
test('setStreamComplete after an already-drained queue finalises immediately', () => {
const cm = new FakeContextManager();
const s = makeScheduler(cm);
let ended = 0;
s.onPlaybackEnded = () => { ended++; };
for (let i = 0; i < 3; i++) s.addBuffer(buf(0.3));
cm.now = 0;
s.playFromPosition(0);
cm.now = 1.0;
drainAllSources(s, cm); // underrun, no end yet
assertEqual(ended, 0, 'no end before completion signal');
s.setStreamComplete(true); // signal arrives now → finalise
assertEqual(ended, 1, 'end fired when completion signalled post-drain');
});
// clearForSeek must reset streamComplete so a post-seek refill cannot inherit a stale "complete"
// and fire a premature end before its own bytes arrive.
test('clearForSeek resets streamComplete (no inherited end on refill)', () => {
const cm = new FakeContextManager();
const s = makeScheduler(cm);
let ended = 0;
s.onPlaybackEnded = () => { ended++; };
for (let i = 0; i < 3; i++) s.addBuffer(buf(0.3));
cm.now = 0;
s.playFromPosition(0);
s.setStreamComplete(true);
s.clearForSeek();
s.setPlaybackOffset(5);
// Post-seek continuation: fresh buffers, playback resumes, stream NOT yet complete.
for (let i = 0; i < 3; i++) s.addBuffer(buf(0.3));
cm.now = 5;
s.playFromPosition(0);
cm.now = 6.0;
drainAllSources(s, cm);
assertEqual(ended, 0, 'no end fired — stale streamComplete was cleared by clearForSeek');
});
// pause() during underrun: setStreamComplete must NOT fire end while the user is paused.
// This is the narrow window the fix to pause() closes: without the underrun_ clear, a paused
// scheduler that was mid-underrun satisfies the setStreamComplete immediate-finalise guard
// (complete && underrun_ && drained) and fires TrackEnded / queue-advance while paused.
test('pause during underrun: setStreamComplete does not fire end while paused', () => {
const cm = new FakeContextManager();
const s = makeScheduler(cm);
let ended = 0;
s.onPlaybackEnded = () => { ended++; };
// A short run of buffers, drain them mid-stream → scheduler parks in underrun.
for (let i = 0; i < 3; i++) s.addBuffer(buf(0.3));
cm.now = 0;
s.playFromPosition(0);
cm.now = 1.0;
drainAllSources(s, cm); // queue drained, streamComplete still false → underrun
assertEqual(s.isActive(), false, 'parked in underrun after drain');
assertEqual(ended, 0, 'no end before pause');
// User pauses while the scheduler is parked in underrun.
s.pause();
// Stream completes with no further buffers (the tail produced nothing new).
// With the fix, pause() cleared underrun_ so this must NOT finalise immediately.
s.setStreamComplete(true);
assertEqual(ended, 0, 'no end fired while paused — setStreamComplete must not fire during pause');
assertEqual(s.isActive(), false, 'scheduler stays inactive after setStreamComplete during pause');
});
// underrun → resume → genuine end fires exactly once: the full composition from a mid-stream gap
// through resumed playback to completion. Confirms no double-fire and no stuck scheduler.
test('underrun → resume → genuine end fires exactly once', () => {
const cm = new FakeContextManager();
const s = makeScheduler(cm);
let ended = 0;
s.onPlaybackEnded = () => { ended++; };
// Drain initial buffers into underrun.
for (let i = 0; i < 3; i++) s.addBuffer(buf(0.3));
cm.now = 0;
s.playFromPosition(0);
cm.now = 1.0;
drainAllSources(s, cm);
assertEqual(s.isActive(), false, 'underrun after initial drain');
assertEqual(ended, 0, 'no end count during underrun');
// Decode catches up: new buffers arrive and the scheduler resumes.
for (let i = 0; i < 3; i++) s.addBuffer(buf(0.3));
s.scheduleNewBuffers();
assertEqual(s.isActive(), true, 'resumed active after refill');
assertEqual(ended, 0, 'still no end after resume');
// Mark the stream complete, then drain the resumed sources to genuine end.
s.setStreamComplete(true);
cm.now = 2.0;
drainAllSources(s, cm);
assertEqual(ended, 1, 'end fires exactly once after genuine completion');
assertEqual(s.isActive(), false, 'inactive after genuine end');
});
// --- run -------------------------------------------------------------------------------------
if (failures.length > 0) {
console.error(failures.join('\n'));