265 lines
6.8 KiB
TypeScript
265 lines
6.8 KiB
TypeScript
// =============================================================================
|
|
// 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<void> | null = null;
|
|
|
|
async function ensureFFmpegLoaded(): Promise<FFmpeg> {
|
|
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<AudioFormat, string> = {
|
|
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<boolean> {
|
|
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<Blob> {
|
|
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<Blob> {
|
|
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;
|