WIP Working on player
This commit is contained in:
415
web/src/services/audioService.ts.backup2
Normal file
415
web/src/services/audioService.ts.backup2
Normal file
@@ -0,0 +1,415 @@
|
||||
import WaveSurfer from "wavesurfer.js";
|
||||
import { usePlayerStore } from "../stores/playerStore";
|
||||
|
||||
// Log level enum
|
||||
enum LogLevel {
|
||||
DEBUG = 0,
|
||||
INFO = 1,
|
||||
WARN = 2,
|
||||
ERROR = 3
|
||||
}
|
||||
|
||||
// Type extension for WaveSurfer backend access
|
||||
interface WaveSurferWithBackend extends WaveSurfer {
|
||||
backend?: {
|
||||
getAudioContext?: () => AudioContext;
|
||||
ac?: AudioContext;
|
||||
audioContext?: AudioContext;
|
||||
};
|
||||
getAudioContext?: () => AudioContext;
|
||||
getContainer?: () => HTMLElement;
|
||||
setContainer?: (container: HTMLElement) => void;
|
||||
}
|
||||
|
||||
class AudioService {
|
||||
private static instance: AudioService;
|
||||
private wavesurfer: WaveSurfer | null = null;
|
||||
private audioContext: AudioContext | null = null;
|
||||
private currentUrl: string | null = null;
|
||||
private lastPlayTime: number = 0;
|
||||
private lastTimeUpdate: number = 0;
|
||||
private readonly TIME_UPDATE_THROTTLE: number = 100;
|
||||
private readonly PLAY_DEBOUNCE_MS: number = 100;
|
||||
private lastSeekTime: number = 0;
|
||||
private readonly SEEK_DEBOUNCE_MS: number = 200;
|
||||
private logLevel: LogLevel = LogLevel.INFO;
|
||||
private playbackAttempts: number = 0;
|
||||
private readonly MAX_PLAYBACK_ATTEMPTS: number = 3;
|
||||
|
||||
private constructor() {
|
||||
this.log(LogLevel.INFO, 'AudioService initialized');
|
||||
}
|
||||
|
||||
private log(level: LogLevel, message: string, ...args: unknown[]) {
|
||||
if (level < this.logLevel) return;
|
||||
|
||||
const prefix = `[AudioService:${LogLevel[level]}]`;
|
||||
|
||||
switch(level) {
|
||||
case LogLevel.DEBUG:
|
||||
if (console.debug) {
|
||||
console.debug(prefix, message, ...args);
|
||||
}
|
||||
break;
|
||||
case LogLevel.INFO:
|
||||
console.info(prefix, message, ...args);
|
||||
break;
|
||||
case LogLevel.WARN:
|
||||
console.warn(prefix, message, ...args);
|
||||
break;
|
||||
case LogLevel.ERROR:
|
||||
console.error(prefix, message, ...args);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Add method to set log level from outside
|
||||
public setLogLevel(level: LogLevel) {
|
||||
this.log(LogLevel.INFO, `Log level set to: ${LogLevel[level]}`);
|
||||
this.logLevel = level;
|
||||
}
|
||||
|
||||
public static getInstance() {
|
||||
if (!this.instance) {
|
||||
this.instance = new AudioService();
|
||||
}
|
||||
return this.instance;
|
||||
}
|
||||
|
||||
public async initialize(container: HTMLElement, url: string) {
|
||||
this.log(LogLevel.DEBUG, 'AudioService.initialize called', { url, containerExists: !!container });
|
||||
|
||||
// Validate inputs
|
||||
if (!container) {
|
||||
this.log(LogLevel.ERROR, 'AudioService: container element is null');
|
||||
throw new Error('Container element is required');
|
||||
}
|
||||
|
||||
if (!url || url === 'null' || url === 'undefined') {
|
||||
this.log(LogLevel.ERROR, 'AudioService: invalid URL', { url });
|
||||
throw new Error('Valid audio URL is required');
|
||||
}
|
||||
|
||||
// If same URL and we already have an instance, just update container reference
|
||||
if (this.currentUrl === url && this.wavesurfer) {
|
||||
this.log(LogLevel.INFO, 'Reusing existing WaveSurfer instance for URL:', url);
|
||||
try {
|
||||
// Check if container is different and needs updating
|
||||
const ws = this.wavesurfer as WaveSurferWithBackend;
|
||||
const currentContainer = ws.getContainer?.();
|
||||
if (currentContainer !== container) {
|
||||
this.log(LogLevel.DEBUG, 'Updating container reference for existing instance');
|
||||
// Update container reference without recreating instance
|
||||
ws.setContainer?.(container);
|
||||
} else {
|
||||
this.log(LogLevel.DEBUG, 'Using existing instance - no changes needed');
|
||||
}
|
||||
return this.wavesurfer;
|
||||
} catch (error) {
|
||||
this.log(LogLevel.ERROR, 'Failed to reuse existing instance:', error);
|
||||
this.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up existing instance if different URL
|
||||
if (this.wavesurfer && this.currentUrl !== url) {
|
||||
this.log(LogLevel.INFO, 'Cleaning up existing instance for new URL:', url);
|
||||
this.cleanup();
|
||||
}
|
||||
|
||||
// Create new WaveSurfer instance
|
||||
this.log(LogLevel.DEBUG, 'Creating new WaveSurfer instance for URL:', url);
|
||||
let ws;
|
||||
try {
|
||||
ws = WaveSurfer.create({
|
||||
container: container,
|
||||
waveColor: "rgba(255,255,255,0.09)",
|
||||
progressColor: "#c8861a",
|
||||
cursorColor: "#e8a22a",
|
||||
barWidth: 2,
|
||||
barRadius: 2,
|
||||
height: 104,
|
||||
normalize: true,
|
||||
// Ensure we can control playback manually
|
||||
autoplay: false,
|
||||
});
|
||||
|
||||
if (!ws) {
|
||||
throw new Error('WaveSurfer.create returned null or undefined');
|
||||
}
|
||||
|
||||
// @ts-expect-error - WaveSurfer typing doesn't expose backend
|
||||
if (!ws.backend) {
|
||||
console.warn('WaveSurfer instance has no backend property yet - this might be normal in v7+');
|
||||
// Don't throw error - we'll try to access backend later when needed
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create WaveSurfer instance:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Store references
|
||||
this.wavesurfer = ws;
|
||||
this.currentUrl = url;
|
||||
|
||||
// Get audio context from wavesurfer
|
||||
// Note: In WaveSurfer v7+, backend might not be available immediately
|
||||
// We'll try to access it now, but also set up a handler to get it when ready
|
||||
this.setupAudioContext(ws);
|
||||
|
||||
// Set up event handlers before loading
|
||||
this.setupEventHandlers();
|
||||
|
||||
// Load the audio with error handling
|
||||
this.log(LogLevel.DEBUG, 'Loading audio URL:', url);
|
||||
try {
|
||||
const loadPromise = new Promise<void>((resolve, reject) => {
|
||||
ws.on('ready', () => {
|
||||
this.log(LogLevel.DEBUG, 'WaveSurfer ready event fired');
|
||||
// Now that WaveSurfer is ready, set up audio context and finalize initialization
|
||||
this.setupAudioContext(ws);
|
||||
|
||||
// Update player store with duration
|
||||
const playerStore = usePlayerStore.getState();
|
||||
playerStore.setDuration(ws.getDuration());
|
||||
|
||||
resolve();
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
this.log(LogLevel.ERROR, 'WaveSurfer error event:', error);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
// Start loading
|
||||
ws.load(url);
|
||||
});
|
||||
|
||||
await loadPromise;
|
||||
this.log(LogLevel.INFO, 'Audio loaded successfully');
|
||||
|
||||
} catch (error) {
|
||||
this.log(LogLevel.ERROR, 'Failed to load audio:', error);
|
||||
this.cleanup();
|
||||
throw error;
|
||||
}
|
||||
|
||||
return ws;
|
||||
}
|
||||
|
||||
private setupEventHandlers() {
|
||||
if (!this.wavesurfer) return;
|
||||
|
||||
const ws = this.wavesurfer;
|
||||
const playerStore = usePlayerStore.getState();
|
||||
|
||||
ws.on("play", () => {
|
||||
this.log(LogLevel.DEBUG, 'AudioService: play event');
|
||||
playerStore.setPlaying(true);
|
||||
});
|
||||
|
||||
ws.on("pause", () => {
|
||||
this.log(LogLevel.DEBUG, 'AudioService: pause event');
|
||||
playerStore.setPlaying(false);
|
||||
});
|
||||
|
||||
ws.on("finish", () => {
|
||||
this.log(LogLevel.DEBUG, 'AudioService: finish event');
|
||||
playerStore.setPlaying(false);
|
||||
});
|
||||
|
||||
ws.on("audioprocess", (time) => {
|
||||
const now = Date.now();
|
||||
if (now - this.lastTimeUpdate >= this.TIME_UPDATE_THROTTLE) {
|
||||
playerStore.setCurrentTime(time);
|
||||
this.lastTimeUpdate = now;
|
||||
}
|
||||
});
|
||||
|
||||
// Note: Ready event is handled in the load promise, so we don't set it up here
|
||||
// to avoid duplicate event handlers
|
||||
}
|
||||
|
||||
public async play(): Promise<void> {
|
||||
if (!this.wavesurfer) {
|
||||
this.log(LogLevel.WARN, 'AudioService: no wavesurfer instance');
|
||||
return;
|
||||
}
|
||||
|
||||
// Debounce rapid play calls
|
||||
const now = Date.now();
|
||||
if (now - this.lastPlayTime < this.PLAY_DEBOUNCE_MS) {
|
||||
this.log(LogLevel.DEBUG, 'Playback debounced - too frequent calls');
|
||||
return;
|
||||
}
|
||||
this.lastPlayTime = now;
|
||||
|
||||
this.log(LogLevel.INFO, 'AudioService.play called');
|
||||
|
||||
try {
|
||||
// Ensure we have a valid audio context
|
||||
await this.ensureAudioContext();
|
||||
|
||||
await this.wavesurfer.play();
|
||||
this.log(LogLevel.INFO, 'Playback started successfully');
|
||||
this.playbackAttempts = 0; // Reset on success
|
||||
} catch (error) {
|
||||
this.playbackAttempts++;
|
||||
this.log(LogLevel.ERROR, `Playback failed (attempt ${this.playbackAttempts}):`, error);
|
||||
|
||||
if (this.playbackAttempts >= this.MAX_PLAYBACK_ATTEMPTS) {
|
||||
this.log(LogLevel.ERROR, 'Max playback attempts reached, resetting player');
|
||||
this.cleanup();
|
||||
// Could trigger re-initialization here if needed
|
||||
} else {
|
||||
// Exponential backoff for retry
|
||||
const delay = 100 * this.playbackAttempts;
|
||||
this.log(LogLevel.WARN, `Retrying playback in ${delay}ms...`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
return this.play(); // Retry
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public pause() {
|
||||
if (!this.wavesurfer) {
|
||||
this.log(LogLevel.WARN, 'AudioService: no wavesurfer instance');
|
||||
return;
|
||||
}
|
||||
|
||||
this.log(LogLevel.INFO, 'AudioService.pause called');
|
||||
this.wavesurfer.pause();
|
||||
}
|
||||
|
||||
public seekTo(time: number) {
|
||||
if (!this.wavesurfer) {
|
||||
this.log(LogLevel.WARN, 'AudioService: no wavesurfer instance');
|
||||
return;
|
||||
}
|
||||
|
||||
this.log(LogLevel.INFO, 'AudioService.seekTo called', { time });
|
||||
this.wavesurfer.setTime(time);
|
||||
}
|
||||
|
||||
public getCurrentTime(): number {
|
||||
if (!this.wavesurfer) return 0;
|
||||
return this.wavesurfer.getCurrentTime();
|
||||
}
|
||||
|
||||
public getDuration(): number {
|
||||
if (!this.wavesurfer) return 0;
|
||||
return this.wavesurfer.getDuration();
|
||||
}
|
||||
|
||||
public isPlaying(): boolean {
|
||||
if (!this.wavesurfer) return false;
|
||||
return this.wavesurfer.isPlaying();
|
||||
}
|
||||
|
||||
public cleanup() {
|
||||
this.log(LogLevel.INFO, 'AudioService.cleanup called');
|
||||
|
||||
if (this.wavesurfer) {
|
||||
try {
|
||||
// Disconnect audio nodes but keep audio context alive
|
||||
this.wavesurfer.unAll();
|
||||
this.wavesurfer.destroy();
|
||||
this.log(LogLevel.DEBUG, 'WaveSurfer instance cleaned up');
|
||||
} catch (error) {
|
||||
this.log(LogLevel.ERROR, 'Error cleaning up WaveSurfer:', error);
|
||||
}
|
||||
this.wavesurfer = null;
|
||||
}
|
||||
|
||||
this.currentUrl = null;
|
||||
// Note: We intentionally don't nullify audioContext to keep it alive
|
||||
}
|
||||
|
||||
private async ensureAudioContext(): Promise<AudioContext> {
|
||||
// If we already have a valid audio context, return it
|
||||
if (this.audioContext) {
|
||||
// Resume if suspended (common in mobile browsers)
|
||||
if (this.audioContext.state === 'suspended') {
|
||||
try {
|
||||
await this.audioContext.resume();
|
||||
console.log('Audio context resumed successfully');
|
||||
} catch (error) {
|
||||
console.error('Failed to resume audio context:', error);
|
||||
}
|
||||
}
|
||||
return this.audioContext;
|
||||
}
|
||||
|
||||
// Create new audio context
|
||||
try {
|
||||
this.audioContext = new (window.AudioContext || (window as { webkitAudioContext?: new () => AudioContext }).webkitAudioContext)();
|
||||
console.log('Audio context created:', this.audioContext.state);
|
||||
|
||||
// Handle context state changes
|
||||
this.audioContext.onstatechange = () => {
|
||||
console.log('Audio context state changed:', this.audioContext?.state);
|
||||
};
|
||||
|
||||
return this.audioContext;
|
||||
} catch (error) {
|
||||
console.error('Failed to create audio context:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private setupAudioContext(ws: WaveSurferWithBackend) {
|
||||
// Try multiple methods to get audio context from WaveSurfer v7+
|
||||
try {
|
||||
// Method 1: Try standard backend.getAudioContext()
|
||||
this.audioContext = ws.backend?.getAudioContext?.() ?? null;
|
||||
|
||||
// Method 2: Try accessing audio context directly from backend
|
||||
if (!this.audioContext) {
|
||||
this.audioContext = ws.backend?.ac ?? null;
|
||||
}
|
||||
|
||||
// Method 3: Try accessing through backend.getAudioContext() without optional chaining
|
||||
if (!this.audioContext) {
|
||||
this.audioContext = ws.backend?.getAudioContext?.() ?? null;
|
||||
}
|
||||
|
||||
// Method 4: Try accessing through wavesurfer.getAudioContext() if it exists
|
||||
if (!this.audioContext && typeof ws.getAudioContext === 'function') {
|
||||
this.audioContext = ws.getAudioContext() ?? null;
|
||||
}
|
||||
|
||||
// Method 5: Try accessing through backend.ac directly
|
||||
if (!this.audioContext) {
|
||||
this.audioContext = ws.backend?.ac ?? null;
|
||||
}
|
||||
|
||||
// Method 6: Try accessing through backend.audioContext
|
||||
if (!this.audioContext) {
|
||||
this.audioContext = ws.backend?.audioContext ?? null;
|
||||
}
|
||||
|
||||
if (this.audioContext) {
|
||||
console.log('Audio context accessed successfully:', this.audioContext.state);
|
||||
} else {
|
||||
console.warn('Could not access audio context from WaveSurfer - playback may have issues');
|
||||
// Log the wavesurfer structure for debugging
|
||||
console.debug('WaveSurfer structure:', {
|
||||
hasBackend: !!ws.backend,
|
||||
backendType: typeof ws.backend,
|
||||
backendKeys: ws.backend ? Object.keys(ws.backend) : 'no backend',
|
||||
wavesurferKeys: Object.keys(ws)
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error accessing audio context:', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
public getAudioContextState(): string | undefined {
|
||||
return this.audioContext?.state;
|
||||
}
|
||||
}
|
||||
|
||||
export const audioService = AudioService.getInstance();
|
||||
Reference in New Issue
Block a user