From ed606d94c70f67ac8b0b36f957ba12af8e58dc43 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Tue, 23 Jun 2026 22:39:05 -0400 Subject: [PATCH 1/2] 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. --- .../Interop/audio/PlaybackScheduler.test.ts | 263 ++++++++++++++++++ .../Interop/audio/PlaybackScheduler.ts | 145 +++++++++- 2 files changed, 404 insertions(+), 4 deletions(-) create mode 100644 DeepDrftPublic/Interop/audio/PlaybackScheduler.test.ts diff --git a/DeepDrftPublic/Interop/audio/PlaybackScheduler.test.ts b/DeepDrftPublic/Interop/audio/PlaybackScheduler.test.ts new file mode 100644 index 0000000..8b87803 --- /dev/null +++ b/DeepDrftPublic/Interop/audio/PlaybackScheduler.test.ts @@ -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 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`); +} diff --git a/DeepDrftPublic/Interop/audio/PlaybackScheduler.ts b/DeepDrftPublic/Interop/audio/PlaybackScheduler.ts index 0f3b587..0dfc6ad 100644 --- a/DeepDrftPublic/Interop/audio/PlaybackScheduler.ts +++ b/DeepDrftPublic/Interop/audio/PlaybackScheduler.ts @@ -2,11 +2,40 @@ * PlaybackScheduler - Manages AudioBuffer storage and playback scheduling. * * Single Responsibility: Store decoded buffers and schedule them for playback. - * Supports pause/resume/seek by retaining all buffers. + * + * Memory model (Phase 21.1 — partial eviction) + * -------------------------------------------- + * The scheduler is the single shared sink both decode paths feed (WAV/MP3/FLAC via + * `IFormatDecoder`, Opus via the WebCodecs `IStreamingDecoder`); eviction lives here once + * and serves both with zero format branches. + * + * THE INDEX/TIME-ANCHOR INVARIANT (the crux of 21.1): + * `playbackOffset` is the absolute track time at which `buffers[0]` begins. Every + * position query and scheduling decision is expressed as `playbackOffset` + a sum of + * `buffers[i].duration` from index 0. Originally `buffers[0]` was always the track start, + * so `playbackOffset` was 0 except after a seek-beyond-buffer. After partial eviction + * `buffers[0]` is no longer the track start — so eviction MUST add the dropped buffers' + * total duration to `playbackOffset`. That one move keeps `getCurrentPosition`, + * `playFromPosition`, the `getTotalDuration`-based clamp/bounds, and the schedule loop all + * exact against a buffer array that no longer starts at absolute time 0. + * + * The second half of the invariant is the array indices. `nextBufferIndex` and every live + * `scheduledSources[].bufferIndex` are absolute positions into `buffers`; splicing `k` + * buffers off the front shifts every surviving index down by `k`, so both must be + * decremented by `k`. Eviction therefore never crosses the live frontier: it will not drop + * a buffer at/after `nextBufferIndex`, nor one still referenced by a scheduled source. */ import { AudioContextManager } from './AudioContextManager.js'; +/** + * Provisional back-retain default. The window-size POLICY (OQ1/OQ3) is not decided yet, so + * this is intentionally a tunable seam (see setBackRetainSeconds), not a baked-in number — + * 21.2 feeds real water-marks in later. The default keeps a few seconds of already-played + * audio so a short seek-back stays in-buffer (UC3) without a network refetch. + */ +const DEFAULT_BACK_RETAIN_SECONDS = 10; + interface ScheduledSource { source: AudioBufferSourceNode; bufferIndex: number; @@ -26,11 +55,17 @@ export class PlaybackScheduler { private nextScheduleTime: number = 0; // AudioContext time for next buffer private isActive_: boolean = false; // Prevents scheduling during pause/stop - // Offset for seek-beyond-buffer scenarios - // When seeking to position T beyond buffers, we clear buffers and set playbackOffset = T - // The new stream starts at T, so buffer positions are relative to T + // Offset for seek-beyond-buffer scenarios AND partial eviction. + // This is the absolute track time at which buffers[0] begins. It is set on + // seek-beyond-buffer (the new stream starts at T) and ADVANCED by eviction (when the + // front k buffers are dropped, their total duration is added here so buffers[0] still + // names the correct absolute time). See the index/time-anchor invariant in the header. private playbackOffset: number = 0; + // Back-retain bound (seconds of already-played audio kept un-evicted). Provisional seam; + // 21.2 will drive this from the window policy. Not a hardcoded eviction decision. + private backRetainSeconds: number = DEFAULT_BACK_RETAIN_SECONDS; + // Callbacks public onPlaybackEnded: (() => void) | null = null; @@ -88,6 +123,102 @@ export class PlaybackScheduler { return this.playbackOffset; } + /** + * Configure the back-retain bound (seconds of already-played audio kept un-evicted). + * Provisional config seam — 21.2 feeds the real window policy in here. Negative values + * are clamped to 0 (retain nothing behind the playhead). + */ + setBackRetainSeconds(seconds: number): void { + this.backRetainSeconds = Math.max(0, seconds); + } + + /** + * Drop already-played buffers from the front of the array, reclaiming their decoded float + * memory, and advance the time anchor so all position/index bookkeeping stays exact. + * + * Eviction frontier: any buffer whose absolute END time is older than + * (currentPosition - backRetainSeconds) is droppable. We evict a contiguous run from the + * front only — buffers are appended in playback order, so the front is always the oldest. + * + * Two hard safety bounds keep the live frontier intact (the second half of the + * index/time-anchor invariant): + * 1. Never evict at/after `nextBufferIndex` — those are not yet scheduled; dropping them + * would lose unplayed audio and corrupt the schedule cursor. + * 2. Never evict a buffer still referenced by a live scheduled source — its + * AudioBufferSourceNode is mid-flight and `handleSourceEnded` still tracks it. + * + * Returns the number of buffers evicted (0 if nothing was droppable). + * + * This is the SHARED eviction both decode paths get for free — no format branch. It does + * not fetch, decode, or back-pressure (those are 21.2/21.3); with producers unchanged it + * makes the *played* region provably memory-bounded on both paths. + */ + evictPlayedBuffers(): number { + if (this.buffers.length === 0) { + return 0; + } + + // Absolute time before which a fully-ended buffer may be dropped. + const evictBefore = this.getCurrentPosition() - this.backRetainSeconds; + + // Lowest index still referenced by a live scheduled source (or buffers.length if none). + // Eviction must not cross this — those sources are playing now. + let firstLiveIndex = this.buffers.length; + for (const scheduled of this.scheduledSources) { + if (scheduled.bufferIndex < firstLiveIndex) { + firstLiveIndex = scheduled.bufferIndex; + } + } + + // Hard ceiling on how many front buffers we may drop: not past the schedule cursor, + // and not past the oldest live source. + const maxEvictable = Math.min(this.nextBufferIndex, firstLiveIndex); + + // Walk the front, accumulating absolute end times, counting droppable buffers. + let evictCount = 0; + let accumulatedEnd = this.playbackOffset; + for (let i = 0; i < maxEvictable; i++) { + accumulatedEnd += this.buffers[i].duration; + // Drop only buffers whose END is strictly behind the retain frontier. + if (accumulatedEnd <= evictBefore) { + evictCount = i + 1; + } else { + break; // later buffers end even later — nothing more is droppable + } + } + + if (evictCount === 0) { + return 0; + } + + // Sum the dropped duration BEFORE splicing, then advance the time anchor by it so + // buffers[0] still names the correct absolute start time. This is the move that keeps + // every position/scheduling query exact against a front-evicted array. + let droppedDuration = 0; + for (let i = 0; i < evictCount; i++) { + droppedDuration += this.buffers[i].duration; + } + + this.buffers.splice(0, evictCount); + + // Advance the absolute time anchor (offset) by the dropped duration AND drop the + // buffer-relative anchor position by the same amount. These two move in lockstep: + // getCurrentPosition() is (playbackAnchorPosition + playbackOffset + elapsed), so + // adjusting only one would make the reported position jump by droppedDuration. + // Moving both by +d / -d leaves the ABSOLUTE position unchanged while keeping + // playbackAnchorPosition buffer-relative (the convention playFromPosition/pause use). + this.playbackOffset += droppedDuration; + this.playbackAnchorPosition -= droppedDuration; + + // Every surviving absolute index shifts down by evictCount. + this.nextBufferIndex -= evictCount; + for (const scheduled of this.scheduledSources) { + scheduled.bufferIndex -= evictCount; + } + + return evictCount; + } + /** * Start or resume playback from a specific position */ @@ -214,6 +345,12 @@ export class PlaybackScheduler { this.scheduledSources.splice(index, 1); } + // A source just finished, so its buffer is now behind the playhead — the natural + // point to reclaim played memory. Eviction is self-contained (no fetch/back-pressure) + // and runs before re-scheduling so index bookkeeping is settled first. This is the + // 21.1 trigger that keeps the PLAYED region bounded with producers unchanged. + this.evictPlayedBuffers(); + // Schedule more buffers if available if (this.nextBufferIndex < this.buffers.length) { this.scheduleBuffersFrom(this.nextBufferIndex, 0); From 07f29a82164e844628e72db4dd18e068af944128 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Tue, 23 Jun 2026 22:49:12 -0400 Subject: [PATCH 2/2] Reconcile eviction comment wording; add handleSourceEnded cascade test (Phase 21.1) The inclusive <= bound is correct; comments now say 'at or behind'. New test drives eviction through the real onended trigger with a live mid-array source pinning the frontier. --- .../Interop/audio/PlaybackScheduler.test.ts | 72 +++++++++++++++++++ .../Interop/audio/PlaybackScheduler.ts | 4 +- 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/DeepDrftPublic/Interop/audio/PlaybackScheduler.test.ts b/DeepDrftPublic/Interop/audio/PlaybackScheduler.test.ts index 8b87803..ec75d0e 100644 --- a/DeepDrftPublic/Interop/audio/PlaybackScheduler.test.ts +++ b/DeepDrftPublic/Interop/audio/PlaybackScheduler.test.ts @@ -253,6 +253,78 @@ test('eviction does not drop buffers under live sources or past the schedule cur 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'); + } +}); + // --- run ------------------------------------------------------------------------------------- if (failures.length > 0) { console.error(failures.join('\n')); diff --git a/DeepDrftPublic/Interop/audio/PlaybackScheduler.ts b/DeepDrftPublic/Interop/audio/PlaybackScheduler.ts index 0dfc6ad..975dc45 100644 --- a/DeepDrftPublic/Interop/audio/PlaybackScheduler.ts +++ b/DeepDrftPublic/Interop/audio/PlaybackScheduler.ts @@ -136,7 +136,7 @@ export class PlaybackScheduler { * Drop already-played buffers from the front of the array, reclaiming their decoded float * memory, and advance the time anchor so all position/index bookkeeping stays exact. * - * Eviction frontier: any buffer whose absolute END time is older than + * Eviction frontier: any buffer whose absolute END time is at or older than * (currentPosition - backRetainSeconds) is droppable. We evict a contiguous run from the * front only — buffers are appended in playback order, so the front is always the oldest. * @@ -179,7 +179,7 @@ export class PlaybackScheduler { let accumulatedEnd = this.playbackOffset; for (let i = 0; i < maxEvictable; i++) { accumulatedEnd += this.buffers[i].duration; - // Drop only buffers whose END is strictly behind the retain frontier. + // Drop buffers whose END is at or behind the retain frontier (inclusive bound). if (accumulatedEnd <= evictBefore) { evictCount = i + 1; } else {