import WaveSurfer from "wavesurfer.js"; import { usePlayerStore } from "../stores/playerStore"; // Log level enum enum LogLevel { DEBUG = 0, INFO = 1, WARN = 2, ERROR = 3 } // 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 lastPlayTime: number = 0; private lastTimeUpdate: number = 0; private readonly TIME_UPDATE_THROTTLE: number = 100; private readonly PLAY_DEBOUNCE_MS: number = 100; private lastSeekTime: number = 0; private readonly SEEK_DEBOUNCE_MS: number = 200; private logLevel: LogLevel = LogLevel.INFO; private playbackAttempts: number = 0; private readonly MAX_PLAYBACK_ATTEMPTS: number = 3; private constructor() { this.log(LogLevel.INFO, 'AudioService initialized'); } private log(level: LogLevel, message: string, ...args: unknown[]) { if (level < this.logLevel) return; const prefix = `[AudioService:${LogLevel[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; } public async initialize(container: HTMLElement, url: string) { this.log(LogLevel.DEBUG, 'AudioService.initialize called', { url, containerExists: !!container }); // Validate inputs if (!container) { this.log(LogLevel.ERROR, 'AudioService: container element is null'); throw new Error('Container element is required'); } if (!url || url === 'null' || url === 'undefined') { this.log(LogLevel.ERROR, 'AudioService: invalid URL', { url }); 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) { this.log(LogLevel.INFO, '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, }); 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 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((resolve, reject) => { ws.on('ready', () => { this.log(LogLevel.DEBUG, 'WaveSurfer ready event fired'); // Now that WaveSurfer is ready, set up audio context and finalize initialization this.setupAudioContext(ws); // Update player store with duration const playerStore = usePlayerStore.getState(); playerStore.setDuration(ws.getDuration()); resolve(); }); 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'); } catch (error) { this.log(LogLevel.ERROR, 'Failed to load audio:', error); this.cleanup(); throw error; } return ws; } private setupEventHandlers() { if (!this.wavesurfer) return; const ws = this.wavesurfer; const playerStore = usePlayerStore.getState(); ws.on("play", () => { this.log(LogLevel.DEBUG, 'AudioService: play event'); playerStore.setPlaying(true); }); ws.on("pause", () => { this.log(LogLevel.DEBUG, 'AudioService: pause event'); playerStore.setPlaying(false); }); ws.on("finish", () => { this.log(LogLevel.DEBUG, 'AudioService: finish event'); playerStore.setPlaying(false); }); ws.on("audioprocess", (time) => { const now = Date.now(); if (now - this.lastTimeUpdate >= this.TIME_UPDATE_THROTTLE) { playerStore.setCurrentTime(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(): Promise { if (!this.wavesurfer) { this.log(LogLevel.WARN, 'AudioService: no wavesurfer instance'); 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; this.log(LogLevel.INFO, 'AudioService.play called'); try { // Ensure we have a valid audio context await this.ensureAudioContext(); await this.wavesurfer.play(); this.log(LogLevel.INFO, 'Playback started successfully'); this.playbackAttempts = 0; // Reset on success } catch (error) { this.playbackAttempts++; this.log(LogLevel.ERROR, `Playback failed (attempt ${this.playbackAttempts}):`, error); 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(); // Retry } } } public pause() { if (!this.wavesurfer) { this.log(LogLevel.WARN, 'AudioService: no wavesurfer instance'); return; } this.log(LogLevel.INFO, 'AudioService.pause called'); this.wavesurfer.pause(); } public seekTo(time: number) { if (!this.wavesurfer) { this.log(LogLevel.WARN, 'AudioService: no wavesurfer instance'); return; } this.log(LogLevel.INFO, '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 { // 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; // Note: We intentionally don't nullify audioContext to keep it alive } 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(); console.log('Audio context resumed successfully'); } catch (error) { console.error('Failed to resume audio context:', error); } } return this.audioContext; } // Create new audio context try { this.audioContext = new (window.AudioContext || (window as { webkitAudioContext?: new () => AudioContext }).webkitAudioContext)(); console.log('Audio context created:', this.audioContext.state); // Handle context state changes this.audioContext.onstatechange = () => { console.log('Audio context state changed:', this.audioContext?.state); }; return this.audioContext; } catch (error) { console.error('Failed to create audio context:', error); throw error; } } private setupAudioContext(ws: WaveSurferWithBackend) { // Try multiple methods to get audio context from WaveSurfer v7+ try { // Method 1: Try standard backend.getAudioContext() this.audioContext = ws.backend?.getAudioContext?.() ?? null; // Method 2: Try accessing audio context directly from backend if (!this.audioContext) { this.audioContext = ws.backend?.ac ?? null; } // Method 3: Try accessing through backend.getAudioContext() without optional chaining if (!this.audioContext) { this.audioContext = ws.backend?.getAudioContext?.() ?? null; } // Method 4: Try accessing through wavesurfer.getAudioContext() if it exists if (!this.audioContext && typeof ws.getAudioContext === 'function') { this.audioContext = ws.getAudioContext() ?? null; } // Method 5: Try accessing through backend.ac directly if (!this.audioContext) { this.audioContext = ws.backend?.ac ?? null; } // Method 6: Try accessing through backend.audioContext if (!this.audioContext) { this.audioContext = ws.backend?.audioContext ?? null; } if (this.audioContext) { console.log('Audio context accessed successfully:', this.audioContext.state); } else { console.warn('Could not access audio context from WaveSurfer - playback may have issues'); // Log the wavesurfer structure for debugging console.debug('WaveSurfer structure:', { hasBackend: !!ws.backend, backendType: typeof ws.backend, backendKeys: ws.backend ? Object.keys(ws.backend) : 'no backend', wavesurferKeys: Object.keys(ws) }); } } catch (error) { console.error('Error accessing audio context:', error); } } public getAudioContextState(): string | undefined { return this.audioContext?.state; } } export const audioService = AudioService.getInstance();