/** * 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_; } }