// Streaming audio processor for large files using WebCodecs API // Handles files > 1GB without loading entire file into memory export interface DecodedAudioFrame { leftChannel: Float32Array; rightChannel: Float32Array; sampleRate: number; timestamp: number; } export interface AudioProcessorConfig { chunkSize: number; bufferSize: number; // seconds of audio to keep in memory sampleRate: number; channels: number; } export class StreamingAudioProcessor { private audioContext: AudioContext; private decoder: AudioDecoder | null = null; private fileBufferQueue: Float32Array[] = []; private fileReader: FileReader | null = null; private totalSamplesProcessed = 0; private sampleRate = 48000; private channels = 2; private isProcessing = false; private abortController: AbortController | null = null; constructor(config: AudioProcessorConfig) { this.audioContext = new AudioContext(); this.sampleRate = config.sampleRate; this.channels = config.channels; } async initializeDecoder(file: File): Promise { // Detect codec from file type const codec = this.getCodecFromFile(file); console.log(`Initializing decoder for codec: ${codec}`); // Check browser support if (!this.isWebCodecsSupported()) { throw new Error(`WebCodecs AudioDecoder not supported in this browser. codec: ${codec}`); } // FLAC is not widely supported by WebCodecs, use traditional decode if (codec === 'flac') { console.warn('FLAC codec not supported by WebCodecs, using traditional decode (may have memory limits)'); throw new Error('FLAC codec requires traditional decode. For large FLAC files, consider converting to MP3/OGG for streaming support.'); } // Check codec support const codecSupport = await AudioDecoder.isConfigSupported({ codec, sampleRate: this.sampleRate, numberOfChannels: this.channels }); console.log(`Codec ${codec} support:`, codecSupport); if (!codecSupport.supported) { throw new Error(`Codec ${codec} not supported by this browser`); } // Create decoder this.decoder = new AudioDecoder({ output: (audioData) => { this.handleDecodedFrame(audioData); }, error: (error) => { console.error('AudioDecoder error:', error); throw new Error(`Decoding failed: ${error}`); } }); await this.decoder.configure({ codec, sampleRate: this.sampleRate, numberOfChannels: this.channels }); console.log(`AudioDecoder configured successfully for ${codec}`); } private getCodecFromFile(file: File): string { const extension = file.name.split('.').pop()?.toLowerCase(); const mimeType = file.type; // Map file extensions to codec strings const codecMap: Record = { 'mp3': 'mp3', 'flac': 'flac', 'wav': 'pcm', 'ogg': 'opus', 'm4a': 'aac', 'aac': 'aac' }; // Try extension first if (extension && codecMap[extension]) { return codecMap[extension]; } // Try MIME type if (mimeType) { if (mimeType.includes('mp3')) return 'mp3'; if (mimeType.includes('flac')) return 'flac'; if (mimeType.includes('wav')) return 'pcm'; if (mimeType.includes('opus')) return 'opus'; if (mimeType.includes('aac')) return 'aac'; } throw new Error(`Unsupported file format: ${extension || 'unknown'}`); } private isWebCodecsSupported(): boolean { return 'AudioDecoder' in window && 'EncodedAudioChunk' in window; } private handleDecodedFrame(audioData: AudioData): void { // Convert AudioData to Float32Array channels const leftChannel = new Float32Array(audioData.numberOfFrames); const rightChannel = new Float32Array(audioData.numberOfFrames); audioData.copyTo(leftChannel, { planeIndex: 0 }); audioData.copyTo(rightChannel, { planeIndex: 1 }); // Add to buffer queue this.fileBufferQueue.push(leftChannel, rightChannel); this.totalSamplesProcessed += audioData.numberOfFrames; // Limit buffer size to prevent memory growth const maxBufferFrames = this.sampleRate * 30; // Keep 30 seconds max const currentBufferFrames = this.fileBufferQueue.length / 2 * leftChannel.length; if (currentBufferFrames > maxBufferFrames) { // Remove oldest data to maintain buffer limit const framesToRemove = currentBufferFrames - maxBufferFrames; const chunksToRemove = Math.ceil(framesToRemove / leftChannel.length) * 2; this.fileBufferQueue.splice(0, chunksToRemove); } } async processLargeFile(file: File): Promise<{ sampleRate: number; duration: number; totalSamples: number; getBufferedSamples: (offset: number, count: number) => { left: Float32Array; right: Float32Array } | null; }> { console.log(`Starting streaming processing for ${file.name} (${(file.size / (1024 * 1024 * 1024)).toFixed(1)}GB)`); this.isProcessing = true; this.abortController = new AbortController(); this.fileBufferQueue = []; this.totalSamplesProcessed = 0; try { await this.initializeDecoder(file); // Get metadata first const metadata = await this.extractMetadata(file); console.log(`Audio metadata: ${metadata.duration.toFixed(1)}s, sampleRate: ${metadata.sampleRate}`); // Process file in chunks await this.streamFileChunks(file); // Flush remaining decoder data if (this.decoder) { await this.decoder.flush(); } const duration = this.totalSamplesProcessed / this.sampleRate; console.log(`✅ Streaming processing complete: ${duration.toFixed(1)}s, ${this.totalSamplesProcessed.toLocaleString()} samples`); return { sampleRate: this.sampleRate, duration, totalSamples: this.totalSamplesProcessed, getBufferedSamples: (offset: number, count: number) => { return this.getSamplesFromBuffer(offset, count); } }; } catch (error) { console.error('❌ Streaming processing failed:', error); throw error; } finally { this.isProcessing = false; this.cleanup(); } } private async extractMetadata(file: File): Promise<{ duration: number; sampleRate: number }> { // Create temporary audio element to get basic metadata return new Promise((resolve, reject) => { const audio = new Audio(); const objectUrl = URL.createObjectURL(file); audio.addEventListener('loadedmetadata', () => { URL.revokeObjectURL(objectUrl); resolve({ duration: audio.duration || 0, sampleRate: this.sampleRate }); }); audio.addEventListener('error', () => { URL.revokeObjectURL(objectUrl); reject(new Error('Failed to load audio metadata')); }); audio.src = objectUrl; audio.load(); }); } private async streamFileChunks(file: File): Promise { const chunkSize = 64 * 1024; // 64KB chunks let offset = 0; console.log(`Starting chunked reading with ${chunkSize} byte chunks...`); while (offset < file.size && !this.abortController?.signal.aborted) { const chunk = file.slice(offset, offset + chunkSize); try { const arrayBuffer = await chunk.arrayBuffer(); if (this.decoder && arrayBuffer.byteLength > 0) { // Create EncodedAudioChunk const encodedChunk = new EncodedAudioChunk({ type: offset === 0 ? 'key' : 'delta', // First chunk is key frame timestamp: (offset / file.size) * 1000000, // microseconds data: arrayBuffer }); // Feed to decoder this.decoder.decode(encodedChunk); } offset += arrayBuffer.byteLength; // Progress logging for large files if (offset % (10 * 1024 * 1024) < chunkSize) { // Every 10MB const progress = (offset / file.size) * 100; console.log(`Processed ${(offset / (1024 * 1024)).toFixed(1)}MB (${progress.toFixed(1)}%)`); } } catch (chunkError) { console.warn(`Failed to process chunk at offset ${offset}:`, chunkError); // Continue with next chunk rather than failing completely } } } private getSamplesFromBuffer(offset: number, count: number): { left: Float32Array; right: Float32Array } | null { if (this.fileBufferQueue.length === 0) return null; const samplesPerChannel = this.fileBufferQueue.length / 2; const samplesPerChunk = this.fileBufferQueue[0]?.length || 0; const samplesAvailable = Math.min(count, samplesPerChannel * samplesPerChunk); if (samplesAvailable <= 0) return null; const leftResult = new Float32Array(samplesAvailable); const rightResult = new Float32Array(samplesAvailable); // Copy samples from buffer queue let samplesCopied = 0; const bufferIndex = Math.floor(offset / samplesPerChunk) * 2; for (let i = bufferIndex; i < this.fileBufferQueue.length && samplesCopied < samplesAvailable; i += 2) { const leftChunk = this.fileBufferQueue[i]; const rightChunk = this.fileBufferQueue[i + 1]; const copyLength = Math.min(leftChunk.length, samplesAvailable - samplesCopied); leftResult.set(leftChunk.subarray(0, copyLength), samplesCopied); rightResult.set(rightChunk.subarray(0, copyLength), samplesCopied); samplesCopied += copyLength; } return { left: leftResult, right: rightResult }; } cleanup(): void { if (this.decoder) { try { this.decoder.close(); } catch (e) { // Ignore cleanup errors } this.decoder = null; } if (this.abortController) { this.abortController.abort(); this.abortController = null; } this.fileBufferQueue = []; } abort(): void { if (this.abortController) { this.abortController.abort(); } } get isReady(): boolean { return this.decoder?.state === 'configured'; } get progress(): number { if (this.fileBufferQueue.length === 0) return 0; return this.totalSamplesProcessed / this.sampleRate; // seconds processed } }