UNPKG

z-web-audio-stream

Version:

iOS Safari-safe Web Audio streaming with separated download/storage optimization, instant playback, and memory management

1,346 lines (1,110 loc) 60.4 kB
// WebAudioManager.ts // iOS Safari-safe Web Audio manager with progressive streaming and memory management // Fixes pitch/speed issues and prevents page reloads on iOS Safari import { AudioChunkStore, type ProgressCallback } from './AudioChunkStore.js'; import { DownloadManager, type DownloadStrategy, type DownloadProgress } from './DownloadManager.js'; import { StreamingAssembler, type AssemblyChunk } from './StreamingAssembler.js'; export interface WebAudioManagerOptions { workletPath?: string; enableCache?: boolean; maxCacheSize?: number; onTimeUpdate?: (currentTime: number, duration: number) => void; onEnded?: () => void; onError?: (error: Error) => void; onProgressiveLoadingStatus?: (status: 'STARTED' | 'PROGRESS' | 'COMPLETED' | 'FAILED', data?: any) => void; // Instant playback options enableInstantPlayback?: boolean; instantPlaybackConfig?: InstantPlaybackConfig; // Privacy options obfuscationKey?: string; } export interface InstantPlaybackConfig { // Download strategy options downloadStrategy?: Partial<DownloadStrategy>; // Storage chunk size for IndexedDB efficiency (1-3MB) storageChunkSize?: number; // Playback chunk size for instant start (256KB-512KB) playbackChunkSize?: number; // Maximum time to wait for initial chunk (ms) maxInitialWaitTime?: number; // Strategy for determining if instant playback should be used strategy?: 'auto' | 'always' | 'never'; // Enable detailed performance logging enablePerformanceLogging?: boolean; } // Legacy InstantPlaybackSession interface (deprecated in v1.2.0) // TODO: Remove in v2.0.0 when old implementation is fully removed interface InstantPlaybackSession { trackId: string; url: string; name: string; startTime: number; isActive: boolean; currentChunk: number; totalChunks: number; loadedChunks: Set<number>; chunkLoadingPromises: Map<number, Promise<void>>; predictiveLoadingActive: boolean; audioBuffer: AudioBuffer | null; metadata: { duration: number; sampleRate: number; numberOfChannels: number; totalSamples: number; } | null; } /** * iOS Safari-safe Web Audio manager with progressive streaming * * Key features: * - iOS-safe AudioContext initialization to fix pitch/speed issues * - Memory-safe progressive loading to prevent page reloads * - IndexedDB-based caching with Safari-specific retry logic * - AudioWorklet-based playback with sample rate monitoring * - Automatic cleanup and storage management */ export class WebAudioManager { private audioContext: AudioContext | null = null; private audioWorkletNode: AudioWorkletNode | null = null; private gainNode: GainNode | null = null; private audioBuffers: Map<string, AudioBuffer> = new Map(); private currentTrackId: string | null = null; private isInitialized = false; private preloadQueue: Set<string> = new Set(); private chunkStore: AudioChunkStore | null = null; // Event callbacks private onTimeUpdate?: (currentTime: number, duration: number) => void; private onEnded?: () => void; private onError?: (error: Error) => void; private onProgressiveLoadingStatus?: (status: 'STARTED' | 'PROGRESS' | 'COMPLETED' | 'FAILED', data?: any) => void; // Position tracking private lastKnownPosition: number = 0; private positionRequestResolvers: Map<string, (position: { currentTime: number, samplePosition: number }) => void> = new Map(); // iOS Safari specific properties private iosSafariDetected: boolean = false; private lastKnownSampleRate: number = 0; private sampleRateMonitorInterval: number | null = null; private silentBuffer: AudioBuffer | null = null; // Chunked transfer settings (iOS optimized) private readonly MAX_TRANSFER_SIZE = this.isIOSSafari() ? 2 * 1024 * 1024 : 8 * 1024 * 1024; // 2MB for iOS, 8MB for others // Download and streaming components private downloadManager: DownloadManager | null = null; private streamingAssembler: StreamingAssembler | null = null; // Configuration private workletPath: string; private enableCache: boolean; private enableInstantPlayback: boolean; private instantPlaybackConfig: InstantPlaybackConfig; private obfuscationKey?: string; // Legacy instant playback state (deprecated, TODO: remove in v2.0.0) private instantPlaybackSessions: Map<string, InstantPlaybackSession> = new Map(); private defaultInstantConfig: InstantPlaybackConfig = { downloadStrategy: { initialChunkSize: 256 * 1024, // 256KB for network downloads standardChunkSize: 512 * 1024, // 512KB for subsequent downloads maxConcurrentDownloads: 4, priorityFirstChunk: true, adaptiveChunkSizing: true }, storageChunkSize: 2 * 1024 * 1024, // 2MB for IndexedDB storage playbackChunkSize: 384 * 1024, // 384KB for instant playback maxInitialWaitTime: 500, // 500ms max wait for initial chunk strategy: 'auto', enablePerformanceLogging: false }; constructor(options: WebAudioManagerOptions = {}) { this.workletPath = options.workletPath || '/audio-worklet-processor.js'; this.enableCache = options.enableCache !== false; this.onTimeUpdate = options.onTimeUpdate; this.onEnded = options.onEnded; this.onError = options.onError; this.onProgressiveLoadingStatus = options.onProgressiveLoadingStatus; this.obfuscationKey = options.obfuscationKey; // Initialize instant playback settings this.enableInstantPlayback = options.enableInstantPlayback !== false; this.instantPlaybackConfig = { ...this.defaultInstantConfig, ...options.instantPlaybackConfig, // Merge download strategy downloadStrategy: { ...this.defaultInstantConfig.downloadStrategy, ...options.instantPlaybackConfig?.downloadStrategy } }; // Detect iOS Safari this.iosSafariDetected = this.isIOSSafari(); if (this.iosSafariDetected) { console.log('[WebAudioManager] iOS Safari detected - applying iOS-specific optimizations'); } // Initialize on first user interaction this.initializeOnUserGesture(); } // iOS Safari detection private isIOSSafari(): boolean { if (typeof navigator === 'undefined') return false; const userAgent = navigator.userAgent; const isIOS = /iPad|iPhone|iPod/.test(userAgent); const isSafari = /Safari/.test(userAgent) && !/Chrome|CriOS|FxiOS|EdgiOS/.test(userAgent); return isIOS && isSafari; } private initializeOnUserGesture() { // SSR Guard if (typeof document === 'undefined') { return; } const initializeAudio = async () => { if (!this.isInitialized) { await this.initialize(); document.removeEventListener('click', initializeAudio); document.removeEventListener('touchstart', initializeAudio); } }; document.addEventListener('click', initializeAudio, { once: true }); document.addEventListener('touchstart', initializeAudio, { once: true }); } // iOS-safe AudioContext creation - implements the ios-safe-audio-context pattern private async createIOSSafeAudioContext(): Promise<AudioContext> { const AudioContextClass = window.AudioContext || (window as any).webkitAudioContext; if (!this.iosSafariDetected) { // Non-iOS: standard AudioContext creation return new AudioContextClass(); } console.log('[WebAudioManager] Creating iOS-safe AudioContext...'); // iOS-safe pattern: Create temporary context to detect broken state let tempContext: AudioContext | null = null; let finalContext: AudioContext; try { // Step 1: Create temporary AudioContext to check for broken state tempContext = new AudioContextClass(); const detectedSampleRate = tempContext.sampleRate; console.log(`[WebAudioManager] iOS temporary context sample rate: ${detectedSampleRate}Hz`); // Step 2: Check if the sample rate suggests a broken state // iOS broken state typically shows unexpected sample rates const isBrokenState = detectedSampleRate !== 44100 && detectedSampleRate !== 48000; if (isBrokenState) { console.log('[WebAudioManager] iOS broken state detected, playing dummy buffer...'); // Step 3: Play dummy buffer to reset state await this.playDummyBuffer(tempContext); // Step 4: Close broken context await tempContext.close(); tempContext = null; // Step 5: Create new context which should have correct sample rate finalContext = new AudioContextClass(); console.log(`[WebAudioManager] iOS fixed context sample rate: ${finalContext.sampleRate}Hz`); } else { // Sample rate looks good, use the temporary context finalContext = tempContext; tempContext = null; console.log('[WebAudioManager] iOS context sample rate is acceptable'); } return finalContext; } catch (error) { console.error('[WebAudioManager] iOS-safe AudioContext creation failed:', error); // Cleanup temporary context if it exists if (tempContext) { try { await tempContext.close(); } catch (cleanupError) { console.warn('[WebAudioManager] Failed to cleanup temporary context:', cleanupError); } } // Fallback to standard AudioContext creation return new AudioContextClass(); } } // Play dummy buffer to reset iOS AudioContext state private async playDummyBuffer(context: AudioContext): Promise<void> { return new Promise((resolve, reject) => { try { // Create very short silent buffer const sampleRate = context.sampleRate; const buffer = context.createBuffer(1, Math.ceil(sampleRate * 0.01), sampleRate); // 10ms silence // Create and configure source const source = context.createBufferSource(); source.buffer = buffer; source.connect(context.destination); // Play dummy buffer source.onended = () => { console.log('[WebAudioManager] iOS dummy buffer played successfully'); resolve(); }; source.start(); // Timeout fallback setTimeout(() => { console.warn('[WebAudioManager] iOS dummy buffer timeout, continuing...'); resolve(); }, 100); } catch (error) { console.warn('[WebAudioManager] Failed to play iOS dummy buffer:', error); resolve(); // Continue anyway } }); } // Create silent buffer for iOS AudioContext reset trick private async createSilentBuffer(): Promise<AudioBuffer> { if (!this.audioContext) throw new Error('AudioContext not initialized'); const sampleRate = this.audioContext.sampleRate; const length = Math.floor(sampleRate * 0.1); // 100ms of silence const buffer = this.audioContext.createBuffer(1, length, sampleRate); return buffer; } // iOS Safari sample rate monitoring and correction private startSampleRateMonitoring(): void { if (!this.iosSafariDetected || !this.audioContext) return; this.lastKnownSampleRate = this.audioContext.sampleRate; // Monitor sample rate changes every 1 second this.sampleRateMonitorInterval = window.setInterval(() => { if (!this.audioContext) return; const currentSampleRate = this.audioContext.sampleRate; if (currentSampleRate !== this.lastKnownSampleRate) { console.warn(`[WebAudioManager] iOS sample rate changed: ${this.lastKnownSampleRate}Hz → ${currentSampleRate}Hz`); this.handleSampleRateChange(currentSampleRate); this.lastKnownSampleRate = currentSampleRate; } }, 1000); } // Handle sample rate changes with silent audio trick private async handleSampleRateChange(newSampleRate: number): Promise<void> { if (!this.audioContext || !this.iosSafariDetected) return; try { console.log('[WebAudioManager] Applying iOS sample rate correction...'); // Create and play silent buffer to reset AudioContext if (!this.silentBuffer) { this.silentBuffer = await this.createSilentBuffer(); } // Play silent audio to stabilize sample rate const source = this.audioContext.createBufferSource(); source.buffer = this.silentBuffer; source.connect(this.audioContext.destination); source.start(); // Update any cached sample rates in worklet if (this.audioWorkletNode) { this.audioWorkletNode.port.postMessage({ type: 'SAMPLE_RATE_UPDATE', sampleRate: newSampleRate }); } console.log(`[WebAudioManager] iOS sample rate correction applied: ${newSampleRate}Hz`); } catch (error) { console.error('[WebAudioManager] Failed to handle sample rate change:', error); } } async initialize(): Promise<void> { // SSR Guard if (typeof window === 'undefined') { throw new Error('Web Audio API not available on server-side'); } try { // Create AudioContext with iOS-safe initialization this.audioContext = await this.createIOSSafeAudioContext(); // Resume context if it's suspended if (this.audioContext.state === 'suspended') { await this.audioContext.resume(); } // Load and register the AudioWorklet await this.audioContext.audioWorklet.addModule(this.workletPath); // Create AudioWorklet node this.audioWorkletNode = new AudioWorkletNode(this.audioContext, 'audio-playback-processor'); // Create gain node for volume control this.gainNode = this.audioContext.createGain(); // Connect: AudioWorklet -> Gain -> Destination this.audioWorkletNode.connect(this.gainNode); this.gainNode.connect(this.audioContext.destination); // Set up message handling this.setupWorkletMessageHandling(); // Initialize chunk store if caching is enabled if (this.enableCache) { this.chunkStore = new AudioChunkStore(this.audioContext, undefined, this.obfuscationKey); await this.chunkStore.initialize(); } // iOS Safari specific initialization if (this.iosSafariDetected) { console.log('[WebAudioManager] Applying iOS Safari optimizations...'); // Create silent buffer for sample rate corrections try { this.silentBuffer = await this.createSilentBuffer(); console.log('[WebAudioManager] iOS silent buffer created successfully'); } catch (error) { console.warn('[WebAudioManager] Failed to create iOS silent buffer:', error); } // Start sample rate monitoring this.startSampleRateMonitoring(); // Send initial iOS configuration to worklet this.audioWorkletNode.port.postMessage({ type: 'IOS_CONFIG', isIOSSafari: true, sampleRate: this.audioContext.sampleRate, maxChunkSize: this.MAX_TRANSFER_SIZE }); } this.isInitialized = true; console.log(`[WebAudioManager] Initialized successfully - AudioContext sample rate: ${this.audioContext.sampleRate}Hz${this.iosSafariDetected ? ' (iOS optimized)' : ''}`); } catch (error) { console.error('Failed to initialize Web Audio API:', error); this.onError?.(error as Error); throw error; } } private setupWorkletMessageHandling() { if (!this.audioWorkletNode) return; this.audioWorkletNode.port.onmessage = (event) => { const { type, currentTime, duration } = event.data; switch (type) { case 'TIME_UPDATE': this.lastKnownPosition = currentTime; this.onTimeUpdate?.(currentTime, duration); break; case 'ENDED': this.onEnded?.(); break; case 'POSITION_RESPONSE': this.lastKnownPosition = currentTime; break; case 'CURRENT_POSITION_RESPONSE': this.lastKnownPosition = currentTime; // Handle real-time position request const { requestId, samplePosition } = event.data; if (requestId && this.positionRequestResolvers.has(requestId)) { const resolver = this.positionRequestResolvers.get(requestId)!; resolver({ currentTime, samplePosition }); this.positionRequestResolvers.delete(requestId); } break; case 'BUFFER_SWITCHED': // Progressive buffer switch completed const { newBufferIndex, newDuration } = event.data; console.log(`[WebAudioManager] Progressive buffer switch completed - Buffer ${newBufferIndex}, Duration: ${newDuration}s`); break; } }; } // Load and decode audio from URL async loadAudio(url: string, trackId: string, progressCallback?: (loaded: number, total: number) => void): Promise<AudioBuffer> { if (!this.audioContext) { await this.initialize(); } try { // Check if already loaded if (this.audioBuffers.has(trackId)) { return this.audioBuffers.get(trackId)!; } console.log(`Loading audio: ${trackId}`); // Fetch audio data with progress tracking const response = await fetch(url); if (!response.ok) { throw new Error(`Failed to fetch audio: ${response.status}`); } const contentLength = response.headers.get('content-length'); const total = contentLength ? parseInt(contentLength, 10) : 0; if (!response.body) { throw new Error('ReadableStream not supported'); } const reader = response.body.getReader(); const chunks: Uint8Array[] = []; let loaded = 0; // Read chunks progressively while (true) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); loaded += value.length; // Call progress callback if (progressCallback && total > 0) { progressCallback(loaded, total); } } // Combine all chunks into single ArrayBuffer const arrayBuffer = new ArrayBuffer(loaded); const uint8Array = new Uint8Array(arrayBuffer); let offset = 0; for (const chunk of chunks) { uint8Array.set(chunk, offset); offset += chunk.length; } // Decode audio data const audioBuffer = await this.audioContext!.decodeAudioData(arrayBuffer); // Enhanced iOS debugging for sample rate issues const contextSampleRate = this.audioContext!.sampleRate; const bufferSampleRate = audioBuffer.sampleRate; if (this.iosSafariDetected) { console.log(`[WebAudioManager] 🍎 iOS Safari audio decode complete:`); console.log(` - AudioContext: ${contextSampleRate}Hz`); console.log(` - AudioBuffer: ${bufferSampleRate}Hz`); console.log(` - Channels: ${audioBuffer.numberOfChannels}`); console.log(` - Duration: ${audioBuffer.duration.toFixed(3)}s`); if (contextSampleRate !== bufferSampleRate) { console.warn(`[WebAudioManager] 🍎 iOS SAMPLE RATE MISMATCH DETECTED! This may cause high-pitched audio.`); // Apply iOS sample rate correction immediately try { await this.handleSampleRateChange(contextSampleRate); } catch (error) { console.error('[WebAudioManager] Failed to apply immediate iOS sample rate correction:', error); } } } // Cache the buffer this.audioBuffers.set(trackId, audioBuffer); console.log(`Audio loaded and cached: ${trackId}`); return audioBuffer; } catch (error) { console.error(`Failed to load audio ${trackId}:`, error); this.onError?.(error as Error); throw error; } } // Play audio from loaded buffer async play(trackId: string): Promise<void> { if (!this.isInitialized) { await this.initialize(); } const audioBuffer = this.audioBuffers.get(trackId); if (!audioBuffer) { throw new Error(`Audio buffer not found for track: ${trackId}`); } this.currentTrackId = trackId; // Extract channel data for worklet const channelData: Float32Array[] = []; for (let i = 0; i < audioBuffer.numberOfChannels; i++) { channelData.push(audioBuffer.getChannelData(i)); } // Send buffer to worklet this.audioWorkletNode!.port.postMessage({ type: 'SET_BUFFER', trackId, channelData, sampleRate: audioBuffer.sampleRate, numberOfChannels: audioBuffer.numberOfChannels, totalSamples: audioBuffer.length }); // Start playback this.audioWorkletNode!.port.postMessage({ type: 'PLAY' }); } // Load and play audio with progressive loading async loadAndPlay(url: string, trackId: string, name?: string): Promise<void> { // First check if we have it cached if (this.chunkStore && this.enableCache) { const isStored = await this.chunkStore.isStored(trackId); if (isStored) { console.log(`[WebAudioManager] Loading from cache: ${trackId}`); const audioBuffer = await this.chunkStore.getAudioBuffer(trackId); if (audioBuffer) { this.audioBuffers.set(trackId, audioBuffer); await this.play(trackId); return; } } } // Load from network const audioBuffer = await this.loadAudio(url, trackId); // Store in cache if enabled if (this.chunkStore && this.enableCache && name) { try { await this.chunkStore.storeAudio(url, trackId, name); } catch (error) { console.warn(`[WebAudioManager] Failed to cache audio: ${error}`); } } await this.play(trackId); } // Instant playback - starts playing first chunk immediately while loading rest async playInstantly(url: string, trackId: string, name: string, options?: { forceInstant?: boolean; onChunkLoaded?: (chunkIndex: number, totalChunks: number) => void; onFullyLoaded?: () => void; onDownloadProgress?: (progress: DownloadProgress) => void; }): Promise<void> { if (!this.isInitialized) { await this.initialize(); } // Check if instant playback should be used if (!this.shouldUseInstantPlayback(url, options?.forceInstant)) { console.log(`[WebAudioManager] Using standard playback for ${trackId}`); return this.loadAndPlay(url, trackId, name); } console.log(`[WebAudioManager] 🚀 Starting separated download/storage instant playback for ${name} (${trackId})`); const startTime = Date.now(); try { this.onProgressiveLoadingStatus?.('STARTED', { trackId, strategy: 'separated-instant' }); // Create download manager with optimized settings this.downloadManager = new DownloadManager({ strategy: this.instantPlaybackConfig.downloadStrategy, onProgress: (progress) => { options?.onDownloadProgress?.(progress); if (this.instantPlaybackConfig.enablePerformanceLogging) { console.log(`[WebAudioManager] Download progress: ${(progress.bytesLoaded / 1024 / 1024).toFixed(2)}MB/${(progress.bytesTotal / 1024 / 1024).toFixed(2)}MB (${(progress.downloadSpeed / 1024 / 1024).toFixed(2)}MB/s)`); } }, onError: (error) => { console.error(`[WebAudioManager] Download error: ${error}`); this.onError?.(error); } }); // Create streaming assembler this.streamingAssembler = new StreamingAssembler({ storageChunkSize: this.instantPlaybackConfig.storageChunkSize || 2 * 1024 * 1024, playbackChunkSize: this.instantPlaybackConfig.playbackChunkSize || 384 * 1024, onPlaybackReady: async (firstChunk) => { const initialLoadTime = Date.now() - startTime; console.log(`[WebAudioManager] 🎵 First chunk ready for playback in ${initialLoadTime}ms (${(firstChunk.totalSize / 1024).toFixed(0)}KB)`); // Decode and start playback with first chunk await this.startPlaybackWithChunk(trackId, firstChunk); }, onChunkAssembled: (assemblyChunk) => { if (this.instantPlaybackConfig.enablePerformanceLogging) { console.log(`[WebAudioManager] Assembled chunk ${assemblyChunk.storageIndex}: ${(assemblyChunk.totalSize / 1024).toFixed(0)}KB from ${assemblyChunk.downloadChunks.length} download chunks`); } // Update progress callback options?.onChunkLoaded?.(assemblyChunk.storageIndex, this.streamingAssembler?.getStats().assembledChunks || 1); // If this is not the first chunk, seamlessly replace the buffer if (assemblyChunk.storageIndex > 0) { this.seamlesslyReplaceBuffer(trackId, assemblyChunk); } }, onProgress: (assembled, total) => { this.onProgressiveLoadingStatus?.('PROGRESS', { trackId, assembled, total, strategy: 'separated-instant' }); } }); // Start the download process const downloadResult = await this.downloadManager.downloadAudio(url, { priorityFirstChunk: true }); // Initialize assembler this.streamingAssembler.initialize(downloadResult.totalSize); // Process download chunks as they arrive for (const downloadChunk of downloadResult.chunks) { this.streamingAssembler.addDownloadChunk(downloadChunk); } // Finalize assembly this.streamingAssembler.finalize(); // Complete the loading process options?.onFullyLoaded?.(); this.onProgressiveLoadingStatus?.('COMPLETED', { trackId, totalLoadTime: Date.now() - startTime, downloadTime: downloadResult.downloadTime, averageSpeed: downloadResult.averageSpeed, strategy: 'separated-instant' }); console.log(`[WebAudioManager] ✅ Separated instant playback complete: ${downloadResult.downloadTime.toFixed(2)}ms download, ${(downloadResult.averageSpeed / 1024 / 1024).toFixed(2)}MB/s`); } catch (error) { console.error(`[WebAudioManager] Separated instant playback failed: ${error}`); this.onProgressiveLoadingStatus?.('FAILED', { trackId, error, strategy: 'separated-instant' }); // Fallback to standard loading console.log(`[WebAudioManager] Falling back to standard loading for ${trackId}`); return this.loadAndPlay(url, trackId, name); } } /** * Start playback with the first assembled chunk */ private async startPlaybackWithChunk(trackId: string, assemblyChunk: AssemblyChunk): Promise<void> { try { // Decode the assembled chunk data const audioBuffer = await this.audioContext!.decodeAudioData(assemblyChunk.data.slice(0)); // Set as current track this.currentTrackId = trackId; this.audioBuffers.set(trackId, audioBuffer); // Extract channel data for worklet const channelData: Float32Array[] = []; for (let i = 0; i < audioBuffer.numberOfChannels; i++) { channelData.push(audioBuffer.getChannelData(i)); } // Send initial buffer to worklet this.audioWorkletNode!.port.postMessage({ type: 'SET_BUFFER', trackId, channelData, sampleRate: audioBuffer.sampleRate, numberOfChannels: audioBuffer.numberOfChannels, totalSamples: audioBuffer.length }); // Start playback this.audioWorkletNode!.port.postMessage({ type: 'PLAY' }); console.log(`[WebAudioManager] 🎵 Started playback with first chunk: ${(assemblyChunk.totalSize / 1024).toFixed(0)}KB, ${audioBuffer.duration.toFixed(2)}s`); } catch (error) { console.error(`[WebAudioManager] Failed to start playback with chunk: ${error}`); throw error; } } /** * Seamlessly replace the current buffer with a larger one containing more audio data */ private async seamlesslyReplaceBuffer(trackId: string, assemblyChunk: AssemblyChunk): Promise<void> { if (this.currentTrackId !== trackId) { console.log(`[WebAudioManager] Skipping buffer replacement for inactive track: ${trackId}`); return; } try { // Get current playback position const currentPosition = this.getCurrentTime(); // Decode the complete assembled chunk const newAudioBuffer = await this.audioContext!.decodeAudioData(assemblyChunk.data.slice(0)); // Update stored buffer this.audioBuffers.set(trackId, newAudioBuffer); // Extract channel data for worklet const channelData: Float32Array[] = []; for (let i = 0; i < newAudioBuffer.numberOfChannels; i++) { channelData.push(newAudioBuffer.getChannelData(i)); } // Send seamless buffer replacement message to worklet this.audioWorkletNode!.port.postMessage({ type: 'REPLACE_BUFFER', trackId, channelData, sampleRate: newAudioBuffer.sampleRate, numberOfChannels: newAudioBuffer.numberOfChannels, totalSamples: newAudioBuffer.length, currentPosition, // Maintain exact playback position startTime: Date.now() }); if (this.instantPlaybackConfig.enablePerformanceLogging) { console.log(`[WebAudioManager] 🔄 Seamlessly replaced buffer: ${(assemblyChunk.totalSize / 1024 / 1024).toFixed(2)}MB, ${newAudioBuffer.duration.toFixed(2)}s (position: ${currentPosition.toFixed(3)}s)`); } } catch (error) { console.error(`[WebAudioManager] Failed to replace buffer seamlessly: ${error}`); // Continue playback with current buffer - don't fail the whole process } } // Preload audio for smooth transitions async preloadAudio(url: string, trackId: string, name: string = 'Unknown'): Promise<void> { // Check if already in memory buffer or currently preloading if (this.audioBuffers.has(trackId) || this.preloadQueue.has(trackId)) { console.log(`[WebAudioManager] Skipping preload for ${trackId}: already loaded or in progress`); return; } // Check if already in chunk store if (this.chunkStore) { const isInChunkStore = await this.chunkStore.isStored(trackId); if (isInChunkStore) { console.log(`[WebAudioManager] Skipping preload for ${trackId}: already in chunk store`); return; } } this.preloadQueue.add(trackId); try { console.log(`[WebAudioManager] Preloading: ${name} (${trackId})`); if (this.chunkStore && this.enableCache) { // Store in chunk store for efficient access await this.chunkStore.storeAudio(url, trackId, name); console.log(`[WebAudioManager] ✅ Preloaded to chunk store: ${name}`); } else { // Fallback to direct memory loading await this.loadAudio(url, trackId); console.log(`[WebAudioManager] ✅ Preloaded to memory: ${name}`); } } catch (error) { console.warn(`[WebAudioManager] Failed to preload ${name} (${trackId}):`, error); } finally { this.preloadQueue.delete(trackId); } } // Control methods async pause(): Promise<void> { if (this.audioWorkletNode) { this.audioWorkletNode.port.postMessage({ type: 'PAUSE' }); } } async resume(): Promise<void> { if (this.audioWorkletNode) { this.audioWorkletNode.port.postMessage({ type: 'PLAY' }); } } async seek(time: number): Promise<void> { if (this.audioWorkletNode) { this.audioWorkletNode.port.postMessage({ type: 'SEEK', time }); } } setVolume(volume: number): void { if (this.gainNode) { this.gainNode.gain.value = Math.max(0, Math.min(1, volume)); } } getCurrentTime(): number { return this.lastKnownPosition; } /** * Get the duration of a loaded audio buffer * @param trackId The track ID to get duration for * @returns Duration in seconds, or null if not loaded */ getBufferDuration(trackId: string): number | null { const audioBuffer = this.audioBuffers.get(trackId); return audioBuffer ? audioBuffer.duration : null; } /** * Check if audio for a specific track is fully loaded * @param trackId The track ID to check * @returns True if audio is loaded and ready for playback */ async isAudioLoaded(trackId: string): Promise<boolean> { // Check if buffer is in memory if (this.audioBuffers.has(trackId)) { return true; } // Check if stored in cache if (this.chunkStore) { return await this.chunkStore.isStored(trackId); } return false; } /** * Get a list of all cached tracks with metadata * @returns Array of cached track information */ async getCachedTracks(): Promise<Array<{ trackId: string; name?: string; duration?: number; size: number; lastAccessed: Date; isLoaded: boolean; }>> { if (!this.chunkStore) { return []; } try { // Access the private getAllMetadata method through any cast const allMetadata = await (this.chunkStore as any).getAllMetadata(); const tracks = []; for (const metadata of allMetadata) { const isLoaded = this.audioBuffers.has(metadata.trackId); const duration = isLoaded ? this.getBufferDuration(metadata.trackId) : metadata.duration; tracks.push({ trackId: metadata.trackId, name: metadata.name, duration: duration || undefined, size: metadata.fileSize, lastAccessed: new Date(metadata.lastAccessed), isLoaded }); } // Sort by last accessed (most recent first) return tracks.sort((a, b) => b.lastAccessed.getTime() - a.lastAccessed.getTime()); } catch (error) { console.warn('[WebAudioManager] Failed to get cached tracks:', error); return []; } } // Determine if instant playback should be used private shouldUseInstantPlayback(url: string, forceInstant?: boolean): boolean { if (!this.enableInstantPlayback) return false; if (forceInstant) return true; const strategy = this.instantPlaybackConfig.strategy; if (strategy === 'never') return false; if (strategy === 'always') return true; // Auto strategy - use instant playback for most scenarios // Could be enhanced with network speed detection, file size estimation, etc. return true; } // Load first chunk for instant playback with Range requests private async loadFirstChunk(session: InstantPlaybackSession, onChunkLoaded?: (chunkIndex: number, totalChunks: number) => void): Promise<AudioBuffer> { const startTime = Date.now(); const targetInitialSize = this.instantPlaybackConfig.playbackChunkSize || 384 * 1024; try { // First, try Range request for just the initial chunk const rangeSupported = await this.checkRangeSupport(session.url); if (rangeSupported) { return await this.loadFirstChunkWithRangeRequest(session, startTime, targetInitialSize, onChunkLoaded); } else { return await this.loadFirstChunkWithProgressiveDownload(session, startTime, targetInitialSize, onChunkLoaded); } } catch (error) { console.warn(`[WebAudioManager] Range request failed, falling back to progressive download: ${error}`); return await this.loadFirstChunkWithProgressiveDownload(session, startTime, targetInitialSize, onChunkLoaded); } } // Check if server supports Range requests private async checkRangeSupport(url: string): Promise<boolean> { try { const response = await fetch(url, { method: 'HEAD' }); const acceptRanges = response.headers.get('accept-ranges'); const contentLength = response.headers.get('content-length'); return acceptRanges === 'bytes' && contentLength !== null; } catch (error) { console.warn(`[WebAudioManager] Range support check failed: ${error}`); return false; } } // Load first chunk using Range request private async loadFirstChunkWithRangeRequest( session: InstantPlaybackSession, startTime: number, targetInitialSize: number, onChunkLoaded?: (chunkIndex: number, totalChunks: number) => void ): Promise<AudioBuffer> { console.log(`[WebAudioManager] Using Range request for initial chunk (${(targetInitialSize / 1024).toFixed(1)}KB)`); // Request only the initial chunk const response = await fetch(session.url, { headers: { 'Range': `bytes=0-${targetInitialSize - 1}` } }); if (!response.ok && response.status !== 206) { throw new Error(`Failed to fetch initial chunk: ${response.status}`); } const chunkBuffer = await response.arrayBuffer(); // Try to decode the partial chunk let audioBuffer: AudioBuffer; try { audioBuffer = await this.audioContext!.decodeAudioData(chunkBuffer); } catch (error) { // If partial decode fails, get more data console.warn(`[WebAudioManager] Partial decode failed, requesting larger chunk: ${error}`); const largerSize = targetInitialSize * 2; const largerResponse = await fetch(session.url, { headers: { 'Range': `bytes=0-${largerSize - 1}` } }); if (!largerResponse.ok && largerResponse.status !== 206) { throw new Error(`Failed to fetch larger chunk: ${largerResponse.status}`); } const largerBuffer = await largerResponse.arrayBuffer(); audioBuffer = await this.audioContext!.decodeAudioData(largerBuffer); } const loadTime = Date.now() - startTime; console.log(`[WebAudioManager] Initial chunk loaded via Range request in ${loadTime}ms (${(chunkBuffer.byteLength / 1024).toFixed(1)}KB)`); // Get file size for calculating total chunks const fileSize = await this.getFileSize(session.url); const estimatedTotalChunks = Math.ceil(fileSize / targetInitialSize); // Store metadata for session session.metadata = { duration: audioBuffer.duration, sampleRate: audioBuffer.sampleRate, numberOfChannels: audioBuffer.numberOfChannels, totalSamples: audioBuffer.length }; session.loadedChunks.add(0); session.audioBuffer = audioBuffer; session.totalChunks = estimatedTotalChunks; // Start loading remaining chunks in background this.loadRemainingChunksWithRangeRequests(session, fileSize, targetInitialSize, onChunkLoaded); onChunkLoaded?.(0, estimatedTotalChunks); return audioBuffer; } // Load first chunk using progressive download (fallback) private async loadFirstChunkWithProgressiveDownload( session: InstantPlaybackSession, startTime: number, targetInitialSize: number, onChunkLoaded?: (chunkIndex: number, totalChunks: number) => void ): Promise<AudioBuffer> { console.log(`[WebAudioManager] Using progressive download for initial chunk`); const response = await fetch(session.url); if (!response.ok) { throw new Error(`Failed to fetch audio file: ${response.status}`); } if (!response.body) { throw new Error('ReadableStream not supported'); } const reader = response.body.getReader(); const chunks: Uint8Array[] = []; let loaded = 0; // Read until we have enough for initial playback let hasEnoughForPlayback = false; while (!hasEnoughForPlayback) { const { done, value } = await reader.read(); if (done) { hasEnoughForPlayback = true; break; } chunks.push(value); loaded += value.length; // Check if we have enough data to start playback if (loaded >= targetInitialSize) { hasEnoughForPlayback = true; } } // Create partial ArrayBuffer for initial decode const partialBuffer = new ArrayBuffer(loaded); const uint8Array = new Uint8Array(partialBuffer); let offset = 0; for (const chunk of chunks) { uint8Array.set(chunk, offset); offset += chunk.length; } // Decode the partial audio data let audioBuffer: AudioBuffer; try { audioBuffer = await this.audioContext!.decodeAudioData(partialBuffer); } catch (error) { // If partial decode fails, try to load more data console.warn(`[WebAudioManager] Partial decode failed, loading more data: ${error}`); // Load the rest of the file const remainingChunks: Uint8Array[] = []; while (true) { const { done, value } = await reader.read(); if (done) break; remainingChunks.push(value); loaded += value.length; } // Create complete buffer const completeBuffer = new ArrayBuffer(loaded); const completeUint8Array = new Uint8Array(completeBuffer); let completeOffset = 0; // Copy initial chunks for (const chunk of chunks) { completeUint8Array.set(chunk, completeOffset); completeOffset += chunk.length; } // Copy remaining chunks for (const chunk of remainingChunks) { completeUint8Array.set(chunk, completeOffset); completeOffset += chunk.length; } // Decode complete file audioBuffer = await this.audioContext!.decodeAudioData(completeBuffer); } const loadTime = Date.now() - startTime; console.log(`[WebAudioManager] Initial chunk loaded progressively in ${loadTime}ms (${(loaded / 1024).toFixed(1)}KB)`); // Store metadata for session session.metadata = { duration: audioBuffer.duration, sampleRate: audioBuffer.sampleRate, numberOfChannels: audioBuffer.numberOfChannels, totalSamples: audioBuffer.length }; session.loadedChunks.add(0); session.audioBuffer = audioBuffer; session.totalChunks = 1; // For progressive download, we load everything at once // Start loading remaining data in background if we didn't get everything if (loaded < targetInitialSize * 3) { // If we got less than 3x initial size, continue loading this.continueLoadingInBackground(session, reader, chunks, loaded, onChunkLoaded); } onChunkLoaded?.(0, 1); return audioBuffer; } // Get file size private async getFileSize(url: string): Promise<number> { try { const response = await fetch(url, { method: 'HEAD' }); const contentLength = response.headers.get('content-length'); return contentLength ? parseInt(contentLength, 10) : 0; } catch (error) { console.warn(`[WebAudioManager] Failed to get file size: ${error}`); return 0; } } // Load remaining chunks using Range requests private async loadRemainingChunksWithRangeRequests( session: InstantPlaybackSession, fileSize: number, initialChunkSize: number, onChunkLoaded?: (chunkIndex: number, totalChunks: number) => void ): Promise<void> { if (!session.isActive) return; const subsequentChunkSize = this.instantPlaybackConfig.storageChunkSize || 2 * 1024 * 1024; let currentOffset = initialChunkSize; let chunkIndex = 1; const loadedChunks: AudioBuffer[] = [session.audioBuffer!]; try { while (currentOffset < fileSize && session.isActive) { const endOffset = Math.min(currentOffset + subsequentChunkSize - 1, fileSize - 1); console.log(`[WebAudioManager] Loading chunk ${chunkIndex} (${(currentOffset / 1024).toFixed(1)}KB - ${(endOffset / 1024).toFixed(1)}KB)`); const response = await fetch(session.url, { headers: { 'Range': `bytes=${currentOffset}-${endOffset}` } }); if (!response.ok && response.status !== 206) { throw new Error(`Failed to fetch chunk ${chunkIndex}: ${response.status}`); } const chunkBuffer = await response.arrayBuffer(); // For now, we'll decode the full file up to this point // In a more advanced implementation, we'd merge chunks const combinedSize = currentOffset + chunkBuffer.byteLength; const combinedBuffer = new ArrayBuffer(combinedSize); const combinedArray = new Uint8Array(combinedBuffer); // Copy initial chunk(s) const initialResponse = await fetch(session.url, { headers: { 'Range': `bytes=0-${currentOffset + chunkBuffer.byteLength - 1}` } }); if (initialResponse.ok || initialResponse.status === 206) { const initialBuffer = await initialResponse.arrayBuffer(); combinedArray.set(new Uint8Array(initialBuffer)); // Decode combined buffer const combinedAudioBuffer = await this.audioContext!.decodeAudioData(combinedBuffer); // Replace buffer seamlessly if (session.isActive && this.currentTrackId === session.trackId) { await this.replaceBufferSeamlessly(session.trackId, combinedAudioBuffer); // Update session session.audioBuffer = combinedAudioBuffer; session.metadata = { duration: combinedAudioBuffer.duration, sampleRate: combinedAudioBuffer.sampleRate, numberOfChannels: combinedAudioBuffer.numberOfChannels, totalSamples: combinedAudioBuffer.length }; session.loadedChunks.add(chunkIndex); onChunkLoaded?.(chunkIndex, session.totalChunks); } } currentOffset = endOffset + 1; chunkIndex++; // Add small delay to prevent overwhelming the browser await new Promise(resolve => setTimeout(resolve, 10)); } console.log(`[WebAudioManager] Completed loading ${chunkIndex - 1} chunks for ${session.trackId}`); } catch (error) { console.warn(`[WebAudioManager] Range request loading failed: ${error}`); } } // Continue loading remaining data in background private async continueLoadingInBackground( session: InstantPlaybackSession, reader: ReadableStreamDefaultReader<Uint8Array>, initialChunks: Uint8Array[], initialLoaded: number, onChunkLoaded?: (chunkIndex: number, totalChunks: number) => void ): Promise<void> { try { const remainingChunks: Uint8Array[] = []; let totalLoaded = initialLoaded; // Continue reading the stream while (true) { const { done, value } = await reader.read(); if (done) break; remainingChunks.push(value); totalLoaded += value.length; // Update session periodically with larger buffer if (remainingChunks.length % 5 === 0) { // Every 5 chunks await this.updateSessionWithLargerBuffer(session, initialChunks, remainingChunks, totalLoaded); } } // Final update with complete buffer if (remainingChunks.length > 0) { await this.updateSessionWithLargerBuffer(session, initialChunks, remainingChunks, totalLoaded); } console.log(`[WebAudioManager] Background loading completed for ${session.trackId} (${(totalLoaded / 1024 / 1024).toFixed(1)}MB)`); } catch (error) { console.warn(`[WebAudioManager] Background loading failed: ${error}`); } finally { reader.releaseLock(); } } // Update session with larger buffer private async updateSessionWithLargerBuffer( session: InstantPlaybackSession, initialChunks: Uint8Array[], remainingChunks: Uint8Array[], totalLoaded: number ): Promise<void> { if (!session.isActive) return; try { // Combine all chunks const completeBuffer = new ArrayBuffer(totalLoaded); const uint8Array = new Uint8Array(completeBuffer); let offset = 0; // Copy initial chunks for (const chunk of initialChunks) { uint8