Files
rehearshalhub/web/src/services/audioService.ts
Mistral Vibe d4c0e9d776 refactor(audio): Phase 2 — simplify AudioService to thin WaveSurfer wrapper
audioService.ts rewritten from ~850 lines to ~130:
- Remove custom logging system with throttle that suppressed ERROR logs
- Remove AudioContext management entirely (initializeAudioContext,
  handleAudioContextResume, setupAudioContext, shareAudioContextWithWaveSurfer,
  ensureAudioContext). WaveSurfer v7 owns its AudioContext; fighting it caused
  prod/dev divergence and silent failures.
- Replace 5-state InitializationState machine + split promise with a single
  isReady boolean set in the 'ready' event handler
- Remove retry/debounce logic from play() — these are UI concerns
- Remove dead methods: canPlayAudio (always returned true), getWaveSurferVersion,
  updatePlayerState, getAudioContextState, setLogLevel
- Extract destroyWaveSurfer() helper so cleanup is one place
- MiniPlayer now passes songId/bandId to play() (was calling with no args)
- SongPage spacebar handler simplified: just checks isReady from hook
- SongPage no longer imports audioService directly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 20:47:10 +02:00

152 lines
4.3 KiB
TypeScript
Executable File

import WaveSurfer from "wavesurfer.js";
import { usePlayerStore } from "../stores/playerStore";
class AudioService {
private static instance: AudioService;
private wavesurfer: WaveSurfer | null = null;
private currentUrl: string | null = null;
private isReady = false;
private lastTimeUpdate = 0;
private constructor() {}
public static getInstance(): AudioService {
if (!this.instance) {
this.instance = new AudioService();
}
return this.instance;
}
// For use in tests only
public static resetInstance(): void {
this.instance = undefined as any;
}
public async initialize(container: HTMLElement, url: string): Promise<void> {
if (!container) throw new Error('Container element is required');
if (!url) throw new Error('Valid audio URL is required');
// Reuse the existing instance when the URL hasn't changed
if (this.currentUrl === url && this.wavesurfer) return;
// Tear down the previous instance before creating a new one
if (this.wavesurfer) this.destroyWaveSurfer();
const ws = WaveSurfer.create({
container,
waveColor: "rgba(255,255,255,0.09)",
progressColor: "#c8861a",
cursorColor: "#e8a22a",
barWidth: 2,
barRadius: 2,
height: 104,
normalize: true,
autoplay: false,
});
this.wavesurfer = ws;
this.currentUrl = url;
this.setupEventHandlers(ws);
await new Promise<void>((resolve, reject) => {
const onReady = async () => {
const duration = ws.getDuration();
if (duration > 0) {
usePlayerStore.getState().setDuration(duration);
this.isReady = true;
resolve();
} else {
reject(new Error('Audio loaded but duration is 0'));
}
};
ws.on('ready', () => { onReady().catch(reject); });
ws.on('error', (err) => reject(err instanceof Error ? err : new Error(String(err))));
ws.load(url);
});
}
private setupEventHandlers(ws: WaveSurfer): void {
ws.on('play', () => usePlayerStore.getState().batchUpdate({ isPlaying: true }));
ws.on('pause', () => usePlayerStore.getState().batchUpdate({ isPlaying: false }));
ws.on('finish', () => usePlayerStore.getState().batchUpdate({ isPlaying: false }));
ws.on('audioprocess', (time) => {
const now = Date.now();
if (now - this.lastTimeUpdate >= 250) {
usePlayerStore.getState().batchUpdate({ currentTime: time });
this.lastTimeUpdate = now;
}
});
}
public async play(songId: string | null = null, bandId: string | null = null): Promise<void> {
if (!this.wavesurfer || !this.isReady) {
console.warn('[AudioService] play() called before ready');
return;
}
await this.wavesurfer.play();
if (songId && bandId) {
usePlayerStore.getState().setCurrentPlayingSong(songId, bandId);
}
}
public pause(): void {
this.wavesurfer?.pause();
}
public seekTo(time: number): void {
if (this.wavesurfer && this.isReady && isFinite(time)) {
this.wavesurfer.setTime(time);
}
}
public getCurrentTime(): number {
return this.wavesurfer?.getCurrentTime() ?? 0;
}
public getDuration(): number {
return this.wavesurfer?.getDuration() ?? 0;
}
public isPlaying(): boolean {
return this.wavesurfer?.isPlaying() ?? false;
}
public isWaveformReady(): boolean {
return this.isReady && !!this.wavesurfer;
}
// Aliases kept for callers until Phase 3 cleans them up
public isReadyForPlayback(): boolean {
return this.isWaveformReady();
}
public canAttemptPlayback(): boolean {
return this.isWaveformReady();
}
public cleanup(): void {
this.destroyWaveSurfer();
const store = usePlayerStore.getState();
store.setCurrentPlayingSong(null, null);
store.batchUpdate({ isPlaying: false, currentTime: 0 });
}
private destroyWaveSurfer(): void {
if (!this.wavesurfer) return;
try {
if (this.wavesurfer.isPlaying()) this.wavesurfer.pause();
this.wavesurfer.unAll();
this.wavesurfer.destroy();
} catch (err) {
console.error('[AudioService] Error destroying WaveSurfer:', err);
}
this.wavesurfer = null;
this.currentUrl = null;
this.isReady = false;
}
}
export const audioService = AudioService.getInstance();
export { AudioService };