// ============================================================================= // Audio Worker // Handles audio conversions using FFmpeg WASM // ============================================================================= import * as Comlink from 'comlink'; import { FFmpeg } from '@ffmpeg/ffmpeg'; import { toBlobURL } from '@ffmpeg/util'; // All supported audio formats type AudioFormat = | 'mp3' | 'wav' | 'ogg' | 'm4a' | 'flac' | 'aac' | 'aiff' | 'alac' | 'wma' | 'opus' | 'm4r' | 'amr'; let ffmpeg: FFmpeg | null = null; let ffmpegLoading: Promise | null = null; async function ensureFFmpegLoaded(): Promise { if (ffmpeg?.loaded) return ffmpeg; if (ffmpegLoading) { await ffmpegLoading; return ffmpeg!; } ffmpegLoading = (async () => { ffmpeg = new FFmpeg(); // Load FFmpeg core from CDN with proper CORS const baseURL = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm'; await ffmpeg.load({ coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'), wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm'), }); console.log('[AudioWorker] FFmpeg WASM initialized'); })(); await ffmpegLoading; return ffmpeg!; } function getFFmpegArgs( _inputFormat: AudioFormat, outputFormat: AudioFormat, bitrate?: number ): string[] { const args: string[] = []; const br = bitrate || 192; // Codec selection based on output format switch (outputFormat) { case 'mp3': args.push('-c:a', 'libmp3lame'); args.push('-b:a', `${br}k`); break; case 'wav': args.push('-c:a', 'pcm_s16le'); break; case 'ogg': args.push('-c:a', 'libvorbis'); args.push('-q:a', '4'); break; case 'm4a': args.push('-c:a', 'aac'); args.push('-b:a', `${br}k`); break; case 'flac': args.push('-c:a', 'flac'); args.push('-compression_level', '5'); break; case 'aac': args.push('-c:a', 'aac'); args.push('-b:a', `${br}k`); break; case 'aiff': args.push('-c:a', 'pcm_s16be'); break; case 'alac': // ALAC in M4A container args.push('-c:a', 'alac'); break; case 'wma': args.push('-c:a', 'wmav2'); args.push('-b:a', `${br}k`); break; case 'opus': args.push('-c:a', 'libopus'); args.push('-b:a', `${br}k`); break; case 'm4r': // M4R is just AAC with different extension (iPhone ringtones) args.push('-c:a', 'aac'); args.push('-b:a', `${br}k`); break; case 'amr': args.push('-c:a', 'libopencore_amrnb'); args.push('-ar', '8000'); // AMR requires 8kHz sample rate args.push('-ac', '1'); // AMR is mono only break; } return args; } function getExtension(format: AudioFormat): string { // ALAC uses m4a container if (format === 'alac') return 'm4a'; return format; } function getMimeType(format: AudioFormat): string { const mimeMap: Record = { mp3: 'audio/mpeg', wav: 'audio/wav', ogg: 'audio/ogg', m4a: 'audio/mp4', flac: 'audio/flac', aac: 'audio/aac', aiff: 'audio/aiff', alac: 'audio/x-m4a', wma: 'audio/x-ms-wma', opus: 'audio/opus', m4r: 'audio/x-m4r', amr: 'audio/amr', }; return mimeMap[format]; } const audioConverter = { /** * Initialize the worker (can be called early to warm up) */ async init(): Promise { try { await ensureFFmpegLoaded(); return true; } catch (error) { console.error('[AudioWorker] Init failed:', error); return false; } }, /** * Convert audio file using FFmpeg */ async convert( arrayBuffer: ArrayBuffer, inputFormat: AudioFormat, outputFormat: AudioFormat, bitrate?: number, onProgress?: (progress: number) => void ): Promise { const ff = await ensureFFmpegLoaded(); const inputExt = getExtension(inputFormat); const outputExt = getExtension(outputFormat); const inputFileName = `input.${inputExt}`; const outputFileName = `output.${outputExt}`; // Register progress callback if provided if (onProgress) { ff.on('progress', ({ progress }) => { onProgress(Math.round(progress * 100)); }); } try { // Write input file to FFmpeg's virtual filesystem await ff.writeFile(inputFileName, new Uint8Array(arrayBuffer)); // Build FFmpeg command const codecArgs = getFFmpegArgs(inputFormat, outputFormat, bitrate); await ff.exec([ '-i', inputFileName, ...codecArgs, '-y', // Overwrite output outputFileName, ]); // Read output file const data = await ff.readFile(outputFileName); // Cleanup await ff.deleteFile(inputFileName); await ff.deleteFile(outputFileName); // Convert to regular ArrayBuffer to avoid SharedArrayBuffer issues const buffer = new ArrayBuffer((data as Uint8Array).length); new Uint8Array(buffer).set(data as Uint8Array); return new Blob([buffer], { type: getMimeType(outputFormat) }); } catch (error) { // Attempt cleanup on error try { await ff.deleteFile(inputFileName); await ff.deleteFile(outputFileName); } catch { // Ignore cleanup errors } throw error; } }, /** * Extract audio from video file (useful for iPhone MOV files) */ async extractAudio( arrayBuffer: ArrayBuffer, inputExtension: string, outputFormat: AudioFormat, bitrate?: number, onProgress?: (progress: number) => void ): Promise { const ff = await ensureFFmpegLoaded(); const inputFileName = `input.${inputExtension}`; const outputExt = getExtension(outputFormat); const outputFileName = `output.${outputExt}`; if (onProgress) { ff.on('progress', ({ progress }) => { onProgress(Math.round(progress * 100)); }); } try { await ff.writeFile(inputFileName, new Uint8Array(arrayBuffer)); const codecArgs = getFFmpegArgs('m4a', outputFormat, bitrate); await ff.exec([ '-i', inputFileName, '-vn', // No video ...codecArgs, '-y', outputFileName, ]); const data = await ff.readFile(outputFileName); await ff.deleteFile(inputFileName); await ff.deleteFile(outputFileName); // Convert to regular ArrayBuffer const buffer = new ArrayBuffer((data as Uint8Array).length); new Uint8Array(buffer).set(data as Uint8Array); return new Blob([buffer], { type: getMimeType(outputFormat) }); } catch (error) { try { await ff.deleteFile(inputFileName); await ff.deleteFile(outputFileName); } catch { // Ignore cleanup errors } throw error; } }, }; Comlink.expose(audioConverter); export type AudioConverterWorker = typeof audioConverter;