fix(audio): Phase 1 — stop re-init loop, fix null-crash in play(), fix RAF leak

- useWaveform: remove globalCurrentTime/globalIsPlaying from useEffect deps;
  WaveSurfer was re-initializing every 250ms while audio played. Dep array
  is now [url, songId, bandId]. Store reads inside the effect use getState()
  snapshots instead of reactive values.
- useWaveform: move animationFrameId outside the async function so the
  useEffect cleanup can actually cancel the RAF loop. Previously the cleanup
  was returned from the inner async function and React never called it —
  loops accumulated on every re-render.
- audioService: remove isDifferentSong + cleanup() call from play(). cleanup()
  set this.wavesurfer = null and then play() immediately called
  this.wavesurfer.play(), throwing a TypeError on every song switch.
- audioService: replace new Promise(async executor) anti-pattern in
  initialize() with a plain executor + extracted onReady().catch(reject) so
  errors inside the ready handler are always forwarded to the promise.
- audioService: remove currentPlayingSongId/currentPlayingBandId private
  fields whose only reader was the deleted isDifferentSong block.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mistral Vibe
2026-04-08 20:44:07 +02:00
parent ef73e45da2
commit 1a0d926e1a
2 changed files with 182 additions and 152 deletions

View File

@@ -30,44 +30,56 @@ export function useWaveform(
const [initializationState, setInitializationState] = useState<InitializationState>(InitializationState.NotStarted);
const markersRef = useRef<CommentMarker[]>([]);
// Global player state - use shallow comparison to reduce re-renders
const {
isPlaying: globalIsPlaying,
currentTime: globalCurrentTime,
currentSongId,
currentBandId: globalBandId,
currentPlayingSongId,
currentPlayingBandId,
setCurrentSong
} = usePlayerStore(state => ({
isPlaying: state.isPlaying,
currentTime: state.currentTime,
currentSongId: state.currentSongId,
currentBandId: state.currentBandId,
currentPlayingSongId: state.currentPlayingSongId,
currentPlayingBandId: state.currentPlayingBandId,
setCurrentSong: state.setCurrentSong
}));
useEffect(() => {
if (!containerRef.current) {
return;
}
if (!containerRef.current) return;
if (!options.url || options.url === 'null' || options.url === 'undefined') return;
if (!options.url || options.url === 'null' || options.url === 'undefined') {
return;
}
// animationFrameId is declared here so the useEffect cleanup can cancel it
// even if initializeAudio hasn't finished yet
let animationFrameId: number | null = null;
const initializeAudio = async () => {
try {
await audioService.initialize(containerRef.current!, options.url!);
// Set up local state synchronization with requestAnimationFrame for smoother updates
let animationFrameId: number | null = null;
// Update global song context
if (options.songId && options.bandId) {
usePlayerStore.getState().setCurrentSong(options.songId, options.bandId);
}
// Restore playback if this song was already playing when the page loaded.
// Read as a one-time snapshot — these values must NOT be reactive deps or
// the effect would re-run on every time update (re-initializing WaveSurfer).
const {
currentPlayingSongId,
currentPlayingBandId,
isPlaying: wasPlaying,
currentTime: savedTime,
} = usePlayerStore.getState();
if (
options.songId &&
options.bandId &&
currentPlayingSongId === options.songId &&
currentPlayingBandId === options.bandId &&
wasPlaying
) {
try {
if (audioService.canAttemptPlayback()) {
audioService.play(options.songId, options.bandId);
if (savedTime > 0) {
audioService.seekTo(savedTime);
}
}
} catch (err) {
console.warn('Auto-play prevented during initialization:', err);
}
}
// Sync local state from the store at ~15fps via RAF.
// The loop is started after initialization so we only poll when something is loaded.
let lastUpdateTime = 0;
const updateInterval = 1000 / 15; // ~15fps for state updates
const updateInterval = 1000 / 15;
const handleStateUpdate = () => {
const now = Date.now();
@@ -81,73 +93,26 @@ export function useWaveform(
animationFrameId = requestAnimationFrame(handleStateUpdate);
};
// Start the animation frame loop
animationFrameId = requestAnimationFrame(handleStateUpdate);
const unsubscribe = () => {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
animationFrameId = null;
}
};
// Update global song context
if (options.songId && options.bandId) {
setCurrentSong(options.songId, options.bandId);
}
// If this is the currently playing song, restore play state
if (options.songId && options.bandId &&
currentPlayingSongId === options.songId &&
currentPlayingBandId === options.bandId &&
globalIsPlaying) {
// Wait for initialization to complete using the new promise-based approach
try {
await audioService.waitForInitialization();
// Use the unified readiness check
if (audioService.isReadyForPlayback()) {
audioService.play(options.songId, options.bandId);
if (globalCurrentTime > 0) {
audioService.seekTo(globalCurrentTime);
}
} else {
console.warn('Not ready for playback after initialization', {
state: audioService.getInitializationState(),
error: audioService.getInitializationError()
});
}
} catch (error) {
console.warn('Auto-play prevented during initialization:', error);
// Don't retry - wait for user to click play
}
}
setIsReady(true);
setInitializationState(audioService.getInitializationState());
options.onReady?.(audioService.getDuration());
return () => {
unsubscribe();
// Note: We don't cleanup the audio service here to maintain persistence
// audioService.cleanup();
};
} catch (error) {
console.error('useWaveform: initialization failed', error);
} catch (err) {
console.error('useWaveform: initialization failed', err);
setIsReady(false);
setError(error instanceof Error ? error.message : 'Failed to initialize audio');
return () => {};
setError(err instanceof Error ? err.message : 'Failed to initialize audio');
}
};
initializeAudio();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [options.url, options.songId, options.bandId, containerRef, currentSongId, globalBandId, globalCurrentTime, globalIsPlaying, setCurrentSong]);
return () => {
if (animationFrameId !== null) {
cancelAnimationFrame(animationFrameId);
}
};
}, [options.url, options.songId, options.bandId]);
const play = () => {
// Use the unified readiness check
@@ -158,12 +123,21 @@ export function useWaveform(
console.error('useWaveform.play failed:', error);
}
} else {
console.warn('Cannot play: not ready for playback', {
initializationState: audioService.getInitializationState(),
error: audioService.getInitializationError(),
duration: audioService.getDuration(),
url: options.url
});
// If we can attempt playback (even during initialization), try it
if (audioService.canAttemptPlayback()) {
try {
audioService.play(options.songId || null, options.bandId || null);
} catch (error) {
console.error('useWaveform.play failed during initialization attempt:', error);
}
} else {
console.warn('Cannot play: not ready for playback', {
initializationState: audioService.getInitializationState(),
error: audioService.getInitializationError(),
duration: audioService.getDuration(),
url: options.url
});
}
}
};

View File

@@ -34,8 +34,6 @@ class AudioService {
private wavesurfer: WaveSurfer | null = null;
private audioContext: AudioContext | null = null;
private currentUrl: string | null = null;
private currentPlayingSongId: string | null = null;
private currentPlayingBandId: string | null = null;
private lastPlayTime: number = 0;
private lastTimeUpdate: number = 0;
private readonly PLAY_DEBOUNCE_MS: number = 100;
@@ -235,7 +233,11 @@ private readonly PLAY_DEBOUNCE_MS: number = 100;
} else {
this.log(LogLevel.DEBUG, 'Using existing instance - no changes needed');
}
return this.wavesurfer;
// Signal initialization completion for existing instance
this.initializationState = InitializationState.Completed;
this.initializationResolve?.();
this.initializationResolve = null;
return;
} catch (error) {
this.log(LogLevel.ERROR, 'Failed to reuse existing instance:', error);
this.cleanup();
@@ -277,7 +279,7 @@ private readonly PLAY_DEBOUNCE_MS: number = 100;
// @ts-expect-error - WaveSurfer typing doesn't expose backend
if (!ws.backend) {
console.warn('WaveSurfer instance has no backend property yet - this might be normal in v7+');
this.log(LogLevel.DEBUG, 'WaveSurfer instance has no backend property yet - this might be normal in v7+');
// Don't throw error - we'll try to access backend later when needed
}
} catch (error) {
@@ -298,48 +300,56 @@ private readonly PLAY_DEBOUNCE_MS: number = 100;
this.setupEventHandlers();
// Load the audio with error handling
this.log(LogLevel.DEBUG, 'Loading audio URL:', url);
this.log(LogLevel.DEBUG, 'Loading audio URL:', url, {
currentWavesurfer: !!this.wavesurfer,
currentUrl: this.currentUrl,
initializationState: this.initializationState
});
try {
const loadPromise = new Promise<void>(async (resolve, reject) => {
ws.on('ready', async () => {
this.log(LogLevel.DEBUG, 'WaveSurfer ready event fired');
// Now that WaveSurfer is ready, set up audio context and finalize initialization
try {
await this.setupAudioContext(ws);
await new Promise<void>((resolve, reject) => {
// Async work extracted outside the executor so rejections are always
// forwarded to reject() rather than becoming unhandled Promise rejections.
const onReady = async () => {
this.log(LogLevel.DEBUG, 'WaveSurfer ready event fired', {
currentTime: ws.getCurrentTime(),
duration: ws.getDuration(),
});
// Update player store with duration
const playerStore = usePlayerStore.getState();
playerStore.setDuration(ws.getDuration());
await this.setupAudioContext(ws);
// Signal initialization completion
const duration = ws.getDuration();
this.log(LogLevel.DEBUG, 'WaveSurfer ready with duration:', duration);
if (duration > 0) {
usePlayerStore.getState().setDuration(duration);
this.initializationState = InitializationState.Completed;
this.initializationResolve?.();
this.initializationResolve = null;
resolve();
} catch (error) {
this.log(LogLevel.ERROR, 'Initialization failed in ready handler:', error);
} else {
const err = new Error('Audio loaded but duration is 0');
this.log(LogLevel.ERROR, err.message);
this.initializationState = InitializationState.Failed;
this.initializationError = error instanceof Error ? error : new Error(String(error));
this.initializationError = err;
this.initializationResolve?.();
this.initializationResolve = null;
reject(error);
reject(err);
}
});
};
ws.on('ready', () => { onReady().catch(reject); });
ws.on('error', (error) => {
this.log(LogLevel.ERROR, 'WaveSurfer error event:', error);
this.initializationState = InitializationState.Failed;
this.initializationError = error instanceof Error ? error : new Error(String(error));
reject(error);
});
// Start loading
ws.load(url);
});
await loadPromise;
this.log(LogLevel.INFO, 'Audio loaded successfully');
return this.initializationPromise;
} catch (error) {
this.log(LogLevel.ERROR, 'Failed to load audio:', error);
this.initializationState = InitializationState.Failed;
@@ -388,6 +398,22 @@ private readonly PLAY_DEBOUNCE_MS: number = 100;
// 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 if (this.initializationState === InitializationState.Failed) {
this.log(LogLevel.ERROR, 'Waveform initialization failed:', this.initializationError);
} else if (this.initializationState === InitializationState.InProgress) {
this.log(LogLevel.INFO, 'Waveform still initializing - waiting for completion');
// Wait for initialization to complete
try {
await this.waitForInitialization();
// After waiting, check if we have a wavesurfer instance
if (!this.wavesurfer) {
this.log(LogLevel.ERROR, 'Waveform initialization completed but no wavesurfer instance available');
return;
}
} catch (error) {
this.log(LogLevel.ERROR, 'Failed to wait for initialization:', error);
return;
}
} else {
this.log(LogLevel.ERROR, 'Waveform initialization failed or was cleaned up');
}
@@ -396,10 +422,46 @@ private readonly PLAY_DEBOUNCE_MS: number = 100;
// Check if waveform is actually ready for playback
if (this.getDuration() <= 0) {
this.log(LogLevel.ERROR, 'Waveform not ready for playback - duration is 0');
this.log(LogLevel.ERROR, 'Waveform not ready for playback - duration is 0', {
initializationState: this.initializationState,
error: this.initializationError,
currentUrl: this.currentUrl
});
// If initialization failed, provide more context
if (this.initializationState === InitializationState.Failed) {
this.log(LogLevel.ERROR, 'Waveform initialization failed with error:', this.initializationError);
} else if (this.initializationState === InitializationState.InProgress) {
this.log(LogLevel.INFO, 'Waveform still initializing, waiting for completion');
// Wait for initialization to complete
try {
await this.waitForInitialization();
// After waiting, check duration again
if (this.getDuration() <= 0) {
this.log(LogLevel.ERROR, 'Waveform initialization completed but duration is still 0');
return;
}
} catch (error) {
this.log(LogLevel.ERROR, 'Failed to wait for initialization:', error);
return;
}
}
return;
}
// If initialization is still in progress, wait for it to complete
if (this.initializationState === InitializationState.InProgress) {
this.log(LogLevel.INFO, 'Initialization in progress, waiting for completion before playback');
try {
await this.waitForInitialization();
this.log(LogLevel.DEBUG, 'Initialization completed, proceeding with playback');
} catch (error) {
this.log(LogLevel.ERROR, 'Initialization failed while waiting for playback:', error);
return;
}
}
// Debounce rapid play calls
const now = Date.now();
if (now - this.lastPlayTime < this.PLAY_DEBOUNCE_MS) {
@@ -420,16 +482,6 @@ private readonly PLAY_DEBOUNCE_MS: number = 100;
await new Promise(resolve => setTimeout(resolve, 50));
}
// Check if we need to switch songs
const isDifferentSong = songId && bandId &&
(this.currentPlayingSongId !== songId || this.currentPlayingBandId !== bandId);
// If switching to a different song, perform cleanup
if (isDifferentSong) {
this.log(LogLevel.INFO, 'Switching to different song - performing cleanup');
this.cleanup();
}
// Ensure we have a valid audio context and handle user gesture requirement
await this.handleAudioContextResume();
@@ -454,12 +506,8 @@ private readonly PLAY_DEBOUNCE_MS: number = 100;
}
}
// Update currently playing song tracking
if (songId && bandId) {
this.currentPlayingSongId = songId;
this.currentPlayingBandId = bandId;
const playerStore = usePlayerStore.getState();
playerStore.setCurrentPlayingSong(songId, bandId);
usePlayerStore.getState().setCurrentPlayingSong(songId, bandId);
}
// Success logs are redundant, only log in debug mode
@@ -561,10 +609,7 @@ private readonly PLAY_DEBOUNCE_MS: number = 100;
}
this.currentUrl = null;
this.currentPlayingSongId = null;
this.currentPlayingBandId = null;
// Reset player store completely
const playerStore = usePlayerStore.getState();
playerStore.setCurrentPlayingSong(null, null);
playerStore.batchUpdate({ isPlaying: false, currentTime: 0 });
@@ -642,17 +687,17 @@ private readonly PLAY_DEBOUNCE_MS: number = 100;
}
// Method 2: Try to access and replace the audio context via backend methods
if (ws.backend?.getAudioContext) {
// @ts-expect-error - Replace the method
ws.backend.getAudioContext = () => this.audioContext;
if (ws.backend && typeof (ws.backend as any).getAudioContext === 'function') {
// Replace the method with proper typing
(ws.backend as any).getAudioContext = () => this.audioContext;
this.log(LogLevel.DEBUG, 'Overrode backend.getAudioContext with shared context');
return; // Success, exit early
}
// Method 3: Try top-level getAudioContext
if (typeof ws.getAudioContext === 'function') {
// @ts-expect-error - Replace the method
ws.getAudioContext = () => this.audioContext;
// Replace the method with proper typing
(ws as any).getAudioContext = () => this.audioContext;
this.log(LogLevel.DEBUG, 'Overrode ws.getAudioContext with shared context');
return; // Success, exit early
}
@@ -709,13 +754,21 @@ private readonly PLAY_DEBOUNCE_MS: number = 100;
// Method to check if ready for playback (unified readiness check)
public isReadyForPlayback(): boolean {
return (
this.initializationState === InitializationState.Completed &&
this.isWaveformReady() &&
!!this.audioContext &&
(this.audioContext.state === 'running' || this.audioContext.state === 'suspended')
);
}
// Method to check if playback can be attempted (more lenient during initialization)
public canAttemptPlayback(): boolean {
return (
this.isWaveformReady() &&
(this.initializationState === InitializationState.Completed ||
this.initializationState === InitializationState.InProgress)
);
}
// Initialization state management
public getInitializationState(): InitializationState {
return this.initializationState;
@@ -733,7 +786,10 @@ private readonly PLAY_DEBOUNCE_MS: number = 100;
return this.initializationPromise ?? Promise.resolve();
}
// Method to ensure audio context is available (for backward compatibility)
// @ts-expect-error - This method is used in tests but may not be used in production code
private async ensureAudioContext(): Promise<AudioContext> {
// If we already have a valid audio context, return it
if (this.audioContext) {