4 Commits

Author SHA1 Message Date
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
8 changed files with 449 additions and 1459 deletions

View File

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

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "react";
import { audioService, InitializationState } from "../services/audioService";
import { audioService } from "../services/audioService";
import { usePlayerStore } from "../stores/playerStore";
export interface UseWaveformOptions {
@@ -22,211 +22,118 @@ export function useWaveform(
containerRef: React.RefObject<HTMLDivElement>,
options: UseWaveformOptions
) {
const [isPlaying, setIsPlaying] = 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 [initializationState, setInitializationState] = useState<InitializationState>(InitializationState.NotStarted);
const markersRef = useRef<CommentMarker[]>([]);
// Global player state - use shallow comparison to reduce re-renders
const {
isPlaying: globalIsPlaying,
currentTime: globalCurrentTime,
currentSongId,
currentBandId: globalBandId,
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
}));
// Playback state comes directly from the store — no intermediate local state
// or RAF polling loop needed. The store is updated by WaveSurfer event handlers
// in AudioService, so these values are always in sync.
const isPlaying = usePlayerStore(state => state.isPlaying);
const currentTime = usePlayerStore(state => state.currentTime);
const duration = usePlayerStore(state => state.duration);
useEffect(() => {
if (!containerRef.current) {
return;
}
if (!options.url || options.url === 'null' || options.url === 'undefined') {
return;
}
if (!containerRef.current) return;
if (!options.url || options.url === 'null' || options.url === 'undefined') return;
const initializeAudio = async () => {
try {
await audioService.initialize(containerRef.current!, options.url!);
// Set up local state synchronization with requestAnimationFrame for smoother updates
let animationFrameId: number | null = null;
let lastUpdateTime = 0;
const updateInterval = 1000 / 15; // ~15fps for state updates
const handleStateUpdate = () => {
const now = Date.now();
if (now - lastUpdateTime >= updateInterval) {
const state = usePlayerStore.getState();
setIsPlaying(state.isPlaying);
setCurrentTime(state.currentTime);
setDuration(state.duration);
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
// Restore playback if this song was already playing when the page loaded.
// Read as a one-time snapshot — these values must NOT be reactive deps or
// the effect would re-run on every time update (re-initializing WaveSurfer).
const {
currentSongId,
currentBandId,
isPlaying: wasPlaying,
currentTime: savedTime,
} = usePlayerStore.getState();
if (
options.songId &&
options.bandId &&
currentSongId === options.songId &&
currentBandId === options.bandId &&
wasPlaying &&
audioService.isWaveformReady()
) {
try {
await audioService.waitForInitialization();
// Use the unified readiness check
if (audioService.isReadyForPlayback()) {
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
await audioService.play(options.songId, options.bandId);
if (savedTime > 0) audioService.seekTo(savedTime);
} catch (err) {
console.warn('Auto-play prevented during initialization:', err);
}
}
setIsReady(true);
setInitializationState(audioService.getInitializationState());
options.onReady?.(audioService.getDuration());
return () => {
unsubscribe();
// Note: We don't cleanup the audio service here to maintain persistence
// audioService.cleanup();
};
} catch (error) {
console.error('useWaveform: initialization failed', error);
setIsReady(true);
options.onReady?.(audioService.getDuration());
} catch (err) {
console.error('useWaveform: initialization failed', err);
setIsReady(false);
setError(error instanceof Error ? error.message : 'Failed to initialize audio');
return () => {};
setError(err instanceof Error ? err.message : 'Failed to initialize audio');
}
};
initializeAudio();
// 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]);
const play = () => {
// Use the unified readiness check
if (audioService.isReadyForPlayback()) {
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
});
if (!audioService.isWaveformReady()) {
console.warn('[useWaveform] play() called but not ready', { url: options.url });
return;
}
audioService.play(options.songId ?? null, options.bandId ?? null)
.catch(err => console.error('useWaveform.play failed:', err));
};
const pause = () => {
try {
audioService.pause();
} catch (error) {
console.error('useWaveform.pause failed:', error);
}
audioService.pause();
};
const seekTo = (time: number) => {
try {
if (isReady && isFinite(time)) {
audioService.seekTo(time);
}
} catch (error) {
console.error('useWaveform.seekTo failed:', error);
}
audioService.seekTo(time);
};
const addMarker = (marker: CommentMarker) => {
if (isReady) {
try {
// This would need proper implementation with the actual wavesurfer instance
const markerElement = document.createElement("div");
markerElement.style.position = "absolute";
markerElement.style.width = "24px";
markerElement.style.height = "24px";
markerElement.style.borderRadius = "50%";
markerElement.style.backgroundColor = "var(--accent)";
markerElement.style.cursor = "pointer";
markerElement.style.zIndex = "9999";
markerElement.style.left = `${(marker.time / audioService.getDuration()) * 100}%`;
markerElement.style.transform = "translateX(-50%) translateY(-50%)";
markerElement.style.top = "50%";
markerElement.style.border = "2px solid white";
markerElement.style.boxShadow = "0 0 4px rgba(0, 0, 0, 0.3)";
markerElement.title = `Comment at ${formatTime(marker.time)}`;
markerElement.onclick = marker.onClick;
if (!isReady) return;
try {
const markerElement = document.createElement("div");
markerElement.style.position = "absolute";
markerElement.style.width = "24px";
markerElement.style.height = "24px";
markerElement.style.borderRadius = "50%";
markerElement.style.backgroundColor = "var(--accent)";
markerElement.style.cursor = "pointer";
markerElement.style.zIndex = "9999";
markerElement.style.left = `${(marker.time / audioService.getDuration()) * 100}%`;
markerElement.style.transform = "translateX(-50%) translateY(-50%)";
markerElement.style.top = "50%";
markerElement.style.border = "2px solid white";
markerElement.style.boxShadow = "0 0 4px rgba(0, 0, 0, 0.3)";
markerElement.title = `Comment at ${formatTime(marker.time)}`;
markerElement.onclick = marker.onClick;
if (marker.icon) {
const iconElement = document.createElement("img");
iconElement.src = marker.icon;
iconElement.style.width = "100%";
iconElement.style.height = "100%";
iconElement.style.borderRadius = "50%";
iconElement.style.objectFit = "cover";
markerElement.appendChild(iconElement);
}
const waveformContainer = containerRef.current;
if (waveformContainer) {
waveformContainer.style.position = "relative";
waveformContainer.appendChild(markerElement);
}
markersRef.current.push(marker);
} catch (error) {
console.error('useWaveform.addMarker failed:', error);
if (marker.icon) {
const iconElement = document.createElement("img");
iconElement.src = marker.icon;
iconElement.style.width = "100%";
iconElement.style.height = "100%";
iconElement.style.borderRadius = "50%";
iconElement.style.objectFit = "cover";
markerElement.appendChild(iconElement);
}
const waveformContainer = containerRef.current;
if (waveformContainer) {
waveformContainer.style.position = "relative";
waveformContainer.appendChild(markerElement);
}
markersRef.current.push(marker);
} catch (err) {
console.error('useWaveform.addMarker failed:', err);
}
};
@@ -241,23 +148,11 @@ export function useWaveform(
markersRef.current = [];
};
return {
isPlaying,
isReady,
currentTime,
duration,
play,
pause,
seekTo,
addMarker,
clearMarkers,
error,
initializationState
};
return { isPlaying, isReady, currentTime, duration, play, pause, seekTo, addMarker, clearMarkers, error };
}
function formatTime(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${String(s).padStart(2, "0")}`;
}
}

View File

@@ -381,7 +381,13 @@ export function SongPage() {
if (target.tagName === "TEXTAREA" || target.tagName === "INPUT") return;
if (e.code === "Space") {
e.preventDefault();
if (isPlaying) { pause(); } else { play(); }
if (isPlaying) {
pause();
} else {
if (isReady) {
play();
}
}
}
};
window.addEventListener("keydown", handleKeyDown);

View File

@@ -1,794 +1,142 @@
import WaveSurfer from "wavesurfer.js";
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 {
private static instance: AudioService;
private wavesurfer: WaveSurfer | null = null;
private audioContext: AudioContext | null = null;
private currentUrl: string | null = null;
private currentPlayingSongId: string | null = null;
private currentPlayingBandId: string | null = null;
private lastPlayTime: number = 0;
private lastTimeUpdate: number = 0;
private readonly PLAY_DEBOUNCE_MS: number = 100;
private lastSeekTime: number = 0;
private readonly SEEK_DEBOUNCE_MS: number = 200;
private logLevel: LogLevel = LogLevel.ERROR;
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 initializationState: InitializationState = InitializationState.NotStarted;
private initializationError: Error | null = null;
private initializationPromise: Promise<void> | null = null;
private initializationResolve: (() => void) | null = null;
private constructor() {
// 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;
}
}
private isReady = false;
private lastTimeUpdate = 0;
// 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() {
private constructor() {}
public static getInstance(): AudioService {
if (!this.instance) {
this.instance = new AudioService();
}
return this.instance;
}
// Initialize audio context - now handles user gesture requirement
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
// For use in tests only
public static resetInstance(): void {
this.instance = undefined as any;
}
public async initialize(container: HTMLElement, url: string): Promise<void> {
this.log(LogLevel.DEBUG, 'AudioService.initialize called', { url, containerExists: !!container });
// Reset initialization state
this.initializationState = InitializationState.InProgress;
this.initializationError = null;
this.initializationPromise = new Promise<void>((resolve) => {
this.initializationResolve = resolve;
if (!container) throw new Error('Container element is required');
if (!url) throw new Error('Valid audio URL is required');
// Reuse the existing instance when the URL hasn't changed
if (this.currentUrl === url && this.wavesurfer) return;
// Tear down the previous instance before creating a new one
if (this.wavesurfer) this.destroyWaveSurfer();
const ws = WaveSurfer.create({
container,
waveColor: "rgba(255,255,255,0.09)",
progressColor: "#c8861a",
cursorColor: "#e8a22a",
barWidth: 2,
barRadius: 2,
height: 104,
normalize: true,
autoplay: false,
});
// Validate inputs
if (!container) {
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) {
// Implementation detail, only log in debug mode
this.log(LogLevel.DEBUG, '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,
// 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.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
await 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>(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();
} catch (error) {
this.log(LogLevel.ERROR, 'Initialization failed in ready handler:', error);
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) => {
this.log(LogLevel.ERROR, 'WaveSurfer error event:', error);
reject(error);
});
// Start loading
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;
}
this.setupEventHandlers(ws);
await new Promise<void>((resolve, reject) => {
const onReady = async () => {
const duration = ws.getDuration();
if (duration > 0) {
usePlayerStore.getState().setDuration(duration);
this.isReady = true;
resolve();
} else {
reject(new Error('Audio loaded but duration is 0'));
}
};
ws.on('ready', () => { onReady().catch(reject); });
ws.on('error', (err) => reject(err instanceof Error ? err : new Error(String(err))));
ws.load(url);
});
}
private setupEventHandlers() {
if (!this.wavesurfer) return;
const ws = this.wavesurfer;
const playerStore = usePlayerStore.getState();
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) => {
private setupEventHandlers(ws: WaveSurfer): void {
ws.on('play', () => usePlayerStore.getState().batchUpdate({ isPlaying: true }));
ws.on('pause', () => usePlayerStore.getState().batchUpdate({ isPlaying: false }));
ws.on('finish', () => usePlayerStore.getState().batchUpdate({ isPlaying: false }));
ws.on('audioprocess', (time) => {
const now = Date.now();
// Throttle state updates to reduce React re-renders
if (now - this.lastTimeUpdate >= 250) {
playerStore.batchUpdate({ currentTime: time });
usePlayerStore.getState().batchUpdate({ currentTime: 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(songId: string | null = null, bandId: string | null = null): Promise<void> {
if (!this.wavesurfer) {
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');
}
if (!this.wavesurfer || !this.isReady) {
console.warn('[AudioService] play() called before ready');
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();
} 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) {
this.currentPlayingSongId = songId;
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
}
await this.wavesurfer.play();
if (songId && bandId) {
usePlayerStore.getState().setCurrentSong(songId, bandId);
}
}
public pause() {
if (!this.wavesurfer) {
this.log(LogLevel.WARN, 'AudioService: no wavesurfer instance');
return;
}
// Only log pause calls in debug mode to reduce noise
this.log(LogLevel.DEBUG, 'AudioService.pause called');
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);
public pause(): void {
this.wavesurfer?.pause();
}
public seekTo(time: number): void {
if (this.wavesurfer && this.isReady && isFinite(time)) {
this.wavesurfer.setTime(time);
}
}
public getCurrentTime(): number {
if (!this.wavesurfer) return 0;
return this.wavesurfer.getCurrentTime();
return this.wavesurfer?.getCurrentTime() ?? 0;
}
public getDuration(): number {
if (!this.wavesurfer) return 0;
return this.wavesurfer.getDuration();
return this.wavesurfer?.getDuration() ?? 0;
}
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 {
// Always stop playback first
if (this.wavesurfer.isPlaying()) {
this.wavesurfer.pause();
}
// 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;
this.currentPlayingSongId = null;
this.currentPlayingBandId = null;
// 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);
}
return this.wavesurfer?.isPlaying() ?? false;
}
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;
return this.isReady && !!this.wavesurfer;
}
// Method to check if audio service is properly initialized
public isInitialized(): boolean {
return !!this.wavesurfer && this.getDuration() > 0 && !!this.audioContext;
public cleanup(): void {
this.destroyWaveSurfer();
const store = usePlayerStore.getState();
store.setCurrentSong(null, null);
store.batchUpdate({ isPlaying: false, currentTime: 0 });
}
// 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
private destroyWaveSurfer(): void {
if (!this.wavesurfer) return;
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)}`);
if (this.wavesurfer.isPlaying()) this.wavesurfer.pause();
this.wavesurfer.unAll();
this.wavesurfer.destroy();
} catch (err) {
console.error('[AudioService] Error destroying WaveSurfer:', err);
}
}
// 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);
this.wavesurfer = null;
this.currentUrl = null;
this.isReady = false;
}
}
export const audioService = AudioService.getInstance();
export { AudioService, LogLevel, InitializationState }; // Export class and enums for testing
export { AudioService };

View File

@@ -4,17 +4,16 @@ interface PlayerState {
isPlaying: boolean;
currentTime: number;
duration: number;
// Set when audio starts playing; cleared on cleanup.
// Drives MiniPlayer visibility and sidebar "go to now playing" links.
currentSongId: 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;
setCurrentTime: (currentTime: number) => void;
setDuration: (duration: number) => void;
setCurrentSong: (songId: string | null, bandId: string | null) => void;
setCurrentPlayingSong: (songId: string | null, bandId: string | null) => 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) => ({
@@ -23,21 +22,16 @@ export const usePlayerStore = create<PlayerState>()((set) => ({
duration: 0,
currentSongId: null,
currentBandId: null,
currentPlayingSongId: null,
currentPlayingBandId: null,
setPlaying: (isPlaying) => set({ isPlaying }),
setCurrentTime: (currentTime) => set({ currentTime }),
setDuration: (duration) => set({ duration }),
setCurrentSong: (songId, bandId) => set({ currentSongId: songId, currentBandId: bandId }),
setCurrentPlayingSong: (songId, bandId) => set({ currentPlayingSongId: songId, currentPlayingBandId: bandId }),
batchUpdate: (updates) => set(updates),
reset: () => set({
isPlaying: false,
currentTime: 0,
duration: 0,
currentSongId: null,
reset: () => set({
isPlaying: false,
currentTime: 0,
duration: 0,
currentSongId: 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,288 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { AudioService } from '../src/services/audioService';
import { usePlayerStore } from '../src/stores/playerStore';
// Mock WaveSurfer
function createMockWaveSurfer() {
return {
backend: {
getAudioContext: vi.fn(() => ({
state: 'running',
sampleRate: 44100,
destination: { channelCount: 2 },
resume: vi.fn().mockResolvedValue(undefined),
onstatechange: null
})),
ac: null,
audioContext: null
},
getAudioContext: vi.fn(),
on: vi.fn(),
// ── WaveSurfer mock ───────────────────────────────────────────────────────────
type EventName = 'ready' | 'error' | 'play' | 'pause' | 'finish' | 'audioprocess';
type EventHandler = (...args: any[]) => void;
function createMockWaveSurfer(duration = 120) {
const handlers: Partial<Record<EventName, EventHandler>> = {};
const ws = {
on: vi.fn((event: EventName, handler: EventHandler) => {
handlers[event] = handler;
}),
load: vi.fn(),
play: vi.fn(),
play: vi.fn().mockResolvedValue(undefined),
pause: vi.fn(),
setTime: vi.fn(),
getCurrentTime: vi.fn(() => 0),
getDuration: vi.fn(() => 120),
getDuration: vi.fn(() => duration),
isPlaying: vi.fn(() => false),
unAll: vi.fn(),
destroy: vi.fn(),
setTime: 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') {
return {
state,
sampleRate: 44100,
destination: { channelCount: 2 },
resume: vi.fn().mockResolvedValue(undefined),
onstatechange: null
};
vi.mock('wavesurfer.js', () => ({
default: { create: vi.fn() },
}));
// ── Helpers ───────────────────────────────────────────────────────────────────
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', () => {
let audioService: AudioService;
let mockWaveSurfer: any;
let mockAudioContext: any;
let service: AudioService;
beforeEach(() => {
// Reset the singleton instance
AudioService.resetInstance();
audioService = AudioService.getInstance();
mockWaveSurfer = createMockWaveSurfer();
mockAudioContext = createMockAudioContext();
// Mock window.AudioContext
(globalThis as any).window = {
AudioContext: vi.fn(() => mockAudioContext) as any
};
service = AudioService.getInstance();
usePlayerStore.getState().reset();
});
afterEach(() => {
vi.restoreAllMocks();
// ── initialize() ────────────────────────────────────────────────────────────
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');
});
it('throws if url is empty', async () => {
await expect(
service.initialize(makeContainer(), '')
).rejects.toThrow('Valid audio URL is required');
});
it('resolves and marks ready when WaveSurfer fires ready with duration > 0', async () => {
await initService(service);
expect(service.isWaveformReady()).toBe(true);
});
it('sets duration in the player store on ready', async () => {
await initService(service, { duration: 180 });
expect(usePlayerStore.getState().duration).toBe(180);
});
it('rejects and stays not-ready when duration is 0', async () => {
const WaveSurfer = (await import('wavesurfer.js')).default;
const mockWs = createMockWaveSurfer(0);
vi.mocked(WaveSurfer.create).mockReturnValue(mockWs as any);
const initPromise = service.initialize(makeContainer(), 'http://example.com/a.mp3');
mockWs._emit('ready');
await expect(initPromise).rejects.toThrow('duration is 0');
expect(service.isWaveformReady()).toBe(false);
});
it('rejects when WaveSurfer fires an error', async () => {
const WaveSurfer = (await import('wavesurfer.js')).default;
const mockWs = createMockWaveSurfer();
vi.mocked(WaveSurfer.create).mockReturnValue(mockWs as any);
const initPromise = service.initialize(makeContainer(), 'http://example.com/a.mp3');
mockWs._emit('error', new Error('network error'));
await expect(initPromise).rejects.toThrow('network error');
});
it('reuses the existing instance for the same URL', 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 createCallCount = vi.mocked(WaveSurfer.create).mock.calls.length;
await service.initialize(makeContainer(), url); // same URL
expect(vi.mocked(WaveSurfer.create).mock.calls.length).toBe(createCallCount); // no new instance
});
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();
});
});
describe('setupAudioContext', () => {
it('should successfully access audio context via backend.getAudioContext()', () => {
audioService['setupAudioContext'](mockWaveSurfer);
expect(mockWaveSurfer.backend.getAudioContext).toHaveBeenCalled();
expect(audioService['audioContext']).toBeDefined();
expect(audioService['audioContext'].state).toBe('running');
// ── play() ───────────────────────────────────────────────────────────────────
describe('play()', () => {
it('does nothing if not ready', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
await service.play('song-1', 'band-1');
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('before ready'));
warnSpy.mockRestore();
});
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();
});
});
it('should fall back to ws.getAudioContext() if backend method fails', () => {
const mockWaveSurferNoBackend = {
...mockWaveSurfer,
backend: null,
getAudioContext: vi.fn(() => mockAudioContext)
};
audioService['setupAudioContext'](mockWaveSurferNoBackend);
expect(mockWaveSurferNoBackend.getAudioContext).toHaveBeenCalled();
expect(audioService['audioContext']).toBeDefined();
// ── 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();
});
});
it('should handle case when no audio context methods work but not throw error', () => {
const mockWaveSurferNoMethods = {
...mockWaveSurfer,
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();
// ── 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();
});
});
it('should handle suspended audio context by resuming it', () => {
const suspendedContext = createMockAudioContext('suspended');
mockWaveSurfer.backend.getAudioContext.mockReturnValue(suspendedContext);
audioService['setupAudioContext'](mockWaveSurfer);
expect(suspendedContext.resume).toHaveBeenCalled();
// ── 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 and currentTime in the store', async () => {
await initService(service);
usePlayerStore.getState().batchUpdate({ isPlaying: true, currentTime: 30 });
service.cleanup();
const state = usePlayerStore.getState();
expect(state.isPlaying).toBe(false);
expect(state.currentTime).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', () => {
expect(() => service.cleanup()).not.toThrow();
});
});
it('should not throw error if audio context cannot be created - just continue', () => {
global.window.AudioContext = vi.fn(() => {
throw new Error('AudioContext creation failed');
}) as any;
const mockWaveSurferNoMethods = {
...mockWaveSurfer,
backend: {
getAudioContext: null,
ac: null,
audioContext: null
},
getAudioContext: null
};
// Should not throw error - just continue without audio context
expect(() => audioService['setupAudioContext'](mockWaveSurferNoMethods))
.not.toThrow();
expect(audioService['audioContext']).toBeNull();
// ── 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;
});
});
});