61e185a2f7
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.
835 lines
39 KiB
TypeScript
835 lines
39 KiB
TypeScript
/**
|
||
* 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`);
|
||
}
|