kv-music/js/id3-writer.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

157 lines
4.3 KiB
JavaScript

import { getCoverBlob } from './utils.js';
async function writeID3v2Tag(mp3Blob, metadata, coverBlob = null) {
const frames = [];
if (metadata.title) {
frames.push(createTextFrame('TIT2', metadata.title));
}
const artistName = metadata.artist?.name || metadata.artists?.[0]?.name;
if (artistName) {
frames.push(createTextFrame('TPE1', artistName));
}
if (metadata.album?.title) {
frames.push(createTextFrame('TALB', metadata.album.title));
}
const albumArtistName = metadata.album?.artist?.name || metadata.artist?.name || metadata.artists?.[0]?.name;
if (albumArtistName) {
frames.push(createTextFrame('TPE2', albumArtistName));
}
if (metadata.trackNumber) {
frames.push(createTextFrame('TRCK', metadata.trackNumber.toString()));
}
if (metadata.album?.releaseDate) {
const year = new Date(metadata.album.releaseDate).getFullYear();
if (!Number.isNaN(year) && Number.isFinite(year)) {
frames.push(createTextFrame('TYER', year.toString()));
frames.push(createTextFrame('TDRC', year.toString()));
}
}
if (metadata.isrc) {
frames.push(createTextFrame('TSRC', metadata.isrc));
}
if (metadata.copyright) {
frames.push(createTextFrame('TCOP', metadata.copyright));
}
frames.push(createTextFrame('TENC', 'Monochrome'));
if (coverBlob) {
frames.push(await createAPICFrame(coverBlob));
}
return buildID3v2Tag(mp3Blob, frames);
}
function createTextFrame(frameId, text) {
// ID3v2.3 UTF-16 encoding with BOM
const bom = new Uint8Array([0xff, 0xfe]); // UTF-16LE BOM
const utf16Bytes = new Uint8Array(text.length * 2);
for (let i = 0; i < text.length; i++) {
const charCode = text.charCodeAt(i);
utf16Bytes[i * 2] = charCode & 0xff;
utf16Bytes[i * 2 + 1] = (charCode >> 8) & 0xff;
}
const frameSize = 1 + bom.length + utf16Bytes.length;
const frame = new Uint8Array(10 + frameSize);
const view = new DataView(frame.buffer);
for (let i = 0; i < 4; i++) {
frame[i] = frameId.charCodeAt(i);
}
view.setUint32(4, frameSize, false);
frame[10] = 0x01; // UTF-16 with BOM
frame.set(bom, 11);
frame.set(utf16Bytes, 11 + bom.length);
return frame;
}
async function createAPICFrame(coverBlob) {
const imageBytes = new Uint8Array(await coverBlob.arrayBuffer());
const mimeType = coverBlob.type || 'image/jpeg';
const mimeBytes = new TextEncoder().encode(mimeType);
const frameSize = 1 + mimeBytes.length + 1 + 1 + 1 + imageBytes.length;
const frame = new Uint8Array(10 + frameSize);
const view = new DataView(frame.buffer);
for (let i = 0; i < 4; i++) {
frame[i] = 'APIC'.charCodeAt(i);
}
view.setUint32(4, frameSize, false);
let offset = 10;
frame[offset++] = 0x00;
frame.set(mimeBytes, offset);
offset += mimeBytes.length;
frame[offset++] = 0x00;
frame[offset++] = 0x03;
frame[offset++] = 0x00;
frame.set(imageBytes, offset);
return frame;
}
function buildID3v2Tag(mp3Blob, frames) {
const framesData = new Uint8Array(frames.reduce((acc, f) => acc + f.length, 0));
let offset = 0;
for (const frame of frames) {
framesData.set(frame, offset);
offset += frame.length;
}
const tagSize = framesData.length;
const header = new Uint8Array(10);
header[0] = 0x49;
header[1] = 0x44;
header[2] = 0x33;
header[3] = 0x03;
header[4] = 0x00;
header[5] = 0x00;
header[6] = (tagSize >> 21) & 0x7f;
header[7] = (tagSize >> 14) & 0x7f;
header[8] = (tagSize >> 7) & 0x7f;
header[9] = tagSize & 0x7f;
return new Blob([header, framesData, mp3Blob], { type: 'audio/mpeg' });
}
export async function addMp3Metadata(mp3Blob, track, api) {
try {
let coverBlob = null;
if (track.album?.cover) {
try {
coverBlob = await getCoverBlob(api, track.album.cover);
} catch (error) {
console.warn('Failed to fetch album art for MP3:', error);
}
}
return await writeID3v2Tag(mp3Blob, track, coverBlob);
} catch (error) {
console.error('Failed to add MP3 metadata:', error);
return mp3Blob;
}
}