Merge Phase 21.1 (PlaybackScheduler partial eviction) into streaming-overhaul
This commit is contained in:
@@ -0,0 +1,335 @@
|
|||||||
|
/**
|
||||||
|
* 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');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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'));
|
||||||
|
console.error(`\n${failures.length} FAILED, ${passed} passed`);
|
||||||
|
process.exit(1);
|
||||||
|
} else {
|
||||||
|
console.log(`ALL ${passed} TESTS PASSED`);
|
||||||
|
}
|
||||||
@@ -2,11 +2,40 @@
|
|||||||
* PlaybackScheduler - Manages AudioBuffer storage and playback scheduling.
|
* PlaybackScheduler - Manages AudioBuffer storage and playback scheduling.
|
||||||
*
|
*
|
||||||
* Single Responsibility: Store decoded buffers and schedule them for playback.
|
* 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';
|
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 {
|
interface ScheduledSource {
|
||||||
source: AudioBufferSourceNode;
|
source: AudioBufferSourceNode;
|
||||||
bufferIndex: number;
|
bufferIndex: number;
|
||||||
@@ -26,11 +55,17 @@ export class PlaybackScheduler {
|
|||||||
private nextScheduleTime: number = 0; // AudioContext time for next buffer
|
private nextScheduleTime: number = 0; // AudioContext time for next buffer
|
||||||
private isActive_: boolean = false; // Prevents scheduling during pause/stop
|
private isActive_: boolean = false; // Prevents scheduling during pause/stop
|
||||||
|
|
||||||
// Offset for seek-beyond-buffer scenarios
|
// Offset for seek-beyond-buffer scenarios AND partial eviction.
|
||||||
// When seeking to position T beyond buffers, we clear buffers and set playbackOffset = T
|
// This is the absolute track time at which buffers[0] begins. It is set on
|
||||||
// The new stream starts at T, so buffer positions are relative to T
|
// 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;
|
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
|
// Callbacks
|
||||||
public onPlaybackEnded: (() => void) | null = null;
|
public onPlaybackEnded: (() => void) | null = null;
|
||||||
|
|
||||||
@@ -88,6 +123,102 @@ export class PlaybackScheduler {
|
|||||||
return this.playbackOffset;
|
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 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.
|
||||||
|
*
|
||||||
|
* 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 buffers whose END is at or behind the retain frontier (inclusive bound).
|
||||||
|
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
|
* Start or resume playback from a specific position
|
||||||
*/
|
*/
|
||||||
@@ -214,6 +345,12 @@ export class PlaybackScheduler {
|
|||||||
this.scheduledSources.splice(index, 1);
|
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
|
// Schedule more buffers if available
|
||||||
if (this.nextBufferIndex < this.buffers.length) {
|
if (this.nextBufferIndex < this.buffers.length) {
|
||||||
this.scheduleBuffersFrom(this.nextBufferIndex, 0);
|
this.scheduleBuffersFrom(this.nextBufferIndex, 0);
|
||||||
|
|||||||
Reference in New Issue
Block a user