Files
rehearshalhub/web/src/services/audioService.ts
Mistral Vibe 037881a821 feat(waveform): precompute and store peaks in DB for instant rendering
Store waveform peaks inline in audio_versions (JSONB columns) so WaveSurfer
can render the waveform immediately on page load without waiting for audio
decode. Adds a 100-point mini-waveform for version selector thumbnails.

Backend:
- Migration 0006: adds waveform_peaks and waveform_peaks_mini JSONB columns
- Worker generates both resolutions (500-pt full, 100-pt mini) during transcode
  and stores them directly in DB — replaces file-based waveform_url approach
- AudioVersionRead schema exposes both fields inline (no extra HTTP round-trip)
- GET /versions/{id}/waveform reads from DB; adds ?resolution=mini support

Frontend:
- audioService.initialize() accepts peaks and calls ws.load(url, Float32Array)
  so waveform renders instantly without audio decode
- useWaveform hook threads peaks option through to audioService
- PlayerPanel passes waveform_peaks from the active version to the hook
- New MiniWaveform SVG component (no WaveSurfer) renders mini peaks in the
  version selector buttons

Fix: docker-compose.dev.yml now runs alembic upgrade head before starting
the API server, so a fresh volume gets the full schema automatically.

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

189 lines
6.2 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 currentContainer: HTMLElement | null = null;
private isReady = false;
private lastTimeUpdate = 0;
// Persistent audio element attached to document.body so playback survives
// SongPage unmounts. WaveSurfer v7 supports passing an existing media element
// via the `media` option — it uses it for playback but does NOT destroy it
// when WaveSurfer.destroy() is called.
private mediaElement: HTMLAudioElement | null = null;
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?.cleanup();
this.instance = undefined as unknown as AudioService;
}
private createMediaElement(): HTMLAudioElement {
// Always create a fresh element — never reuse the one from a destroyed
// WaveSurfer instance. WaveSurfer.destroy() aborts its internal fetch
// signal, which can poison the same element when the next instance tries
// to load a new URL. A new element has no lingering aborted state.
// The element is appended to document.body so it outlives SongPage unmounts.
const el = document.createElement('audio');
el.style.display = 'none';
document.body.appendChild(el);
return el;
}
public async initialize(container: HTMLElement, url: string, peaks?: number[] | null): Promise<void> {
if (!container) throw new Error('Container element is required');
if (!url) throw new Error('Valid audio URL is required');
// Same URL and same container — nothing to do
if (this.currentUrl === url && this.wavesurfer && this.currentContainer === container) return;
// Same URL, different container: navigated away and back to the same song.
// Move the waveform canvas to the new container without reloading audio.
if (this.currentUrl === url && this.wavesurfer) {
this.wavesurfer.setOptions({ container });
this.currentContainer = container;
return;
}
// Different URL — tear down the previous instance and clear stale store state
if (this.wavesurfer) {
this.destroyWaveSurfer();
usePlayerStore.getState().batchUpdate({ isPlaying: false, currentTime: 0, duration: 0 });
}
this.mediaElement = this.createMediaElement();
const ws = WaveSurfer.create({
container,
// Fresh audio element per song. Lives on document.body so playback
// continues even when the SongPage container is removed from the DOM.
media: this.mediaElement,
waveColor: "rgba(20,184,166,0.18)",
progressColor: "#14b8a6",
cursorColor: "#2dd4bf",
barWidth: 2,
barRadius: 2,
height: 104,
normalize: true,
autoplay: false,
});
this.wavesurfer = ws;
this.currentUrl = url;
this.currentContainer = container;
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))));
// Pass pre-computed peaks to WaveSurfer so the waveform renders immediately
// without waiting for the full audio to decode (WaveSurfer v7 feature).
if (peaks && peaks.length > 0) {
ws.load(url, [new Float32Array(peaks)]);
} else {
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) return;
await this.wavesurfer.play();
if (songId && bandId) {
usePlayerStore.getState().setCurrentSong(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;
}
public cleanup(): void {
this.destroyWaveSurfer();
const store = usePlayerStore.getState();
store.setCurrentSong(null, null);
store.batchUpdate({ isPlaying: false, currentTime: 0, duration: 0 });
}
private destroyWaveSurfer(): void {
if (!this.wavesurfer) return;
try {
this.wavesurfer.unAll();
this.wavesurfer.destroy();
// Remove the old media element after WaveSurfer finishes its own cleanup.
if (this.mediaElement) {
this.mediaElement.pause();
this.mediaElement.remove();
}
} catch (err) {
console.error('[AudioService] Error destroying WaveSurfer:', err);
}
this.mediaElement = null;
this.wavesurfer = null;
this.currentUrl = null;
this.currentContainer = null;
this.isReady = false;
}
}
export const audioService = AudioService.getInstance();
export { AudioService };