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>
189 lines
6.2 KiB
TypeScript
Executable File
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 };
|