/** * Audio playback controller */ import { getAudioContext, resumeAudioContext } from './context'; export class AudioPlayer { private audioContext: AudioContext; private audioBuffer: AudioBuffer | null = null; private sourceNode: AudioBufferSourceNode | null = null; private gainNode: GainNode; private startTime: number = 0; private pauseTime: number = 0; private isPlaying: boolean = false; private isPaused: boolean = false; constructor() { this.audioContext = getAudioContext(); this.gainNode = this.audioContext.createGain(); this.gainNode.connect(this.audioContext.destination); } /** * Load an audio buffer for playback */ loadBuffer(buffer: AudioBuffer): void { this.stop(); this.audioBuffer = buffer; this.pauseTime = 0; } /** * Start playback from current position */ async play(startOffset: number = 0): Promise { if (!this.audioBuffer) { throw new Error('No audio buffer loaded'); } // Resume audio context if needed await resumeAudioContext(); // Calculate start offset BEFORE stopping (since stop() resets pauseTime) const offset = this.isPaused ? this.pauseTime : startOffset; // Stop any existing playback this.stop(); // Create new source node this.sourceNode = this.audioContext.createBufferSource(); this.sourceNode.buffer = this.audioBuffer; this.sourceNode.connect(this.gainNode); // Set start time this.startTime = this.audioContext.currentTime - offset; // Start playback this.sourceNode.start(0, offset); this.isPlaying = true; this.isPaused = false; // Handle playback end this.sourceNode.onended = () => { if (this.isPlaying) { this.isPlaying = false; this.isPaused = false; this.pauseTime = 0; } }; } /** * Pause playback */ pause(): void { if (!this.isPlaying) return; // Save current time BEFORE calling stop (which resets it) const savedTime = this.getCurrentTime(); this.stop(); this.pauseTime = savedTime; this.isPaused = true; } /** * Stop playback */ stop(): void { if (this.sourceNode) { try { // Clear onended callback first to prevent interference this.sourceNode.onended = null; this.sourceNode.stop(); } catch (error) { // Ignore errors if already stopped } this.sourceNode.disconnect(); this.sourceNode = null; } this.isPlaying = false; this.isPaused = false; this.pauseTime = 0; this.startTime = 0; } /** * Get current playback time in seconds */ getCurrentTime(): number { if (!this.audioBuffer) return 0; if (this.isPlaying) { const currentTime = this.audioContext.currentTime - this.startTime; return Math.min(currentTime, this.audioBuffer.duration); } return this.pauseTime; } /** * Seek to a specific time * @param time - Time in seconds to seek to * @param autoPlay - Whether to automatically start playback after seeking (default: false) */ async seek(time: number, autoPlay: boolean = false): Promise { if (!this.audioBuffer) return; const wasPlaying = this.isPlaying; const clampedTime = Math.max(0, Math.min(time, this.audioBuffer.duration)); this.stop(); this.pauseTime = clampedTime; // Auto-play if requested, or continue playing if was already playing if (autoPlay || wasPlaying) { await this.play(clampedTime); } else { this.isPaused = true; } } /** * Set playback volume (0 to 1) */ setVolume(volume: number): void { const clampedVolume = Math.max(0, Math.min(1, volume)); this.gainNode.gain.setValueAtTime(clampedVolume, this.audioContext.currentTime); } /** * Get current volume */ getVolume(): number { return this.gainNode.gain.value; } /** * Get playback state */ getState(): { isPlaying: boolean; isPaused: boolean; currentTime: number; duration: number; } { return { isPlaying: this.isPlaying, isPaused: this.isPaused, currentTime: this.getCurrentTime(), duration: this.audioBuffer?.duration ?? 0, }; } /** * Get audio buffer */ getBuffer(): AudioBuffer | null { return this.audioBuffer; } /** * Check if audio is loaded */ hasBuffer(): boolean { return this.audioBuffer !== null; } /** * Cleanup resources */ dispose(): void { this.stop(); this.gainNode.disconnect(); this.audioBuffer = null; } }