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:
daniel-c-harvey
2026-06-25 15:54:53 -04:00
parent 48e58c266d
commit 4ab430d232
4 changed files with 275 additions and 44 deletions
@@ -495,8 +495,8 @@ test('underrun resumes when new buffers arrive', () => {
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));
// Decode catches up: enough buffers arrive to clear the 1s rebuffer lead (4 × 0.3 = 1.2s).
for (let i = 0; i < 4; i++) s.addBuffer(buf(0.3));
s.scheduleNewBuffers();
assertEqual(s.isActive(), true, 'resumed active after refill');
@@ -507,6 +507,78 @@ test('underrun resumes when new buffers arrive', () => {
}
});
// === Rebuffer hysteresis (Opus-startup thrash fix) ===========================================
//
// After a mid-stream underrun the scheduler must NOT resume on the first arriving buffer (which,
// for ~20 ms Opus packets, plays one buffer, drains, and re-parks — the audible start/stop thrash).
// It re-accumulates a healthy decoded LEAD (DEFAULT_MIN_PLAYBACK_LEAD_SECONDS = 1s) first. The
// streamComplete override is the escape hatch so a genuine short tail still plays out, never parking
// forever. These drive the real handleSourceEnded/scheduleNewBuffers/setStreamComplete paths.
// Below the rebuffer lead: a thin refill must keep the scheduler parked (no resume, no false end);
// once the accumulated lead crosses the threshold, it resumes.
test('underrun does not resume below the rebuffer lead, resumes once it is met', () => {
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, 'parked in underrun');
// Only 0.6s of fresh lead arrives — below the 1s rebuffer threshold. Must stay parked.
for (let i = 0; i < 2; i++) s.addBuffer(buf(0.3));
s.scheduleNewBuffers();
assertEqual(s.isActive(), false, 'still parked — lead below the rebuffer threshold');
assertEqual(ended, 0, 'no false end while re-accumulating lead');
const priv = s as unknown as { scheduledSources: unknown[] };
assertEqual(priv.scheduledSources.length, 0, 'nothing scheduled below the threshold');
// More lead arrives, crossing the threshold (0.6 + 0.6 = 1.2s ≥ 1s) → now resume.
for (let i = 0; i < 2; i++) s.addBuffer(buf(0.3));
s.scheduleNewBuffers();
assertEqual(s.isActive(), true, 'resumes once the lead crosses the threshold');
assertEqual(ended, 0, 'still no false end after resume');
});
// Genuine-end tail SHORTER than the rebuffer lead: while parked, a small tail arrives AND the stream
// completes. The threshold is overridden so the tail plays out and the genuine end fires exactly
// once — the scheduler must never park forever waiting for a lead that will never come.
test('streamComplete tail below the rebuffer lead still plays out and fires end 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);
cm.now = 1.0;
drainAllSources(s, cm); // underrun
assertEqual(s.isActive(), false, 'parked in underrun');
// A short final tail (0.6s, below the 1s threshold) arrives; the hysteresis keeps it parked.
for (let i = 0; i < 2; i++) s.addBuffer(buf(0.3));
s.scheduleNewBuffers();
assertEqual(s.isActive(), false, 'parked — tail below threshold, stream not yet complete');
assertEqual(ended, 0, 'no end before completion');
// The stream completes: the threshold no longer applies → the tail schedules and plays out.
s.setStreamComplete(true);
assertEqual(s.isActive(), true, 'resumed to play out the final tail on completion');
assertEqual(ended, 0, 'end not fired until the tail drains');
// Drain the tail → genuine end fires exactly once.
cm.now = 2.0;
drainAllSources(s, cm);
assertEqual(ended, 1, 'genuine end fires exactly once after the tail drains');
assertEqual(s.isActive(), false, 'inactive after genuine end');
});
// GENUINE end: stream complete AND queue drains → onPlaybackEnded fires exactly once.
test('genuine end (streamComplete + drained) fires onPlaybackEnded exactly once', () => {
const cm = new FakeContextManager();
@@ -617,8 +689,8 @@ test('underrun → resume → genuine end fires exactly once', () => {
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));
// Decode catches up: enough buffers arrive to clear the 1s rebuffer lead (4 × 0.3 = 1.2s).
for (let i = 0; i < 4; i++) s.addBuffer(buf(0.3));
s.scheduleNewBuffers();
assertEqual(s.isActive(), true, 'resumed active after refill');
assertEqual(ended, 0, 'still no end after resume');
@@ -632,6 +704,76 @@ test('underrun → resume → genuine end fires exactly once', () => {
assertEqual(s.isActive(), false, 'inactive after genuine end');
});
// === Complete-without-start (force-start fallback) ==========================================
//
// The C# producer calls StartStreamingPlayback after MarkStreamCompleteAsync when
// _streamingPlaybackStarted is still false (total audio below the start threshold). The JS-side
// effect is playFromPosition(0) called with streamComplete already true. This section covers the
// scheduler-side guarantee: sub-threshold buffers + streamComplete already set + forced
// playFromPosition drains and fires end exactly once, never zero, never twice.
//
// The C# transition itself is not exercisable here (requires StreamingAudioPlayerService +
// AudioInteropService), so the test covers the scheduler drain-and-end-once contract directly.
// Forced start after completion: sub-threshold total audio, streamComplete set BEFORE
// playFromPosition(0), sources drain and onPlaybackEnded fires exactly once.
test('forced start on complete stream: sub-threshold buffers drain and fire end exactly once', () => {
const cm = new FakeContextManager();
const s = makeScheduler(cm);
let ended = 0;
s.onPlaybackEnded = () => { ended++; };
// Sub-threshold buffers (0.4s total, below the 1s rebuffer lead). Never started.
for (let i = 0; i < 2; i++) s.addBuffer(buf(0.2));
// Stream marks complete BEFORE playback starts — the C# completion-path ordering:
// MarkStreamCompleteAsync fires first, then StartStreamingPlayback is called because
// _streamingPlaybackStarted is false. setStreamComplete with underrun_=false returns
// early (sets the flag but does not schedule/finalize — that is correct, nothing to drain yet).
s.setStreamComplete(true);
assertEqual(ended, 0, 'no end fired at setStreamComplete — playback not yet started');
assertEqual(s.isActive(), false, 'scheduler inactive before forced start');
// Forced start: C# calls startStreamingPlayback() → playFromPosition(0).
// With streamComplete already true and buffers present, this schedules all buffers.
cm.now = 0;
s.playFromPosition(0);
const priv = s as unknown as { scheduledSources: unknown[] };
if (priv.scheduledSources.length === 0) {
throw new Error('expected sources scheduled after forced playFromPosition');
}
assertEqual(ended, 0, 'end not fired yet — sources must drain first');
assertEqual(s.isActive(), true, 'scheduler active while sources are scheduled');
// Drain sources → streamComplete is true → genuine end fires exactly once.
cm.now = 0.5;
drainAllSources(s, cm);
assertEqual(ended, 1, 'end fires exactly once after forced-start drain');
assertEqual(s.isActive(), false, 'scheduler inactive after genuine end');
});
// No double-fire: calling setStreamComplete again after end has already fired is a no-op.
test('setStreamComplete after forced-start drain is a no-op (no double end)', () => {
const cm = new FakeContextManager();
const s = makeScheduler(cm);
let ended = 0;
s.onPlaybackEnded = () => { ended++; };
for (let i = 0; i < 2; i++) s.addBuffer(buf(0.2));
s.setStreamComplete(true);
cm.now = 0;
s.playFromPosition(0);
cm.now = 0.5;
drainAllSources(s, cm);
assertEqual(ended, 1, 'end fired once after forced-start drain');
// A redundant setStreamComplete (e.g. called again from a stale C# path) must not re-fire.
s.setStreamComplete(true);
assertEqual(ended, 1, 'still exactly one end after redundant setStreamComplete');
});
// --- run -------------------------------------------------------------------------------------
if (failures.length > 0) {
console.error(failures.join('\n'));