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