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