/** * 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 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`); }