Files
deepdrft/DeepDrftPublic/Interop/audio/PlaybackScheduler.test.ts
T
daniel-c-harvey 61e185a2f7 audio: widen forward decode cushion 30/15->60/30s + add [BP-DIAG] back-pressure instrumentation
Byte cap (96MB) unchanged as the hard OOM bound; the wider time window only lets sparse Opus use existing memory headroom to ride out decode jitter. Diag logs pin whether the block is back-pressure or decode throughput.
2026-06-25 21:52:20 -04:00

835 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* PlaybackScheduler partial-eviction tests (Phase 21.1) — the anchor/index bookkeeping.
*
* The crux of 21.1 is that getCurrentPosition / playFromPosition / the schedule loop stay
* exact against a buffer array that no longer begins at absolute time 0 after front eviction.
* That math is pure given a clock and buffer durations, so it is testable in Node without a
* browser by injecting fakes for AudioContextManager and AudioBuffer (the scheduler only ever
* reads contextManager.currentTime, getGainNode(), getContext().createBufferSource(), and
* buffer.duration).
*
* Same harness convention as OpusStreamDecoder.test.ts: no test runner in this repo, run a
* copy from the COMPILED output so the `.js` import specifier resolves:
*
* dotnet build DeepDrftPublic/DeepDrftPublic.csproj
* cp DeepDrftPublic/Interop/audio/PlaybackScheduler.test.ts DeepDrftPublic/wwwroot/js/audio/
* node DeepDrftPublic/wwwroot/js/audio/PlaybackScheduler.test.ts
*
* A thrown error / non-zero exit signals failure; "ALL <n> TESTS PASSED" signals success.
* Excluded from the production tsc build via tsconfig `exclude: Interop/ ** /*.test.ts`.
*/
import { PlaybackScheduler } from './PlaybackScheduler.js';
import type { AudioContextManager } from './AudioContextManager.js';
// --- tiny inline harness (no dependencies) ---------------------------------------------------
let passed = 0;
const failures: string[] = [];
function test(name: string, fn: () => void): void {
try {
fn();
passed++;
} catch (e) {
failures.push(`FAIL: ${name}\n ${(e as Error).message}`);
}
}
function assertClose(actual: number, expected: number, msg?: string, eps = 1e-9): void {
if (Math.abs(actual - expected) > eps) {
throw new Error(`${msg ?? 'assertClose'}: expected ${expected}, got ${actual}`);
}
}
function assertEqual(actual: unknown, expected: unknown, msg?: string): void {
if (actual !== expected) {
throw new Error(`${msg ?? 'assertEqual'}: expected ${String(expected)}, got ${String(actual)}`);
}
}
// --- fakes -----------------------------------------------------------------------------------
/** A buffer source that records start/stop and fires onended on demand. */
class FakeSource {
public buffer: unknown = null;
public onended: (() => void) | null = null;
public started = false;
public stopped = false;
connect(): void { /* no-op */ }
start(): void { this.started = true; }
stop(): void {
this.stopped = true;
// The real Web Audio fires onended when a source is stopped; the scheduler relies on
// that for cleanup. Mirror it so handleSourceEnded paths are exercised.
this.onended?.();
}
}
/** Controllable clock + the minimal AudioContext surface the scheduler touches. */
class FakeContextManager {
public now = 0;
public sources: FakeSource[] = [];
get currentTime(): number { return this.now; }
getGainNode(): unknown { return {}; }
getContext(): unknown {
const self = this;
return {
createBufferSource(): FakeSource {
const s = new FakeSource();
self.sources.push(s);
return s;
}
};
}
}
/** A decoded buffer is, for the scheduler's purposes, just a duration. */
function buf(duration: number): AudioBuffer {
return { duration } as AudioBuffer;
}
/**
* A decoded buffer carrying realistic byte-footprint fields (length + numberOfChannels) for the
* OQ3 byte-ceiling test. Models 48 kHz stereo float PCM: length = duration × 48000 frames, 2 ch.
*/
function bufBytes(duration: number): AudioBuffer {
return { duration, length: Math.round(duration * 48000), numberOfChannels: 2 } as AudioBuffer;
}
function makeScheduler(cm: FakeContextManager): PlaybackScheduler {
// The scheduler only uses the subset FakeContextManager implements.
return new PlaybackScheduler(cm as unknown as AudioContextManager);
}
/**
* Drive the schedule cursor to the end of the buffer array WITHOUT running playback to
* completion, then clear the live-source set so neither nextBufferIndex nor a live source
* pins eviction. This isolates the back-retain threshold math from the live-frontier guards
* (which are exercised by their own tests).
*
* The lookahead in scheduleBuffersFrom only schedules ~500ms ahead per call; pushing the clock
* far back makes "lookahead" small so a single scheduleNewBuffers() call schedules everything
* remaining. We then drop the (white-box) live-source list and reset the schedule cursor to the
* end, leaving the array intact for a direct evictPlayedBuffers() call at a chosen position.
*/
function advanceCursorToEnd(s: PlaybackScheduler, cm: FakeContextManager): void {
const priv = s as unknown as { nextScheduleTime: number; nextBufferIndex: number; scheduledSources: unknown[] };
// Make the existing schedule anchor look "now" so the lookahead window is tiny, then let
// the scheduler lay down every remaining buffer in one pass.
priv.nextScheduleTime = cm.now;
s.scheduleNewBuffers();
// Repeat until the cursor reaches the end (lookahead may break early on long arrays).
let guard = 0;
while ((priv.nextBufferIndex as number) < s.getBufferCount() && guard++ < 1000) {
priv.nextScheduleTime = cm.now;
s.scheduleNewBuffers();
}
// Unpin the front: discard live sources without firing the onended cascade.
cm.sources.forEach(x => { x.onended = null; x.stopped = true; });
priv.scheduledSources.length = 0;
}
// --- tests -----------------------------------------------------------------------------------
// Position correctness after eviction: query current position after the front of the buffer
// array has been evicted; it must still equal wall-clock track time.
test('position stays exact after a front eviction', () => {
const cm = new FakeContextManager();
const s = makeScheduler(cm);
s.setBackRetainSeconds(0); // retain nothing behind the playhead — evict aggressively
// Ten 1s buffers, track [0,10).
for (let i = 0; i < 10; i++) s.addBuffer(buf(1));
cm.now = 0;
s.playFromPosition(0); // schedules a 500ms lookahead worth of sources from index 0
advanceCursorToEnd(s, cm);
cm.now = 3.0;
const dropped = s.evictPlayedBuffers();
if (dropped <= 0) throw new Error('expected front buffers to be evicted at t=3 with 0s retain');
// Absolute position must read 3.0 regardless of how many front buffers were dropped.
assertClose(s.getCurrentPosition(), 3.0, 'position after eviction');
// And buffers[0] no longer being the track start is reflected in the advanced offset.
if (s.getPlaybackOffset() <= 0) {
throw new Error('expected playbackOffset to advance past 0 after eviction');
}
});
// Eviction threshold respected: buffers older than back-retain are released; those within are
// kept. With back-retain = 2s at position 5, end<=3 is droppable, end in (3,..] is retained.
// Driven deterministically: advance the schedule cursor to the end (so nextBufferIndex does
// not pin eviction), clear live sources, then call eviction directly at a known position.
test('back-retain bound governs what is evicted', () => {
const cm = new FakeContextManager();
const s = makeScheduler(cm);
s.setBackRetainSeconds(2);
for (let i = 0; i < 10; i++) s.addBuffer(buf(1)); // track [0,10)
cm.now = 0;
s.playFromPosition(0);
advanceCursorToEnd(s, cm); // nextBufferIndex == 10, no live sources
cm.now = 5.0; // playhead at absolute t=5
const evicted = s.evictPlayedBuffers();
// currentPosition is 5.0; backRetain 2 => evictBefore = 3. Buffers ending at 1,2,3 are
// droppable (3 buffers); the buffer ending at 4 must be retained.
assertEqual(evicted, 3, 'evicted count under 2s back-retain at t=5');
assertEqual(s.getBufferCount(), 7, 'seven buffers retained');
assertClose(s.getPlaybackOffset(), 3.0, 'offset == dropped duration');
assertClose(s.getCurrentPosition(), 5.0, 'position unchanged by eviction');
});
// Resume-after-pause with an evicted front: playFromPosition resumes at the correct absolute
// time against the shortened array.
test('resume after pause lands at correct absolute time post-eviction', () => {
const cm = new FakeContextManager();
const s = makeScheduler(cm);
s.setBackRetainSeconds(1);
for (let i = 0; i < 10; i++) s.addBuffer(buf(1)); // [0,10)
cm.now = 0;
s.playFromPosition(0);
advanceCursorToEnd(s, cm);
cm.now = 4.0;
s.evictPlayedBuffers(); // back-retain 1 at t=4 => drops buffers ending <=3 (3 buffers)
// Pause at t=4: returns absolute position 4.0.
const paused = s.pause();
assertClose(paused, 4.0, 'pause returns absolute position');
// Front was evicted, so offset advanced. The buffer-relative anchor must net to absolute 4.
assertClose(s.getCurrentPosition(), 4.0, 'position holds at 4 while paused');
// Resume the way AudioPlayer.play does: buffer-relative = absolute - offset.
cm.now = 4.0;
const bufferRelative = paused - s.getPlaybackOffset();
if (bufferRelative < 0) throw new Error('buffer-relative resume position went negative');
s.playFromPosition(bufferRelative);
cm.now = 4.0;
assertClose(s.getCurrentPosition(), 4.0, 'resume restored absolute position');
});
// Seek-back into still-retained buffers works: with back-retain holding recent audio, a short
// backward seek stays in-buffer (queryable/playable), no clamp to the new front.
test('short seek-back into retained region resolves in-buffer', () => {
const cm = new FakeContextManager();
const s = makeScheduler(cm);
s.setBackRetainSeconds(3);
for (let i = 0; i < 10; i++) s.addBuffer(buf(1)); // [0,10)
cm.now = 0;
s.playFromPosition(0);
advanceCursorToEnd(s, cm);
cm.now = 6.0;
s.evictPlayedBuffers(); // back-retain 3 at t=6 => evictBefore=3, drops buffers ending <=3
const offset = s.getPlaybackOffset();
// back-retain 3 at t=6 => evictBefore=3, so buffers ending <=3 dropped, offset==3.
assertClose(offset, 3.0, 'offset after eviction with 3s retain');
// The retained region is [offset, totalEnd) == [3, 10). A seek back to t=4 is inside it.
const seekTarget = 4.0;
const bufferRelative = seekTarget - offset; // 1.0 into the retained array
if (bufferRelative < 0) throw new Error('seek-back target fell below retained front (should be in-buffer)');
cm.now = 6.0;
s.playFromPosition(bufferRelative);
cm.now = 6.0;
assertClose(s.getCurrentPosition(), seekTarget, 'seek-back resolved to absolute target');
});
// Eviction never crosses the live frontier: a buffer still referenced by an unstopped source
// must not be dropped even if the clock says it is "behind".
test('eviction does not drop buffers under live sources or past the schedule cursor', () => {
const cm = new FakeContextManager();
const s = makeScheduler(cm);
s.setBackRetainSeconds(0);
for (let i = 0; i < 10; i++) s.addBuffer(buf(1));
cm.now = 0;
s.playFromPosition(0); // schedules ~first 500ms+ of sources; they remain live (not ended)
// Jump the clock far ahead WITHOUT ending the live sources.
cm.now = 9.0;
const before = s.getBufferCount();
const dropped = s.evictPlayedBuffers();
// Nothing past the schedule cursor or under a live source may be dropped. The scheduled
// (live) sources pin the front, so eviction is bounded — it must not strip the whole array.
if (s.getBufferCount() < 0) throw new Error('buffer count went negative');
assertEqual(s.getBufferCount(), before - dropped, 'count matches dropped');
// The live sources start at index 0, so firstLiveIndex pins eviction at 0 — nothing drops.
assertEqual(dropped, 0, 'no eviction while front sources are live');
});
// handleSourceEnded cascade: eviction fires from the real production trigger (onended), not
// via a direct evictPlayedBuffers() call. Confirms the anchor/index invariants hold end-to-end
// through the scheduler's own event handling while playback is still active with a live source.
//
// Setup: 0.3s buffers so the 500ms lookahead window fits exactly two sources after
// playFromPosition(0). Buffer 0 ends at ~0.31s, buffer 1 ends at ~0.61s — both are scheduled.
// Clock is then advanced to t=0.6 so buffer 0's end (0.31) < evictBefore (0.6) while the live
// source on buffer 1 pins firstLiveIndex=1, blocking further eviction. This is the mid-array
// pinning scenario that later waves (21.2/21.3) build on.
test('eviction via handleSourceEnded: position exact, live bufferIndex decremented, frontier respected', () => {
const cm = new FakeContextManager();
const s = makeScheduler(cm);
// Retain nothing behind the playhead — evict aggressively so the cascade fires.
s.setBackRetainSeconds(0);
// Eight 0.3s buffers. scheduleBuffersFrom with lookaheadTarget=0.5s at t=0:
// after buf 0: nextScheduleTime≈0.31, lookahead=0.31 < 0.5 → continues
// after buf 1: nextScheduleTime≈0.61, lookahead=0.61 > 0.5 → breaks
// → exactly two sources are live after playFromPosition.
for (let i = 0; i < 8; i++) s.addBuffer(buf(0.3));
cm.now = 0;
s.playFromPosition(0);
// Reach inside to see which sources were scheduled and what bufferIndex they hold.
const priv = s as unknown as {
scheduledSources: Array<{ source: FakeSource; bufferIndex: number; startTime: number; endTime: number }>;
nextBufferIndex: number;
};
// Confirm two sources are live — the setup guarantee.
if (priv.scheduledSources.length < 2) {
throw new Error(`Expected ≥2 scheduled sources after playFromPosition, got ${priv.scheduledSources.length}`);
}
// Identify the first and second scheduled sources by bufferIndex order.
const sorted = [...priv.scheduledSources].sort((a, b) => a.bufferIndex - b.bufferIndex);
const firstScheduled = sorted[0]; // bufferIndex 0
const secondScheduled = sorted[1]; // bufferIndex 1
const secondBufferIndexBefore = secondScheduled.bufferIndex; // must be 1
// Record the second FakeSource so we can assert it was not stopped by eviction.
const secondFakeSource = secondScheduled.source as unknown as FakeSource;
// Advance clock to 0.6s. Buffer 0 ends at ~0.31s → evictBefore=0.6, end=0.31 ≤ 0.6 →
// droppable. Buffer 1 ends at ~0.61s → its live source pins firstLiveIndex=1 → NOT dropped.
cm.now = 0.6;
// Confirm playback is still active before firing the cascade.
assertEqual(s.isActive(), true, 'isActive must be true before cascade');
// Fire the cascade via the production trigger: stop the first source, which calls onended,
// which calls handleSourceEnded, which calls evictPlayedBuffers internally.
(firstScheduled.source as unknown as FakeSource).stop();
// (a) Absolute position must remain exactly 0.6.
assertClose(s.getCurrentPosition(), 0.6, 'position after handleSourceEnded cascade');
// (b) The second live source's bufferIndex must have been decremented by 1 (the one evicted
// front buffer), shifting it from absolute index 1 to absolute index 0.
const expectedSecondIndex = secondBufferIndexBefore - 1;
assertEqual(secondScheduled.bufferIndex, expectedSecondIndex, 'live source bufferIndex decremented');
// (c) Eviction stopped at firstLiveIndex=1, not nextBufferIndex — the second buffer was
// NOT dropped. Verify the second source was not stopped (it remained live throughout).
assertEqual(secondFakeSource.stopped, false, 'live second source not stopped by eviction');
// And the scheduler still has buffers (the array was not wiped past the frontier).
if (s.getBufferCount() === 0) {
throw new Error('eviction wiped all buffers — should have stopped at firstLiveIndex');
}
});
// === Phase 21.2 back-pressure: the forward water-mark signal =================================
//
// The signal is pure given the clock + buffer durations + the playhead position, so it is
// testable in Node with the same fakes. We drive forward lookahead by adding buffers (fill) and
// advancing the clock (drain), and assert the hysteresis latch and the OQ3 byte ceiling.
/**
* Fill the scheduler with `count` 1 s buffers, start playback at t=0, and advance the schedule
* cursor to the end so nextBufferIndex does not pin anything. Leaves all `count` buffers decoded
* and the playhead at the clock position the caller sets afterwards.
*/
function fillAndStart(s: PlaybackScheduler, cm: FakeContextManager, count: number): void {
for (let i = 0; i < count; i++) s.addBuffer(buf(1));
cm.now = 0;
s.playFromPosition(0);
advanceCursorToEnd(s, cm);
}
// High-water reached → production pauses; the signal reflects the forward lookahead.
test('evaluateProductionPause latches true when forward lookahead reaches high-water', () => {
const cm = new FakeContextManager();
const s = makeScheduler(cm);
s.setForwardWindow(10, 5, 0); // high 10s, low 5s, byte cap disabled
fillAndStart(s, cm, 40); // 40s decoded, track [0,40)
cm.now = 0; // playhead at 0 → forward lookahead = 40s ≥ 10s high-water
assertEqual(s.getForwardLookaheadSeconds(), 40, 'lookahead is full decoded tail at t=0');
assertEqual(s.evaluateProductionPause(), true, 'pauses at/above high-water');
});
// Below high-water but above low-water while NOT yet paused → stays unpaused (no premature pause).
test('evaluateProductionPause stays false in the hysteresis band before the high-water crossing', () => {
const cm = new FakeContextManager();
const s = makeScheduler(cm);
s.setForwardWindow(10, 5, 0);
fillAndStart(s, cm, 8); // 8s decoded
cm.now = 0; // lookahead 8s: between low(5) and high(10), never latched → unpaused
assertEqual(s.evaluateProductionPause(), false, 'no pause until high-water is actually reached');
});
// Hysteresis: once paused at high-water, stays paused through the band until lookahead drains
// below low-water, then resumes. Drain is modeled by advancing the clock (playhead moves forward,
// shrinking forward lookahead).
test('evaluateProductionPause holds through the band and resumes only below low-water', () => {
const cm = new FakeContextManager();
const s = makeScheduler(cm);
s.setForwardWindow(10, 5, 0);
fillAndStart(s, cm, 40); // track [0,40)
cm.now = 0;
assertEqual(s.evaluateProductionPause(), true, 'latched at high-water (40s ahead)');
// Playhead at 32 → lookahead 8s: in the band (5..10) → must STAY paused (hysteresis).
cm.now = 32;
assertEqual(s.getForwardLookaheadSeconds(), 8, 'lookahead drained to 8s');
assertEqual(s.evaluateProductionPause(), true, 'still paused inside the band');
// Playhead at 36 → lookahead 4s ≤ low-water 5 → resume.
cm.now = 36;
assertEqual(s.getForwardLookaheadSeconds(), 4, 'lookahead below low-water');
assertEqual(s.evaluateProductionPause(), false, 'resumes below low-water');
// Refill back over high-water re-latches (the next chunk would re-pause).
for (let i = 0; i < 20; i++) s.addBuffer(buf(1)); // +20s decoded ahead
assertEqual(s.evaluateProductionPause(), true, 're-latches when fill exceeds high-water again');
});
// OQ3 hard byte ceiling pauses production independent of the time window, and releases as soon as
// the footprint is back under the cap (no separate low-water band on the byte guard).
test('OQ3 byte ceiling pauses regardless of the time window', () => {
const cm = new FakeContextManager();
const s = makeScheduler(cm);
// Each 1s buffer here is 48000 frames × 2 ch × 4 bytes = 384000 bytes. Cap at ~1.5 MB ≈ 4 buffers.
const perBuffer = 48000 * 2 * 4;
s.setForwardWindow(1000, 500, perBuffer * 4); // time window huge so only the byte cap can fire
for (let i = 0; i < 6; i++) s.addBuffer(bufBytes(1)); // 6 buffers > 4-buffer cap
cm.now = 0;
s.playFromPosition(0);
advanceCursorToEnd(s, cm);
cm.now = 0;
if (s.getDecodedByteEstimate() <= perBuffer * 4) {
throw new Error('test setup: byte estimate should exceed the cap');
}
assertEqual(s.evaluateProductionPause(), true, 'byte ceiling pauses even with a huge time window');
});
// clear() / clearForSeek() release the latch so a fresh stream/seek starts unthrottled (C2).
test('clear and clearForSeek release the back-pressure latch (C2 latency parity)', () => {
const cm = new FakeContextManager();
const s = makeScheduler(cm);
s.setForwardWindow(10, 5, 0);
fillAndStart(s, cm, 40);
cm.now = 0;
assertEqual(s.evaluateProductionPause(), true, 'latched');
s.clear();
// After clear there are no buffers, lookahead is 0, and the latch is reset → unpaused.
assertEqual(s.evaluateProductionPause(), false, 'clear resets the latch and empties fill');
fillAndStart(s, cm, 40);
cm.now = 0;
assertEqual(s.evaluateProductionPause(), true, 'latched again after refill');
s.clearForSeek();
assertEqual(s.evaluateProductionPause(), false, 'clearForSeek resets the latch');
});
// Production defaults (no setForwardWindow): the widened 60s/30s cushion. The byte cap is the
// UNCHANGED hard OOM bound; these defaults only govern the time window. buf(1) carries no byte
// fields, so getDecodedByteEstimate is NaN and the byte guard never fires — the time window alone
// governs, which is exactly what we want to pin here.
test('default forward window throttles at 60s and resumes at 30s (no setForwardWindow)', () => {
const cm = new FakeContextManager();
const s = makeScheduler(cm);
// Deliberately no setForwardWindow() — exercise the PRODUCTION defaults (high 60s / low 30s).
for (let i = 0; i < 70; i++) s.addBuffer(buf(1)); // 70s decoded, track [0,70)
cm.now = 0;
s.playFromPosition(0);
advanceCursorToEnd(s, cm);
cm.now = 0; // forward lookahead = 70s ≥ 60s high-water
assertEqual(s.evaluateProductionPause(), true, 'pauses at the 60s default high-water');
cm.now = 35; // lookahead 35s: inside the 30..60 band → stays paused (hysteresis)
assertEqual(s.evaluateProductionPause(), true, 'holds through the widened band');
cm.now = 45; // lookahead 25s ≤ 30s low-water → resume
assertEqual(s.evaluateProductionPause(), false, 'resumes at the 30s default low-water');
});
// Lookahead correctness in the underrun state + the prime block hypothesis directly refuted: when the
// playhead has drained the queue mid-stream, forward lookahead must read ~0 (not a stale-high value)
// so production is NOT throttled while decoded audio is genuinely low.
test('forward lookahead is exact during an underrun park and never trips a false pause', () => {
const cm = new FakeContextManager();
const s = makeScheduler(cm);
// 5s decoded, playback started, stream NOT complete.
for (let i = 0; i < 5; i++) s.addBuffer(buf(1));
cm.now = 0;
s.playFromPosition(0);
// Playhead advances past the decoded tail and the queue drains → mid-stream underrun park.
cm.now = 6;
drainAllSources(s, cm);
assertEqual(s.isActive(), false, 'parked in underrun');
// At the park the playhead sits at the decoded tail: forward lookahead is 0, so production must
// NOT be throttled (the "paused while decoded audio is low" hypothesis must not hold here).
assertClose(s.getForwardLookaheadSeconds(), 0, 'lookahead is 0 at the underrun tail');
assertEqual(s.evaluateProductionPause(), false, 'low decoded audio does not pause production');
// Refill arriving during the park grows the lead monotonically; lookahead reflects exactly it,
// measured against the FROZEN playhead — not a stale pre-underrun position.
s.addBuffer(buf(1));
s.addBuffer(buf(1));
assertClose(s.getForwardLookaheadSeconds(), 2, 'lookahead equals the freshly-accumulated lead');
assertEqual(s.evaluateProductionPause(), false, 'still unthrottled well below the 60s high-water');
});
// === 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: 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 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');
}
});
// === 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();
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: 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');
// 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');
});
// === 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'));
console.error(`\n${failures.length} FAILED, ${passed} passed`);
process.exit(1);
} else {
console.log(`ALL ${passed} TESTS PASSED`);
}