Files
deepdrft/DeepDrftPublic/Interop/audio/PlaybackScheduler.test.ts
T
daniel-c-harvey 29e8747c69 21.2 review remediation: pause-spin, OQ7 comment, rename, C2 cross-check
Skip the back-pressure interop poll while paused (UC5). Document complete()
draining the stash in full by design. Rename scheduler isProductionPaused to
evaluateProductionPause (latch-advancing); window exposure name unchanged.
2026-06-23 23:28:42 -04:00

452 lines
21 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');
});
// --- 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`);
}