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:
daniel-c-harvey
2026-06-23 22:39:05 -04:00
parent ccf7d3dbe3
commit ed606d94c7
2 changed files with 404 additions and 4 deletions
@@ -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);