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