753 lines
36 KiB
TypeScript
753 lines
36 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';
|
||
import { decodePressure } from './decodePressure.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;
|
||
|
||
/**
|
||
* Forward back-pressure water-marks (Phase 21.2 — the bound on the *unplayed* region).
|
||
*
|
||
* The single back-pressure signal is the scheduler's decoded forward lookahead: how many
|
||
* seconds of decoded audio sit AHEAD of the playhead (OQ7). Production (the C# read loop and,
|
||
* for Opus, the demux/decode feed) pauses above the high-water mark and resumes below the
|
||
* low-water mark — classic hysteresis so the two producers do not chatter on/off per chunk.
|
||
*
|
||
* Time-based defaults — the cushion, NOT the memory bound:
|
||
* - HIGH (60 s): the most decoded lookahead we hold ahead of the playhead before throttling.
|
||
* Comfortably above the playback-start minimum (`AudioPlayer.minBuffersForPlayback = 6`
|
||
* buffers, each typically 0.06 – 1 s depending on format/chunk size), so C2 holds — first
|
||
* audio never waits on a throttle (the high-water is reached only well after playback runs).
|
||
* - LOW (30 s): resume producing here. Kept generous so the forward fill never drains to the
|
||
* ~500 ms scheduler lookahead under network/decode jitter (AC3 — no starvation).
|
||
*
|
||
* Why 60/30 and not the old 30/15: the time window is a CUSHION knob, not the memory guarantee —
|
||
* the OQ3 byte ceiling below is the hard OOM bound. The old 30 s was sized for WAV's byte density
|
||
* and needlessly starved the cushion for the async WebCodecs Opus path, whose decoded float
|
||
* footprint is tiny (48 kHz stereo ≈ 0.37 MB/s, so 60 s ≈ 23 MB — a fraction of the 96 MB cap)
|
||
* yet whose per-packet decode jitter (HW-accel-off software decode, main-thread AudioData copies)
|
||
* needs a deeper buffer to stay ahead of the playhead. Doubling the window lets Opus use the memory
|
||
* headroom the byte cap already permits. The byte cap is UNCHANGED, so a high-footprint stream
|
||
* still pauses at exactly the same footprint as before — the OOM fix does not regress.
|
||
*
|
||
* OQ3 hard memory ceiling: an absolute byte cap on total decoded float held, independent of the
|
||
* time window. This is the guard-rail that makes "1 GB never OOMs" a guarantee rather than a
|
||
* tuning hope — production pauses on `lookahead >= high OR bytes > cap`, whichever fires first, so
|
||
* the footprint can never exceed the cap regardless of the time window. The decoded f32 footprint
|
||
* scales with sample rate × channels (not source codec), so for high-sample-rate / multichannel
|
||
* audio the byte cap fires before 60 s (bounding memory exactly as the old 30 s window's byte
|
||
* estimate did); for sparse 48 kHz stereo Opus the time window fires first, at ~23 MB. Estimated
|
||
* as channels × frames × 4 (f32).
|
||
*/
|
||
const DEFAULT_FORWARD_HIGH_WATER_SECONDS = 60;
|
||
const DEFAULT_FORWARD_LOW_WATER_SECONDS = 30;
|
||
const DEFAULT_MAX_DECODED_BYTES = 96 * 1024 * 1024; // ~96 MB of decoded float PCM — the HARD OOM bound
|
||
const BYTES_PER_FLOAT_SAMPLE = 4;
|
||
|
||
/**
|
||
* Rebuffer hysteresis lead — the minimum SECONDS of decoded-but-unscheduled audio that must
|
||
* accumulate ahead of the schedule cursor before playback may (re)start after a mid-stream underrun.
|
||
*
|
||
* Why seconds, not a buffer count: the per-buffer duration differs wildly by format. A WAV/lossless
|
||
* segment is a sizeable slab (~0.1–0.4 s); a single Opus WebCodecs packet is ~20 ms. The old resume
|
||
* path re-anchored on the FIRST arriving buffer, so for Opus it scheduled ~20 ms, drained it, parked,
|
||
* resumed on the next ~20 ms, and so on — the audible start/stop thrash during the WebCodecs decode
|
||
* ramp. Gating on a fixed LEAD in seconds gives a resume the same cushion a fresh start has,
|
||
* independent of format. 1 s is the same order as the lossless playback-start lead (~6 segments) and
|
||
* sits far below the 60 s forward high-water, so back-pressure never throttles production while the
|
||
* scheduler is still re-accumulating this lead. Tunable; not magic.
|
||
*/
|
||
const DEFAULT_MIN_PLAYBACK_LEAD_SECONDS = 1.0;
|
||
|
||
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;
|
||
|
||
// Forward back-pressure water-marks + the OQ3 hard byte ceiling (Phase 21.2). This is the
|
||
// single shared window policy (OQ6): both producers call evaluateProductionPause() and honor it
|
||
// in their own way — the C# read loop stops ReadAsync, the Opus feed stops demux/decode.
|
||
private forwardHighWaterSeconds: number = DEFAULT_FORWARD_HIGH_WATER_SECONDS;
|
||
private forwardLowWaterSeconds: number = DEFAULT_FORWARD_LOW_WATER_SECONDS;
|
||
private maxDecodedBytes: number = DEFAULT_MAX_DECODED_BYTES;
|
||
|
||
// Rebuffer hysteresis lead (seconds). The minimum decoded-but-unscheduled audio that must sit
|
||
// ahead of the schedule cursor before playback may (re)start — at a fresh start AND after a
|
||
// mid-stream underrun. Without it the underrun resume re-anchored on the first arriving buffer
|
||
// and thrashed on the Opus decode ramp. See DEFAULT_MIN_PLAYBACK_LEAD_SECONDS.
|
||
private minPlaybackLeadSeconds: number = DEFAULT_MIN_PLAYBACK_LEAD_SECONDS;
|
||
|
||
// Hysteresis latch for the production pause. Once forward fill crosses the high-water mark we
|
||
// stay paused until it drains below the low-water mark, so the two producers do not flap
|
||
// on/off around a single threshold (and a paused producer does not resume for one chunk only
|
||
// to re-pause immediately). False until first crossing; flips on the band edges.
|
||
// Mutated by evaluateProductionPause() — named to signal the state-advance on each call.
|
||
private productionPaused_: boolean = false;
|
||
|
||
// True once the producer (C# read loop / Opus feed) has signalled that ALL bytes are in and
|
||
// every decodable buffer has been added. This is the discriminator between a genuine
|
||
// end-of-track and a transient gap. End-of-playback fires ONLY when this is true AND the
|
||
// scheduled queue has drained — a drained queue while this is false is a startup/underrun gap,
|
||
// not the end (Opus decodes via WebCodecs asynchronously, so the first AudioBuffer can lag the
|
||
// playback-start minimum, briefly leaving zero scheduled sources before real playback). Reset
|
||
// by clear/clearForSeek/resetToStart; set by setStreamComplete.
|
||
private streamComplete: boolean = false;
|
||
|
||
// True while playback is logically running but the decoded queue ran dry mid-stream (underrun).
|
||
// We stop the scheduler (isActive_ = false) so no source schedules against a stale anchor, but
|
||
// remember we must re-anchor and resume the moment new buffers arrive — distinct from a paused/
|
||
// stopped player, which clears this. Without it, scheduleNewBuffers would silently no-op on the
|
||
// !isActive_ guard and playback would never recover from a starvation gap.
|
||
private underrun_: boolean = false;
|
||
|
||
// 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);
|
||
}
|
||
|
||
/**
|
||
* Mark whether the byte stream is complete (all bytes received and all decodable buffers added).
|
||
* The end-of-playback callback fires only when this is true AND the scheduled queue has drained —
|
||
* so a drained queue while the stream is still in flight (startup/underrun) is never mistaken for
|
||
* end-of-track. Set true by AudioPlayer on markStreamComplete / decoder isComplete; set false on a
|
||
* fresh stream or a range-continuation reinit. Setting it true while playback has already drained
|
||
* mid-stream finalises the track immediately (the genuine-end signal arrived after the queue
|
||
* emptied — e.g. the very last buffers were the tail).
|
||
*/
|
||
setStreamComplete(complete: boolean): void {
|
||
this.streamComplete = complete;
|
||
// Only act when the genuine-end signal lands while we are parked in underrun (logically
|
||
// playing but starved); a drained queue with no playback in flight — never started, or
|
||
// already finished — is left untouched. Gated on underrun_, not isActive_, which is false
|
||
// during a parked underrun.
|
||
if (!complete || !this.underrun_) {
|
||
return;
|
||
}
|
||
// The rebuffer threshold no longer applies — a complete stream yields no further buffers:
|
||
// - tail buffers accumulated below the threshold while we were parked (the new hysteresis
|
||
// kept us parked) → schedule them out; scheduleNewBuffers' underrun branch now resumes
|
||
// because streamComplete overrides the lead gate, and handleSourceEnded fires the genuine
|
||
// end when they drain. Without this the buffers would never schedule and we would park
|
||
// forever (queue drained, isActive_ false, threshold never met).
|
||
// - no tail at all (cursor already at the decoded end) → this drained state IS the end.
|
||
if (this.nextBufferIndex < this.buffers.length) {
|
||
this.scheduleNewBuffers();
|
||
} else if (this.scheduledSources.length === 0) {
|
||
this.finishPlayback();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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);
|
||
}
|
||
|
||
/**
|
||
* Configure the forward back-pressure water-marks (seconds of decoded lookahead) and the OQ3
|
||
* hard byte ceiling. Provisional config seam — 21.4 tunes the numbers. Low is clamped below
|
||
* high so the hysteresis band is always valid; non-positive byte cap disables the OQ3 guard.
|
||
*/
|
||
setForwardWindow(highWaterSeconds: number, lowWaterSeconds: number, maxDecodedBytes: number): void {
|
||
this.forwardHighWaterSeconds = Math.max(0, highWaterSeconds);
|
||
this.forwardLowWaterSeconds = Math.max(0, Math.min(lowWaterSeconds, this.forwardHighWaterSeconds));
|
||
this.maxDecodedBytes = maxDecodedBytes;
|
||
}
|
||
|
||
/**
|
||
* Seconds of decoded audio sitting AHEAD of the current playhead — the forward fill. This is
|
||
* the single back-pressure signal (OQ7): the absolute end time of the last decoded buffer
|
||
* minus the current playback position. Never negative (clamped at 0 when the playhead has
|
||
* caught up to or passed the decoded tail).
|
||
*/
|
||
getForwardLookaheadSeconds(): number {
|
||
const decodedEnd = this.getTotalDuration() + this.playbackOffset;
|
||
return Math.max(0, decodedEnd - this.getCurrentPosition());
|
||
}
|
||
|
||
/**
|
||
* Estimated bytes of decoded float PCM currently retained (OQ3 input). Web Audio AudioBuffers
|
||
* are 32-bit float per sample per channel; frames = duration × sampleRate. Summed across the
|
||
* retained buffers only — evicted buffers are already reclaimed, so this tracks the live
|
||
* footprint, not the whole track.
|
||
*/
|
||
getDecodedByteEstimate(): number {
|
||
let bytes = 0;
|
||
for (const b of this.buffers) {
|
||
bytes += b.length * b.numberOfChannels * BYTES_PER_FLOAT_SAMPLE;
|
||
}
|
||
return bytes;
|
||
}
|
||
|
||
/**
|
||
* The single shared production-pause decision (Phase 21.2, OQ6/OQ7). Both producers — the C#
|
||
* read loop (21.2a) and the Opus demux/decode feed (21.2b) — call this and stop producing
|
||
* while it returns true. Hysteresis: pause when forward lookahead exceeds the high-water mark
|
||
* OR the decoded byte estimate exceeds the OQ3 ceiling; resume only once forward lookahead has
|
||
* drained below the low-water mark AND the byte estimate is back under the ceiling. The
|
||
* byte-ceiling test has no separate low-water band — it is the hard guard rail, so it releases
|
||
* as soon as eviction brings the footprint back under the cap.
|
||
*
|
||
* Named `evaluateProductionPause` (not `isProductionPaused`) because each call may ADVANCE the
|
||
* hysteresis latch (`productionPaused_`), making it a state-advancing evaluation, not a pure
|
||
* read. `AudioPlayer.isProductionPaused()` is the pure-predicate wrapper exposed to callers
|
||
* outside the scheduler.
|
||
*/
|
||
evaluateProductionPause(): boolean {
|
||
const lookahead = this.getForwardLookaheadSeconds();
|
||
const overByteCeiling = this.maxDecodedBytes > 0 && this.getDecodedByteEstimate() > this.maxDecodedBytes;
|
||
|
||
if (this.productionPaused_) {
|
||
// Stay paused until BOTH the time window has drained below low-water AND the byte
|
||
// footprint is back under the ceiling.
|
||
if (lookahead <= this.forwardLowWaterSeconds && !overByteCeiling) {
|
||
this.productionPaused_ = false;
|
||
}
|
||
} else if (lookahead >= this.forwardHighWaterSeconds || overByteCeiling) {
|
||
this.productionPaused_ = true;
|
||
}
|
||
|
||
return this.productionPaused_;
|
||
}
|
||
|
||
/**
|
||
* 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
|
||
*/
|
||
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 currently-decoded buffers. This is
|
||
// end-of-track ONLY if the stream is complete; otherwise it is a startup/underrun
|
||
// gap (decode hasn't caught up to the playhead yet) and firing onPlaybackEnded here
|
||
// would be a FALSE end — exactly the Opus-startup misfire. When complete, finish;
|
||
// when still streaming, park in underrun so scheduleNewBuffers resumes on the next
|
||
// decoded buffer rather than the player being stuck "playing" with nothing scheduled.
|
||
if (this.streamComplete) {
|
||
this.finishPlayback();
|
||
} else {
|
||
this.underrun_ = true;
|
||
this.playbackAnchorPosition = position;
|
||
this.nextBufferIndex = startBufferIndex;
|
||
this.isActive_ = false; // no source to schedule yet; resume() re-anchors on refill
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Set timing anchors
|
||
this.underrun_ = false;
|
||
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
|
||
}
|
||
|
||
// Resume from a mid-stream underrun: the queue had drained ahead of decode and we parked
|
||
// (isActive_ = false, underrun_ = true) instead of firing a false end. Newly decoded
|
||
// buffers are now available at nextBufferIndex, so re-anchor the clock at the resume point
|
||
// and re-enable scheduling. We re-anchor (rather than reusing the stale nextScheduleTime
|
||
// captured before the gap) so the resumed audio is contiguous from "now" — a stale anchor
|
||
// would schedule the next source in the past and the browser would drop or rush it.
|
||
if (this.underrun_) {
|
||
// Rebuffer hysteresis: do NOT resume on the first arriving buffer. With an empty scheduled
|
||
// tail, resuming on a single buffer plays it (~20 ms for Opus) and immediately re-drains,
|
||
// re-parking — the audible start/stop thrash on the Opus WebCodecs decode ramp. Stay parked
|
||
// and keep accumulating until a healthy lead has rebuilt, so the resumed playback has the
|
||
// same cushion a fresh start does. While parked the playhead is frozen, so each arriving
|
||
// buffer grows the lead monotonically toward the threshold (no starvation/deadlock).
|
||
//
|
||
// streamComplete overrides the gate: a finished stream produces no further buffers, so a
|
||
// tail shorter than the lead MUST still play out (here and via setStreamComplete) rather
|
||
// than park forever. handleSourceEnded fires the genuine end once that tail drains.
|
||
if (!this.streamComplete && !this.hasMinimumPlaybackLead()) {
|
||
return; // still re-accumulating the rebuffer lead — remain parked
|
||
}
|
||
this.underrun_ = false;
|
||
this.isActive_ = true;
|
||
this.playbackAnchorTime = this.contextManager.currentTime;
|
||
this.nextScheduleTime = this.contextManager.currentTime + 0.01;
|
||
this.scheduleBuffersFrom(this.nextBufferIndex, 0);
|
||
return;
|
||
}
|
||
|
||
// 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);
|
||
}
|
||
|
||
// The scheduled queue drained AND the cursor caught up to every decoded buffer. Whether
|
||
// this is the end depends on the stream:
|
||
// - streamComplete: genuine end-of-track — finish and fire onPlaybackEnded.
|
||
// - still streaming: a mid-stream UNDERRUN (decode fell behind the playhead — the Opus
|
||
// WebCodecs startup gap, or a network stall). Firing onPlaybackEnded here is the false
|
||
// end this guards against. Park in underrun; scheduleNewBuffers resumes on the next
|
||
// decoded buffer.
|
||
if (this.scheduledSources.length === 0 && this.nextBufferIndex >= this.buffers.length) {
|
||
if (this.streamComplete) {
|
||
this.finishPlayback();
|
||
} else {
|
||
this.underrun_ = true;
|
||
// Mid-stream underrun: the scheduled queue drained and decode has not caught up. Report it
|
||
// as decode pressure so the visualizer throttles — a sustained run of these is exactly the
|
||
// HW-accel-off starvation the auto-throttle protects against. The hysteresis in the signal
|
||
// ignores a lone startup-ramp underrun; only a sustained run engages the throttle.
|
||
decodePressure.report();
|
||
// Hold the playhead at the decoded tail so getCurrentPosition stays exact during
|
||
// the gap. isActive_ goes false so no stale-anchor scheduling occurs; resume
|
||
// re-anchors at currentTime when buffers arrive.
|
||
this.playbackAnchorPosition = this.getCurrentPosition() - this.playbackOffset;
|
||
this.playbackAnchorTime = 0;
|
||
this.isActive_ = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Finalise playback: stop the clock, reset anchors, and fire the end-of-playback callback. The
|
||
* single genuine-end path, reached only when the stream is complete AND the queue has fully
|
||
* drained (handleSourceEnded / setStreamComplete) or playback resumed past a complete stream's
|
||
* end (playFromPosition). Never called for a transient startup/underrun gap.
|
||
*/
|
||
private finishPlayback(): void {
|
||
this.isActive_ = false;
|
||
this.underrun_ = 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
|
||
// Clear the underrun flag: if the queue drained mid-stream and the user pauses before new
|
||
// buffers arrive, a subsequent setStreamComplete must not fire finishPlayback while still
|
||
// paused. On resume, playFromPosition re-parks underrun if the decoded tail still hasn't
|
||
// caught up, so no genuine end is lost by clearing it here.
|
||
this.underrun_ = false;
|
||
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.underrun_ = false;
|
||
this.streamComplete = 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.underrun_ = false;
|
||
this.streamComplete = false;
|
||
this.stopAllSources();
|
||
this.buffers = [];
|
||
this.playbackAnchorPosition = 0;
|
||
this.playbackAnchorTime = 0;
|
||
this.nextBufferIndex = 0;
|
||
this.nextScheduleTime = 0;
|
||
this.playbackOffset = 0;
|
||
// Release the back-pressure latch — a fresh stream must start unthrottled so its first
|
||
// chunks decode immediately (C2: no throttle-induced first-audio stall).
|
||
this.productionPaused_ = false;
|
||
}
|
||
|
||
/**
|
||
* Clear buffers but keep offset - for seek-beyond-buffer scenarios
|
||
*/
|
||
clearForSeek(): void {
|
||
this.isActive_ = false;
|
||
this.underrun_ = false;
|
||
// The range continuation is a fresh byte stream — it is NOT complete until its own
|
||
// markStreamComplete. Reset so a stale "complete" from the pre-seek stream cannot make the
|
||
// post-seek refill fire a premature end before its bytes arrive.
|
||
this.streamComplete = 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
|
||
// Release the back-pressure latch — the post-seek continuation must refill from the new
|
||
// offset without inheriting the pre-seek paused state.
|
||
this.productionPaused_ = false;
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
}
|
||
|
||
/**
|
||
* True once at least `minPlaybackLeadSeconds` of decoded-but-unscheduled audio sits ahead of the
|
||
* schedule cursor — the rebuffer-hysteresis gate for both a fresh playback start (cursor at 0, so
|
||
* this measures the whole decoded head) and an underrun resume (cursor at the drained tail, so this
|
||
* measures only the freshly-accumulated lead). Sums only up to the threshold and short-circuits, so
|
||
* it is bounded (~one threshold's worth of buffers) regardless of how much is buffered ahead.
|
||
*/
|
||
hasMinimumPlaybackLead(): boolean {
|
||
let lead = 0;
|
||
for (let i = this.nextBufferIndex; i < this.buffers.length; i++) {
|
||
lead += this.buffers[i].duration;
|
||
if (lead >= this.minPlaybackLeadSeconds) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Check if playback is active
|
||
*/
|
||
isActive(): boolean {
|
||
return this.isActive_;
|
||
}
|
||
}
|