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

@@ -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) {