import WaveSurfer from "wavesurfer.js"; import { usePlayerStore } from "../stores/playerStore"; // Log level enum (will be exported at end of file) enum LogLevel { DEBUG = 0, INFO = 1, WARN = 2, ERROR = 3 } // Initialization state enum enum InitializationState { NotStarted = 'not_started', InProgress = 'in_progress', Completed = 'completed', Failed = 'failed' } // Type extension for WaveSurfer backend access interface WaveSurferWithBackend extends WaveSurfer { backend?: { getAudioContext?: () => AudioContext; ac?: AudioContext; audioContext?: AudioContext; }; getAudioContext?: () => AudioContext; getContainer?: () => HTMLElement; setContainer?: (container: HTMLElement) => void; } class AudioService { private static instance: AudioService; private wavesurfer: WaveSurfer | null = null; private audioContext: AudioContext | null = null; private currentUrl: string | null = null; private currentPlayingSongId: string | null = null; private currentPlayingBandId: string | null = null; private lastPlayTime: number = 0; private lastTimeUpdate: number = 0; private readonly PLAY_DEBOUNCE_MS: number = 100; private lastSeekTime: number = 0; private readonly SEEK_DEBOUNCE_MS: number = 200; private logLevel: LogLevel = LogLevel.ERROR; private playbackAttempts: number = 0; private readonly MAX_PLAYBACK_ATTEMPTS: number = 3; private lastLogTime: number = 0; private readonly LOG_THROTTLE_MS: number = 100; // Initialization tracking private initializationState: InitializationState = InitializationState.NotStarted; private initializationError: Error | null = null; private initializationPromise: Promise | null = null; private initializationResolve: (() => void) | null = null; private constructor() { // Set appropriate log level based on environment this.setLogLevel(this.detectLogLevel()); this.log(LogLevel.INFO, `AudioService initialized (log level: ${LogLevel[this.logLevel]})`); } private detectLogLevel(): LogLevel { try { // Development environment: localhost or explicit debug mode const isDevelopment = typeof window !== 'undefined' && window.location && (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'); // Check for debug query parameter (with safety checks) const hasDebugParam = typeof window !== 'undefined' && window.location && window.location.search && window.location.search.includes('audioDebug=true'); if (isDevelopment || hasDebugParam) { return LogLevel.DEBUG; } } catch (error) { // If anything goes wrong, default to WARN level console.warn('Error detecting log level, defaulting to WARN:', error); } // Production: warn level to reduce noise return LogLevel.WARN; } private log(level: LogLevel, message: string, ...args: unknown[]) { // Skip if below current log level if (level < this.logLevel) return; // Throttle rapid-fire logs to prevent console flooding const now = Date.now(); if (now - this.lastLogTime < this.LOG_THROTTLE_MS) { return; // Skip this log to prevent spam } this.lastLogTime = now; const prefix = `[AudioService:${LogLevel[level]}]`; // Use appropriate console method based on log level switch(level) { case LogLevel.DEBUG: if (console.debug) { console.debug(prefix, message, ...args); } break; case LogLevel.INFO: console.info(prefix, message, ...args); break; case LogLevel.WARN: console.warn(prefix, message, ...args); break; case LogLevel.ERROR: console.error(prefix, message, ...args); break; } } // Add method to set log level from outside public setLogLevel(level: LogLevel) { this.log(LogLevel.INFO, `Log level set to: ${LogLevel[level]}`); this.logLevel = level; } public static getInstance() { if (!this.instance) { this.instance = new AudioService(); } return this.instance; } // Initialize audio context - now handles user gesture requirement public async initializeAudioContext() { try { if (!this.audioContext) { this.audioContext = new (window.AudioContext || (window as { webkitAudioContext?: new () => AudioContext }).webkitAudioContext)(); this.log(LogLevel.INFO, 'Audio context created', { state: this.audioContext.state, sampleRate: this.audioContext.sampleRate }); // Set up state change monitoring this.audioContext.onstatechange = () => { this.log(LogLevel.DEBUG, 'Audio context state changed:', this.audioContext?.state); }; } // Resume suspended audio context if (this.audioContext && this.audioContext.state === 'suspended') { await this.audioContext.resume(); this.log(LogLevel.INFO, 'Audio context resumed successfully'); } return this.audioContext; } catch (error) { this.log(LogLevel.ERROR, 'Failed to initialize audio context:', error); throw new Error(`Failed to initialize audio context: ${error instanceof Error ? error.message : String(error)}`); } } // New method to handle audio context resume with user gesture private async handleAudioContextResume(): Promise { if (!this.audioContext) { await this.initializeAudioContext(); } // Handle suspended audio context (common in mobile browsers and autoplay policies) if (this.audioContext && this.audioContext.state === 'suspended') { try { this.log(LogLevel.INFO, 'Attempting to resume suspended audio context...'); await this.audioContext.resume(); this.log(LogLevel.INFO, 'Audio context resumed successfully'); } catch (error) { this.log(LogLevel.ERROR, 'Failed to resume audio context:', error); // If resume fails, we might need to create a new context // This can happen if the context was closed or terminated if (this.audioContext && (this.audioContext.state as string) === 'closed') { this.log(LogLevel.WARN, 'Audio context closed, creating new one'); this.audioContext = null; await this.initializeAudioContext(); } throw error; } } } // Method for testing: reset the singleton instance public static resetInstance(): void { this.instance = undefined as any; } public async initialize(container: HTMLElement, url: string): Promise { this.log(LogLevel.DEBUG, 'AudioService.initialize called', { url, containerExists: !!container }); // Reset initialization state this.initializationState = InitializationState.InProgress; this.initializationError = null; this.initializationPromise = new Promise((resolve) => { this.initializationResolve = resolve; }); // Validate inputs if (!container) { this.log(LogLevel.ERROR, 'AudioService: container element is null'); this.initializationState = InitializationState.Failed; this.initializationError = new Error('Container element is required'); this.initializationResolve?.(); this.initializationResolve = null; throw new Error('Container element is required'); } if (!url || url === 'null' || url === 'undefined') { this.log(LogLevel.ERROR, 'AudioService: invalid URL', { url }); this.initializationState = InitializationState.Failed; this.initializationError = new Error('Valid audio URL is required'); this.initializationResolve?.(); this.initializationResolve = null; throw new Error('Valid audio URL is required'); } // If same URL and we already have an instance, just update container reference if (this.currentUrl === url && this.wavesurfer) { // Implementation detail, only log in debug mode this.log(LogLevel.DEBUG, 'Reusing existing WaveSurfer instance for URL:', url); try { // Check if container is different and needs updating const ws = this.wavesurfer as WaveSurferWithBackend; const currentContainer = ws.getContainer?.(); if (currentContainer !== container) { this.log(LogLevel.DEBUG, 'Updating container reference for existing instance'); // Update container reference without recreating instance ws.setContainer?.(container); } else { this.log(LogLevel.DEBUG, 'Using existing instance - no changes needed'); } return this.wavesurfer; } catch (error) { this.log(LogLevel.ERROR, 'Failed to reuse existing instance:', error); this.cleanup(); } } // Clean up existing instance if different URL if (this.wavesurfer && this.currentUrl !== url) { this.log(LogLevel.INFO, 'Cleaning up existing instance for new URL:', url); this.cleanup(); } // Create new WaveSurfer instance this.log(LogLevel.DEBUG, 'Creating new WaveSurfer instance for URL:', url); let ws; try { ws = WaveSurfer.create({ container: container, waveColor: "rgba(255,255,255,0.09)", progressColor: "#c8861a", cursorColor: "#e8a22a", barWidth: 2, barRadius: 2, height: 104, normalize: true, // Ensure we can control playback manually autoplay: false, // Development-specific settings for better debugging ...(typeof window !== 'undefined' && window.location && window.location.hostname === 'localhost' && this.audioContext && { backend: 'WebAudio', audioContext: this.audioContext, audioRate: 1, }), }); if (!ws) { throw new Error('WaveSurfer.create returned null or undefined'); } // @ts-expect-error - WaveSurfer typing doesn't expose backend if (!ws.backend) { console.warn('WaveSurfer instance has no backend property yet - this might be normal in v7+'); // Don't throw error - we'll try to access backend later when needed } } catch (error) { console.error('Failed to create WaveSurfer instance:', error); throw error; } // Store references this.wavesurfer = ws; this.currentUrl = url; // Get audio context from wavesurfer // Note: In WaveSurfer v7+, backend might not be available immediately // We'll try to access it now, but also set up a handler to get it when ready await this.setupAudioContext(ws); // Set up event handlers before loading this.setupEventHandlers(); // Load the audio with error handling this.log(LogLevel.DEBUG, 'Loading audio URL:', url); try { const loadPromise = new Promise(async (resolve, reject) => { ws.on('ready', async () => { this.log(LogLevel.DEBUG, 'WaveSurfer ready event fired'); // Now that WaveSurfer is ready, set up audio context and finalize initialization try { await this.setupAudioContext(ws); // Update player store with duration const playerStore = usePlayerStore.getState(); playerStore.setDuration(ws.getDuration()); // Signal initialization completion this.initializationState = InitializationState.Completed; this.initializationResolve?.(); this.initializationResolve = null; resolve(); } catch (error) { this.log(LogLevel.ERROR, 'Initialization failed in ready handler:', error); this.initializationState = InitializationState.Failed; this.initializationError = error instanceof Error ? error : new Error(String(error)); this.initializationResolve?.(); this.initializationResolve = null; reject(error); } }); ws.on('error', (error) => { this.log(LogLevel.ERROR, 'WaveSurfer error event:', error); reject(error); }); // Start loading ws.load(url); }); await loadPromise; this.log(LogLevel.INFO, 'Audio loaded successfully'); return this.initializationPromise; } catch (error) { this.log(LogLevel.ERROR, 'Failed to load audio:', error); this.initializationState = InitializationState.Failed; this.initializationError = error instanceof Error ? error : new Error(String(error)); this.initializationResolve?.(); this.initializationResolve = null; this.cleanup(); throw error; } } private setupEventHandlers() { if (!this.wavesurfer) return; const ws = this.wavesurfer; const playerStore = usePlayerStore.getState(); ws.on("play", () => { playerStore.batchUpdate({ isPlaying: true }); }); ws.on("pause", () => { playerStore.batchUpdate({ isPlaying: false }); }); ws.on("finish", () => { playerStore.batchUpdate({ isPlaying: false }); }); ws.on("audioprocess", (time) => { const now = Date.now(); // Throttle state updates to reduce React re-renders if (now - this.lastTimeUpdate >= 250) { playerStore.batchUpdate({ currentTime: time }); this.lastTimeUpdate = now; } }); // Note: Ready event is handled in the load promise, so we don't set it up here // to avoid duplicate event handlers } public async play(songId: string | null = null, bandId: string | null = null): Promise { if (!this.wavesurfer) { this.log(LogLevel.WARN, 'AudioService: no wavesurfer instance - cannot play'); // Provide more context about why there's no wavesurfer instance if (!this.currentUrl) { this.log(LogLevel.ERROR, 'No audio URL has been set - waveform not initialized'); } else { this.log(LogLevel.ERROR, 'Waveform initialization failed or was cleaned up'); } return; } // Check if waveform is actually ready for playback if (this.getDuration() <= 0) { this.log(LogLevel.ERROR, 'Waveform not ready for playback - duration is 0'); return; } // Debounce rapid play calls const now = Date.now(); if (now - this.lastPlayTime < this.PLAY_DEBOUNCE_MS) { this.log(LogLevel.DEBUG, 'Playback debounced - too frequent calls'); return; } this.lastPlayTime = now; // Only log play calls in debug mode to reduce noise this.log(LogLevel.DEBUG, 'AudioService.play called', { songId, bandId }); try { // Always stop current playback first to ensure only one audio plays at a time if (this.isPlaying()) { this.log(LogLevel.INFO, 'Stopping current playback before starting new one'); this.pause(); // Small delay to ensure cleanup await new Promise(resolve => setTimeout(resolve, 50)); } // Check if we need to switch songs const isDifferentSong = songId && bandId && (this.currentPlayingSongId !== songId || this.currentPlayingBandId !== bandId); // If switching to a different song, perform cleanup if (isDifferentSong) { this.log(LogLevel.INFO, 'Switching to different song - performing cleanup'); this.cleanup(); } // Ensure we have a valid audio context and handle user gesture requirement await this.handleAudioContextResume(); // Try to play - this might fail due to autoplay policy try { await this.wavesurfer.play(); } catch (playError) { this.log(LogLevel.WARN, 'Initial play attempt failed, trying alternative approach:', playError); // If play fails due to autoplay policy, try to resume audio context and retry if (playError instanceof Error && (playError.name === 'NotAllowedError' || playError.name === 'InvalidStateError')) { this.log(LogLevel.INFO, 'Playback blocked by browser autoplay policy, attempting recovery...'); // Ensure audio context is properly resumed await this.handleAudioContextResume(); // Try playing again await this.wavesurfer.play(); } else { // For other errors, throw them to be handled by the retry logic throw playError; } } // Update currently playing song tracking if (songId && bandId) { this.currentPlayingSongId = songId; this.currentPlayingBandId = bandId; const playerStore = usePlayerStore.getState(); playerStore.setCurrentPlayingSong(songId, bandId); } // Success logs are redundant, only log in debug mode this.log(LogLevel.DEBUG, 'Playback started successfully'); this.playbackAttempts = 0; // Reset on success } catch (error) { this.playbackAttempts++; this.log(LogLevel.ERROR, `Playback failed (attempt ${this.playbackAttempts}):`, error); // Handle specific audio context errors if (error instanceof Error && (error.name === 'NotAllowedError' || error.name === 'InvalidStateError')) { this.log(LogLevel.ERROR, 'Playback blocked by browser autoplay policy'); // Don't retry immediately - wait for user gesture if (this.playbackAttempts >= this.MAX_PLAYBACK_ATTEMPTS) { this.log(LogLevel.ERROR, 'Max playback attempts reached, resetting player'); // Don't cleanup wavesurfer - just reset state this.playbackAttempts = 0; } return; // Don't retry autoplay errors } if (this.playbackAttempts >= this.MAX_PLAYBACK_ATTEMPTS) { this.log(LogLevel.ERROR, 'Max playback attempts reached, resetting player'); this.cleanup(); // Could trigger re-initialization here if needed } else { // Exponential backoff for retry const delay = 100 * this.playbackAttempts; this.log(LogLevel.WARN, `Retrying playback in ${delay}ms...`); await new Promise(resolve => setTimeout(resolve, delay)); return this.play(songId, bandId); // Retry } } } public pause() { if (!this.wavesurfer) { this.log(LogLevel.WARN, 'AudioService: no wavesurfer instance'); return; } // Only log pause calls in debug mode to reduce noise this.log(LogLevel.DEBUG, 'AudioService.pause called'); this.wavesurfer.pause(); } public seekTo(time: number) { if (!this.wavesurfer) { this.log(LogLevel.WARN, "AudioService: no wavesurfer instance"); return; } // Debounce seek operations to prevent jitter const now = Date.now(); if (now - this.lastSeekTime < this.SEEK_DEBOUNCE_MS) { this.log(LogLevel.DEBUG, "Seek debounced - too frequent"); return; } this.lastSeekTime = now; // Only log seek operations in debug mode to reduce noise this.log(LogLevel.DEBUG, "AudioService.seekTo called", { time }); this.wavesurfer.setTime(time); } public getCurrentTime(): number { if (!this.wavesurfer) return 0; return this.wavesurfer.getCurrentTime(); } public getDuration(): number { if (!this.wavesurfer) return 0; return this.wavesurfer.getDuration(); } public isPlaying(): boolean { if (!this.wavesurfer) return false; return this.wavesurfer.isPlaying(); } public cleanup() { this.log(LogLevel.INFO, 'AudioService.cleanup called'); if (this.wavesurfer) { try { // Always stop playback first if (this.wavesurfer.isPlaying()) { this.wavesurfer.pause(); } // Disconnect audio nodes but keep audio context alive this.wavesurfer.unAll(); this.wavesurfer.destroy(); this.log(LogLevel.DEBUG, 'WaveSurfer instance cleaned up'); } catch (error) { this.log(LogLevel.ERROR, 'Error cleaning up WaveSurfer:', error); } this.wavesurfer = null; } this.currentUrl = null; this.currentPlayingSongId = null; this.currentPlayingBandId = null; // Reset player store completely const playerStore = usePlayerStore.getState(); playerStore.setCurrentPlayingSong(null, null); playerStore.batchUpdate({ isPlaying: false, currentTime: 0 }); // Note: We intentionally don't nullify audioContext to keep it alive } private async setupAudioContext(ws: WaveSurferWithBackend) { // Simplified and more robust audio context setup try { // If we already have an audio context, ensure WaveSurfer uses it if (this.audioContext) { this.log(LogLevel.DEBUG, 'Using existing audio context'); // Centralized method to share audio context with WaveSurfer this.shareAudioContextWithWaveSurfer(ws); return; } // Fallback: Try to get audio context from WaveSurfer (for compatibility) if (ws.backend?.getAudioContext) { this.audioContext = ws.backend.getAudioContext(); this.log(LogLevel.DEBUG, 'Audio context accessed via backend.getAudioContext()'); } else if (typeof ws.getAudioContext === 'function') { this.audioContext = ws.getAudioContext(); this.log(LogLevel.DEBUG, 'Audio context accessed via ws.getAudioContext()'); } if (this.audioContext) { this.log(LogLevel.INFO, 'Audio context initialized from WaveSurfer', { state: this.audioContext.state, sampleRate: this.audioContext.sampleRate }); // Resume suspended audio context automatically if (this.audioContext.state === 'suspended') { try { await this.audioContext.resume(); this.log(LogLevel.INFO, 'Audio context resumed successfully'); } catch (error) { this.log(LogLevel.WARN, 'Failed to resume audio context:', error); } } // Set up state change monitoring this.audioContext.onstatechange = () => { this.log(LogLevel.DEBUG, 'Audio context state changed:', this.audioContext?.state); }; return; } // Don't create new audio context if WaveSurfer doesn't provide methods // This maintains backward compatibility and allows graceful degradation this.log(LogLevel.DEBUG, 'No audio context available from WaveSurfer, continuing without it'); } catch (error) { this.log(LogLevel.ERROR, 'Error setting up audio context:', error); // Don't throw - we can continue with our existing audio context } } private shareAudioContextWithWaveSurfer(ws: WaveSurferWithBackend) { if (!this.audioContext) { this.log(LogLevel.WARN, 'No audio context available to share'); return; } try { // Method 1: Try to set via backend if available if (ws.backend) { ws.backend.audioContext = this.audioContext; this.log(LogLevel.DEBUG, 'Shared audio context with WaveSurfer backend'); return; // Success, exit early } // Method 2: Try to access and replace the audio context via backend methods if (ws.backend?.getAudioContext) { // @ts-expect-error - Replace the method ws.backend.getAudioContext = () => this.audioContext; this.log(LogLevel.DEBUG, 'Overrode backend.getAudioContext with shared context'); return; // Success, exit early } // Method 3: Try top-level getAudioContext if (typeof ws.getAudioContext === 'function') { // @ts-expect-error - Replace the method ws.getAudioContext = () => this.audioContext; this.log(LogLevel.DEBUG, 'Overrode ws.getAudioContext with shared context'); return; // Success, exit early } this.log(LogLevel.WARN, 'Could not share audio context with WaveSurfer - no compatible method found'); } catch (error) { this.log(LogLevel.WARN, 'Could not share audio context with WaveSurfer:', error); } } public getAudioContextState(): string | undefined { return this.audioContext?.state; } // Method to check if audio can be played (respects browser autoplay policies) public canPlayAudio(): boolean { // Must have a wavesurfer instance if (!this.wavesurfer) { return false; } // Must have a valid duration (waveform loaded) if (this.getDuration() <= 0) { return false; } // If we have an active audio context that's running, we can play if (this.audioContext?.state === 'running') { return true; } // If audio context is suspended, we might be able to resume it with user gesture if (this.audioContext?.state === 'suspended') { return true; // User gesture can resume it } // If no audio context exists, we can create one with user gesture return true; // User gesture can create it } // Method to check if waveform is ready for playback public isWaveformReady(): boolean { return !!this.wavesurfer && this.getDuration() > 0; } // Method to check if audio service is properly initialized public isInitialized(): boolean { return !!this.wavesurfer && this.getDuration() > 0 && !!this.audioContext; } // Method to check if ready for playback (unified readiness check) public isReadyForPlayback(): boolean { return ( this.initializationState === InitializationState.Completed && this.isWaveformReady() && !!this.audioContext && (this.audioContext.state === 'running' || this.audioContext.state === 'suspended') ); } // Initialization state management public getInitializationState(): InitializationState { return this.initializationState; } public getInitializationError(): Error | null { return this.initializationError; } public isInitializationComplete(): boolean { return this.initializationState === InitializationState.Completed; } public async waitForInitialization(): Promise { return this.initializationPromise ?? Promise.resolve(); } // Method to ensure audio context is available (for backward compatibility) private async ensureAudioContext(): Promise { // If we already have a valid audio context, return it if (this.audioContext) { // Resume if suspended (common in mobile browsers) if (this.audioContext.state === 'suspended') { try { await this.audioContext.resume(); this.log(LogLevel.INFO, 'Audio context resumed successfully'); } catch (error) { this.log(LogLevel.ERROR, 'Failed to resume audio context:', error); throw error; } } return this.audioContext; } // Create new audio context try { this.audioContext = new (window.AudioContext || (window as { webkitAudioContext?: new () => AudioContext }).webkitAudioContext)(); this.log(LogLevel.INFO, 'New audio context created', { state: this.audioContext.state, sampleRate: this.audioContext.sampleRate }); // Set up state change monitoring this.audioContext.onstatechange = () => { this.log(LogLevel.DEBUG, 'Audio context state changed:', this.audioContext?.state); }; return this.audioContext; } catch (error) { this.log(LogLevel.ERROR, 'Failed to create audio context:', error); throw new Error(`Audio context creation failed: ${error instanceof Error ? error.message : String(error)}`); } } // Method to get WaveSurfer version for debugging public getWaveSurferVersion(): string | null { if (this.wavesurfer) { // @ts-expect-error - WaveSurfer version might not be in types return this.wavesurfer.version || 'unknown'; } return null; } // Method to update multiple player state values at once public updatePlayerState(updates: { isPlaying?: boolean; currentTime?: number; duration?: number; }) { const playerStore = usePlayerStore.getState(); playerStore.batchUpdate(updates); } } export const audioService = AudioService.getInstance(); export { AudioService, LogLevel, InitializationState }; // Export class and enums for testing