9 Commits

Author SHA1 Message Date
Mistral Vibe
48a73246a1 fix(lint): resolve eslint errors and warnings
- audioService: replace 'as any' with 'as unknown as AudioService' in
  resetInstance() to satisfy @typescript-eslint/no-explicit-any
- SongPage: add isReady to spacebar useEffect deps so the handler always
  sees the current readiness state
- useWaveform: add containerRef to deps (stable ref, safe to include);
  suppress exhaustive-deps for options.onReady with explanation — adding
  an un-memoized callback would cause initialization on every render

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 21:52:44 +02:00
Mistral Vibe
3405325cbb chore: remove accidental backup file 2026-04-08 21:47:29 +02:00
Mistral Vibe
a0cc10ffca fix(audio): re-attach waveform canvas on re-navigation to same song
When navigating away from SongPage and back to the same song, the container
div is a new DOM element but the URL is unchanged. The previous early-return
(currentUrl === url) would skip initialization entirely, leaving WaveSurfer
pointing at the detached old container — nothing rendered.

Fix: track currentContainer alongside currentUrl. When URL matches but container
has changed, call wavesurfer.setOptions({ container }) which moves the existing
canvas into the new container without reloading audio or interrupting playback.
WaveSurfer v7 renderer.setOptions() supports this: it calls
newParent.appendChild(this.container) to relocate the canvas div.

Three paths in initialize():
  1. Same URL + same container → no-op
  2. Same URL + new container  → setOptions re-attach (no reload)
  3. Different URL             → full teardown and reload

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 21:31:08 +02:00
Mistral Vibe
8b7415954c fix(audio): fresh media element per song to avoid AbortError on switch
When WaveSurfer.destroy() is called it aborts its internal fetch AbortController.
If the same HTMLAudioElement is immediately passed to a new WaveSurfer instance,
the aborted signal is still draining — the new instance's loadAudio call sees it
and throws AbortError: signal is aborted without reason.

Fix: create a new <audio> element for every new song via createMediaElement().
destroyWaveSurfer() removes and discards the old element (inside the existing
try/catch so jsdom test noise is suppressed). The new element is still appended
to document.body so playback survives SongPage unmounts.

resetInstance() now delegates to cleanup() to properly tear down the media
element between tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 21:17:43 +02:00
Mistral Vibe
d08eebf0eb fix(audio): survive navigation, clear stale state, silence noisy logs
Bug 1 — playback stops on navigation:
WaveSurfer v7 creates its <audio> element inside the container div. When
SongPage unmounts, the container is removed from the DOM, taking the audio
element with it and stopping playback. Fix: AudioService owns a persistent
hidden <audio> element on document.body and passes it to WaveSurfer via the
`media` option. WaveSurfer uses it for playback but does not destroy it on
WaveSurfer.destroy(), so audio survives any number of navigations.

Bug 2 — stale playhead/duration when opening a new song:
initialize() called destroyWaveSurfer() but never reset the store, so the
previous song's currentTime, duration, and isPlaying leaked into the new song's
load sequence. Fix: reset those three fields in the store immediately after
tearing down the old WaveSurfer instance. cleanup() also now resets duration.

Bug 3 — excessive console noise on mobile:
Remove console.warn from play() (silent return when not ready) and from
useWaveform's play() wrapper. Only console.error on actual errors remains.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 21:10:21 +02:00
Mistral Vibe
25dca3c788 refactor(audio): Phase 4 — unify song tracking, remove compat aliases
playerStore: remove currentPlayingSongId/currentPlayingBandId/setCurrentPlayingSong.
Single pair (currentSongId/currentBandId) now set exclusively when play() is
called, not when the page opens. This means MiniPlayer and sidebar links only
appear after audio has been started — correct UX for a "now playing" widget.

audioService: play() calls setCurrentSong instead of setCurrentPlayingSong;
cleanup() clears it. Remove isReadyForPlayback() and canAttemptPlayback()
aliases — all callers now use isWaveformReady() directly.

useWaveform: remove setCurrentSong call from init (store updated by play()
now); restore-playback snapshot reads currentSongId/currentBandId.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 20:58:16 +02:00
Mistral Vibe
7508d78a86 refactor(audio): Phase 3 — replace RAF polling loop with store subscription
useWaveform.ts:
- Remove requestAnimationFrame polling loop that was re-running after every
  re-initialization and leaking across renders when cleanup didn't fire
- Remove local useState for isPlaying/currentTime/duration; these now come
  directly from usePlayerStore selectors — WaveSurfer event handlers in
  AudioService already write to the store, so no intermediate sync needed
- The useEffect is now a clean async init only; no cleanup needed (AudioService
  persists intentionally across page navigations)

tests/:
- Delete 3 obsolete test files that tested removed APIs (logging system,
  setupAudioContext, ensureAudioContext, initializeAudioContext)
- Add tests/audioService.test.ts: 25 tests covering initialize(), play(),
  pause(), seekTo(), cleanup(), and all WaveSurfer event→store mappings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 20:54:18 +02:00
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
Mistral Vibe
1a0d926e1a 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>
2026-04-08 20:44:07 +02:00
9 changed files with 519 additions and 1877 deletions

View File

@@ -94,7 +94,9 @@ export function MiniPlayer() {
if (isPlaying) { if (isPlaying) {
audioService.pause(); audioService.pause();
} else { } else {
audioService.play(); audioService.play(currentSongId, currentBandId).catch(err => {
console.warn('MiniPlayer playback failed:', err);
});
} }
}} }}
style={ style={

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { audioService, InitializationState } from "../services/audioService"; import { audioService } from "../services/audioService";
import { usePlayerStore } from "../stores/playerStore"; import { usePlayerStore } from "../stores/playerStore";
export interface UseWaveformOptions { export interface UseWaveformOptions {
@@ -22,175 +22,84 @@ export function useWaveform(
containerRef: React.RefObject<HTMLDivElement>, containerRef: React.RefObject<HTMLDivElement>,
options: UseWaveformOptions options: UseWaveformOptions
) { ) {
const [isPlaying, setIsPlaying] = useState(false);
const [isReady, setIsReady] = useState(false); const [isReady, setIsReady] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [initializationState, setInitializationState] = useState<InitializationState>(InitializationState.NotStarted);
const markersRef = useRef<CommentMarker[]>([]); const markersRef = useRef<CommentMarker[]>([]);
// Global player state - use shallow comparison to reduce re-renders // Playback state comes directly from the store — no intermediate local state
const { // or RAF polling loop needed. The store is updated by WaveSurfer event handlers
isPlaying: globalIsPlaying, // in AudioService, so these values are always in sync.
currentTime: globalCurrentTime, const isPlaying = usePlayerStore(state => state.isPlaying);
currentSongId, const currentTime = usePlayerStore(state => state.currentTime);
currentBandId: globalBandId, const duration = usePlayerStore(state => state.duration);
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(() => { useEffect(() => {
if (!containerRef.current) { if (!containerRef.current) return;
return; if (!options.url || options.url === 'null' || options.url === 'undefined') return;
}
if (!options.url || options.url === 'null' || options.url === 'undefined') {
return;
}
const initializeAudio = async () => { const initializeAudio = async () => {
try { try {
await audioService.initialize(containerRef.current!, options.url!); await audioService.initialize(containerRef.current!, options.url!);
// Set up local state synchronization with requestAnimationFrame for smoother updates // Restore playback if this song was already playing when the page loaded.
let animationFrameId: number | null = null; // Read as a one-time snapshot — these values must NOT be reactive deps or
let lastUpdateTime = 0; // the effect would re-run on every time update (re-initializing WaveSurfer).
const updateInterval = 1000 / 15; // ~15fps for state updates const {
currentSongId,
currentBandId,
isPlaying: wasPlaying,
currentTime: savedTime,
} = usePlayerStore.getState();
const handleStateUpdate = () => { if (
const now = Date.now(); options.songId &&
if (now - lastUpdateTime >= updateInterval) { options.bandId &&
const state = usePlayerStore.getState(); currentSongId === options.songId &&
setIsPlaying(state.isPlaying); currentBandId === options.bandId &&
setCurrentTime(state.currentTime); wasPlaying &&
setDuration(state.duration); audioService.isWaveformReady()
lastUpdateTime = now; ) {
}
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 { try {
await audioService.waitForInitialization(); await audioService.play(options.songId, options.bandId);
if (savedTime > 0) audioService.seekTo(savedTime);
// Use the unified readiness check } catch (err) {
if (audioService.isReadyForPlayback()) { console.warn('Auto-play prevented during initialization:', err);
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); setIsReady(true);
setInitializationState(audioService.getInitializationState());
options.onReady?.(audioService.getDuration()); options.onReady?.(audioService.getDuration());
} catch (err) {
return () => { console.error('useWaveform: initialization failed', err);
unsubscribe();
// Note: We don't cleanup the audio service here to maintain persistence
// audioService.cleanup();
};
} catch (error) {
console.error('useWaveform: initialization failed', error);
setIsReady(false); setIsReady(false);
setError(error instanceof Error ? error.message : 'Failed to initialize audio'); setError(err instanceof Error ? err.message : 'Failed to initialize audio');
return () => {};
} }
}; };
initializeAudio(); initializeAudio();
// 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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, [options.url, options.songId, options.bandId, containerRef, currentSongId, globalBandId, globalCurrentTime, globalIsPlaying, setCurrentSong]); }, [options.url, options.songId, options.bandId, containerRef]);
const play = () => { const play = () => {
// Use the unified readiness check audioService.play(options.songId ?? null, options.bandId ?? null)
if (audioService.isReadyForPlayback()) { .catch(err => console.error('[useWaveform] play failed:', err));
try {
audioService.play(options.songId || null, options.bandId || null);
} catch (error) {
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
});
}
}; };
const pause = () => { const pause = () => {
try {
audioService.pause(); audioService.pause();
} catch (error) {
console.error('useWaveform.pause failed:', error);
}
}; };
const seekTo = (time: number) => { const seekTo = (time: number) => {
try {
if (isReady && isFinite(time)) {
audioService.seekTo(time); audioService.seekTo(time);
}
} catch (error) {
console.error('useWaveform.seekTo failed:', error);
}
}; };
const addMarker = (marker: CommentMarker) => { const addMarker = (marker: CommentMarker) => {
if (isReady) { if (!isReady) return;
try { try {
// This would need proper implementation with the actual wavesurfer instance
const markerElement = document.createElement("div"); const markerElement = document.createElement("div");
markerElement.style.position = "absolute"; markerElement.style.position = "absolute";
markerElement.style.width = "24px"; markerElement.style.width = "24px";
@@ -224,9 +133,8 @@ export function useWaveform(
} }
markersRef.current.push(marker); markersRef.current.push(marker);
} catch (error) { } catch (err) {
console.error('useWaveform.addMarker failed:', error); console.error('useWaveform.addMarker failed:', err);
}
} }
}; };
@@ -241,19 +149,7 @@ export function useWaveform(
markersRef.current = []; markersRef.current = [];
}; };
return { return { isPlaying, isReady, currentTime, duration, play, pause, seekTo, addMarker, clearMarkers, error };
isPlaying,
isReady,
currentTime,
duration,
play,
pause,
seekTo,
addMarker,
clearMarkers,
error,
initializationState
};
} }
function formatTime(seconds: number): string { function formatTime(seconds: number): string {

View File

@@ -381,12 +381,18 @@ export function SongPage() {
if (target.tagName === "TEXTAREA" || target.tagName === "INPUT") return; if (target.tagName === "TEXTAREA" || target.tagName === "INPUT") return;
if (e.code === "Space") { if (e.code === "Space") {
e.preventDefault(); e.preventDefault();
if (isPlaying) { pause(); } else { play(); } if (isPlaying) {
pause();
} else {
if (isReady) {
play();
}
}
} }
}; };
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 ─────────────────────────────────────────────────────────────

View File

@@ -1,259 +1,74 @@
import WaveSurfer from "wavesurfer.js"; import WaveSurfer from "wavesurfer.js";
import { usePlayerStore } from "../stores/playerStore"; import { usePlayerStore } from "../stores/playerStore";
// Log level enum (will be exported at end of file)
enum LogLevel {
DEBUG = 0,
INFO = 1,
WARN = 2,
ERROR = 3
}
// Initialization state enum
enum InitializationState {
NotStarted = 'not_started',
InProgress = 'in_progress',
Completed = 'completed',
Failed = 'failed'
}
// 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 { class AudioService {
private static instance: AudioService; private static instance: AudioService;
private wavesurfer: WaveSurfer | null = null; private wavesurfer: WaveSurfer | null = null;
private audioContext: AudioContext | null = null;
private currentUrl: string | null = null; private currentUrl: string | null = null;
private currentPlayingSongId: string | null = null; private currentContainer: HTMLElement | null = null;
private currentPlayingBandId: string | null = null; private isReady = false;
private lastPlayTime: number = 0; private lastTimeUpdate = 0;
private lastTimeUpdate: number = 0; // Persistent audio element attached to document.body so playback survives
private readonly PLAY_DEBOUNCE_MS: number = 100; // SongPage unmounts. WaveSurfer v7 supports passing an existing media element
private lastSeekTime: number = 0; // via the `media` option — it uses it for playback but does NOT destroy it
private readonly SEEK_DEBOUNCE_MS: number = 200; // when WaveSurfer.destroy() is called.
private logLevel: LogLevel = LogLevel.ERROR; private mediaElement: HTMLAudioElement | null = null;
private playbackAttempts: number = 0;
private readonly MAX_PLAYBACK_ATTEMPTS: number = 3;
private lastLogTime: number = 0;
private readonly LOG_THROTTLE_MS: number = 100;
// Initialization tracking private constructor() {}
private initializationState: InitializationState = InitializationState.NotStarted;
private initializationError: Error | null = null;
private initializationPromise: Promise<void> | null = null;
private initializationResolve: (() => void) | null = null;
private constructor() { public static getInstance(): AudioService {
// Set appropriate log level based on environment
this.setLogLevel(this.detectLogLevel());
this.log(LogLevel.INFO, `AudioService initialized (log level: ${LogLevel[this.logLevel]})`);
}
private detectLogLevel(): LogLevel {
try {
// Development environment: localhost or explicit debug mode
const isDevelopment = typeof window !== 'undefined' &&
window.location &&
(window.location.hostname === 'localhost' ||
window.location.hostname === '127.0.0.1');
// Check for debug query parameter (with safety checks)
const hasDebugParam = typeof window !== 'undefined' &&
window.location &&
window.location.search &&
window.location.search.includes('audioDebug=true');
if (isDevelopment || hasDebugParam) {
return LogLevel.DEBUG;
}
} catch (error) {
// If anything goes wrong, default to WARN level
console.warn('Error detecting log level, defaulting to WARN:', error);
}
// Production: warn level to reduce noise
return LogLevel.WARN;
}
private log(level: LogLevel, message: string, ...args: unknown[]) {
// Skip if below current log level
if (level < this.logLevel) return;
// Throttle rapid-fire logs to prevent console flooding
const now = Date.now();
if (now - this.lastLogTime < this.LOG_THROTTLE_MS) {
return; // Skip this log to prevent spam
}
this.lastLogTime = now;
const prefix = `[AudioService:${LogLevel[level]}]`;
// Use appropriate console method based on log 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) { if (!this.instance) {
this.instance = new AudioService(); this.instance = new AudioService();
} }
return this.instance; return this.instance;
} }
// Initialize audio context - now handles user gesture requirement // For use in tests only
public async initializeAudioContext() {
try {
if (!this.audioContext) {
this.audioContext = new (window.AudioContext || (window as { webkitAudioContext?: new () => AudioContext }).webkitAudioContext)();
this.log(LogLevel.INFO, 'Audio context created', {
state: this.audioContext.state,
sampleRate: this.audioContext.sampleRate
});
// Set up state change monitoring
this.audioContext.onstatechange = () => {
this.log(LogLevel.DEBUG, 'Audio context state changed:', this.audioContext?.state);
};
}
// Resume suspended audio context
if (this.audioContext && this.audioContext.state === 'suspended') {
await this.audioContext.resume();
this.log(LogLevel.INFO, 'Audio context resumed successfully');
}
return this.audioContext;
} catch (error) {
this.log(LogLevel.ERROR, 'Failed to initialize audio context:', error);
throw new Error(`Failed to initialize audio context: ${error instanceof Error ? error.message : String(error)}`);
}
}
// New method to handle audio context resume with user gesture
private async handleAudioContextResume(): Promise<void> {
if (!this.audioContext) {
await this.initializeAudioContext();
}
// Handle suspended audio context (common in mobile browsers and autoplay policies)
if (this.audioContext && this.audioContext.state === 'suspended') {
try {
this.log(LogLevel.INFO, 'Attempting to resume suspended audio context...');
await this.audioContext.resume();
this.log(LogLevel.INFO, 'Audio context resumed successfully');
} catch (error) {
this.log(LogLevel.ERROR, 'Failed to resume audio context:', error);
// If resume fails, we might need to create a new context
// This can happen if the context was closed or terminated
if (this.audioContext && (this.audioContext.state as string) === 'closed') {
this.log(LogLevel.WARN, 'Audio context closed, creating new one');
this.audioContext = null;
await this.initializeAudioContext();
}
throw error;
}
}
}
// Method for testing: reset the singleton instance
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> {
this.log(LogLevel.DEBUG, 'AudioService.initialize called', { url, containerExists: !!container }); if (!container) throw new Error('Container element is required');
if (!url) throw new Error('Valid audio URL is required');
// Reset initialization state // Same URL and same container — nothing to do
this.initializationState = InitializationState.InProgress; if (this.currentUrl === url && this.wavesurfer && this.currentContainer === container) return;
this.initializationError = null;
this.initializationPromise = new Promise<void>((resolve) => {
this.initializationResolve = resolve;
});
// Validate inputs // Same URL, different container: navigated away and back to the same song.
if (!container) { // Move the waveform canvas to the new container without reloading audio.
this.log(LogLevel.ERROR, 'AudioService: container element is null');
this.initializationState = InitializationState.Failed;
this.initializationError = new Error('Container element is required');
this.initializationResolve?.();
this.initializationResolve = null;
throw new Error('Container element is required');
}
if (!url || url === 'null' || url === 'undefined') {
this.log(LogLevel.ERROR, 'AudioService: invalid URL', { url });
this.initializationState = InitializationState.Failed;
this.initializationError = new Error('Valid audio URL is required');
this.initializationResolve?.();
this.initializationResolve = null;
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) { if (this.currentUrl === url && this.wavesurfer) {
// Implementation detail, only log in debug mode this.wavesurfer.setOptions({ container });
this.log(LogLevel.DEBUG, 'Reusing existing WaveSurfer instance for URL:', url); this.currentContainer = container;
try { return;
// 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 // Different URL — tear down the previous instance and clear stale store state
if (this.wavesurfer && this.currentUrl !== url) { if (this.wavesurfer) {
this.log(LogLevel.INFO, 'Cleaning up existing instance for new URL:', url); this.destroyWaveSurfer();
this.cleanup(); usePlayerStore.getState().batchUpdate({ isPlaying: false, currentTime: 0, duration: 0 });
} }
// Create new WaveSurfer instance this.mediaElement = this.createMediaElement();
this.log(LogLevel.DEBUG, 'Creating new WaveSurfer instance for URL:', url);
let ws; const ws = WaveSurfer.create({
try { container,
ws = WaveSurfer.create({ // Fresh audio element per song. Lives on document.body so playback
container: container, // 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",
@@ -261,534 +76,106 @@ private readonly PLAY_DEBOUNCE_MS: number = 100;
barRadius: 2, barRadius: 2,
height: 104, height: 104,
normalize: true, normalize: true,
// Ensure we can control playback manually
autoplay: false, autoplay: false,
// Development-specific settings for better debugging
...(typeof window !== 'undefined' && window.location && window.location.hostname === 'localhost' && this.audioContext && {
backend: 'WebAudio',
audioContext: this.audioContext,
audioRate: 1,
}),
}); });
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.wavesurfer = ws;
this.currentUrl = url; this.currentUrl = url;
this.currentContainer = container;
this.setupEventHandlers(ws);
// Get audio context from wavesurfer await new Promise<void>((resolve, reject) => {
// Note: In WaveSurfer v7+, backend might not be available immediately const onReady = async () => {
// We'll try to access it now, but also set up a handler to get it when ready const duration = ws.getDuration();
await this.setupAudioContext(ws); if (duration > 0) {
usePlayerStore.getState().setDuration(duration);
// Set up event handlers before loading this.isReady = true;
this.setupEventHandlers();
// Load the audio with error handling
this.log(LogLevel.DEBUG, 'Loading audio URL:', url);
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);
// Update player store with duration
const playerStore = usePlayerStore.getState();
playerStore.setDuration(ws.getDuration());
// Signal initialization completion
this.initializationState = InitializationState.Completed;
this.initializationResolve?.();
this.initializationResolve = null;
resolve(); resolve();
} catch (error) { } else {
this.log(LogLevel.ERROR, 'Initialization failed in ready handler:', error); reject(new Error('Audio loaded but duration is 0'));
this.initializationState = InitializationState.Failed;
this.initializationError = error instanceof Error ? error : new Error(String(error));
this.initializationResolve?.();
this.initializationResolve = null;
reject(error);
} }
}); };
ws.on('error', (error) => { ws.on('ready', () => { onReady().catch(reject); });
this.log(LogLevel.ERROR, 'WaveSurfer error event:', error); ws.on('error', (err) => reject(err instanceof Error ? err : new Error(String(err))));
reject(error);
});
// Start loading
ws.load(url); 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;
this.initializationError = error instanceof Error ? error : new Error(String(error));
this.initializationResolve?.();
this.initializationResolve = null;
this.cleanup();
throw error;
}
} }
private setupEventHandlers() { private setupEventHandlers(ws: WaveSurfer): void {
if (!this.wavesurfer) return; ws.on('play', () => usePlayerStore.getState().batchUpdate({ isPlaying: true }));
ws.on('pause', () => usePlayerStore.getState().batchUpdate({ isPlaying: false }));
const ws = this.wavesurfer; ws.on('finish', () => usePlayerStore.getState().batchUpdate({ isPlaying: false }));
const playerStore = usePlayerStore.getState(); ws.on('audioprocess', (time) => {
ws.on("play", () => {
playerStore.batchUpdate({ isPlaying: true });
});
ws.on("pause", () => {
playerStore.batchUpdate({ isPlaying: false });
});
ws.on("finish", () => {
playerStore.batchUpdate({ isPlaying: false });
});
ws.on("audioprocess", (time) => {
const now = Date.now(); const now = Date.now();
// Throttle state updates to reduce React re-renders
if (now - this.lastTimeUpdate >= 250) { if (now - this.lastTimeUpdate >= 250) {
playerStore.batchUpdate({ currentTime: time }); usePlayerStore.getState().batchUpdate({ currentTime: time });
this.lastTimeUpdate = now; 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(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) { if (!this.wavesurfer || !this.isReady) return;
this.log(LogLevel.WARN, 'AudioService: no wavesurfer instance - cannot play');
// 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 {
this.log(LogLevel.ERROR, 'Waveform initialization failed or was cleaned up');
}
return;
}
// Check if waveform is actually ready for playback
if (this.getDuration() <= 0) {
this.log(LogLevel.ERROR, 'Waveform not ready for playback - duration is 0');
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;
// Only log play calls in debug mode to reduce noise
this.log(LogLevel.DEBUG, 'AudioService.play called', { songId, bandId });
try {
// Always stop current playback first to ensure only one audio plays at a time
if (this.isPlaying()) {
this.log(LogLevel.INFO, 'Stopping current playback before starting new one');
this.pause();
// Small delay to ensure cleanup
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();
// Try to play - this might fail due to autoplay policy
try {
await this.wavesurfer.play(); await this.wavesurfer.play();
} catch (playError) {
this.log(LogLevel.WARN, 'Initial play attempt failed, trying alternative approach:', playError);
// If play fails due to autoplay policy, try to resume audio context and retry
if (playError instanceof Error && (playError.name === 'NotAllowedError' || playError.name === 'InvalidStateError')) {
this.log(LogLevel.INFO, 'Playback blocked by browser autoplay policy, attempting recovery...');
// Ensure audio context is properly resumed
await this.handleAudioContextResume();
// Try playing again
await this.wavesurfer.play();
} else {
// For other errors, throw them to be handled by the retry logic
throw playError;
}
}
// Update currently playing song tracking
if (songId && bandId) { if (songId && bandId) {
this.currentPlayingSongId = songId; usePlayerStore.getState().setCurrentSong(songId, bandId);
this.currentPlayingBandId = bandId;
const playerStore = usePlayerStore.getState();
playerStore.setCurrentPlayingSong(songId, bandId);
}
// Success logs are redundant, only log in debug mode
this.log(LogLevel.DEBUG, 'Playback started successfully');
this.playbackAttempts = 0; // Reset on success
} catch (error) {
this.playbackAttempts++;
this.log(LogLevel.ERROR, `Playback failed (attempt ${this.playbackAttempts}):`, error);
// Handle specific audio context errors
if (error instanceof Error && (error.name === 'NotAllowedError' || error.name === 'InvalidStateError')) {
this.log(LogLevel.ERROR, 'Playback blocked by browser autoplay policy');
// Don't retry immediately - wait for user gesture
if (this.playbackAttempts >= this.MAX_PLAYBACK_ATTEMPTS) {
this.log(LogLevel.ERROR, 'Max playback attempts reached, resetting player');
// Don't cleanup wavesurfer - just reset state
this.playbackAttempts = 0;
}
return; // Don't retry autoplay errors
}
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(songId, bandId); // Retry
}
} }
} }
public pause() { public pause(): void {
if (!this.wavesurfer) { this.wavesurfer?.pause();
this.log(LogLevel.WARN, 'AudioService: no wavesurfer instance');
return;
} }
// Only log pause calls in debug mode to reduce noise public seekTo(time: number): void {
this.log(LogLevel.DEBUG, 'AudioService.pause called'); if (this.wavesurfer && this.isReady && isFinite(time)) {
this.wavesurfer.pause();
}
public seekTo(time: number) {
if (!this.wavesurfer) {
this.log(LogLevel.WARN, "AudioService: no wavesurfer instance");
return;
}
// Debounce seek operations to prevent jitter
const now = Date.now();
if (now - this.lastSeekTime < this.SEEK_DEBOUNCE_MS) {
this.log(LogLevel.DEBUG, "Seek debounced - too frequent");
return;
}
this.lastSeekTime = now;
// Only log seek operations in debug mode to reduce noise
this.log(LogLevel.DEBUG, "AudioService.seekTo called", { time });
this.wavesurfer.setTime(time); this.wavesurfer.setTime(time);
} }
}
public getCurrentTime(): number { public getCurrentTime(): number {
if (!this.wavesurfer) return 0; return this.wavesurfer?.getCurrentTime() ?? 0;
return this.wavesurfer.getCurrentTime();
} }
public getDuration(): number { public getDuration(): number {
if (!this.wavesurfer) return 0; return this.wavesurfer?.getDuration() ?? 0;
return this.wavesurfer.getDuration();
} }
public isPlaying(): boolean { public isPlaying(): boolean {
if (!this.wavesurfer) return false; return this.wavesurfer?.isPlaying() ?? false;
return this.wavesurfer.isPlaying();
} }
public cleanup() { public isWaveformReady(): boolean {
this.log(LogLevel.INFO, 'AudioService.cleanup called'); return this.isReady && !!this.wavesurfer;
}
if (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 { try {
// Always stop playback first
if (this.wavesurfer.isPlaying()) {
this.wavesurfer.pause();
}
// Disconnect audio nodes but keep audio context alive
this.wavesurfer.unAll(); this.wavesurfer.unAll();
this.wavesurfer.destroy(); this.wavesurfer.destroy();
this.log(LogLevel.DEBUG, 'WaveSurfer instance cleaned up'); // Remove the old media element after WaveSurfer finishes its own cleanup.
} catch (error) { if (this.mediaElement) {
this.log(LogLevel.ERROR, 'Error cleaning up WaveSurfer:', error); this.mediaElement.pause();
this.mediaElement.remove();
} }
} catch (err) {
console.error('[AudioService] Error destroying WaveSurfer:', err);
}
this.mediaElement = null;
this.wavesurfer = null; this.wavesurfer = null;
}
this.currentUrl = null; this.currentUrl = null;
this.currentPlayingSongId = null; this.currentContainer = null;
this.currentPlayingBandId = null; this.isReady = false;
// Reset player store completely
const playerStore = usePlayerStore.getState();
playerStore.setCurrentPlayingSong(null, null);
playerStore.batchUpdate({ isPlaying: false, currentTime: 0 });
// Note: We intentionally don't nullify audioContext to keep it alive
}
private async setupAudioContext(ws: WaveSurferWithBackend) {
// Simplified and more robust audio context setup
try {
// If we already have an audio context, ensure WaveSurfer uses it
if (this.audioContext) {
this.log(LogLevel.DEBUG, 'Using existing audio context');
// Centralized method to share audio context with WaveSurfer
this.shareAudioContextWithWaveSurfer(ws);
return;
}
// Fallback: Try to get audio context from WaveSurfer (for compatibility)
if (ws.backend?.getAudioContext) {
this.audioContext = ws.backend.getAudioContext();
this.log(LogLevel.DEBUG, 'Audio context accessed via backend.getAudioContext()');
} else if (typeof ws.getAudioContext === 'function') {
this.audioContext = ws.getAudioContext();
this.log(LogLevel.DEBUG, 'Audio context accessed via ws.getAudioContext()');
}
if (this.audioContext) {
this.log(LogLevel.INFO, 'Audio context initialized from WaveSurfer', {
state: this.audioContext.state,
sampleRate: this.audioContext.sampleRate
});
// Resume suspended audio context automatically
if (this.audioContext.state === 'suspended') {
try {
await this.audioContext.resume();
this.log(LogLevel.INFO, 'Audio context resumed successfully');
} catch (error) {
this.log(LogLevel.WARN, 'Failed to resume audio context:', error);
}
}
// Set up state change monitoring
this.audioContext.onstatechange = () => {
this.log(LogLevel.DEBUG, 'Audio context state changed:', this.audioContext?.state);
};
return;
}
// Don't create new audio context if WaveSurfer doesn't provide methods
// This maintains backward compatibility and allows graceful degradation
this.log(LogLevel.DEBUG, 'No audio context available from WaveSurfer, continuing without it');
} catch (error) {
this.log(LogLevel.ERROR, 'Error setting up audio context:', error);
// Don't throw - we can continue with our existing audio context
}
}
private shareAudioContextWithWaveSurfer(ws: WaveSurferWithBackend) {
if (!this.audioContext) {
this.log(LogLevel.WARN, 'No audio context available to share');
return;
}
try {
// Method 1: Try to set via backend if available
if (ws.backend) {
ws.backend.audioContext = this.audioContext;
this.log(LogLevel.DEBUG, 'Shared audio context with WaveSurfer backend');
return; // Success, exit early
}
// 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;
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;
this.log(LogLevel.DEBUG, 'Overrode ws.getAudioContext with shared context');
return; // Success, exit early
}
this.log(LogLevel.WARN, 'Could not share audio context with WaveSurfer - no compatible method found');
} catch (error) {
this.log(LogLevel.WARN, 'Could not share audio context with WaveSurfer:', error);
}
}
public getAudioContextState(): string | undefined {
return this.audioContext?.state;
}
// Method to check if audio can be played (respects browser autoplay policies)
public canPlayAudio(): boolean {
// Must have a wavesurfer instance
if (!this.wavesurfer) {
return false;
}
// Must have a valid duration (waveform loaded)
if (this.getDuration() <= 0) {
return false;
}
// If we have an active audio context that's running, we can play
if (this.audioContext?.state === 'running') {
return true;
}
// If audio context is suspended, we might be able to resume it with user gesture
if (this.audioContext?.state === 'suspended') {
return true; // User gesture can resume it
}
// If no audio context exists, we can create one with user gesture
return true; // User gesture can create it
}
// Method to check if waveform is ready for playback
public isWaveformReady(): boolean {
return !!this.wavesurfer && this.getDuration() > 0;
}
// Method to check if audio service is properly initialized
public isInitialized(): boolean {
return !!this.wavesurfer && this.getDuration() > 0 && !!this.audioContext;
}
// 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')
);
}
// Initialization state management
public getInitializationState(): InitializationState {
return this.initializationState;
}
public getInitializationError(): Error | null {
return this.initializationError;
}
public isInitializationComplete(): boolean {
return this.initializationState === InitializationState.Completed;
}
public async waitForInitialization(): Promise<void> {
return this.initializationPromise ?? Promise.resolve();
}
// Method to ensure audio context is available (for backward compatibility)
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();
this.log(LogLevel.INFO, 'Audio context resumed successfully');
} catch (error) {
this.log(LogLevel.ERROR, 'Failed to resume audio context:', error);
throw error;
}
}
return this.audioContext;
}
// Create new audio context
try {
this.audioContext = new (window.AudioContext || (window as { webkitAudioContext?: new () => AudioContext }).webkitAudioContext)();
this.log(LogLevel.INFO, 'New audio context created', {
state: this.audioContext.state,
sampleRate: this.audioContext.sampleRate
});
// Set up state change monitoring
this.audioContext.onstatechange = () => {
this.log(LogLevel.DEBUG, 'Audio context state changed:', this.audioContext?.state);
};
return this.audioContext;
} catch (error) {
this.log(LogLevel.ERROR, 'Failed to create audio context:', error);
throw new Error(`Audio context creation failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
// Method to get WaveSurfer version for debugging
public getWaveSurferVersion(): string | null {
if (this.wavesurfer) {
// @ts-expect-error - WaveSurfer version might not be in types
return this.wavesurfer.version || 'unknown';
}
return null;
}
// Method to update multiple player state values at once
public updatePlayerState(updates: {
isPlaying?: boolean;
currentTime?: number;
duration?: number;
}) {
const playerStore = usePlayerStore.getState();
playerStore.batchUpdate(updates);
} }
} }
export const audioService = AudioService.getInstance(); export const audioService = AudioService.getInstance();
export { AudioService, LogLevel, InitializationState }; // Export class and enums for testing export { AudioService };

View File

@@ -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();

View File

@@ -4,17 +4,16 @@ interface PlayerState {
isPlaying: boolean; isPlaying: boolean;
currentTime: number; currentTime: number;
duration: number; duration: number;
// Set when audio starts playing; cleared on cleanup.
// Drives MiniPlayer visibility and sidebar "go to now playing" links.
currentSongId: string | null; currentSongId: string | null;
currentBandId: string | null; currentBandId: string | null;
currentPlayingSongId: string | null; // Track which song is actively playing
currentPlayingBandId: string | null; // Track which band's song is actively playing
setPlaying: (isPlaying: boolean) => void; setPlaying: (isPlaying: boolean) => void;
setCurrentTime: (currentTime: number) => void; setCurrentTime: (currentTime: number) => void;
setDuration: (duration: number) => void; setDuration: (duration: number) => void;
setCurrentSong: (songId: string | null, bandId: string | null) => void; setCurrentSong: (songId: string | null, bandId: string | null) => void;
setCurrentPlayingSong: (songId: string | null, bandId: string | null) => void;
reset: () => void; reset: () => void;
batchUpdate: (updates: Partial<Omit<PlayerState, 'setPlaying' | 'setCurrentTime' | 'setDuration' | 'setCurrentSong' | 'setCurrentPlayingSong' | 'reset' | 'batchUpdate'>>) => void; batchUpdate: (updates: Partial<Omit<PlayerState, 'setPlaying' | 'setCurrentTime' | 'setDuration' | 'setCurrentSong' | 'reset' | 'batchUpdate'>>) => void;
} }
export const usePlayerStore = create<PlayerState>()((set) => ({ export const usePlayerStore = create<PlayerState>()((set) => ({
@@ -23,13 +22,10 @@ export const usePlayerStore = create<PlayerState>()((set) => ({
duration: 0, duration: 0,
currentSongId: null, currentSongId: null,
currentBandId: null, currentBandId: null,
currentPlayingSongId: null,
currentPlayingBandId: null,
setPlaying: (isPlaying) => set({ isPlaying }), setPlaying: (isPlaying) => set({ isPlaying }),
setCurrentTime: (currentTime) => set({ currentTime }), setCurrentTime: (currentTime) => set({ currentTime }),
setDuration: (duration) => set({ duration }), setDuration: (duration) => set({ duration }),
setCurrentSong: (songId, bandId) => set({ currentSongId: songId, currentBandId: bandId }), setCurrentSong: (songId, bandId) => set({ currentSongId: songId, currentBandId: bandId }),
setCurrentPlayingSong: (songId, bandId) => set({ currentPlayingSongId: songId, currentPlayingBandId: bandId }),
batchUpdate: (updates) => set(updates), batchUpdate: (updates) => set(updates),
reset: () => set({ reset: () => set({
isPlaying: false, isPlaying: false,
@@ -37,7 +33,5 @@ export const usePlayerStore = create<PlayerState>()((set) => ({
duration: 0, duration: 0,
currentSongId: null, currentSongId: null,
currentBandId: null, currentBandId: null,
currentPlayingSongId: null,
currentPlayingBandId: null
}) })
})); }));

View File

@@ -1,97 +0,0 @@
import { AudioService } from '../src/services/audioService';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
describe('AudioService Audio Context Initialization', () => {
let audioService: AudioService;
beforeEach(() => {
// Reset the singleton instance before each test
AudioService.resetInstance();
audioService = AudioService.getInstance();
// Mock AudioContext for testing
global.window.AudioContext = vi.fn().mockImplementation(() => ({
state: 'suspended',
sampleRate: 44100,
resume: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
onstatechange: null,
suspend: vi.fn().mockResolvedValue(undefined)
})) as any;
});
afterEach(() => {
// Clean up any audio contexts
if (audioService['audioContext']) {
audioService['audioContext'].close?.().catch(() => {});
}
// Clean up mock
delete global.window.AudioContext;
});
it('should initialize audio context successfully', async () => {
const context = await audioService.initializeAudioContext();
expect(context).toBeDefined();
expect(context.state).toBe('suspended'); // Should start suspended
expect(audioService['audioContext']).toBe(context);
});
it('should handle audio context resume', async () => {
// Initialize context first
await audioService.initializeAudioContext();
// Mock user gesture by resuming
const resumeSpy = vi.spyOn(audioService['audioContext']!, 'resume');
// This should attempt to resume the context
await audioService['handleAudioContextResume']();
expect(resumeSpy).toHaveBeenCalled();
});
it('should share audio context with WaveSurfer', async () => {
// Initialize audio context
await audioService.initializeAudioContext();
// Create mock WaveSurfer instance
const mockWaveSurfer = {
backend: {
audioContext: null
}
};
// Call the sharing method
audioService['shareAudioContextWithWaveSurfer'](mockWaveSurfer);
// Verify context was shared
expect(mockWaveSurfer.backend.audioContext).toBe(audioService['audioContext']);
});
it('should handle WaveSurfer without backend gracefully', async () => {
// Initialize audio context
await audioService.initializeAudioContext();
// Create mock WaveSurfer instance without backend
const mockWaveSurfer = {
getAudioContext: vi.fn()
};
// This should not throw
expect(() => {
audioService['shareAudioContextWithWaveSurfer'](mockWaveSurfer);
}).not.toThrow();
});
it('should check initialization state correctly', async () => {
// Initially should not be initialized
expect(audioService.isInitialized()).toBe(false);
// After audio context initialization, still not fully initialized
await audioService.initializeAudioContext();
expect(audioService.isInitialized()).toBe(false);
// Note: Full initialization would require WaveSurfer instance
// which we can't easily mock here without DOM
});
});

View File

@@ -1,259 +1,315 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { AudioService } from '../src/services/audioService'; import { AudioService } from '../src/services/audioService';
import { usePlayerStore } from '../src/stores/playerStore';
// Mock WaveSurfer // ── WaveSurfer mock ───────────────────────────────────────────────────────────
function createMockWaveSurfer() {
return { type EventName = 'ready' | 'error' | 'play' | 'pause' | 'finish' | 'audioprocess';
backend: { type EventHandler = (...args: any[]) => void;
getAudioContext: vi.fn(() => ({
state: 'running', function createMockWaveSurfer(duration = 120) {
sampleRate: 44100, const handlers: Partial<Record<EventName, EventHandler>> = {};
destination: { channelCount: 2 },
resume: vi.fn().mockResolvedValue(undefined), const ws = {
onstatechange: null on: vi.fn((event: EventName, handler: EventHandler) => {
})), handlers[event] = handler;
ac: null, }),
audioContext: null
},
getAudioContext: vi.fn(),
on: vi.fn(),
load: vi.fn(), load: vi.fn(),
play: vi.fn(), play: vi.fn().mockResolvedValue(undefined),
pause: vi.fn(), pause: vi.fn(),
setTime: vi.fn(),
getCurrentTime: vi.fn(() => 0), getCurrentTime: vi.fn(() => 0),
getDuration: vi.fn(() => 120), getDuration: vi.fn(() => duration),
isPlaying: vi.fn(() => false), isPlaying: vi.fn(() => false),
unAll: vi.fn(), unAll: vi.fn(),
destroy: vi.fn(), destroy: vi.fn(),
setTime: vi.fn() setOptions: vi.fn(),
// Helper to fire events from tests
_emit: (event: EventName, ...args: any[]) => handlers[event]?.(...args),
}; };
return ws;
} }
function createMockAudioContext(state: 'suspended' | 'running' | 'closed' = 'running') { vi.mock('wavesurfer.js', () => ({
return { default: { create: vi.fn() },
state, }));
sampleRate: 44100,
destination: { channelCount: 2 }, // ── Helpers ───────────────────────────────────────────────────────────────────
resume: vi.fn().mockResolvedValue(undefined),
onstatechange: null function makeContainer(): HTMLElement {
}; return document.createElement('div');
} }
async function initService(
service: AudioService,
opts: { url?: string; duration?: number } = {}
): Promise<ReturnType<typeof createMockWaveSurfer>> {
const { url = 'http://example.com/audio.mp3', duration = 120 } = opts;
const WaveSurfer = (await import('wavesurfer.js')).default;
const mockWs = createMockWaveSurfer(duration);
vi.mocked(WaveSurfer.create).mockReturnValue(mockWs as any);
const initPromise = service.initialize(makeContainer(), url);
// Fire 'ready' to complete initialization
mockWs._emit('ready');
await initPromise;
return mockWs;
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('AudioService', () => { describe('AudioService', () => {
let audioService: AudioService; let service: AudioService;
let mockWaveSurfer: any;
let mockAudioContext: any;
beforeEach(() => { beforeEach(() => {
// Reset the singleton instance
AudioService.resetInstance(); AudioService.resetInstance();
audioService = AudioService.getInstance(); service = AudioService.getInstance();
usePlayerStore.getState().reset();
mockWaveSurfer = createMockWaveSurfer();
mockAudioContext = createMockAudioContext();
// Mock window.AudioContext
(globalThis as any).window = {
AudioContext: vi.fn(() => mockAudioContext) as any
};
}); });
afterEach(() => { // ── initialize() ────────────────────────────────────────────────────────────
vi.restoreAllMocks();
describe('initialize()', () => {
it('throws if container is null', async () => {
await expect(
service.initialize(null as any, 'http://example.com/a.mp3')
).rejects.toThrow('Container element is required');
}); });
describe('setupAudioContext', () => { it('throws if url is empty', async () => {
it('should successfully access audio context via backend.getAudioContext()', () => { await expect(
audioService['setupAudioContext'](mockWaveSurfer); service.initialize(makeContainer(), '')
).rejects.toThrow('Valid audio URL is required');
expect(mockWaveSurfer.backend.getAudioContext).toHaveBeenCalled();
expect(audioService['audioContext']).toBeDefined();
expect(audioService['audioContext'].state).toBe('running');
}); });
it('should fall back to ws.getAudioContext() if backend method fails', () => { it('resolves and marks ready when WaveSurfer fires ready with duration > 0', async () => {
const mockWaveSurferNoBackend = { await initService(service);
...mockWaveSurfer, expect(service.isWaveformReady()).toBe(true);
backend: null,
getAudioContext: vi.fn(() => mockAudioContext)
};
audioService['setupAudioContext'](mockWaveSurferNoBackend);
expect(mockWaveSurferNoBackend.getAudioContext).toHaveBeenCalled();
expect(audioService['audioContext']).toBeDefined();
}); });
it('should handle case when no audio context methods work but not throw error', () => { it('sets duration in the player store on ready', async () => {
const mockWaveSurferNoMethods = { await initService(service, { duration: 180 });
...mockWaveSurfer, expect(usePlayerStore.getState().duration).toBe(180);
backend: {
getAudioContext: null,
ac: null,
audioContext: null
},
getAudioContext: null
};
// Should not throw error - just continue without audio context
audioService['setupAudioContext'](mockWaveSurferNoMethods);
// Audio context should remain null in this case
expect(audioService['audioContext']).toBeNull();
}); });
it('should handle suspended audio context by resuming it', () => { it('rejects and stays not-ready when duration is 0', async () => {
const suspendedContext = createMockAudioContext('suspended'); const WaveSurfer = (await import('wavesurfer.js')).default;
mockWaveSurfer.backend.getAudioContext.mockReturnValue(suspendedContext); const mockWs = createMockWaveSurfer(0);
vi.mocked(WaveSurfer.create).mockReturnValue(mockWs as any);
audioService['setupAudioContext'](mockWaveSurfer); const initPromise = service.initialize(makeContainer(), 'http://example.com/a.mp3');
mockWs._emit('ready');
expect(suspendedContext.resume).toHaveBeenCalled(); await expect(initPromise).rejects.toThrow('duration is 0');
expect(service.isWaveformReady()).toBe(false);
}); });
it('should not throw error if audio context cannot be created - just continue', () => { it('rejects when WaveSurfer fires an error', async () => {
global.window.AudioContext = vi.fn(() => { const WaveSurfer = (await import('wavesurfer.js')).default;
throw new Error('AudioContext creation failed'); const mockWs = createMockWaveSurfer();
}) as any; vi.mocked(WaveSurfer.create).mockReturnValue(mockWs as any);
const mockWaveSurferNoMethods = { const initPromise = service.initialize(makeContainer(), 'http://example.com/a.mp3');
...mockWaveSurfer, mockWs._emit('error', new Error('network error'));
backend: {
getAudioContext: null,
ac: null,
audioContext: null
},
getAudioContext: null
};
// Should not throw error - just continue without audio context await expect(initPromise).rejects.toThrow('network error');
expect(() => audioService['setupAudioContext'](mockWaveSurferNoMethods)) });
.not.toThrow();
expect(audioService['audioContext']).toBeNull(); 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 mockWs = createMockWaveSurfer();
vi.mocked(WaveSurfer.create).mockReturnValue(mockWs as any);
const url = 'http://example.com/same.mp3';
const p1 = service.initialize(makeContainer(), url);
mockWs._emit('ready');
await p1;
const newContainer = makeContainer(); // different DOM element, same URL
const createCount = vi.mocked(WaveSurfer.create).mock.calls.length;
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 () => {
const WaveSurfer = (await import('wavesurfer.js')).default;
const mockWs1 = createMockWaveSurfer();
vi.mocked(WaveSurfer.create).mockReturnValue(mockWs1 as any);
const p1 = service.initialize(makeContainer(), 'http://example.com/a.mp3');
mockWs1._emit('ready');
await p1;
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;
expect(mockWs1.destroy).toHaveBeenCalled();
});
});
// ── play() ───────────────────────────────────────────────────────────────────
describe('play()', () => {
it('does nothing if not ready', async () => {
// play() silently returns when not ready — no throw, no warn
await expect(service.play('song-1', 'band-1')).resolves.toBeUndefined();
});
it('calls wavesurfer.play()', async () => {
const mockWs = await initService(service);
await service.play();
expect(mockWs.play).toHaveBeenCalled();
});
it('updates currentSongId/BandId in the store', async () => {
await initService(service);
await service.play('song-1', 'band-1');
const state = usePlayerStore.getState();
expect(state.currentSongId).toBe('song-1');
expect(state.currentBandId).toBe('band-1');
});
it('does not update store ids when called without ids', async () => {
await initService(service);
await service.play();
const state = usePlayerStore.getState();
expect(state.currentSongId).toBeNull();
});
});
// ── pause() ──────────────────────────────────────────────────────────────────
describe('pause()', () => {
it('calls wavesurfer.pause() when ready', async () => {
const mockWs = await initService(service);
service.pause();
expect(mockWs.pause).toHaveBeenCalled();
});
it('does nothing if not initialized', () => {
expect(() => service.pause()).not.toThrow();
});
});
// ── seekTo() ─────────────────────────────────────────────────────────────────
describe('seekTo()', () => {
it('calls wavesurfer.setTime() with the given time', async () => {
const mockWs = await initService(service);
service.seekTo(42);
expect(mockWs.setTime).toHaveBeenCalledWith(42);
});
it('does nothing for non-finite values', async () => {
const mockWs = await initService(service);
service.seekTo(Infinity);
service.seekTo(NaN);
expect(mockWs.setTime).not.toHaveBeenCalled();
});
it('does nothing if not ready', () => {
expect(() => service.seekTo(10)).not.toThrow();
});
});
// ── cleanup() ────────────────────────────────────────────────────────────────
describe('cleanup()', () => {
it('destroys WaveSurfer and marks service not-ready', async () => {
const mockWs = await initService(service);
service.cleanup();
expect(mockWs.destroy).toHaveBeenCalled();
expect(service.isWaveformReady()).toBe(false);
});
it('resets isPlaying, currentTime, and duration in the store', async () => {
await initService(service, { duration: 180 });
usePlayerStore.getState().batchUpdate({ isPlaying: true, currentTime: 30 });
service.cleanup();
const state = usePlayerStore.getState();
expect(state.isPlaying).toBe(false);
expect(state.currentTime).toBe(0);
expect(state.duration).toBe(0);
});
it('is safe to call when not initialized', () => {
expect(() => service.cleanup()).not.toThrow();
});
});
// ── WaveSurfer event → store sync ────────────────────────────────────────────
describe('WaveSurfer event handlers', () => {
it('play event sets store isPlaying=true', async () => {
const mockWs = await initService(service);
mockWs._emit('play');
expect(usePlayerStore.getState().isPlaying).toBe(true);
});
it('pause event sets store isPlaying=false', async () => {
const mockWs = await initService(service);
usePlayerStore.getState().batchUpdate({ isPlaying: true });
mockWs._emit('pause');
expect(usePlayerStore.getState().isPlaying).toBe(false);
});
it('finish event sets store isPlaying=false', async () => {
const mockWs = await initService(service);
usePlayerStore.getState().batchUpdate({ isPlaying: true });
mockWs._emit('finish');
expect(usePlayerStore.getState().isPlaying).toBe(false);
});
it('audioprocess event updates store currentTime (throttled at 250ms)', async () => {
const mockWs = await initService(service);
// First emission at t=300 passes throttle (300 - lastUpdateTime:0 >= 250)
vi.spyOn(Date, 'now').mockReturnValue(300);
mockWs._emit('audioprocess', 15.5);
expect(usePlayerStore.getState().currentTime).toBe(15.5);
// Second emission at t=400 is throttled (400 - 300 = 100 < 250)
vi.spyOn(Date, 'now').mockReturnValue(400);
mockWs._emit('audioprocess', 16.0);
expect(usePlayerStore.getState().currentTime).toBe(15.5); // unchanged
});
}); });
}); });
describe('ensureAudioContext', () => {
it('should return existing audio context if available', async () => {
audioService['audioContext'] = mockAudioContext;
const result = await audioService['ensureAudioContext']();
expect(result).toBe(mockAudioContext);
});
it('should resume suspended audio context', async () => {
const suspendedContext = createMockAudioContext('suspended');
audioService['audioContext'] = suspendedContext;
const result = await audioService['ensureAudioContext']();
expect(suspendedContext.resume).toHaveBeenCalled();
expect(result).toBe(suspendedContext);
});
it('should create new audio context if none exists', async () => {
const result = await audioService['ensureAudioContext']();
expect(global.window.AudioContext).toHaveBeenCalled();
expect(result).toBeDefined();
expect(result.state).toBe('running');
});
it('should throw error if audio context creation fails', async () => {
global.window.AudioContext = vi.fn(() => {
throw new Error('Creation failed');
}) as any;
await expect(audioService['ensureAudioContext']())
.rejects
.toThrow('Audio context creation failed: Creation failed');
});
});
describe('getWaveSurferVersion', () => {
it('should return WaveSurfer version if available', () => {
audioService['wavesurfer'] = {
version: '7.12.5'
} as any;
expect(audioService.getWaveSurferVersion()).toBe('7.12.5');
});
it('should return unknown if version not available', () => {
audioService['wavesurfer'] = {} as any;
expect(audioService.getWaveSurferVersion()).toBe('unknown');
});
it('should return null if no wavesurfer instance', () => {
audioService['wavesurfer'] = null;
expect(audioService.getWaveSurferVersion()).toBeNull();
});
});
describe('initializeAudioContext', () => {
it('should initialize audio context successfully', async () => {
const result = await audioService.initializeAudioContext();
expect(result).toBeDefined();
expect(result.state).toBe('running');
expect(audioService['audioContext']).toBe(result);
});
it('should resume suspended audio context', async () => {
const suspendedContext = createMockAudioContext('suspended');
global.window.AudioContext = vi.fn(() => suspendedContext) as any;
const result = await audioService.initializeAudioContext();
expect(suspendedContext.resume).toHaveBeenCalled();
expect(result).toBe(suspendedContext);
});
it('should handle audio context creation errors', async () => {
global.window.AudioContext = vi.fn(() => {
throw new Error('AudioContext creation failed');
}) as any;
await expect(audioService.initializeAudioContext())
.rejects
.toThrow('Failed to initialize audio context: AudioContext creation failed');
});
});
describe('cleanup', () => {
it('should stop playback and clean up properly', () => {
// Mock a playing wavesurfer instance
const mockWavesurfer = {
isPlaying: vi.fn(() => true),
pause: vi.fn(),
unAll: vi.fn(),
destroy: vi.fn()
};
audioService['wavesurfer'] = mockWavesurfer;
audioService['currentPlayingSongId'] = 'song-123';
audioService['currentPlayingBandId'] = 'band-456';
audioService.cleanup();
expect(mockWavesurfer.pause).toHaveBeenCalled();
expect(mockWavesurfer.unAll).toHaveBeenCalled();
expect(mockWavesurfer.destroy).toHaveBeenCalled();
expect(audioService['wavesurfer']).toBeNull();
expect(audioService['currentPlayingSongId']).toBeNull();
expect(audioService['currentPlayingBandId']).toBeNull();
});
it('should handle cleanup when no wavesurfer instance exists', () => {
audioService['wavesurfer'] = null;
audioService['currentPlayingSongId'] = 'song-123';
expect(() => audioService.cleanup()).not.toThrow();
expect(audioService['currentPlayingSongId']).toBeNull();
});
});
});

View File

@@ -1,187 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { AudioService, LogLevel } from '../src/services/audioService';
describe('AudioService Logging Optimization', () => {
let audioService: AudioService;
let consoleSpy: any;
beforeEach(() => {
AudioService.resetInstance();
// Spy on console methods
consoleSpy = {
debug: vi.spyOn(console, 'debug').mockImplementation(() => {}),
info: vi.spyOn(console, 'info').mockImplementation(() => {}),
warn: vi.spyOn(console, 'warn').mockImplementation(() => {}),
error: vi.spyOn(console, 'error').mockImplementation(() => {})
};
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('Environment-based Log Level Detection', () => {
it('should use DEBUG level in development environment (localhost)', () => {
// Mock localhost environment
const originalLocation = window.location;
delete (window as any).location;
window.location = { hostname: 'localhost' } as any;
audioService = AudioService.getInstance();
expect(consoleSpy.info).toHaveBeenCalledWith(
expect.stringContaining('DEBUG')
);
window.location = originalLocation;
});
it('should use WARN level in production environment', () => {
// Mock production environment
const originalLocation = window.location;
delete (window as any).location;
window.location = { hostname: 'example.com' } as any;
audioService = AudioService.getInstance();
expect(consoleSpy.info).toHaveBeenCalledWith(
expect.stringContaining('WARN')
);
window.location = originalLocation;
});
it('should use DEBUG level with audioDebug query parameter', () => {
// Mock production environment with debug parameter
const originalLocation = window.location;
delete (window as any).location;
window.location = {
hostname: 'example.com',
search: '?audioDebug=true'
} as any;
audioService = AudioService.getInstance();
expect(consoleSpy.info).toHaveBeenCalledWith(
expect.stringContaining('log level: DEBUG')
);
window.location = originalLocation;
});
});
describe('Log Throttling', () => {
it('should throttle rapid-fire log calls', () => {
// Mock development environment
const originalLocation = window.location;
delete (window as any).location;
window.location = { hostname: 'localhost' } as any;
audioService = AudioService.getInstance();
// Call log multiple times rapidly
for (let i = 0; i < 10; i++) {
audioService['log'](LogLevel.DEBUG, `Test log ${i}`);
}
// Should only log a few times due to throttling
expect(consoleSpy.debug).toHaveBeenCalled();
// Should be called fewer times due to throttling
const callCount = consoleSpy.debug.mock.calls.length;
expect(callCount).toBeLessThanOrEqual(3);
window.location = originalLocation;
});
});
describe('Log Level Filtering', () => {
it('should filter out logs below current log level', () => {
// Mock production environment (WARN level)
const originalLocation = window.location;
delete (window as any).location;
window.location = { hostname: 'example.com' } as any;
audioService = AudioService.getInstance();
// Try to log INFO level message in WARN environment
audioService['log'](LogLevel.INFO, 'This should not appear');
// Should not call console.info
expect(consoleSpy.info).not.toHaveBeenCalledWith(
expect.stringContaining('This should not appear')
);
// WARN level should appear
audioService['log'](LogLevel.WARN, 'This should appear');
expect(consoleSpy.warn).toHaveBeenCalledWith(
expect.stringContaining('This should appear')
);
window.location = originalLocation;
});
it('should allow DEBUG logs in development environment', () => {
// Mock development environment
const originalLocation = window.location;
delete (window as any).location;
window.location = { hostname: 'localhost' } as any;
audioService = AudioService.getInstance();
// DEBUG level should appear in development
audioService['log'](LogLevel.DEBUG, 'Debug message');
expect(consoleSpy.debug).toHaveBeenCalledWith(
expect.stringContaining('Debug message')
);
window.location = originalLocation;
});
});
describe('Verbose Log Reduction', () => {
it('should not log play/pause/seek calls in production', () => {
// Mock production environment
const originalLocation = window.location;
delete (window as any).location;
window.location = { hostname: 'example.com' } as any;
audioService = AudioService.getInstance();
// These should not appear in production (INFO level, but production uses WARN)
audioService['log'](LogLevel.INFO, 'AudioService.play called');
audioService['log'](LogLevel.INFO, 'AudioService.pause called');
audioService['log'](LogLevel.INFO, 'AudioService.seekTo called');
// Should not call console.info for these
expect(consoleSpy.info).not.toHaveBeenCalledWith(
expect.stringContaining('AudioService.play called')
);
expect(consoleSpy.info).not.toHaveBeenCalledWith(
expect.stringContaining('AudioService.pause called')
);
expect(consoleSpy.info).not.toHaveBeenCalledWith(
expect.stringContaining('AudioService.seekTo called')
);
window.location = originalLocation;
});
it('should log errors in both environments', () => {
// Test in production environment
const originalLocation = window.location;
delete (window as any).location;
window.location = { hostname: 'example.com' } as any;
audioService = AudioService.getInstance();
// Error logs should always appear
audioService['log'](LogLevel.ERROR, 'Critical error');
expect(consoleSpy.error).toHaveBeenCalledWith(
expect.stringContaining('Critical error')
);
window.location = originalLocation;
});
});
});