Files
deepdrft/DeepDrftPublic/Interop/audio/PlaybackScheduler.ts
T
daniel-c-harvey 67422e922d fix(audio): guard underrun/stream-complete against false end-of-playback
pause() clears underrun_ so setStreamComplete can't fire TrackEnded while paused; resetToStart() resets streamComplete. Prior fix: underrun_ park + streamComplete discriminator prevent the Opus-startup false-end. Tests: 18 PlaybackScheduler cases including pause-during-underrun and underrun->resume->genuine-end-once.
2026-06-25 15:16:22 -04:00

673 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
/**
* 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.
*
* Provisional time-based defaults (OQ1 — 21.4 tunes them):
* - HIGH (30 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; at most a few seconds
* even at the high end), so C2 holds — first audio never waits on a throttle (the high-water
* is reached only well after playback is already running).
* - LOW (15 s): resume producing here. Kept generous so the forward fill never drains to the
* ~500 ms scheduler lookahead under normal network jitter (AC3 — no starvation).
*
* 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 — if a pathological stream packs an unusual amount of decoded audio into the time
* window, the byte cap still pauses production. Estimated as channels × frames × 4 bytes (f32).
*/
const DEFAULT_FORWARD_HIGH_WATER_SECONDS = 30;
const DEFAULT_FORWARD_LOW_WATER_SECONDS = 15;
const DEFAULT_MAX_DECODED_BYTES = 96 * 1024 * 1024; // ~96 MB of decoded float PCM
const BYTES_PER_FLOAT_SAMPLE = 4;
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;
// 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;
// If the queue already drained mid-stream (we are parked in underrun) when the genuine-end
// signal arrives, finalise now — the tail produced no more buffers, so this drained state is
// the real end. Gated on underrun_ (logically-playing-but-starved), not isActive_, which is
// false during a parked underrun. A drained queue with no playback in flight (never started,
// or already finished) is left untouched.
if (complete && this.underrun_ &&
this.scheduledSources.length === 0 && this.nextBufferIndex >= this.buffers.length) {
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_) {
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;
// 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;
}
/**
* Check if playback is active
*/
isActive(): boolean {
return this.isActive_;
}
}