Add partial eviction to PlaybackScheduler (Phase 21.1)
Drop already-played buffers from the front while advancing the time anchor so position/index bookkeeping stays exact. Shared by both decode paths, no format branch. Back-retain is a config seam for 21.2.
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
// --- 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`);
|
||||
}
|
||||
Reference in New Issue
Block a user