From 9c4c3cda348e0c3dc99468e4b0d57f444c988ef4 Mon Sep 17 00:00:00 2001 From: Mistral Vibe Date: Wed, 8 Apr 2026 18:38:28 +0200 Subject: [PATCH] WIP: Investigating audio context and player issues --- web/src/hooks/useWaveform.ts | 31 ++++-- web/src/main.tsx | 10 +- web/src/services/audioService.ts | 170 ++++++++++++++++++------------- 3 files changed, 126 insertions(+), 85 deletions(-) diff --git a/web/src/hooks/useWaveform.ts b/web/src/hooks/useWaveform.ts index 842e123..23abccc 100755 --- a/web/src/hooks/useWaveform.ts +++ b/web/src/hooks/useWaveform.ts @@ -107,9 +107,16 @@ export function useWaveform( const checkReady = setInterval(() => { if (audioService.getDuration() > 0) { clearInterval(checkReady); - audioService.play(options.songId, options.bandId); - if (globalCurrentTime > 0) { - audioService.seekTo(globalCurrentTime); + // Only attempt to play if we have a valid audio context + // This prevents autoplay policy violations + try { + audioService.play(options.songId, options.bandId); + if (globalCurrentTime > 0) { + audioService.seekTo(globalCurrentTime); + } + } catch (error) { + console.warn('Auto-play prevented by browser policy, waiting for user gesture:', error); + // Don't retry - wait for user to click play } } }, 50); @@ -138,11 +145,19 @@ export function useWaveform( }, [options.url, options.songId, options.bandId, containerRef, currentSongId, globalBandId, globalCurrentTime, globalIsPlaying, setCurrentSong]); const play = () => { - - try { - audioService.play(options.songId || null, options.bandId || null); - } catch (error) { - console.error('useWaveform.play failed:', error); + // Only attempt to play if waveform is ready + if (audioService.isWaveformReady()) { + try { + audioService.play(options.songId || null, options.bandId || null); + } catch (error) { + console.error('useWaveform.play failed:', error); + } + } else { + console.warn('Cannot play: waveform not ready', { + hasWavesurfer: !!audioService.isPlaying(), + duration: audioService.getDuration(), + url: options.url + }); } }; diff --git a/web/src/main.tsx b/web/src/main.tsx index 3932a2e..cd61ac1 100755 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,17 +1,13 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import App from "./App.tsx"; -import { audioService } from "./services/audioService"; const root = document.getElementById("root"); if (!root) throw new Error("No #root element found"); -// Initialize audio context at app startup for better performance -// This prevents audio context creation delays during first playback -audioService.initializeAudioContext().catch(error => { - console.error('Failed to initialize audio context:', error); - // Continue app initialization even if audio context fails -}); +// Note: Audio context initialization is now deferred until first user gesture +// to comply with browser autoplay policies. The audio service will create +// the audio context when the user first interacts with playback controls. createRoot(root).render( diff --git a/web/src/services/audioService.ts b/web/src/services/audioService.ts index 12ed749..c585639 100755 --- a/web/src/services/audioService.ts +++ b/web/src/services/audioService.ts @@ -117,22 +117,16 @@ private readonly PLAY_DEBOUNCE_MS: number = 100; return this.instance; } - // Initialize audio context at app startup + // 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 initialized at app startup', { + this.log(LogLevel.INFO, 'Audio context created', { state: this.audioContext.state, sampleRate: this.audioContext.sampleRate }); - // Handle audio context suspension (common in mobile browsers) - if (this.audioContext.state === 'suspended') { - await this.audioContext.resume(); - this.log(LogLevel.INFO, 'Audio context resumed successfully'); - } - // Set up state change monitoring this.audioContext.onstatechange = () => { this.log(LogLevel.DEBUG, 'Audio context state changed:', this.audioContext?.state); @@ -145,6 +139,32 @@ private readonly PLAY_DEBOUNCE_MS: number = 100; } } + // 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; @@ -208,7 +228,7 @@ private readonly PLAY_DEBOUNCE_MS: number = 100; // Ensure we can control playback manually autoplay: false, // Development-specific settings for better debugging - ...(typeof window !== 'undefined' && window.location && window.location.hostname === 'localhost' && { + ...(typeof window !== 'undefined' && window.location && window.location.hostname === 'localhost' && this.audioContext && { backend: 'WebAudio', audioContext: this.audioContext, audioRate: 1, @@ -311,7 +331,13 @@ private readonly PLAY_DEBOUNCE_MS: number = 100; public async play(songId: string | null = null, bandId: string | null = null): Promise { if (!this.wavesurfer) { - this.log(LogLevel.WARN, 'AudioService: no wavesurfer instance'); + 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; } @@ -345,17 +371,30 @@ private readonly PLAY_DEBOUNCE_MS: number = 100; this.cleanup(); } - // Ensure we have a valid audio context - await this.ensureAudioContext(); + // Ensure we have a valid audio context and handle user gesture requirement + await this.handleAudioContextResume(); - // Handle suspended audio context (common in mobile browsers) - if (this.audioContext?.state === 'suspended') { - await this.audioContext.resume(); - this.log(LogLevel.INFO, 'Audio context resumed successfully'); + // 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; + } } - await this.wavesurfer.play(); - // Update currently playing song tracking if (songId && bandId) { this.currentPlayingSongId = songId; @@ -372,18 +411,16 @@ private readonly PLAY_DEBOUNCE_MS: number = 100; this.log(LogLevel.ERROR, `Playback failed (attempt ${this.playbackAttempts}):`, error); // Handle specific audio context errors - if (error instanceof Error && error.name === 'NotAllowedError') { + if (error instanceof Error && (error.name === 'NotAllowedError' || error.name === 'InvalidStateError')) { this.log(LogLevel.ERROR, 'Playback blocked by browser autoplay policy'); - // Try to resume audio context and retry - if (this.audioContext?.state === 'suspended') { - try { - await this.audioContext.resume(); - this.log(LogLevel.INFO, 'Audio context resumed, retrying playback'); - return this.play(songId, bandId); // Retry after resuming - } catch (resumeError) { - this.log(LogLevel.ERROR, 'Failed to resume audio context:', resumeError); - } + + // 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) { @@ -476,41 +513,6 @@ private readonly PLAY_DEBOUNCE_MS: number = 100; // 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(); - 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 (this should only happen if initializeAudioContext wasn't called) - 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 - }); - - // Handle context state changes - 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))); - } - } private setupAudioContext(ws: WaveSurferWithBackend) { // Simplified audio context setup - we now manage audio context centrally @@ -521,14 +523,12 @@ private readonly PLAY_DEBOUNCE_MS: number = 100; try { // Method 1: Try to set via backend if available if (ws.backend) { - // @ts-expect-error - WaveSurfer typing doesn't expose this ws.backend.audioContext = this.audioContext; this.log(LogLevel.DEBUG, 'Shared audio context with WaveSurfer backend'); } // Method 2: Try to access and replace the audio context if (ws.backend?.getAudioContext) { - const originalGetAudioContext = ws.backend.getAudioContext; // @ts-expect-error - Replace the method ws.backend.getAudioContext = () => this.audioContext; this.log(LogLevel.DEBUG, 'Overrode backend.getAudioContext with shared context'); @@ -536,7 +536,6 @@ private readonly PLAY_DEBOUNCE_MS: number = 100; // Method 3: Try top-level getAudioContext if (typeof ws.getAudioContext === 'function') { - const originalGetAudioContext = ws.getAudioContext; // @ts-expect-error - Replace the method ws.getAudioContext = () => this.audioContext; this.log(LogLevel.DEBUG, 'Overrode ws.getAudioContext with shared context'); @@ -563,11 +562,11 @@ private readonly PLAY_DEBOUNCE_MS: number = 100; sampleRate: this.audioContext.sampleRate }); - // Handle audio context suspension (common in mobile browsers) + // Note: We don't automatically resume suspended audio contexts here + // because that requires a user gesture. The resume will be handled + // in handleAudioContextResume() when the user clicks play. if (this.audioContext.state === 'suspended') { - this.audioContext.resume().catch(error => { - this.log(LogLevel.ERROR, 'Failed to resume audio context:', error); - }); + this.log(LogLevel.DEBUG, 'Audio context is suspended, will resume on user gesture'); } // Set up state change monitoring @@ -587,6 +586,37 @@ private readonly PLAY_DEBOUNCE_MS: number = 100; 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 get WaveSurfer version for debugging public getWaveSurferVersion(): string | null { if (this.wavesurfer) {