Files
deepdrft/DeepDrftPublic/Interop/audio/AudioPlayer.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

818 lines
35 KiB
TypeScript

/**
* AudioPlayer - Main orchestrator for audio playback.
*
* Composes specialized managers following Single Responsibility Principle:
* - AudioContextManager: Web Audio API context and routing
* - StreamDecoder: WAV parsing and decoding
* - PlaybackScheduler: Buffer storage and playback scheduling
*/
import { AudioContextManager } from './AudioContextManager.js';
import { StreamDecoder } from './StreamDecoder.js';
import { PlaybackScheduler } from './PlaybackScheduler.js';
import { IFormatDecoder } from './IFormatDecoder.js';
import { IStreamingDecoder } from './IStreamingDecoder.js';
import { WavFormatDecoder } from './WavFormatDecoder.js';
import { Mp3FormatDecoder } from './Mp3FormatDecoder.js';
import { FlacFormatDecoder } from './FlacFormatDecoder.js';
import { OpusStreamDecoder } from './OpusStreamDecoder.js';
import { OpusSeekData, parseSidecar, resolveOpusByteOffset, OpusSeekResolution, OPUS_SAMPLE_RATE } from './OpusSidecar.js';
export interface AudioResult {
success: boolean;
error?: string;
seekBeyondBuffer?: boolean;
byteOffset?: number;
}
export interface StreamingResult extends AudioResult {
canStartStreaming?: boolean;
headerParsed?: boolean;
bufferCount?: number;
duration?: number;
// Phase 21.2a back-pressure signal piggybacked on the chunk result the C# read loop already
// awaits — true means the scheduler's forward fill is over the high-water mark and the loop
// should stop calling ReadAsync until it drains (no extra interop hop in the common case).
productionPaused?: boolean;
}
export interface AudioState {
isPlaying: boolean;
isPaused: boolean;
currentTime: number;
duration: number;
volume: number;
}
type ProgressCallback = (currentTime: number) => void;
type EndCallback = () => void;
export class AudioPlayer {
private contextManager: AudioContextManager;
private streamDecoder: StreamDecoder;
private scheduler: PlaybackScheduler;
// The Opus WebCodecs decode path (IStreamingDecoder seam), used INSTEAD of streamDecoder when the
// active stream is Ogg Opus. Null for WAV/MP3/FLAC, which keep the streamDecoder path unchanged.
// Holding both is deliberate: the change is the decode stage only; the same scheduler/Web Audio
// graph feeds from whichever decoder is active for the current stream.
private opusDecoder: IStreamingDecoder | null = null;
// The sidecar in effect for the active Opus stream (its seek index resolves byte offsets). Distinct
// from pendingOpusSidecar, which is the one set for the NEXT stream init.
private activeOpusSidecar: OpusSeekData | null = null;
// The landing time of the most recent seek-beyond-buffer page resolution (seconds). Set by
// seekBeyondBuffer, consumed by reinitializeFromOffset to trim leading decoded frames so the
// audible position matches the requested seek target (AC9 fine re-sync, §3.4a step 4).
private _seekLandingTime: number = 0;
// Playback state
private isPlaying: boolean = false;
private isPaused: boolean = false;
private pausePosition: number = 0;
private duration: number = 0;
// Streaming state
private isStreamingMode: boolean = false;
private streamingStarted: boolean = false;
private streamingCompleted: boolean = false;
private minBuffersForPlayback: number = 6;
// Callbacks
private onProgressCallback: ProgressCallback | null = null;
private onEndCallback: EndCallback | null = null;
private progressInterval: number | null = null;
// Pending Opus sidecar (setup header + seek index), parsed from the one-time sidecar fetch and
// applied to the OpusFormatDecoder when the next Opus stream initializes. Wave 18.5 sets this
// (via setOpusSidecar) before initializeStreaming; this class never fetches it.
private pendingOpusSidecar: OpusSeekData | null = null;
constructor() {
this.contextManager = new AudioContextManager();
this.streamDecoder = new StreamDecoder(this.contextManager);
this.scheduler = new PlaybackScheduler(this.contextManager);
// Wire up scheduler callbacks
this.scheduler.onPlaybackEnded = () => this.handlePlaybackEnded();
}
// ==================== Initialization ====================
async initialize(): Promise<AudioResult> {
try {
await this.contextManager.initialize();
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
async ensureAudioContextReady(): Promise<AudioResult> {
try {
await this.contextManager.ensureReady();
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
// ==================== Streaming ====================
async initializeStreaming(totalStreamLength: number, contentType: string): Promise<AudioResult> {
try {
// Full cleanup before starting new stream
this.stopProgressTracking();
this.scheduler.clear();
this.streamDecoder.reset();
this.disposeOpusDecoder();
this.resetState();
this.isStreamingMode = true;
// Opus routes to the WebCodecs streaming seam (IStreamingDecoder); WAV/MP3/FLAC keep the
// StreamDecoder wrap-and-decode path byte-for-byte. The sidecar (setup header + seek index)
// must already be set (setOpusSidecar, before init) — without it Opus cannot be decoded or
// seeked, so we fall back by leaving opusDecoder null and using the StreamDecoder path,
// which the server's C2 fallback (lossless bytes) matches. In practice the C# resolver only
// selects Opus when the sidecar parsed, so the null branch is defensive.
if (this.isOpusContentType(contentType) && this.pendingOpusSidecar) {
this.activeOpusSidecar = this.pendingOpusSidecar;
// Align the AudioContext to 48 kHz NOW, before any Opus bytes flow — the format is
// already resolved (C# resolves Opus + injects the sidecar before this call), so the
// target rate is known up front. Done here, the decoder's own lazy
// recreateWithSampleRate(48000) in ensureConfigured hits its sampleRate-equal early
// return and is a no-op; the live graph is never close()'d and rebuilt mid-decode (the
// teardown that double-decoded the stream and OOM'd the tab with HW accel off). The
// recreate seam itself stays — it is the WAV path's mechanism for non-44.1 sources and
// remains the defensive backstop here.
if (this.contextManager.sampleRate !== OPUS_SAMPLE_RATE) {
await this.contextManager.recreateWithSampleRate(OPUS_SAMPLE_RATE);
}
// Pass the shared back-pressure signal (21.2b): the Opus decoder stops demuxing/
// decoding new packets while the scheduler is full, so the WebCodecs decode queue
// and decodedQueue do not balloon behind a throttled socket (OQ7). Same signal the
// C# read loop honors — one policy, two thin hooks.
this.opusDecoder = new OpusStreamDecoder(
this.contextManager,
this.pendingOpusSidecar,
() => this.scheduler.evaluateProductionPause());
return { success: true };
}
// Non-Opus (or Opus-without-sidecar): the existing StreamDecoder path, unchanged. The
// context sample rate is untouched here, so the WAV/lossless path is byte-for-byte
// unaffected by the Opus up-front alignment above.
const formatDecoder = this.createFormatDecoder(contentType);
this.streamDecoder.initialize(totalStreamLength, formatDecoder);
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
private isOpusContentType(contentType: string): boolean {
return contentType.includes('audio/ogg') || contentType.includes('audio/opus');
}
private disposeOpusDecoder(): void {
if (this.opusDecoder) {
this.opusDecoder.dispose();
this.opusDecoder = null;
}
this.activeOpusSidecar = null;
}
/**
* Inject the Opus sidecar (setup header + seek index) for the next Opus stream. Wave 18.5 calls
* this with the raw sidecar bytes (from its one-time HTTP fetch) BEFORE initializeStreaming; the
* parsed result is applied to the OpusFormatDecoder when the stream initializes. This is the
* injection seam — the player owns no transport, only the parse + hand-off.
*
* @returns success:false with an error if the bytes are not a valid sidecar blob.
*/
setOpusSidecar(sidecarBytes: Uint8Array): AudioResult {
const parsed = parseSidecar(sidecarBytes);
if (!parsed) {
return { success: false, error: 'Invalid Opus sidecar blob' };
}
this.pendingOpusSidecar = parsed;
return { success: true };
}
/**
* Select a format decoder from the response Content-Type for the StreamDecoder (wrap-and-decode)
* path. Opus is NOT handled here — it routes to the WebCodecs IStreamingDecoder seam in
* initializeStreaming. This factory serves WAV/MP3/FLAC only.
*/
private createFormatDecoder(contentType: string): IFormatDecoder {
if (contentType.includes('audio/mpeg') || contentType.includes('audio/mp3')) {
return new Mp3FormatDecoder();
}
if (contentType.includes('audio/flac') || contentType.includes('audio/x-flac')) {
return new FlacFormatDecoder();
}
return new WavFormatDecoder(); // default (audio/wav, unknown)
}
/**
* Signal to the decoder that the C# streaming loop has finished sending bytes.
* This sets streamComplete=true and flushes any remaining decoded tail segments.
* Must be called after the ReadAsync loop exits, regardless of whether
* Content-Length was known — without it the tail-decode path is dead when
* Content-Length is absent.
*/
async markStreamComplete(): Promise<StreamingResult> {
try {
const results = this.opusDecoder
? await this.opusDecoder.complete()
: (await this.streamDecoder.markStreamComplete()).map(r => r.buffer);
if (results.length > 0) {
for (const buffer of results) {
this.scheduler.addBuffer(buffer);
}
if (this.streamingStarted && this.isPlaying) {
this.scheduler.scheduleNewBuffers();
}
}
this.streamingCompleted = true;
// Hand the genuine-end signal to the scheduler AFTER the tail buffers are added and
// scheduled: now an empty scheduled queue is a real end-of-track, not a startup gap, so
// the scheduler may fire onPlaybackEnded when its queue drains. If the queue was already
// empty at this point (the tail produced no buffers, or they were already played),
// setStreamComplete finalises immediately.
this.scheduler.setStreamComplete(true);
return { success: true, bufferCount: this.scheduler.getBufferCount() };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
async processStreamingChunk(chunk: Uint8Array): Promise<StreamingResult> {
return this.opusDecoder
? this.processOpusChunk(chunk)
: this.processFormatChunk(chunk);
}
/** Opus (WebCodecs) chunk path. Mirrors processFormatChunk's add->schedule->report shape. */
private async processOpusChunk(chunk: Uint8Array): Promise<StreamingResult> {
try {
const decoder = this.opusDecoder!;
const buffers = await decoder.push(chunk);
// Duration is known up front from the sidecar — surface it as soon as the decoder reports it,
// NOT gated on the first decoded buffers. The C# layer locks Duration on the first chunk whose
// result carries a value (the `Duration == null` guard), and WebCodecs decode is async, so the
// earliest chunks can return zero buffers; gating duration on buffers means C# captures the
// initial 0 and never overwrites it — the WAV header path sets duration on chunk 1 because its
// header parses synchronously, which is the asymmetry this closes. Set once so a seek (which
// reinitialises the decoder) cannot overwrite it.
if (this.duration === 0 && decoder.totalDuration) {
this.duration = decoder.totalDuration;
}
if (buffers.length > 0) {
for (const buffer of buffers) {
this.scheduler.addBuffer(buffer);
}
if (this.streamingStarted && this.isPlaying) {
this.scheduler.scheduleNewBuffers();
}
}
if (decoder.hasFatalError) {
return { success: false, error: 'Opus decode failed' };
}
// "headerParsed" maps to the decoder being configured (codec ready). canStart needs the
// min buffer count, exactly as the WAV path requires before first playback.
const headerParsed = decoder.ready;
const canStart = headerParsed && this.scheduler.hasMinimumBuffers(this.minBuffersForPlayback);
return {
success: true,
canStartStreaming: canStart,
headerParsed,
bufferCount: this.scheduler.getBufferCount(),
duration: this.duration,
productionPaused: this.scheduler.evaluateProductionPause()
};
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
/** WAV/MP3/FLAC (StreamDecoder) chunk path — unchanged from before the Opus seam split. */
private async processFormatChunk(chunk: Uint8Array): Promise<StreamingResult> {
try {
const results = await this.streamDecoder.processChunk(chunk);
if (results.length > 0) {
for (const result of results) {
this.scheduler.addBuffer(result.buffer);
}
// Update duration estimate — set once only; reinitializeFromOffset does not
// reset this.duration, so after a seek-beyond-buffer the synthesised header's
// shorter DataSize cannot overwrite the original full-track duration.
const estimatedDuration = this.streamDecoder.getEstimatedDuration();
if (estimatedDuration && this.duration === 0) {
this.duration = estimatedDuration;
}
// Schedule new buffers if already playing
if (this.streamingStarted && this.isPlaying) {
this.scheduler.scheduleNewBuffers();
}
}
// Check if streaming is complete. The StreamDecoder self-detects completion by byte
// count (WAV/MP3/FLAC); propagate that to the scheduler so a drained queue past this
// point is treated as a genuine end. Buffers from this chunk were already added above,
// so any final end fires through handleSourceEnded when they drain.
if (this.streamDecoder.isComplete) {
this.streamingCompleted = true;
this.scheduler.setStreamComplete(true);
}
const canStart = this.streamDecoder.headerParsed &&
this.scheduler.hasMinimumBuffers(this.minBuffersForPlayback);
return {
success: true,
canStartStreaming: canStart,
headerParsed: this.streamDecoder.headerParsed,
bufferCount: this.scheduler.getBufferCount(),
duration: this.duration,
productionPaused: this.scheduler.evaluateProductionPause()
};
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
async startStreamingPlayback(): Promise<AudioResult> {
if (!this.scheduler.hasBuffers()) {
return { success: false, error: 'No buffers available' };
}
try {
// A backgrounded tab leaves AudioContext suspended. createBufferSource/start
// against a suspended context produces no audio without throwing — the same
// failure mode that was fixed for play() (resume path). Awaiting ensureReady()
// here guarantees the context is running before playFromPosition schedules
// any AudioBufferSourceNodes.
await this.contextManager.ensureReady();
this.streamingStarted = true;
this.isPlaying = true;
this.isPaused = false;
this.pausePosition = 0;
this.scheduler.playFromPosition(0);
this.startProgressTracking();
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
// ==================== Playback Control ====================
async play(): Promise<AudioResult> {
if (!this.isStreamingMode) {
return { success: false, error: 'Not in streaming mode' };
}
if (!this.streamingStarted || !this.scheduler.hasBuffers()) {
return { success: false, error: 'Streaming not ready' };
}
// Don't restart if already playing
if (this.isPlaying) {
return { success: true };
}
try {
// Must await: a backgrounded tab leaves AudioContext suspended, and
// createBufferSource/source.start against a suspended context produces
// no audio without throwing. Firing ensureReady() without await meant
// play() returned success but the user heard nothing.
await this.contextManager.ensureReady();
this.isPlaying = true;
this.isPaused = false;
// Resume from pause position. pausePosition is absolute track time;
// playFromPosition expects a buffer-relative position (excludes playbackOffset).
const bufferRelativePosition = this.pausePosition - this.scheduler.getPlaybackOffset();
this.scheduler.playFromPosition(Math.max(0, bufferRelativePosition));
this.startProgressTracking();
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
pause(): AudioResult {
if (!this.isPlaying) {
return { success: false, error: 'Not playing' };
}
try {
this.pausePosition = this.scheduler.pause();
this.isPlaying = false;
this.isPaused = true;
this.stopProgressTracking();
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
stop(): AudioResult {
try {
this.scheduler.clear();
this.streamDecoder.reset();
this.disposeOpusDecoder();
this.resetState();
this.stopProgressTracking();
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
unload(): AudioResult {
return this.stop();
}
seek(position: number): AudioResult {
if (!this.isStreamingMode || position < 0 || position > this.duration) {
return { success: false, error: 'Invalid seek position' };
}
// bufferStart is the absolute track time at which buffers[0] begins. Under Phase 21.1
// partial eviction this is the start of the RETAINED BACK-WINDOW TAIL — eviction advances
// playbackOffset as it drops played buffers off the front — so [bufferStart, bufferEnd] is
// exactly the window currently held in memory.
const bufferStart = this.scheduler.getPlaybackOffset();
const bufferEnd = this.scheduler.getTotalDuration() + bufferStart;
// The window-miss test for BOTH directions, and the 21.3 refill trigger for backward seeks.
// Position must be within [bufferStart, bufferEnd] AND the scheduler must hold buffers to
// resolve from the retained window:
// - position >= bufferStart AND hasBuffers : UC3 — seek back within the retained back-window.
// Served from buffer with NO network refetch. (The lower bound is load-bearing: after
// eviction or a prior seek-beyond-buffer, bufferStart > 0, and a target below it would
// otherwise produce a negative bufferRelativePosition in seekWithinBuffer, silently clamping
// to position 0.)
// - position < bufferStart : UC4 — seek back PAST the retained tail (the window was evicted).
// Falls through to seekBeyondBuffer, which is the existing Range path run toward an EARLIER
// offset. This is the 21.3 window-miss refill: "a seek the listener didn't initiate" reuses
// the same per-path resolver + reinit a forward seek-beyond-buffer uses, no new mechanism.
// - position > bufferEnd : UC2/UC5 — forward seek beyond buffer, unchanged.
// - !hasBuffers (degenerate [P,P] window post-recovery): the window check above would
// spuriously route ANY target to seekWithinBuffer (bufferStart==bufferEnd==seekPosition
// after recoverFromFailedRefill). Force seekBeyondBuffer so a same-target retry actually
// refetches (AC6 retry contract). The !hasBuffers guard only fires in the degenerate case —
// a populated retained window has buffers and is unaffected (AC4 not regressed).
if (position >= bufferStart && position <= bufferEnd && this.scheduler.hasBuffers()) {
return this.seekWithinBuffer(position);
} else {
// Seeking outside the retained window, or to any position in an empty scheduler —
// signal C# to fetch a new stream from the resolved offset.
return this.seekBeyondBuffer(position);
}
}
/**
* Seek within currently buffered content
*/
private seekWithinBuffer(position: number): AudioResult {
try {
const wasPlaying = this.isPlaying;
this.scheduler.stopAllSources();
// Adjust position relative to buffer start (subtract playback offset)
const bufferRelativePosition = position - this.scheduler.getPlaybackOffset();
this.pausePosition = position;
if (wasPlaying) {
this.scheduler.playFromPosition(Math.max(0, bufferRelativePosition));
}
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
/**
* Seek beyond buffered content - calculate byte offset for server request
*/
private seekBeyondBuffer(position: number): AudioResult {
try {
// Opus: resolve the offset from the precomputed seek index (the accurate VBR-safe transfer
// function). The returned offset is a real page start, so the Range continuation lands the
// demuxer/decoder Ogg-sync-aligned. Also capture the landing time (t_page ≤ position) so
// reinitializeFromOffset can trim the leading decoded frames and land precisely at `position`
// (AC9 fine re-sync, §3.4a step 4).
if (this.opusDecoder) {
if (!this.activeOpusSidecar) {
return { success: false, error: 'Cannot calculate byte offset' };
}
const resolution: OpusSeekResolution = resolveOpusByteOffset(this.activeOpusSidecar, position);
this._seekLandingTime = resolution.landingTimeSeconds;
return {
success: true,
seekBeyondBuffer: true,
byteOffset: resolution.byteOffset
};
}
// WAV/MP3/FLAC: the header must be parsed for byte-offset math.
if (!this.streamDecoder.getFormatInfo()) {
return { success: false, error: 'Cannot calculate byte offset' };
}
// calculateByteOffset returns a file-absolute offset (byte position from the
// start of the file on disk, header included) — exactly what the Range request
// needs. The format decoder owns the header-offset addition and frame alignment.
const fileOffset = this.streamDecoder.calculateByteOffset(position);
// Signal that C# needs to request a new stream from this file-absolute offset
return {
success: true,
seekBeyondBuffer: true,
byteOffset: fileOffset
};
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
/**
* Resolve the file-absolute byte offset to begin a stream at `position`, WITHOUT requiring active
* playback or buffered audio (the "load at timestamp" entry point — Phase 18 wave 18.6 format switch).
* Unlike seek(), it has no duration guard and never routes to the within-buffer path: a fresh load has
* no scheduler window, so the answer is always "start the byte stream here". For Opus the sidecar
* resolves the offset (and captures the page landing time for the lead-trim) immediately after init; for
* WAV the header must already be parsed (feed the byte-0 segment first). Returns success:false when the
* decoder cannot yet resolve an offset (no header / no sidecar), so the caller can probe and retry.
*/
resolveStreamOffset(position: number): AudioResult {
if (!this.isStreamingMode) {
return { success: false, error: 'Not in streaming mode' };
}
return this.seekBeyondBuffer(position);
}
/**
* Get the total buffered duration (for C# to check if seek is within buffer)
*/
getBufferedDuration(): number {
return this.scheduler.getTotalDuration() + this.scheduler.getPlaybackOffset();
}
/**
* The shared back-pressure signal (Phase 21.2a), polled by the C# read loop WHILE it is
* already throttled to learn when the forward fill has drained below the low-water mark and it
* may resume reading. The steady-state (unthrottled) loop never calls this — it reads the
* piggybacked productionPaused flag off each chunk result instead, so there is no extra
* interop hop until back-pressure actually engages.
*/
isProductionPaused(): boolean {
return this.scheduler.evaluateProductionPause();
}
/**
* Calculate byte offset for a time position (for C# layer)
*/
calculateByteOffset(positionSeconds: number): number {
if (this.opusDecoder) {
return this.activeOpusSidecar
? resolveOpusByteOffset(this.activeOpusSidecar, positionSeconds).byteOffset
: 0;
}
if (!this.streamDecoder.getFormatInfo()) return 0;
return this.streamDecoder.calculateByteOffset(positionSeconds);
}
/**
* Reinitialize for offset streaming after seek-beyond-buffer
* Called by C# after receiving new stream from server
*/
reinitializeFromOffset(totalStreamLength: number, seekPosition: number): AudioResult {
try {
// Stop current playback
this.stopProgressTracking();
this.isPlaying = false;
// Clear buffers and set new offset
this.scheduler.clearForSeek();
this.scheduler.setPlaybackOffset(seekPosition);
// Reinitialize the active decoder for the Range-continuation stream (206 body, no header/
// setup pages). Opus resets demux + codec state (keeping the cached config) and arms the
// lead-trim so decoded audio starts at `seekPosition`, not at the page boundary (AC9). The
// StreamDecoder path uses totalStreamLength (the 206 Content-Length) to detect completion.
if (this.opusDecoder) {
this.opusDecoder.reinitializeForRangeContinuation(this._seekLandingTime, seekPosition);
} else {
this.streamDecoder.reinitializeForRangeContinuation(totalStreamLength);
}
// Update state
this.pausePosition = seekPosition;
this.streamingStarted = false; // Will restart when new buffers arrive
this.streamingCompleted = false;
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
/**
* Recover the player into a clean, paused-but-loaded state after a window-miss REFILL failed
* (Phase 21.3 / AC6). A refill is "a seek the listener didn't initiate"; when its Range fetch or
* reinit fails mid-stream, the pre-seek loop has already been cancelled and drained, but the
* scheduler is still holding stale pre-seek buffers and is still `isActive_`. Left alone it would
* play the retained tail to exhaustion and fire `onPlaybackEnded` — a SILENT FALSE END (the
* "wedged playing with a starved scheduler" AC6 forbids).
*
* The recovery mirrors `PlaybackScheduler.playFromPosition`'s end-of-buffer recovery in spirit:
* stop pretending to play. We stop all sources and clear the buffers for a seek (clearForSeek
* keeps no stale audio but is ready to accept a fresh continuation), set the offset to the
* requested seek position, and leave the player paused there. The track stays loaded so the
* listener can retry the seek or pick another track — no new transport control, only a recoverable
* stop (C4). A subsequent seek to the same target re-enters seekBeyondBuffer cleanly because the
* offset names the seek position and the scheduler is empty (so it routes to a fresh fetch).
*
* @param seekPosition The seek target the failed refill was aiming for; becomes the resume anchor.
*/
recoverFromFailedRefill(seekPosition: number): AudioResult {
try {
this.stopProgressTracking();
// Halt the starved scheduler and drop the stale pre-seek buffers so no false end can fire.
this.scheduler.clearForSeek();
this.scheduler.setPlaybackOffset(seekPosition);
// Paused-but-loaded: not playing, not mid-seek-stream. pausePosition anchors a retry.
this.isPlaying = false;
this.isPaused = true;
this.pausePosition = seekPosition;
this.streamingStarted = false;
this.streamingCompleted = false;
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
// ==================== Volume ====================
setVolume(volume: number): AudioResult {
try {
this.contextManager.setVolume(volume);
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
// ==================== State ====================
getCurrentTime(): number {
if (this.isPlaying) {
return this.scheduler.getCurrentPosition();
}
return this.pausePosition;
}
getState(): AudioState {
return {
isPlaying: this.isPlaying,
isPaused: this.isPaused,
currentTime: this.getCurrentTime(),
duration: this.duration,
volume: this.contextManager.getVolume()
};
}
// ==================== Callbacks ====================
setOnProgressCallback(callback: ProgressCallback): void {
this.onProgressCallback = callback;
}
setOnEndCallback(callback: EndCallback): void {
this.onEndCallback = callback;
}
// ==================== Spectrum Analysis ====================
getSpectrumData(): number[] {
return this.contextManager.getSpectrumAnalyzer().getFrequencyData();
}
setSpectrumHighPass(freq: number): AudioResult {
try {
this.contextManager.getSpectrumAnalyzer().setHighPass(freq);
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
setSpectrumLowPass(freq: number): AudioResult {
try {
this.contextManager.getSpectrumAnalyzer().setLowPass(freq);
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
setSpectrumSlope(dbPerDecade: number): AudioResult {
try {
this.contextManager.getSpectrumAnalyzer().setSlopeCorrection(dbPerDecade);
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
startSpectrumAnimation(callbackId: string, callback: (data: number[]) => void): void {
this.contextManager.getSpectrumAnalyzer().addCallback(callbackId, callback);
}
stopSpectrumAnimation(callbackId: string): void {
this.contextManager.getSpectrumAnalyzer().removeCallback(callbackId);
}
getLevelDb(): number {
return this.contextManager.getSpectrumAnalyzer().getLevelDb();
}
startLevelAnimation(callbackId: string, callback: (db: number) => void): void {
this.contextManager.getSpectrumAnalyzer().addLevelCallback(callbackId, callback);
}
stopLevelAnimation(callbackId: string): void {
this.contextManager.getSpectrumAnalyzer().removeLevelCallback(callbackId);
}
// ==================== Private Methods ====================
private resetState(): void {
this.isPlaying = false;
this.isPaused = false;
this.pausePosition = 0;
this.duration = 0;
this.isStreamingMode = false;
this.streamingStarted = false;
this.streamingCompleted = false;
this._seekLandingTime = 0;
}
private handlePlaybackEnded(): void {
this.isPlaying = false;
this.isPaused = false;
this.pausePosition = 0;
this.stopProgressTracking();
this.onEndCallback?.();
}
private startProgressTracking(): void {
this.stopProgressTracking();
this.progressInterval = window.setInterval(() => {
if (this.onProgressCallback && this.isPlaying) {
this.onProgressCallback(this.getCurrentTime());
}
}, 100);
}
private stopProgressTracking(): void {
if (this.progressInterval) {
clearInterval(this.progressInterval);
this.progressInterval = null;
}
}
// ==================== Cleanup ====================
dispose(): void {
this.stop();
this.stopProgressTracking();
this.contextManager.dispose();
}
}