Cleanup
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { audioService } from "../services/audioService";
|
import { audioService, InitializationState } from "../services/audioService";
|
||||||
import { usePlayerStore } from "../stores/playerStore";
|
import { usePlayerStore } from "../stores/playerStore";
|
||||||
|
|
||||||
export interface UseWaveformOptions {
|
export interface UseWaveformOptions {
|
||||||
@@ -27,6 +27,7 @@ export function useWaveform(
|
|||||||
const [currentTime, setCurrentTime] = useState(0);
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
const [duration, setDuration] = useState(0);
|
const [duration, setDuration] = useState(0);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [initializationState, setInitializationState] = useState<InitializationState>(InitializationState.NotStarted);
|
||||||
const markersRef = useRef<CommentMarker[]>([]);
|
const markersRef = useRef<CommentMarker[]>([]);
|
||||||
|
|
||||||
// Global player state - use shallow comparison to reduce re-renders
|
// Global player state - use shallow comparison to reduce re-renders
|
||||||
@@ -103,26 +104,30 @@ export function useWaveform(
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Wait for the waveform to be ready and audio context to be available
|
// Wait for initialization to complete using the new promise-based approach
|
||||||
const checkReady = setInterval(() => {
|
try {
|
||||||
if (audioService.getDuration() > 0) {
|
await audioService.waitForInitialization();
|
||||||
clearInterval(checkReady);
|
|
||||||
// Only attempt to play if we have a valid audio context
|
// Use the unified readiness check
|
||||||
// This prevents autoplay policy violations
|
if (audioService.isReadyForPlayback()) {
|
||||||
try {
|
audioService.play(options.songId, options.bandId);
|
||||||
audioService.play(options.songId, options.bandId);
|
if (globalCurrentTime > 0) {
|
||||||
if (globalCurrentTime > 0) {
|
audioService.seekTo(globalCurrentTime);
|
||||||
audioService.seekTo(globalCurrentTime);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Auto-play prevented by browser policy, waiting for user gesture:', error);
|
|
||||||
// Don't retry - wait for user to click play
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
console.warn('Not ready for playback after initialization', {
|
||||||
|
state: audioService.getInitializationState(),
|
||||||
|
error: audioService.getInitializationError()
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, 50);
|
} catch (error) {
|
||||||
|
console.warn('Auto-play prevented during initialization:', error);
|
||||||
|
// Don't retry - wait for user to click play
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsReady(true);
|
setIsReady(true);
|
||||||
|
setInitializationState(audioService.getInitializationState());
|
||||||
options.onReady?.(audioService.getDuration());
|
options.onReady?.(audioService.getDuration());
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -145,16 +150,17 @@ export function useWaveform(
|
|||||||
}, [options.url, options.songId, options.bandId, containerRef, currentSongId, globalBandId, globalCurrentTime, globalIsPlaying, setCurrentSong]);
|
}, [options.url, options.songId, options.bandId, containerRef, currentSongId, globalBandId, globalCurrentTime, globalIsPlaying, setCurrentSong]);
|
||||||
|
|
||||||
const play = () => {
|
const play = () => {
|
||||||
// Only attempt to play if waveform is ready
|
// Use the unified readiness check
|
||||||
if (audioService.isWaveformReady()) {
|
if (audioService.isReadyForPlayback()) {
|
||||||
try {
|
try {
|
||||||
audioService.play(options.songId || null, options.bandId || null);
|
audioService.play(options.songId || null, options.bandId || null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('useWaveform.play failed:', error);
|
console.error('useWaveform.play failed:', error);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.warn('Cannot play: waveform not ready', {
|
console.warn('Cannot play: not ready for playback', {
|
||||||
hasWavesurfer: !!audioService.isPlaying(),
|
initializationState: audioService.getInitializationState(),
|
||||||
|
error: audioService.getInitializationError(),
|
||||||
duration: audioService.getDuration(),
|
duration: audioService.getDuration(),
|
||||||
url: options.url
|
url: options.url
|
||||||
});
|
});
|
||||||
@@ -235,7 +241,19 @@ export function useWaveform(
|
|||||||
markersRef.current = [];
|
markersRef.current = [];
|
||||||
};
|
};
|
||||||
|
|
||||||
return { isPlaying, isReady, currentTime, duration, play, pause, seekTo, addMarker, clearMarkers, error };
|
return {
|
||||||
|
isPlaying,
|
||||||
|
isReady,
|
||||||
|
currentTime,
|
||||||
|
duration,
|
||||||
|
play,
|
||||||
|
pause,
|
||||||
|
seekTo,
|
||||||
|
addMarker,
|
||||||
|
clearMarkers,
|
||||||
|
error,
|
||||||
|
initializationState
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(seconds: number): string {
|
function formatTime(seconds: number): string {
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ enum LogLevel {
|
|||||||
ERROR = 3
|
ERROR = 3
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialization state enum
|
||||||
|
enum InitializationState {
|
||||||
|
NotStarted = 'not_started',
|
||||||
|
InProgress = 'in_progress',
|
||||||
|
Completed = 'completed',
|
||||||
|
Failed = 'failed'
|
||||||
|
}
|
||||||
|
|
||||||
// Type extension for WaveSurfer backend access
|
// Type extension for WaveSurfer backend access
|
||||||
interface WaveSurferWithBackend extends WaveSurfer {
|
interface WaveSurferWithBackend extends WaveSurfer {
|
||||||
backend?: {
|
backend?: {
|
||||||
@@ -39,6 +47,12 @@ private readonly PLAY_DEBOUNCE_MS: number = 100;
|
|||||||
private lastLogTime: number = 0;
|
private lastLogTime: number = 0;
|
||||||
private readonly LOG_THROTTLE_MS: number = 100;
|
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() {
|
private constructor() {
|
||||||
// Set appropriate log level based on environment
|
// Set appropriate log level based on environment
|
||||||
this.setLogLevel(this.detectLogLevel());
|
this.setLogLevel(this.detectLogLevel());
|
||||||
@@ -132,6 +146,13 @@ private readonly PLAY_DEBOUNCE_MS: number = 100;
|
|||||||
this.log(LogLevel.DEBUG, 'Audio context state changed:', this.audioContext?.state);
|
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;
|
return this.audioContext;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.log(LogLevel.ERROR, 'Failed to initialize audio context:', error);
|
this.log(LogLevel.ERROR, 'Failed to initialize audio context:', error);
|
||||||
@@ -170,17 +191,32 @@ private readonly PLAY_DEBOUNCE_MS: number = 100;
|
|||||||
this.instance = undefined as any;
|
this.instance = undefined as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async initialize(container: HTMLElement, url: string) {
|
public async initialize(container: HTMLElement, url: string): Promise<void> {
|
||||||
this.log(LogLevel.DEBUG, 'AudioService.initialize called', { url, containerExists: !!container });
|
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;
|
||||||
|
});
|
||||||
|
|
||||||
// Validate inputs
|
// Validate inputs
|
||||||
if (!container) {
|
if (!container) {
|
||||||
this.log(LogLevel.ERROR, 'AudioService: container element is null');
|
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');
|
throw new Error('Container element is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!url || url === 'null' || url === 'undefined') {
|
if (!url || url === 'null' || url === 'undefined') {
|
||||||
this.log(LogLevel.ERROR, 'AudioService: invalid URL', { url });
|
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');
|
throw new Error('Valid audio URL is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,7 +292,7 @@ private readonly PLAY_DEBOUNCE_MS: number = 100;
|
|||||||
// Get audio context from wavesurfer
|
// Get audio context from wavesurfer
|
||||||
// Note: In WaveSurfer v7+, backend might not be available immediately
|
// 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
|
// We'll try to access it now, but also set up a handler to get it when ready
|
||||||
this.setupAudioContext(ws);
|
await this.setupAudioContext(ws);
|
||||||
|
|
||||||
// Set up event handlers before loading
|
// Set up event handlers before loading
|
||||||
this.setupEventHandlers();
|
this.setupEventHandlers();
|
||||||
@@ -264,17 +300,31 @@ private readonly PLAY_DEBOUNCE_MS: number = 100;
|
|||||||
// Load the audio with error handling
|
// Load the audio with error handling
|
||||||
this.log(LogLevel.DEBUG, 'Loading audio URL:', url);
|
this.log(LogLevel.DEBUG, 'Loading audio URL:', url);
|
||||||
try {
|
try {
|
||||||
const loadPromise = new Promise<void>((resolve, reject) => {
|
const loadPromise = new Promise<void>(async (resolve, reject) => {
|
||||||
ws.on('ready', () => {
|
ws.on('ready', async () => {
|
||||||
this.log(LogLevel.DEBUG, 'WaveSurfer ready event fired');
|
this.log(LogLevel.DEBUG, 'WaveSurfer ready event fired');
|
||||||
// Now that WaveSurfer is ready, set up audio context and finalize initialization
|
// Now that WaveSurfer is ready, set up audio context and finalize initialization
|
||||||
this.setupAudioContext(ws);
|
try {
|
||||||
|
await this.setupAudioContext(ws);
|
||||||
// Update player store with duration
|
|
||||||
const playerStore = usePlayerStore.getState();
|
// Update player store with duration
|
||||||
playerStore.setDuration(ws.getDuration());
|
const playerStore = usePlayerStore.getState();
|
||||||
|
playerStore.setDuration(ws.getDuration());
|
||||||
resolve();
|
|
||||||
|
// 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) => {
|
ws.on('error', (error) => {
|
||||||
@@ -289,13 +339,16 @@ private readonly PLAY_DEBOUNCE_MS: number = 100;
|
|||||||
await loadPromise;
|
await loadPromise;
|
||||||
this.log(LogLevel.INFO, 'Audio loaded successfully');
|
this.log(LogLevel.INFO, 'Audio loaded successfully');
|
||||||
|
|
||||||
|
return this.initializationPromise;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.log(LogLevel.ERROR, 'Failed to load audio:', 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();
|
this.cleanup();
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
return ws;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private setupEventHandlers() {
|
private setupEventHandlers() {
|
||||||
@@ -341,6 +394,12 @@ private readonly PLAY_DEBOUNCE_MS: number = 100;
|
|||||||
return;
|
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
|
// Debounce rapid play calls
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - this.lastPlayTime < this.PLAY_DEBOUNCE_MS) {
|
if (now - this.lastPlayTime < this.PLAY_DEBOUNCE_MS) {
|
||||||
@@ -514,36 +573,15 @@ private readonly PLAY_DEBOUNCE_MS: number = 100;
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private setupAudioContext(ws: WaveSurferWithBackend) {
|
private async setupAudioContext(ws: WaveSurferWithBackend) {
|
||||||
// Simplified audio context setup - we now manage audio context centrally
|
// Simplified and more robust audio context setup
|
||||||
try {
|
try {
|
||||||
// If we already have an audio context, ensure WaveSurfer uses it
|
// If we already have an audio context, ensure WaveSurfer uses it
|
||||||
if (this.audioContext) {
|
if (this.audioContext) {
|
||||||
// Try multiple ways to share the audio context with WaveSurfer
|
this.log(LogLevel.DEBUG, 'Using existing audio context');
|
||||||
try {
|
|
||||||
// Method 1: Try to set via backend if available
|
// Centralized method to share audio context with WaveSurfer
|
||||||
if (ws.backend) {
|
this.shareAudioContextWithWaveSurfer(ws);
|
||||||
ws.backend.audioContext = this.audioContext;
|
|
||||||
this.log(LogLevel.DEBUG, 'Shared audio context with WaveSurfer backend');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Method 2: Try to access and replace the audio context
|
|
||||||
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');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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');
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
this.log(LogLevel.WARN, 'Could not share audio context with WaveSurfer, but continuing:', error);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -562,23 +600,69 @@ private readonly PLAY_DEBOUNCE_MS: number = 100;
|
|||||||
sampleRate: this.audioContext.sampleRate
|
sampleRate: this.audioContext.sampleRate
|
||||||
});
|
});
|
||||||
|
|
||||||
// Note: We don't automatically resume suspended audio contexts here
|
// Resume suspended audio context automatically
|
||||||
// because that requires a user gesture. The resume will be handled
|
|
||||||
// in handleAudioContextResume() when the user clicks play.
|
|
||||||
if (this.audioContext.state === 'suspended') {
|
if (this.audioContext.state === 'suspended') {
|
||||||
this.log(LogLevel.DEBUG, 'Audio context is suspended, will resume on user gesture');
|
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
|
// Set up state change monitoring
|
||||||
this.audioContext.onstatechange = () => {
|
this.audioContext.onstatechange = () => {
|
||||||
this.log(LogLevel.DEBUG, 'Audio context state changed:', this.audioContext?.state);
|
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) {
|
} catch (error) {
|
||||||
this.log(LogLevel.ERROR, 'Error setting up audio context:', error);
|
this.log(LogLevel.ERROR, 'Error setting up audio context:', error);
|
||||||
// Don't throw - we can continue with our existing audio context
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -617,6 +701,75 @@ private readonly PLAY_DEBOUNCE_MS: number = 100;
|
|||||||
return !!this.wavesurfer && this.getDuration() > 0;
|
return !!this.wavesurfer && this.getDuration() > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Method to check if audio service is properly initialized
|
||||||
|
public isInitialized(): boolean {
|
||||||
|
return !!this.wavesurfer && this.getDuration() > 0 && !!this.audioContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method to check if ready for playback (unified readiness check)
|
||||||
|
public isReadyForPlayback(): boolean {
|
||||||
|
return (
|
||||||
|
this.initializationState === InitializationState.Completed &&
|
||||||
|
this.isWaveformReady() &&
|
||||||
|
!!this.audioContext &&
|
||||||
|
(this.audioContext.state === 'running' || this.audioContext.state === 'suspended')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialization state management
|
||||||
|
public getInitializationState(): InitializationState {
|
||||||
|
return this.initializationState;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getInitializationError(): Error | null {
|
||||||
|
return this.initializationError;
|
||||||
|
}
|
||||||
|
|
||||||
|
public isInitializationComplete(): boolean {
|
||||||
|
return this.initializationState === InitializationState.Completed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async waitForInitialization(): Promise<void> {
|
||||||
|
return this.initializationPromise ?? Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method to ensure audio context is available (for backward compatibility)
|
||||||
|
private async ensureAudioContext(): Promise<AudioContext> {
|
||||||
|
// If we already have a valid audio context, return it
|
||||||
|
if (this.audioContext) {
|
||||||
|
// Resume if suspended (common in mobile browsers)
|
||||||
|
if (this.audioContext.state === 'suspended') {
|
||||||
|
try {
|
||||||
|
await this.audioContext.resume();
|
||||||
|
this.log(LogLevel.INFO, 'Audio context resumed successfully');
|
||||||
|
} catch (error) {
|
||||||
|
this.log(LogLevel.ERROR, 'Failed to resume audio context:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.audioContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new audio context
|
||||||
|
try {
|
||||||
|
this.audioContext = new (window.AudioContext || (window as { webkitAudioContext?: new () => AudioContext }).webkitAudioContext)();
|
||||||
|
this.log(LogLevel.INFO, 'New audio context created', {
|
||||||
|
state: this.audioContext.state,
|
||||||
|
sampleRate: this.audioContext.sampleRate
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set up state change monitoring
|
||||||
|
this.audioContext.onstatechange = () => {
|
||||||
|
this.log(LogLevel.DEBUG, 'Audio context state changed:', this.audioContext?.state);
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.audioContext;
|
||||||
|
} catch (error) {
|
||||||
|
this.log(LogLevel.ERROR, 'Failed to create audio context:', error);
|
||||||
|
throw new Error(`Audio context creation failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Method to get WaveSurfer version for debugging
|
// Method to get WaveSurfer version for debugging
|
||||||
public getWaveSurferVersion(): string | null {
|
public getWaveSurferVersion(): string | null {
|
||||||
if (this.wavesurfer) {
|
if (this.wavesurfer) {
|
||||||
@@ -638,4 +791,4 @@ private readonly PLAY_DEBOUNCE_MS: number = 100;
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const audioService = AudioService.getInstance();
|
export const audioService = AudioService.getInstance();
|
||||||
export { AudioService, LogLevel }; // Export class and enum for testing
|
export { AudioService, LogLevel, InitializationState }; // Export class and enums for testing
|
||||||
|
|||||||
97
web/tests/audioContextInitialization.test.ts
Normal file
97
web/tests/audioContextInitialization.test.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
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
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user