WIP: Investigating audio context and player issues
This commit is contained in:
@@ -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
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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(
|
||||
<StrictMode>
|
||||
|
||||
@@ -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<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
|
||||
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<void> {
|
||||
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<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) {
|
||||
// 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) {
|
||||
|
||||
Reference in New Issue
Block a user