ed606d94c7
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.
458 lines
18 KiB
TypeScript
458 lines
18 KiB
TypeScript
/**
|
|
* 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_;
|
|
}
|
|
}
|