kv-music/js/mp3-encoder.js
gpulch 8a17bddbc3 feat: add MP3 320kbps download option with ffmpeg.wasm
Implements MP3 320kbps download functionality using ffmpeg.wasm for
industry-standard encoding with libmp3lame.

Features:
- New MP3_320 quality option in download settings UI
- ID3v2.3 metadata writing (title, artist, album, cover art, ISRC, etc.)
- Non-blocking encoding via Web Worker to keep UI responsive
- Proper UTF-16 with BOM text encoding for international characters
- Album artist fallback to track artist (mirrors FLAC/M4A behavior)
- Automatic format detection for downloaded audio
- Year validation to prevent writing NaN to ID3 tags

Implementation:
- mp3-encoder.js: Main encoder module with worker orchestration
- mp3-encoder.worker.js: FFmpeg Web Worker for async encoding
- id3-writer.js: ID3v2.3 tag writer with synchsafe size encoding
- Updates to api.js, metadata.js, utils.js for MP3 support
- Vite config excludes @ffmpeg packages from dep optimization

Technical details:
- Uses @ffmpeg/ffmpeg (libmp3lame 320kbps CBR, 44.1kHz)
- FFmpeg binary lazy-loaded from CDN (~25MB, cached)
- Encoding runs in separate thread (non-blocking UI)
- Proper error handling with distinct encoding vs network errors
- Memory-efficient: transfers ArrayBuffer with zero-copy

Dependencies:
- @ffmpeg/ffmpeg ^0.12.10
- @ffmpeg/util ^0.12.1
- Removed: package-lock.json (project uses bun.lock)

Closes maintainer request to use ffmpeg.wasm instead of lamejs.
2026-02-22 19:13:03 +01:00

47 lines
1.6 KiB
JavaScript

async function encodeToMp3Worker(audioBlob, onProgress = 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' });
worker.onmessage = (e) => {
const { type, blob, message, stage, progress } = e.data;
if (type === 'complete') {
worker.terminate();
resolve(blob);
} else if (type === 'error') {
worker.terminate();
reject(new Error(message));
} else if (type === 'progress' && onProgress) {
onProgress({ stage, message, progress });
} else if (type === 'log') {
console.log('[FFmpeg]', message);
}
};
worker.onerror = (error) => {
worker.terminate();
reject(new Error('Worker failed: ' + error.message));
};
// Transfer audio data to worker
worker.postMessage({
audioData
}, [audioData]);
});
}
export async function encodeToMp3(audioBlob, onProgress = null) {
try {
// Use Web Worker for non-blocking FFmpeg encoding
if (typeof Worker !== 'undefined') {
return await encodeToMp3Worker(audioBlob, onProgress);
}
throw new Error('Web Workers are required for MP3 encoding');
} catch (error) {
console.error('MP3 encoding failed:', error);
throw new Error('Failed to encode MP3: ' + error.message);
}
}