diff --git a/js/ffmpeg.js b/js/ffmpeg.js new file mode 100644 index 0000000..2a280bd --- /dev/null +++ b/js/ffmpeg.js @@ -0,0 +1,96 @@ +class FfmpegError extends Error { + constructor(message) { + super(message); + this.name = 'FfmpegError'; + this.code = 'FFMPEG_FAILED'; + } +} + +async function ffmpegWorker( + audioBlob, + args = {}, + outputName = 'output', + outputMime = 'application/octet-stream', + onProgress = null, + signal = null +) { + const audioData = await audioBlob.arrayBuffer(); + + return new Promise((resolve, reject) => { + const worker = new Worker(new URL('./ffmpeg.worker.js', import.meta.url), { type: 'module' }); + + // Handle abort signal + const abortHandler = () => { + worker.terminate(); + reject(new FfmpegError('FFMPEG aborted')); + }; + + if (signal) { + if (signal.aborted) { + abortHandler(); + return; + } + signal.addEventListener('abort', abortHandler); + } + + worker.onmessage = (e) => { + const { type, blob, message, stage, progress } = e.data; + + if (type === 'complete') { + if (signal) signal.removeEventListener('abort', abortHandler); + worker.terminate(); + resolve(blob); + } else if (type === 'error') { + if (signal) signal.removeEventListener('abort', abortHandler); + worker.terminate(); + reject(new FfmpegError(message)); + } else if (type === 'progress' && onProgress) { + onProgress({ stage, message, progress }); + } else if (type === 'log') { + console.log('[FFmpeg]', message); + } + }; + + worker.onerror = (error) => { + if (signal) signal.removeEventListener('abort', abortHandler); + worker.terminate(); + reject(new FfmpegError('Worker failed: ' + error.message)); + }; + + // Transfer audio data to worker + worker.postMessage( + { + audioData, + ...args, + output: { + name: outputName, + mime: outputMime, + }, + }, + [audioData] + ); + }); +} + +export async function ffmpeg( + audioBlob, + args = {}, + outputName = 'output', + outputMime = 'application/octet-stream', + onProgress = null, + signal = null +) { + try { + // Use Web Worker for non-blocking FFmpeg encoding + if (typeof Worker !== 'undefined') { + return await ffmpegWorker(audioBlob, args, outputName, outputMime, onProgress, signal); + } + + throw new FfmpegError('Web Workers are required for FFMPEG'); + } catch (error) { + console.error('FFMPEG failed:', error); + throw error; + } +} + +export { FfmpegError }; diff --git a/js/mp3-encoder.worker.js b/js/ffmpeg.worker.js similarity index 68% rename from js/mp3-encoder.worker.js rename to js/ffmpeg.worker.js index 2a2176c..763388a 100644 --- a/js/mp3-encoder.worker.js +++ b/js/ffmpeg.worker.js @@ -36,39 +36,41 @@ async function loadFFmpeg() { } self.onmessage = async (e) => { - const { audioData } = e.data; + const { + audioData, + args = [], + output = { + name: 'output', + mime: 'application/octet-stream', + }, + encodeStartMessage = 'Encoding...', + encodeEndMessage = 'Finalizing...', + } = e.data; try { await loadFFmpeg(); - self.postMessage({ type: 'progress', stage: 'encoding', message: 'Encoding to MP3 320kbps...' }); + self.postMessage({ type: 'progress', stage: 'encoding', message: encodeStartMessage }); try { // Write input file to FFmpeg virtual filesystem await ffmpeg.writeFile('input', new Uint8Array(audioData)); - // Encode to MP3 with 320kbps CBR, strip source metadata to avoid duplicate ID3 tags - await ffmpeg.exec([ - '-i', - 'input', - '-map_metadata', - '-1', - '-c:a', - 'libmp3lame', - '-b:a', - '320k', - '-ar', - '44100', - 'output.mp3', - ]); + const ffmpegArgs = ['-i', 'input', ...args, output.name]; - self.postMessage({ type: 'progress', stage: 'finalizing', message: 'Finalizing MP3...' }); + // Log the exact FFmpeg command being run for debugging. + self.postMessage({ type: 'log', message: `Running with args: ${ffmpegArgs.join(' ')}` }); + + // Run FFMPEG with the provided arguments. + await ffmpeg.exec(ffmpegArgs); + + self.postMessage({ type: 'progress', stage: 'finalizing', message: encodeEndMessage }); // Read output file - use Uint8Array directly to avoid extra bytes from ArrayBuffer - const data = await ffmpeg.readFile('output.mp3'); - const mp3Blob = new Blob([data], { type: 'audio/mpeg' }); + const data = await ffmpeg.readFile(output.name); + const outputBlob = new Blob([data], { type: output.mime }); - self.postMessage({ type: 'complete', blob: mp3Blob }); + self.postMessage({ type: 'complete', blob: outputBlob }); } finally { // Always cleanup virtual filesystem files try { @@ -77,7 +79,7 @@ self.onmessage = async (e) => { // File may not exist if writeFile failed } try { - await ffmpeg.deleteFile('output.mp3'); + await ffmpeg.deleteFile(output.name); } catch { // File may not exist if exec failed } diff --git a/js/mp3-encoder.js b/js/mp3-encoder.js index 114f827..6664fb0 100644 --- a/js/mp3-encoder.js +++ b/js/mp3-encoder.js @@ -1,3 +1,5 @@ +import { ffmpeg } from './ffmpeg'; + class MP3EncodingError extends Error { constructor(message) { super(message); @@ -6,71 +8,20 @@ class MP3EncodingError extends Error { } } -async function encodeToMp3Worker(audioBlob, onProgress = null, signal = null) { - const audioData = await audioBlob.arrayBuffer(); - - return new Promise((resolve, reject) => { - const worker = new Worker(new URL('./mp3-encoder.worker.js', import.meta.url), { type: 'module' }); - - // Handle abort signal - const abortHandler = () => { - worker.terminate(); - reject(new MP3EncodingError('MP3 encoding aborted')); - }; - - if (signal) { - if (signal.aborted) { - abortHandler(); - return; - } - signal.addEventListener('abort', abortHandler); - } - - worker.onmessage = (e) => { - const { type, blob, message, stage, progress } = e.data; - - if (type === 'complete') { - if (signal) signal.removeEventListener('abort', abortHandler); - worker.terminate(); - resolve(blob); - } else if (type === 'error') { - if (signal) signal.removeEventListener('abort', abortHandler); - worker.terminate(); - reject(new MP3EncodingError(message)); - } else if (type === 'progress' && onProgress) { - onProgress({ stage, message, progress }); - } else if (type === 'log') { - console.log('[FFmpeg]', message); - } - }; - - worker.onerror = (error) => { - if (signal) signal.removeEventListener('abort', abortHandler); - worker.terminate(); - reject(new MP3EncodingError('Worker failed: ' + error.message)); - }; - - // Transfer audio data to worker - worker.postMessage( - { - audioData, - }, - [audioData] - ); - }); -} - export async function encodeToMp3(audioBlob, onProgress = null, signal = null) { try { // Use Web Worker for non-blocking FFmpeg encoding if (typeof Worker !== 'undefined') { - return await encodeToMp3Worker(audioBlob, onProgress, signal); + const args = ['-map_metadata', '-1', '-c:a', 'libmp3lame', '-b:a', '320k', '-ar', '44100']; + + return await ffmpeg(audioBlob, { args }, 'output.mp3', 'audio/mpeg', onProgress, signal); } throw new MP3EncodingError('Web Workers are required for MP3 encoding'); } catch (error) { console.error('MP3 encoding failed:', error); - throw error; + + throw new MP3EncodingError(error?.message ?? error); } }