WIP: Investigating audio context and player issues

This commit is contained in:
Mistral Vibe
2026-04-08 18:38:28 +02:00
parent 9c032d0774
commit 9c4c3cda34
3 changed files with 126 additions and 85 deletions

View File

@@ -107,9 +107,16 @@ export function useWaveform(
const checkReady = setInterval(() => { const checkReady = setInterval(() => {
if (audioService.getDuration() > 0) { if (audioService.getDuration() > 0) {
clearInterval(checkReady); clearInterval(checkReady);
audioService.play(options.songId, options.bandId); // Only attempt to play if we have a valid audio context
if (globalCurrentTime > 0) { // This prevents autoplay policy violations
audioService.seekTo(globalCurrentTime); 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); }, 50);
@@ -138,11 +145,19 @@ export function useWaveform(
}, [options.url, options.songId, options.bandId, containerRef, currentSongId, globalBandId, globalCurrentTime, globalIsPlaying, setCurrentSong]); }, [options.url, options.songId, options.bandId, containerRef, currentSongId, globalBandId, globalCurrentTime, globalIsPlaying, setCurrentSong]);
const play = () => { const play = () => {
// Only attempt to play if waveform is ready
try { if (audioService.isWaveformReady()) {
audioService.play(options.songId || null, options.bandId || null); try {
} catch (error) { audioService.play(options.songId || null, options.bandId || null);
console.error('useWaveform.play failed:', error); } 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
});
} }
}; };

View File

@@ -1,17 +1,13 @@
import { StrictMode } from "react"; import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import App from "./App.tsx"; import App from "./App.tsx";
import { audioService } from "./services/audioService";
const root = document.getElementById("root"); const root = document.getElementById("root");
if (!root) throw new Error("No #root element found"); if (!root) throw new Error("No #root element found");
// Initialize audio context at app startup for better performance // Note: Audio context initialization is now deferred until first user gesture
// This prevents audio context creation delays during first playback // to comply with browser autoplay policies. The audio service will create
audioService.initializeAudioContext().catch(error => { // the audio context when the user first interacts with playback controls.
console.error('Failed to initialize audio context:', error);
// Continue app initialization even if audio context fails
});
createRoot(root).render( createRoot(root).render(
<StrictMode> <StrictMode>

View File

@@ -117,22 +117,16 @@ private readonly PLAY_DEBOUNCE_MS: number = 100;
return this.instance; return this.instance;
} }
// Initialize audio context at app startup // Initialize audio context - now handles user gesture requirement
public async initializeAudioContext() { public async initializeAudioContext() {
try { try {
if (!this.audioContext) { if (!this.audioContext) {
this.audioContext = new (window.AudioContext || (window as { webkitAudioContext?: new () => AudioContext }).webkitAudioContext)(); 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, state: this.audioContext.state,
sampleRate: this.audioContext.sampleRate 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 // Set up state change monitoring
this.audioContext.onstatechange = () => { this.audioContext.onstatechange = () => {
this.log(LogLevel.DEBUG, 'Audio context state changed:', this.audioContext?.state); 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<void> {
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 // Method for testing: reset the singleton instance
public static resetInstance(): void { public static resetInstance(): void {
this.instance = undefined as any; this.instance = undefined as any;
@@ -208,7 +228,7 @@ private readonly PLAY_DEBOUNCE_MS: number = 100;
// Ensure we can control playback manually // Ensure we can control playback manually
autoplay: false, autoplay: false,
// Development-specific settings for better debugging // 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', backend: 'WebAudio',
audioContext: this.audioContext, audioContext: this.audioContext,
audioRate: 1, 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<void> { public async play(songId: string | null = null, bandId: string | null = null): Promise<void> {
if (!this.wavesurfer) { 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; return;
} }
@@ -345,17 +371,30 @@ private readonly PLAY_DEBOUNCE_MS: number = 100;
this.cleanup(); this.cleanup();
} }
// Ensure we have a valid audio context // Ensure we have a valid audio context and handle user gesture requirement
await this.ensureAudioContext(); await this.handleAudioContextResume();
// Handle suspended audio context (common in mobile browsers) // Try to play - this might fail due to autoplay policy
if (this.audioContext?.state === 'suspended') { try {
await this.audioContext.resume(); await this.wavesurfer.play();
this.log(LogLevel.INFO, 'Audio context resumed successfully'); } 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 // Update currently playing song tracking
if (songId && bandId) { if (songId && bandId) {
this.currentPlayingSongId = songId; this.currentPlayingSongId = songId;
@@ -372,18 +411,16 @@ private readonly PLAY_DEBOUNCE_MS: number = 100;
this.log(LogLevel.ERROR, `Playback failed (attempt ${this.playbackAttempts}):`, error); this.log(LogLevel.ERROR, `Playback failed (attempt ${this.playbackAttempts}):`, error);
// Handle specific audio context errors // 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'); this.log(LogLevel.ERROR, 'Playback blocked by browser autoplay policy');
// Try to resume audio context and retry
if (this.audioContext?.state === 'suspended') { // Don't retry immediately - wait for user gesture
try { if (this.playbackAttempts >= this.MAX_PLAYBACK_ATTEMPTS) {
await this.audioContext.resume(); this.log(LogLevel.ERROR, 'Max playback attempts reached, resetting player');
this.log(LogLevel.INFO, 'Audio context resumed, retrying playback'); // Don't cleanup wavesurfer - just reset state
return this.play(songId, bandId); // Retry after resuming this.playbackAttempts = 0;
} catch (resumeError) {
this.log(LogLevel.ERROR, 'Failed to resume audio context:', resumeError);
}
} }
return; // Don't retry autoplay errors
} }
if (this.playbackAttempts >= this.MAX_PLAYBACK_ATTEMPTS) { 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 // Note: We intentionally don't nullify audioContext to keep it alive
} }
private async ensureAudioContext(): Promise<AudioContext> {
// 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) { private setupAudioContext(ws: WaveSurferWithBackend) {
// Simplified audio context setup - we now manage audio context centrally // Simplified audio context setup - we now manage audio context centrally
@@ -521,14 +523,12 @@ private readonly PLAY_DEBOUNCE_MS: number = 100;
try { try {
// Method 1: Try to set via backend if available // Method 1: Try to set via backend if available
if (ws.backend) { if (ws.backend) {
// @ts-expect-error - WaveSurfer typing doesn't expose this
ws.backend.audioContext = this.audioContext; ws.backend.audioContext = this.audioContext;
this.log(LogLevel.DEBUG, 'Shared audio context with WaveSurfer backend'); this.log(LogLevel.DEBUG, 'Shared audio context with WaveSurfer backend');
} }
// Method 2: Try to access and replace the audio context // Method 2: Try to access and replace the audio context
if (ws.backend?.getAudioContext) { if (ws.backend?.getAudioContext) {
const originalGetAudioContext = ws.backend.getAudioContext;
// @ts-expect-error - Replace the method // @ts-expect-error - Replace the method
ws.backend.getAudioContext = () => this.audioContext; ws.backend.getAudioContext = () => this.audioContext;
this.log(LogLevel.DEBUG, 'Overrode backend.getAudioContext with shared context'); 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 // Method 3: Try top-level getAudioContext
if (typeof ws.getAudioContext === 'function') { if (typeof ws.getAudioContext === 'function') {
const originalGetAudioContext = ws.getAudioContext;
// @ts-expect-error - Replace the method // @ts-expect-error - Replace the method
ws.getAudioContext = () => this.audioContext; ws.getAudioContext = () => this.audioContext;
this.log(LogLevel.DEBUG, 'Overrode ws.getAudioContext with shared context'); 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 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') { if (this.audioContext.state === 'suspended') {
this.audioContext.resume().catch(error => { this.log(LogLevel.DEBUG, 'Audio context is suspended, will resume on user gesture');
this.log(LogLevel.ERROR, 'Failed to resume audio context:', error);
});
} }
// Set up state change monitoring // Set up state change monitoring
@@ -587,6 +586,37 @@ private readonly PLAY_DEBOUNCE_MS: number = 100;
return this.audioContext?.state; 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 // Method to get WaveSurfer version for debugging
public getWaveSurferVersion(): string | null { public getWaveSurferVersion(): string | null {
if (this.wavesurfer) { if (this.wavesurfer) {