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>
This commit is contained in:
Mistral Vibe
2026-04-08 20:47:10 +02:00
parent 1a0d926e1a
commit d4c0e9d776
4 changed files with 159 additions and 899 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 {
@@ -27,7 +27,6 @@ export function useWaveform(
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[]>([]);
useEffect(() => {
@@ -62,22 +61,19 @@ export function useWaveform(
options.bandId &&
currentPlayingSongId === options.songId &&
currentPlayingBandId === options.bandId &&
wasPlaying
wasPlaying &&
audioService.isWaveformReady()
) {
try {
if (audioService.canAttemptPlayback()) {
audioService.play(options.songId, options.bandId);
if (savedTime > 0) {
audioService.seekTo(savedTime);
}
}
await audioService.play(options.songId, options.bandId);
if (savedTime > 0) audioService.seekTo(savedTime);
} catch (err) {
console.warn('Auto-play prevented during initialization:', err);
}
}
// Sync local state from the store at ~15fps via RAF.
// The loop is started after initialization so we only poll when something is loaded.
// The loop is started after initialization so we only poll when loaded.
let lastUpdateTime = 0;
const updateInterval = 1000 / 15;
@@ -96,7 +92,6 @@ export function useWaveform(
animationFrameId = requestAnimationFrame(handleStateUpdate);
setIsReady(true);
setInitializationState(audioService.getInitializationState());
options.onReady?.(audioService.getDuration());
} catch (err) {
console.error('useWaveform: initialization failed', err);
@@ -115,92 +110,60 @@ export function useWaveform(
}, [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 {
// If we can attempt playback (even during initialization), try it
if (audioService.canAttemptPlayback()) {
try {
audioService.play(options.songId || null, options.bandId || null);
} catch (error) {
console.error('useWaveform.play failed during initialization attempt:', 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);
}
};
@@ -215,23 +178,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,850 +1,151 @@
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 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');
}
// Signal initialization completion for existing instance
this.initializationState = InitializationState.Completed;
this.initializationResolve?.();
this.initializationResolve = null;
return;
} 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) {
this.log(LogLevel.DEBUG, '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, {
currentWavesurfer: !!this.wavesurfer,
currentUrl: this.currentUrl,
initializationState: this.initializationState
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);
});
try {
await new Promise<void>((resolve, reject) => {
// Async work extracted outside the executor so rejections are always
// forwarded to reject() rather than becoming unhandled Promise rejections.
const onReady = async () => {
this.log(LogLevel.DEBUG, 'WaveSurfer ready event fired', {
currentTime: ws.getCurrentTime(),
duration: ws.getDuration(),
});
await this.setupAudioContext(ws);
const duration = ws.getDuration();
this.log(LogLevel.DEBUG, 'WaveSurfer ready with duration:', duration);
if (duration > 0) {
usePlayerStore.getState().setDuration(duration);
this.initializationState = InitializationState.Completed;
this.initializationResolve?.();
this.initializationResolve = null;
resolve();
} else {
const err = new Error('Audio loaded but duration is 0');
this.log(LogLevel.ERROR, err.message);
this.initializationState = InitializationState.Failed;
this.initializationError = err;
this.initializationResolve?.();
this.initializationResolve = null;
reject(err);
}
};
ws.on('ready', () => { onReady().catch(reject); });
ws.on('error', (error) => {
this.log(LogLevel.ERROR, 'WaveSurfer error event:', error);
this.initializationState = InitializationState.Failed;
this.initializationError = error instanceof Error ? error : new Error(String(error));
reject(error);
});
ws.load(url);
});
this.log(LogLevel.INFO, 'Audio loaded successfully');
} 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() {
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 if (this.initializationState === InitializationState.Failed) {
this.log(LogLevel.ERROR, 'Waveform initialization failed:', this.initializationError);
} else if (this.initializationState === InitializationState.InProgress) {
this.log(LogLevel.INFO, 'Waveform still initializing - waiting for completion');
// Wait for initialization to complete
try {
await this.waitForInitialization();
// After waiting, check if we have a wavesurfer instance
if (!this.wavesurfer) {
this.log(LogLevel.ERROR, 'Waveform initialization completed but no wavesurfer instance available');
return;
}
} catch (error) {
this.log(LogLevel.ERROR, 'Failed to wait for initialization:', error);
return;
}
} 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', {
initializationState: this.initializationState,
error: this.initializationError,
currentUrl: this.currentUrl
});
// If initialization failed, provide more context
if (this.initializationState === InitializationState.Failed) {
this.log(LogLevel.ERROR, 'Waveform initialization failed with error:', this.initializationError);
} else if (this.initializationState === InitializationState.InProgress) {
this.log(LogLevel.INFO, 'Waveform still initializing, waiting for completion');
// Wait for initialization to complete
try {
await this.waitForInitialization();
// After waiting, check duration again
if (this.getDuration() <= 0) {
this.log(LogLevel.ERROR, 'Waveform initialization completed but duration is still 0');
return;
}
} catch (error) {
this.log(LogLevel.ERROR, 'Failed to wait for initialization:', error);
return;
}
}
return;
}
// If initialization is still in progress, wait for it to complete
if (this.initializationState === InitializationState.InProgress) {
this.log(LogLevel.INFO, 'Initialization in progress, waiting for completion before playback');
try {
await this.waitForInitialization();
this.log(LogLevel.DEBUG, 'Initialization completed, proceeding with playback');
} catch (error) {
this.log(LogLevel.ERROR, 'Initialization failed while waiting for playback:', error);
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));
}
// 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;
}
}
if (songId && bandId) {
usePlayerStore.getState().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().setCurrentPlayingSong(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;
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 && typeof (ws.backend as any).getAudioContext === 'function') {
// Replace the method with proper typing
(ws.backend as any).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') {
// Replace the method with proper typing
(ws as any).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;
}
// Method to check if ready for playback (unified readiness check)
public isReadyForPlayback(): boolean {
return (
this.isWaveformReady() &&
!!this.audioContext &&
(this.audioContext.state === 'running' || this.audioContext.state === 'suspended')
);
}
// Method to check if playback can be attempted (more lenient during initialization)
public canAttemptPlayback(): boolean {
return (
this.isWaveformReady() &&
(this.initializationState === InitializationState.Completed ||
this.initializationState === InitializationState.InProgress)
);
}
// 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)
// @ts-expect-error - This method is used in tests but may not be used in production code
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
// Aliases kept for callers until Phase 3 cleans them up
public isReadyForPlayback(): boolean {
return this.isWaveformReady();
}
public canAttemptPlayback(): boolean {
return this.isWaveformReady();
}
public cleanup(): void {
this.destroyWaveSurfer();
const store = usePlayerStore.getState();
store.setCurrentPlayingSong(null, null);
store.batchUpdate({ isPlaying: false, currentTime: 0 });
}
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 };