Compare commits
5 Commits
feature/st
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48a73246a1 | ||
|
|
3405325cbb | ||
|
|
a0cc10ffca | ||
|
|
8b7415954c | ||
|
|
d08eebf0eb |
@@ -77,15 +77,16 @@ export function useWaveform(
|
|||||||
};
|
};
|
||||||
|
|
||||||
initializeAudio();
|
initializeAudio();
|
||||||
}, [options.url, options.songId, options.bandId]);
|
// containerRef is a stable ref object — safe to include.
|
||||||
|
// options.onReady is intentionally omitted: it's a callback that callers
|
||||||
|
// may not memoize, and re-running initialization on every render would be
|
||||||
|
// worse than stale-closing over it for the brief window after mount.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [options.url, options.songId, options.bandId, containerRef]);
|
||||||
|
|
||||||
const play = () => {
|
const play = () => {
|
||||||
if (!audioService.isWaveformReady()) {
|
|
||||||
console.warn('[useWaveform] play() called but not ready', { url: options.url });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
audioService.play(options.songId ?? null, options.bandId ?? null)
|
audioService.play(options.songId ?? null, options.bandId ?? null)
|
||||||
.catch(err => console.error('useWaveform.play failed:', err));
|
.catch(err => console.error('[useWaveform] play failed:', err));
|
||||||
};
|
};
|
||||||
|
|
||||||
const pause = () => {
|
const pause = () => {
|
||||||
|
|||||||
@@ -392,7 +392,7 @@ export function SongPage() {
|
|||||||
};
|
};
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [isPlaying, play, pause]);
|
}, [isPlaying, isReady, play, pause]);
|
||||||
|
|
||||||
// ── Comments ─────────────────────────────────────────────────────────────
|
// ── Comments ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,14 @@ class AudioService {
|
|||||||
private static instance: AudioService;
|
private static instance: AudioService;
|
||||||
private wavesurfer: WaveSurfer | null = null;
|
private wavesurfer: WaveSurfer | null = null;
|
||||||
private currentUrl: string | null = null;
|
private currentUrl: string | null = null;
|
||||||
|
private currentContainer: HTMLElement | null = null;
|
||||||
private isReady = false;
|
private isReady = false;
|
||||||
private lastTimeUpdate = 0;
|
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() {}
|
private constructor() {}
|
||||||
|
|
||||||
@@ -19,21 +25,50 @@ class AudioService {
|
|||||||
|
|
||||||
// For use in tests only
|
// For use in tests only
|
||||||
public static resetInstance(): void {
|
public static resetInstance(): void {
|
||||||
this.instance = undefined as any;
|
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): Promise<void> {
|
public async initialize(container: HTMLElement, url: string): Promise<void> {
|
||||||
if (!container) throw new Error('Container element is required');
|
if (!container) throw new Error('Container element is required');
|
||||||
if (!url) throw new Error('Valid audio URL is required');
|
if (!url) throw new Error('Valid audio URL is required');
|
||||||
|
|
||||||
// Reuse the existing instance when the URL hasn't changed
|
// Same URL and same container — nothing to do
|
||||||
if (this.currentUrl === url && this.wavesurfer) return;
|
if (this.currentUrl === url && this.wavesurfer && this.currentContainer === container) return;
|
||||||
|
|
||||||
// Tear down the previous instance before creating a new one
|
// Same URL, different container: navigated away and back to the same song.
|
||||||
if (this.wavesurfer) this.destroyWaveSurfer();
|
// 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({
|
const ws = WaveSurfer.create({
|
||||||
container,
|
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(255,255,255,0.09)",
|
waveColor: "rgba(255,255,255,0.09)",
|
||||||
progressColor: "#c8861a",
|
progressColor: "#c8861a",
|
||||||
cursorColor: "#e8a22a",
|
cursorColor: "#e8a22a",
|
||||||
@@ -46,6 +81,7 @@ class AudioService {
|
|||||||
|
|
||||||
this.wavesurfer = ws;
|
this.wavesurfer = ws;
|
||||||
this.currentUrl = url;
|
this.currentUrl = url;
|
||||||
|
this.currentContainer = container;
|
||||||
this.setupEventHandlers(ws);
|
this.setupEventHandlers(ws);
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
@@ -80,10 +116,7 @@ class AudioService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async play(songId: string | null = null, bandId: string | null = null): Promise<void> {
|
public async play(songId: string | null = null, bandId: string | null = null): Promise<void> {
|
||||||
if (!this.wavesurfer || !this.isReady) {
|
if (!this.wavesurfer || !this.isReady) return;
|
||||||
console.warn('[AudioService] play() called before ready');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await this.wavesurfer.play();
|
await this.wavesurfer.play();
|
||||||
if (songId && bandId) {
|
if (songId && bandId) {
|
||||||
usePlayerStore.getState().setCurrentSong(songId, bandId);
|
usePlayerStore.getState().setCurrentSong(songId, bandId);
|
||||||
@@ -120,20 +153,26 @@ class AudioService {
|
|||||||
this.destroyWaveSurfer();
|
this.destroyWaveSurfer();
|
||||||
const store = usePlayerStore.getState();
|
const store = usePlayerStore.getState();
|
||||||
store.setCurrentSong(null, null);
|
store.setCurrentSong(null, null);
|
||||||
store.batchUpdate({ isPlaying: false, currentTime: 0 });
|
store.batchUpdate({ isPlaying: false, currentTime: 0, duration: 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
private destroyWaveSurfer(): void {
|
private destroyWaveSurfer(): void {
|
||||||
if (!this.wavesurfer) return;
|
if (!this.wavesurfer) return;
|
||||||
try {
|
try {
|
||||||
if (this.wavesurfer.isPlaying()) this.wavesurfer.pause();
|
|
||||||
this.wavesurfer.unAll();
|
this.wavesurfer.unAll();
|
||||||
this.wavesurfer.destroy();
|
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) {
|
} catch (err) {
|
||||||
console.error('[AudioService] Error destroying WaveSurfer:', err);
|
console.error('[AudioService] Error destroying WaveSurfer:', err);
|
||||||
}
|
}
|
||||||
|
this.mediaElement = null;
|
||||||
this.wavesurfer = null;
|
this.wavesurfer = null;
|
||||||
this.currentUrl = null;
|
this.currentUrl = null;
|
||||||
|
this.currentContainer = null;
|
||||||
this.isReady = false;
|
this.isReady = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,415 +0,0 @@
|
|||||||
import WaveSurfer from "wavesurfer.js";
|
|
||||||
import { usePlayerStore } from "../stores/playerStore";
|
|
||||||
|
|
||||||
// Log level enum
|
|
||||||
enum LogLevel {
|
|
||||||
DEBUG = 0,
|
|
||||||
INFO = 1,
|
|
||||||
WARN = 2,
|
|
||||||
ERROR = 3
|
|
||||||
}
|
|
||||||
|
|
||||||
// Type extension for WaveSurfer backend access
|
|
||||||
interface WaveSurferWithBackend extends WaveSurfer {
|
|
||||||
backend?: {
|
|
||||||
getAudioContext?: () => AudioContext;
|
|
||||||
ac?: AudioContext;
|
|
||||||
audioContext?: AudioContext;
|
|
||||||
};
|
|
||||||
getAudioContext?: () => AudioContext;
|
|
||||||
getContainer?: () => HTMLElement;
|
|
||||||
setContainer?: (container: HTMLElement) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
class AudioService {
|
|
||||||
private static instance: AudioService;
|
|
||||||
private wavesurfer: WaveSurfer | null = null;
|
|
||||||
private audioContext: AudioContext | null = null;
|
|
||||||
private currentUrl: string | null = null;
|
|
||||||
private lastPlayTime: number = 0;
|
|
||||||
private lastTimeUpdate: number = 0;
|
|
||||||
private readonly TIME_UPDATE_THROTTLE: number = 100;
|
|
||||||
private readonly PLAY_DEBOUNCE_MS: number = 100;
|
|
||||||
private lastSeekTime: number = 0;
|
|
||||||
private readonly SEEK_DEBOUNCE_MS: number = 200;
|
|
||||||
private logLevel: LogLevel = LogLevel.INFO;
|
|
||||||
private playbackAttempts: number = 0;
|
|
||||||
private readonly MAX_PLAYBACK_ATTEMPTS: number = 3;
|
|
||||||
|
|
||||||
private constructor() {
|
|
||||||
this.log(LogLevel.INFO, 'AudioService initialized');
|
|
||||||
}
|
|
||||||
|
|
||||||
private log(level: LogLevel, message: string, ...args: unknown[]) {
|
|
||||||
if (level < this.logLevel) return;
|
|
||||||
|
|
||||||
const prefix = `[AudioService:${LogLevel[level]}]`;
|
|
||||||
|
|
||||||
switch(level) {
|
|
||||||
case LogLevel.DEBUG:
|
|
||||||
if (console.debug) {
|
|
||||||
console.debug(prefix, message, ...args);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case LogLevel.INFO:
|
|
||||||
console.info(prefix, message, ...args);
|
|
||||||
break;
|
|
||||||
case LogLevel.WARN:
|
|
||||||
console.warn(prefix, message, ...args);
|
|
||||||
break;
|
|
||||||
case LogLevel.ERROR:
|
|
||||||
console.error(prefix, message, ...args);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add method to set log level from outside
|
|
||||||
public setLogLevel(level: LogLevel) {
|
|
||||||
this.log(LogLevel.INFO, `Log level set to: ${LogLevel[level]}`);
|
|
||||||
this.logLevel = level;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static getInstance() {
|
|
||||||
if (!this.instance) {
|
|
||||||
this.instance = new AudioService();
|
|
||||||
}
|
|
||||||
return this.instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async initialize(container: HTMLElement, url: string) {
|
|
||||||
this.log(LogLevel.DEBUG, 'AudioService.initialize called', { url, containerExists: !!container });
|
|
||||||
|
|
||||||
// Validate inputs
|
|
||||||
if (!container) {
|
|
||||||
this.log(LogLevel.ERROR, 'AudioService: container element is null');
|
|
||||||
throw new Error('Container element is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!url || url === 'null' || url === 'undefined') {
|
|
||||||
this.log(LogLevel.ERROR, 'AudioService: invalid URL', { url });
|
|
||||||
throw new Error('Valid audio URL is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
// If same URL and we already have an instance, just update container reference
|
|
||||||
if (this.currentUrl === url && this.wavesurfer) {
|
|
||||||
this.log(LogLevel.INFO, 'Reusing existing WaveSurfer instance for URL:', url);
|
|
||||||
try {
|
|
||||||
// Check if container is different and needs updating
|
|
||||||
const ws = this.wavesurfer as WaveSurferWithBackend;
|
|
||||||
const currentContainer = ws.getContainer?.();
|
|
||||||
if (currentContainer !== container) {
|
|
||||||
this.log(LogLevel.DEBUG, 'Updating container reference for existing instance');
|
|
||||||
// Update container reference without recreating instance
|
|
||||||
ws.setContainer?.(container);
|
|
||||||
} else {
|
|
||||||
this.log(LogLevel.DEBUG, 'Using existing instance - no changes needed');
|
|
||||||
}
|
|
||||||
return this.wavesurfer;
|
|
||||||
} catch (error) {
|
|
||||||
this.log(LogLevel.ERROR, 'Failed to reuse existing instance:', error);
|
|
||||||
this.cleanup();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up existing instance if different URL
|
|
||||||
if (this.wavesurfer && this.currentUrl !== url) {
|
|
||||||
this.log(LogLevel.INFO, 'Cleaning up existing instance for new URL:', url);
|
|
||||||
this.cleanup();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new WaveSurfer instance
|
|
||||||
this.log(LogLevel.DEBUG, 'Creating new WaveSurfer instance for URL:', url);
|
|
||||||
let ws;
|
|
||||||
try {
|
|
||||||
ws = WaveSurfer.create({
|
|
||||||
container: container,
|
|
||||||
waveColor: "rgba(255,255,255,0.09)",
|
|
||||||
progressColor: "#c8861a",
|
|
||||||
cursorColor: "#e8a22a",
|
|
||||||
barWidth: 2,
|
|
||||||
barRadius: 2,
|
|
||||||
height: 104,
|
|
||||||
normalize: true,
|
|
||||||
// Ensure we can control playback manually
|
|
||||||
autoplay: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!ws) {
|
|
||||||
throw new Error('WaveSurfer.create returned null or undefined');
|
|
||||||
}
|
|
||||||
|
|
||||||
// @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+');
|
|
||||||
// Don't throw error - we'll try to access backend later when needed
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to create WaveSurfer instance:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store references
|
|
||||||
this.wavesurfer = ws;
|
|
||||||
this.currentUrl = url;
|
|
||||||
|
|
||||||
// Get audio context from wavesurfer
|
|
||||||
// Note: In WaveSurfer v7+, backend might not be available immediately
|
|
||||||
// We'll try to access it now, but also set up a handler to get it when ready
|
|
||||||
this.setupAudioContext(ws);
|
|
||||||
|
|
||||||
// Set up event handlers before loading
|
|
||||||
this.setupEventHandlers();
|
|
||||||
|
|
||||||
// Load the audio with error handling
|
|
||||||
this.log(LogLevel.DEBUG, 'Loading audio URL:', url);
|
|
||||||
try {
|
|
||||||
const loadPromise = new Promise<void>((resolve, reject) => {
|
|
||||||
ws.on('ready', () => {
|
|
||||||
this.log(LogLevel.DEBUG, 'WaveSurfer ready event fired');
|
|
||||||
// Now that WaveSurfer is ready, set up audio context and finalize initialization
|
|
||||||
this.setupAudioContext(ws);
|
|
||||||
|
|
||||||
// Update player store with duration
|
|
||||||
const playerStore = usePlayerStore.getState();
|
|
||||||
playerStore.setDuration(ws.getDuration());
|
|
||||||
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
|
|
||||||
ws.on('error', (error) => {
|
|
||||||
this.log(LogLevel.ERROR, 'WaveSurfer error event:', error);
|
|
||||||
reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start loading
|
|
||||||
ws.load(url);
|
|
||||||
});
|
|
||||||
|
|
||||||
await loadPromise;
|
|
||||||
this.log(LogLevel.INFO, 'Audio loaded successfully');
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
this.log(LogLevel.ERROR, 'Failed to load audio:', error);
|
|
||||||
this.cleanup();
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ws;
|
|
||||||
}
|
|
||||||
|
|
||||||
private setupEventHandlers() {
|
|
||||||
if (!this.wavesurfer) return;
|
|
||||||
|
|
||||||
const ws = this.wavesurfer;
|
|
||||||
const playerStore = usePlayerStore.getState();
|
|
||||||
|
|
||||||
ws.on("play", () => {
|
|
||||||
this.log(LogLevel.DEBUG, 'AudioService: play event');
|
|
||||||
playerStore.setPlaying(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
ws.on("pause", () => {
|
|
||||||
this.log(LogLevel.DEBUG, 'AudioService: pause event');
|
|
||||||
playerStore.setPlaying(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
ws.on("finish", () => {
|
|
||||||
this.log(LogLevel.DEBUG, 'AudioService: finish event');
|
|
||||||
playerStore.setPlaying(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
ws.on("audioprocess", (time) => {
|
|
||||||
const now = Date.now();
|
|
||||||
if (now - this.lastTimeUpdate >= this.TIME_UPDATE_THROTTLE) {
|
|
||||||
playerStore.setCurrentTime(time);
|
|
||||||
this.lastTimeUpdate = now;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Note: Ready event is handled in the load promise, so we don't set it up here
|
|
||||||
// to avoid duplicate event handlers
|
|
||||||
}
|
|
||||||
|
|
||||||
public async play(): Promise<void> {
|
|
||||||
if (!this.wavesurfer) {
|
|
||||||
this.log(LogLevel.WARN, 'AudioService: no wavesurfer instance');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Debounce rapid play calls
|
|
||||||
const now = Date.now();
|
|
||||||
if (now - this.lastPlayTime < this.PLAY_DEBOUNCE_MS) {
|
|
||||||
this.log(LogLevel.DEBUG, 'Playback debounced - too frequent calls');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.lastPlayTime = now;
|
|
||||||
|
|
||||||
this.log(LogLevel.INFO, 'AudioService.play called');
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Ensure we have a valid audio context
|
|
||||||
await this.ensureAudioContext();
|
|
||||||
|
|
||||||
await this.wavesurfer.play();
|
|
||||||
this.log(LogLevel.INFO, 'Playback started successfully');
|
|
||||||
this.playbackAttempts = 0; // Reset on success
|
|
||||||
} catch (error) {
|
|
||||||
this.playbackAttempts++;
|
|
||||||
this.log(LogLevel.ERROR, `Playback failed (attempt ${this.playbackAttempts}):`, error);
|
|
||||||
|
|
||||||
if (this.playbackAttempts >= this.MAX_PLAYBACK_ATTEMPTS) {
|
|
||||||
this.log(LogLevel.ERROR, 'Max playback attempts reached, resetting player');
|
|
||||||
this.cleanup();
|
|
||||||
// Could trigger re-initialization here if needed
|
|
||||||
} else {
|
|
||||||
// Exponential backoff for retry
|
|
||||||
const delay = 100 * this.playbackAttempts;
|
|
||||||
this.log(LogLevel.WARN, `Retrying playback in ${delay}ms...`);
|
|
||||||
await new Promise(resolve => setTimeout(resolve, delay));
|
|
||||||
return this.play(); // Retry
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public pause() {
|
|
||||||
if (!this.wavesurfer) {
|
|
||||||
this.log(LogLevel.WARN, 'AudioService: no wavesurfer instance');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.log(LogLevel.INFO, 'AudioService.pause called');
|
|
||||||
this.wavesurfer.pause();
|
|
||||||
}
|
|
||||||
|
|
||||||
public seekTo(time: number) {
|
|
||||||
if (!this.wavesurfer) {
|
|
||||||
this.log(LogLevel.WARN, 'AudioService: no wavesurfer instance');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.log(LogLevel.INFO, 'AudioService.seekTo called', { time });
|
|
||||||
this.wavesurfer.setTime(time);
|
|
||||||
}
|
|
||||||
|
|
||||||
public getCurrentTime(): number {
|
|
||||||
if (!this.wavesurfer) return 0;
|
|
||||||
return this.wavesurfer.getCurrentTime();
|
|
||||||
}
|
|
||||||
|
|
||||||
public getDuration(): number {
|
|
||||||
if (!this.wavesurfer) return 0;
|
|
||||||
return this.wavesurfer.getDuration();
|
|
||||||
}
|
|
||||||
|
|
||||||
public isPlaying(): boolean {
|
|
||||||
if (!this.wavesurfer) return false;
|
|
||||||
return this.wavesurfer.isPlaying();
|
|
||||||
}
|
|
||||||
|
|
||||||
public cleanup() {
|
|
||||||
this.log(LogLevel.INFO, 'AudioService.cleanup called');
|
|
||||||
|
|
||||||
if (this.wavesurfer) {
|
|
||||||
try {
|
|
||||||
// Disconnect audio nodes but keep audio context alive
|
|
||||||
this.wavesurfer.unAll();
|
|
||||||
this.wavesurfer.destroy();
|
|
||||||
this.log(LogLevel.DEBUG, 'WaveSurfer instance cleaned up');
|
|
||||||
} catch (error) {
|
|
||||||
this.log(LogLevel.ERROR, 'Error cleaning up WaveSurfer:', error);
|
|
||||||
}
|
|
||||||
this.wavesurfer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.currentUrl = null;
|
|
||||||
// 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();
|
|
||||||
console.log('Audio context resumed successfully');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to resume audio context:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return this.audioContext;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new audio context
|
|
||||||
try {
|
|
||||||
this.audioContext = new (window.AudioContext || (window as { webkitAudioContext?: new () => AudioContext }).webkitAudioContext)();
|
|
||||||
console.log('Audio context created:', this.audioContext.state);
|
|
||||||
|
|
||||||
// Handle context state changes
|
|
||||||
this.audioContext.onstatechange = () => {
|
|
||||||
console.log('Audio context state changed:', this.audioContext?.state);
|
|
||||||
};
|
|
||||||
|
|
||||||
return this.audioContext;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to create audio context:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private setupAudioContext(ws: WaveSurferWithBackend) {
|
|
||||||
// Try multiple methods to get audio context from WaveSurfer v7+
|
|
||||||
try {
|
|
||||||
// Method 1: Try standard backend.getAudioContext()
|
|
||||||
this.audioContext = ws.backend?.getAudioContext?.() ?? null;
|
|
||||||
|
|
||||||
// Method 2: Try accessing audio context directly from backend
|
|
||||||
if (!this.audioContext) {
|
|
||||||
this.audioContext = ws.backend?.ac ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Method 3: Try accessing through backend.getAudioContext() without optional chaining
|
|
||||||
if (!this.audioContext) {
|
|
||||||
this.audioContext = ws.backend?.getAudioContext?.() ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Method 4: Try accessing through wavesurfer.getAudioContext() if it exists
|
|
||||||
if (!this.audioContext && typeof ws.getAudioContext === 'function') {
|
|
||||||
this.audioContext = ws.getAudioContext() ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Method 5: Try accessing through backend.ac directly
|
|
||||||
if (!this.audioContext) {
|
|
||||||
this.audioContext = ws.backend?.ac ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Method 6: Try accessing through backend.audioContext
|
|
||||||
if (!this.audioContext) {
|
|
||||||
this.audioContext = ws.backend?.audioContext ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.audioContext) {
|
|
||||||
console.log('Audio context accessed successfully:', this.audioContext.state);
|
|
||||||
} else {
|
|
||||||
console.warn('Could not access audio context from WaveSurfer - playback may have issues');
|
|
||||||
// Log the wavesurfer structure for debugging
|
|
||||||
console.debug('WaveSurfer structure:', {
|
|
||||||
hasBackend: !!ws.backend,
|
|
||||||
backendType: typeof ws.backend,
|
|
||||||
backendKeys: ws.backend ? Object.keys(ws.backend) : 'no backend',
|
|
||||||
wavesurferKeys: Object.keys(ws)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error accessing audio context:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
public getAudioContextState(): string | undefined {
|
|
||||||
return this.audioContext?.state;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const audioService = AudioService.getInstance();
|
|
||||||
@@ -23,6 +23,7 @@ function createMockWaveSurfer(duration = 120) {
|
|||||||
isPlaying: vi.fn(() => false),
|
isPlaying: vi.fn(() => false),
|
||||||
unAll: vi.fn(),
|
unAll: vi.fn(),
|
||||||
destroy: vi.fn(),
|
destroy: vi.fn(),
|
||||||
|
setOptions: vi.fn(),
|
||||||
// Helper to fire events from tests
|
// Helper to fire events from tests
|
||||||
_emit: (event: EventName, ...args: any[]) => handlers[event]?.(...args),
|
_emit: (event: EventName, ...args: any[]) => handlers[event]?.(...args),
|
||||||
};
|
};
|
||||||
@@ -114,7 +115,24 @@ describe('AudioService', () => {
|
|||||||
await expect(initPromise).rejects.toThrow('network error');
|
await expect(initPromise).rejects.toThrow('network error');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reuses the existing instance for the same URL', async () => {
|
it('does nothing when called with same URL and same container', async () => {
|
||||||
|
const WaveSurfer = (await import('wavesurfer.js')).default;
|
||||||
|
const mockWs = createMockWaveSurfer();
|
||||||
|
vi.mocked(WaveSurfer.create).mockReturnValue(mockWs as any);
|
||||||
|
|
||||||
|
const url = 'http://example.com/same.mp3';
|
||||||
|
const container = makeContainer();
|
||||||
|
const p1 = service.initialize(container, url);
|
||||||
|
mockWs._emit('ready');
|
||||||
|
await p1;
|
||||||
|
|
||||||
|
const createCount = vi.mocked(WaveSurfer.create).mock.calls.length;
|
||||||
|
await service.initialize(container, url); // exact same reference
|
||||||
|
expect(vi.mocked(WaveSurfer.create).mock.calls.length).toBe(createCount);
|
||||||
|
expect(mockWs.setOptions).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-attaches waveform to new container when same URL but container changed (re-navigation)', async () => {
|
||||||
const WaveSurfer = (await import('wavesurfer.js')).default;
|
const WaveSurfer = (await import('wavesurfer.js')).default;
|
||||||
const mockWs = createMockWaveSurfer();
|
const mockWs = createMockWaveSurfer();
|
||||||
vi.mocked(WaveSurfer.create).mockReturnValue(mockWs as any);
|
vi.mocked(WaveSurfer.create).mockReturnValue(mockWs as any);
|
||||||
@@ -124,9 +142,29 @@ describe('AudioService', () => {
|
|||||||
mockWs._emit('ready');
|
mockWs._emit('ready');
|
||||||
await p1;
|
await p1;
|
||||||
|
|
||||||
const createCallCount = vi.mocked(WaveSurfer.create).mock.calls.length;
|
const newContainer = makeContainer(); // different DOM element, same URL
|
||||||
await service.initialize(makeContainer(), url); // same URL
|
const createCount = vi.mocked(WaveSurfer.create).mock.calls.length;
|
||||||
expect(vi.mocked(WaveSurfer.create).mock.calls.length).toBe(createCallCount); // no new instance
|
await service.initialize(newContainer, url);
|
||||||
|
|
||||||
|
// No new WaveSurfer instance — just a canvas re-attach
|
||||||
|
expect(vi.mocked(WaveSurfer.create).mock.calls.length).toBe(createCount);
|
||||||
|
expect(mockWs.setOptions).toHaveBeenCalledWith({ container: newContainer });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets store state when URL changes', async () => {
|
||||||
|
await initService(service, { url: 'http://example.com/a.mp3', duration: 180 });
|
||||||
|
usePlayerStore.getState().batchUpdate({ isPlaying: true, currentTime: 45 });
|
||||||
|
|
||||||
|
const WaveSurfer = (await import('wavesurfer.js')).default;
|
||||||
|
const mockWs2 = createMockWaveSurfer();
|
||||||
|
vi.mocked(WaveSurfer.create).mockReturnValue(mockWs2 as any);
|
||||||
|
const p2 = service.initialize(makeContainer(), 'http://example.com/b.mp3');
|
||||||
|
mockWs2._emit('ready');
|
||||||
|
await p2;
|
||||||
|
|
||||||
|
// State should be reset, not carry over from the previous song
|
||||||
|
expect(usePlayerStore.getState().currentTime).toBe(0);
|
||||||
|
expect(usePlayerStore.getState().isPlaying).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('destroys old instance when URL changes', async () => {
|
it('destroys old instance when URL changes', async () => {
|
||||||
@@ -152,10 +190,8 @@ describe('AudioService', () => {
|
|||||||
|
|
||||||
describe('play()', () => {
|
describe('play()', () => {
|
||||||
it('does nothing if not ready', async () => {
|
it('does nothing if not ready', async () => {
|
||||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
// play() silently returns when not ready — no throw, no warn
|
||||||
await service.play('song-1', 'band-1');
|
await expect(service.play('song-1', 'band-1')).resolves.toBeUndefined();
|
||||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('before ready'));
|
|
||||||
warnSpy.mockRestore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls wavesurfer.play()', async () => {
|
it('calls wavesurfer.play()', async () => {
|
||||||
@@ -225,23 +261,14 @@ describe('AudioService', () => {
|
|||||||
expect(service.isWaveformReady()).toBe(false);
|
expect(service.isWaveformReady()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resets isPlaying and currentTime in the store', async () => {
|
it('resets isPlaying, currentTime, and duration in the store', async () => {
|
||||||
await initService(service);
|
await initService(service, { duration: 180 });
|
||||||
usePlayerStore.getState().batchUpdate({ isPlaying: true, currentTime: 30 });
|
usePlayerStore.getState().batchUpdate({ isPlaying: true, currentTime: 30 });
|
||||||
service.cleanup();
|
service.cleanup();
|
||||||
const state = usePlayerStore.getState();
|
const state = usePlayerStore.getState();
|
||||||
expect(state.isPlaying).toBe(false);
|
expect(state.isPlaying).toBe(false);
|
||||||
expect(state.currentTime).toBe(0);
|
expect(state.currentTime).toBe(0);
|
||||||
});
|
expect(state.duration).toBe(0);
|
||||||
|
|
||||||
it('pauses before destroying if playing', async () => {
|
|
||||||
const callOrder: string[] = [];
|
|
||||||
const mockWs = await initService(service);
|
|
||||||
mockWs.isPlaying.mockReturnValue(true);
|
|
||||||
mockWs.pause.mockImplementation(() => callOrder.push('pause'));
|
|
||||||
mockWs.destroy.mockImplementation(() => callOrder.push('destroy'));
|
|
||||||
service.cleanup();
|
|
||||||
expect(callOrder).toEqual(['pause', 'destroy']);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is safe to call when not initialized', () => {
|
it('is safe to call when not initialized', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user