/** * PlaybackScheduler - Manages AudioBuffer storage and playback scheduling. * * Single Responsibility: Store decoded buffers and schedule them for playback. * Supports pause/resume/seek by retaining all buffers. */ import { AudioContextManager } from './AudioContextManager.js'; 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 // When seeking to position T beyond buffers, we clear buffers and set playbackOffset = T // The new stream starts at T, so buffer positions are relative to T private playbackOffset: number = 0; // 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); console.log(`📦 Buffer[${this.buffers.length - 1}] added: ${buffer.duration.toFixed(3)}s (total: ${this.getTotalDuration().toFixed(3)}s)`); } /** * 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; console.log(`📍 Playback offset set to ${offset.toFixed(3)}s`); } /** * Get the current playback offset */ getPlaybackOffset(): number { return this.playbackOffset; } /** * Start or resume playback from a specific position */ playFromPosition(position: number): void { this.stopAllSources(); // Find which buffer contains this position let accumulatedTime = 0; let startBufferIndex = 0; let offsetInBuffer = 0; for (let i = 0; i < this.buffers.length; i++) { const bufferDuration = this.buffers[i].duration; if (accumulatedTime + bufferDuration > position) { startBufferIndex = i; offsetInBuffer = position - accumulatedTime; break; } accumulatedTime += bufferDuration; startBufferIndex = i + 1; } if (startBufferIndex >= this.buffers.length) { // Position landed at or past the end of all buffers. Previously this // returned silently, leaving the player stuck "playing" with no source // scheduled — a pause near the end followed by play never recovered. // Treat this as end-of-track so listeners (UI / end callback) fire. console.log('Position at/beyond available buffers — ending playback'); this.isActive_ = false; this.playbackAnchorTime = 0; this.playbackAnchorPosition = 0; this.onPlaybackEnded?.(); return; } console.log(`▶️ Playing from ${position.toFixed(3)}s: buffer[${startBufferIndex}] offset=${offsetInBuffer.toFixed(3)}s`); // Set timing anchors this.playbackAnchorPosition = position; this.playbackAnchorTime = this.contextManager.currentTime; this.nextScheduleTime = this.contextManager.currentTime + 0.01; // Small lookahead this.nextBufferIndex = startBufferIndex; this.isActive_ = true; // Enable scheduling // Schedule buffers this.scheduleBuffersFrom(startBufferIndex, offsetInBuffer); } /** * Schedule newly decoded buffers during live streaming */ scheduleNewBuffers(): void { if (this.nextBufferIndex >= this.buffers.length) { return; // No new buffers } // Use isActive_ as the sentinel for "playback is running", not nextScheduleTime === 0. // AudioContext.currentTime can legitimately be 0 at context creation, which would cause // nextScheduleTime === 0 to incorrectly reset a value already set by playFromPosition. if (!this.isActive_) { return; } this.scheduleBuffersFrom(this.nextBufferIndex, 0); } /** * Internal: Schedule buffers starting from a specific index */ private scheduleBuffersFrom(startIndex: number, offsetInFirstBuffer: number): void { const lookaheadTarget = 0.5; // Schedule up to 500ms ahead const gainNode = this.contextManager.getGainNode(); for (let i = startIndex; i < this.buffers.length; i++) { const buffer = this.buffers[i]; const isFirstBuffer = (i === startIndex && offsetInFirstBuffer > 0); const offset = isFirstBuffer ? offsetInFirstBuffer : 0; const duration = buffer.duration - offset; // Create and configure source const source = this.contextManager.getContext().createBufferSource(); source.buffer = buffer; source.connect(gainNode); const scheduleTime = this.nextScheduleTime; const endTime = scheduleTime + duration; // Track scheduled source const scheduled: ScheduledSource = { source, bufferIndex: i, startTime: scheduleTime, endTime }; this.scheduledSources.push(scheduled); // Set up ended callback source.onended = () => this.handleSourceEnded(scheduled); // Schedule the source source.start(scheduleTime, offset); console.log(`🎵 Scheduled buffer[${i}]: ${scheduleTime.toFixed(3)}s -> ${endTime.toFixed(3)}s`); // 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) { console.log(`📋 Lookahead: ${(lookahead * 1000).toFixed(0)}ms buffered`); 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); } // Schedule more buffers if available if (this.nextBufferIndex < this.buffers.length) { this.scheduleBuffersFrom(this.nextBufferIndex, 0); } // Check if all playback has finished if (this.scheduledSources.length === 0 && this.nextBufferIndex >= this.buffers.length) { console.log('✓ Playback complete'); this.isActive_ = false; this.playbackAnchorTime = 0; this.playbackAnchorPosition = 0; this.onPlaybackEnded?.(); } } /** * Pause playback - saves position and stops sources */ pause(): number { const position = this.getCurrentPosition(); this.isActive_ = false; // Prevent handleSourceEnded from scheduling more this.stopAllSources(); this.playbackAnchorPosition = position; this.playbackAnchorTime = 0; this.nextScheduleTime = 0; console.log(`⏸️ Paused at ${position.toFixed(3)}s`); return position; } /** * Stop all scheduled sources */ stopAllSources(): void { for (const scheduled of this.scheduledSources) { try { scheduled.source.stop(); } catch { // Source may already be stopped } } this.scheduledSources = []; } /** * Reset to beginning (for stop) */ resetToStart(): void { this.isActive_ = false; this.stopAllSources(); this.playbackAnchorPosition = 0; this.playbackAnchorTime = 0; this.nextBufferIndex = 0; this.nextScheduleTime = 0; console.log('⏮️ Reset to start'); } /** * Full reset - clears all buffers and resets offset */ clear(): void { this.isActive_ = false; this.stopAllSources(); this.buffers = []; this.playbackAnchorPosition = 0; this.playbackAnchorTime = 0; this.nextBufferIndex = 0; this.nextScheduleTime = 0; this.playbackOffset = 0; console.log('🗑️ Scheduler cleared'); } /** * Clear buffers but keep offset - for seek-beyond-buffer scenarios */ clearForSeek(): void { this.isActive_ = false; this.stopAllSources(); this.buffers = []; this.playbackAnchorPosition = 0; this.playbackAnchorTime = 0; this.nextBufferIndex = 0; this.nextScheduleTime = 0; // Note: playbackOffset is NOT reset - it will be set by the caller console.log('🗑️ Scheduler cleared for seek (offset preserved)'); } /** * 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_; } }