/** * PlaybackScheduler - Manages AudioBuffer storage and playback scheduling. * * Single Responsibility: Store decoded buffers and schedule them for playback. * * 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; startTime: number; endTime: number; } export class PlaybackScheduler { private contextManager: AudioContextManager; private buffers: AudioBuffer[] = []; private scheduledSources: ScheduledSource[] = []; // Playback timing private playbackAnchorTime: number = 0; // AudioContext time when playback started/resumed private playbackAnchorPosition: number = 0; // Position in audio when playback started/resumed private nextBufferIndex: number = 0; // Next buffer to schedule during live streaming private nextScheduleTime: number = 0; // AudioContext time for next buffer private isActive_: boolean = false; // Prevents scheduling during pause/stop // 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; constructor(contextManager: AudioContextManager) { this.contextManager = contextManager; } /** * Add a decoded buffer to storage */ addBuffer(buffer: AudioBuffer): void { this.buffers.push(buffer); } /** * Get total duration of all stored buffers */ getTotalDuration(): number { return this.buffers.reduce((sum, b) => sum + b.duration, 0); } /** * Get number of stored buffers */ getBufferCount(): number { return this.buffers.length; } /** * Get current playback position in seconds (includes playbackOffset for seek-beyond-buffer) */ getCurrentPosition(): number { // Use isActive_ as the sentinel for "playback is running", not playbackAnchorTime == 0. // AudioContext.currentTime can legitimately be 0 at context creation, so comparing // against 0 would incorrectly treat an active stream started at t=0 as paused. if (!this.isActive_) { return this.playbackAnchorPosition + this.playbackOffset; } const elapsed = this.contextManager.currentTime - this.playbackAnchorTime; return Math.min(this.playbackAnchorPosition + this.playbackOffset + elapsed, this.getTotalDuration() + this.playbackOffset); } /** * Set the playback offset for seek-beyond-buffer scenarios * This represents the absolute time position where the current buffers start */ setPlaybackOffset(offset: number): void { this.playbackOffset = offset; } /** * Get the current playback offset */ getPlaybackOffset(): number { 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 */ playFromPosition(position: number): void { this.stopAllSources(); // Find which buffer contains this position let accumulatedTime = 0; let startBufferIndex = 0; let offsetInBuffer = 0; for (let i = 0; i < this.buffers.length; i++) { const bufferDuration = this.buffers[i].duration; if (accumulatedTime + bufferDuration > position) { startBufferIndex = i; offsetInBuffer = position - accumulatedTime; break; } accumulatedTime += bufferDuration; startBufferIndex = i + 1; } if (startBufferIndex >= this.buffers.length) { // Position landed at or past the end of all buffers. Previously this // returned silently, leaving the player stuck "playing" with no source // scheduled — a pause near the end followed by play never recovered. // Treat this as end-of-track so listeners (UI / end callback) fire. this.isActive_ = false; this.playbackAnchorTime = 0; this.playbackAnchorPosition = 0; this.onPlaybackEnded?.(); return; } // Set timing anchors this.playbackAnchorPosition = position; this.playbackAnchorTime = this.contextManager.currentTime; this.nextScheduleTime = this.contextManager.currentTime + 0.01; // Small lookahead this.nextBufferIndex = startBufferIndex; this.isActive_ = true; // Enable scheduling // Schedule buffers this.scheduleBuffersFrom(startBufferIndex, offsetInBuffer); } /** * Schedule newly decoded buffers during live streaming */ scheduleNewBuffers(): void { if (this.nextBufferIndex >= this.buffers.length) { return; // No new buffers } // Use isActive_ as the sentinel for "playback is running", not nextScheduleTime === 0. // AudioContext.currentTime can legitimately be 0 at context creation, which would cause // nextScheduleTime === 0 to incorrectly reset a value already set by playFromPosition. if (!this.isActive_) { return; } this.scheduleBuffersFrom(this.nextBufferIndex, 0); } /** * Internal: Schedule buffers starting from a specific index */ private scheduleBuffersFrom(startIndex: number, offsetInFirstBuffer: number): void { const lookaheadTarget = 0.5; // Schedule up to 500ms ahead const gainNode = this.contextManager.getGainNode(); for (let i = startIndex; i < this.buffers.length; i++) { const buffer = this.buffers[i]; const isFirstBuffer = (i === startIndex && offsetInFirstBuffer > 0); const offset = isFirstBuffer ? offsetInFirstBuffer : 0; const duration = buffer.duration - offset; // Create and configure source const source = this.contextManager.getContext().createBufferSource(); source.buffer = buffer; source.connect(gainNode); const scheduleTime = this.nextScheduleTime; const endTime = scheduleTime + duration; // Track scheduled source const scheduled: ScheduledSource = { source, bufferIndex: i, startTime: scheduleTime, endTime }; this.scheduledSources.push(scheduled); // Set up ended callback source.onended = () => this.handleSourceEnded(scheduled); // Schedule the source source.start(scheduleTime, offset); // Update for next buffer this.nextScheduleTime = endTime; this.nextBufferIndex = i + 1; // Check if we have enough lookahead const lookahead = this.nextScheduleTime - this.contextManager.currentTime; if (lookahead > lookaheadTarget) { break; } } } /** * Handle a source finishing playback */ private handleSourceEnded(scheduled: ScheduledSource): void { // Ignore if we're paused/stopped (sources fire onended when stopped) if (!this.isActive_) { return; } // Remove from scheduled list const index = this.scheduledSources.indexOf(scheduled); if (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 if (this.nextBufferIndex < this.buffers.length) { this.scheduleBuffersFrom(this.nextBufferIndex, 0); } // Check if all playback has finished if (this.scheduledSources.length === 0 && this.nextBufferIndex >= this.buffers.length) { this.isActive_ = false; this.playbackAnchorTime = 0; this.playbackAnchorPosition = 0; this.onPlaybackEnded?.(); } } /** * Pause playback - saves position and stops sources */ pause(): number { const position = this.getCurrentPosition(); this.isActive_ = false; // Prevent handleSourceEnded from scheduling more this.stopAllSources(); // getCurrentPosition() returns absolute time (anchor + playbackOffset); the anchor // is buffer-relative, so strip the offset back out before storing it. this.playbackAnchorPosition = position - this.playbackOffset; this.playbackAnchorTime = 0; this.nextScheduleTime = 0; return position; } /** * Stop all scheduled sources */ stopAllSources(): void { for (const scheduled of this.scheduledSources) { try { scheduled.source.stop(); } catch { // Source may already be stopped } } this.scheduledSources = []; } /** * Reset to beginning (for stop) */ resetToStart(): void { this.isActive_ = false; this.stopAllSources(); this.playbackAnchorPosition = 0; this.playbackAnchorTime = 0; this.nextBufferIndex = 0; this.nextScheduleTime = 0; } /** * Full reset - clears all buffers and resets offset */ clear(): void { this.isActive_ = false; this.stopAllSources(); this.buffers = []; this.playbackAnchorPosition = 0; this.playbackAnchorTime = 0; this.nextBufferIndex = 0; this.nextScheduleTime = 0; this.playbackOffset = 0; } /** * Clear buffers but keep offset - for seek-beyond-buffer scenarios */ clearForSeek(): void { this.isActive_ = false; this.stopAllSources(); this.buffers = []; this.playbackAnchorPosition = 0; this.playbackAnchorTime = 0; this.nextBufferIndex = 0; this.nextScheduleTime = 0; // Note: playbackOffset is NOT reset - it will be set by the caller } /** * Check if we have buffers */ hasBuffers(): boolean { return this.buffers.length > 0; } /** * Check if we have minimum buffers for playback */ hasMinimumBuffers(minCount: number): boolean { return this.buffers.length >= minCount; } /** * Check if playback is active */ isActive(): boolean { return this.isActive_; } }