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.
This commit is contained in:
gpulch 2026-02-22 19:13:03 +01:00
parent ac8957ada3
commit 8a17bddbc3
11 changed files with 371 additions and 10188 deletions

View file

@ -5,6 +5,8 @@
"": {
"name": "monochrome",
"dependencies": {
"@ffmpeg/ffmpeg": "^0.12.10",
"@ffmpeg/util": "^0.12.1",
"@neutralinojs/lib": "^6.5.0",
"butterchurn": "^2.6.7",
"butterchurn-presets": "^2.4.7",
@ -304,6 +306,12 @@
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="],
"@ffmpeg/ffmpeg": ["@ffmpeg/ffmpeg@0.12.15", "", { "dependencies": { "@ffmpeg/types": "^0.12.4" } }, "sha512-1C8Obr4GsN3xw+/1Ww6PFM84wSQAGsdoTuTWPOj2OizsRDLT4CXTaVjPhkw6ARyDus1B9X/L2LiXHqYYsGnRFw=="],
"@ffmpeg/types": ["@ffmpeg/types@0.12.4", "", {}, "sha512-k9vJQNBGTxE5AhYDtOYR5rO5fKsspbg51gbcwtbkw2lCdoIILzklulcjJfIDwrtn7XhDeF2M+THwJ2FGrLeV6A=="],
"@ffmpeg/util": ["@ffmpeg/util@0.12.2", "", {}, "sha512-ouyoW+4JB7WxjeZ2y6KpRvB+dLp7Cp4ro8z0HIVpZVCM7AwFlHa0c4R8Y/a4M3wMqATpYKhC7lSFHQ0T11MEDw=="],
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],

View file

@ -3934,6 +3934,7 @@
<select id="download-quality-setting">
<option value="HI_RES_LOSSLESS">Hi-Res FLAC (24-bit)</option>
<option value="LOSSLESS">FLAC (Lossless)</option>
<option value="MP3_320">MP3 320kbps</option>
<option value="HIGH">AAC 320kbps</option>
<option value="LOW">AAC 96kbps</option>
</select>

View file

@ -10,6 +10,7 @@ import { trackDateSettings } from './storage.js';
import { APICache } from './cache.js';
import { addMetadataToAudio } from './metadata.js';
import { DashDownloader } from './dash-downloader.js';
import { encodeToMp3 } from './mp3-encoder.js';
export const DASH_MANIFEST_UNAVAILABLE_CODE = 'DASH_MANIFEST_UNAVAILABLE';
const TIDAL_V2_TOKEN = 'txNoH4kkV41MfH25';
@ -1051,7 +1052,10 @@ export class LosslessAPI {
const { onProgress, track } = options;
try {
const lookup = await this.getTrack(id, quality);
// MP3_320 is not a native TIDAL quality, we download LOSSLESS and convert
const downloadQuality = quality === 'MP3_320' ? 'LOSSLESS' : quality;
const lookup = await this.getTrack(id, downloadQuality);
let streamUrl;
let blob;
@ -1074,8 +1078,8 @@ export class LosslessAPI {
});
} catch (dashError) {
console.error('DASH download failed:', dashError);
// Fallback to LOSSLESS if DASH fails
if (quality !== 'LOSSLESS') {
// Fallback to LOSSLESS if DASH fails, but not if we're already downloading LOSSLESS
if (downloadQuality !== 'LOSSLESS') {
console.warn('Falling back to LOSSLESS (16-bit) download.');
return this.downloadTrack(id, 'LOSSLESS', filename, options);
}
@ -1130,6 +1134,21 @@ export class LosslessAPI {
}
}
// Convert to MP3 320kbps if requested
if (quality === 'MP3_320') {
try {
blob = await encodeToMp3(blob, onProgress);
} catch (encodingError) {
if (onProgress) {
onProgress({
stage: 'error',
message: `Encoding failed: ${encodingError.message}`,
});
}
throw new Error(`MP3 encoding failed: ${encodingError.message}`);
}
}
// Add metadata if track information is provided
if (track) {
if (onProgress) {
@ -1157,6 +1176,9 @@ export class LosslessAPI {
throw error;
}
console.error('Download failed:', error);
if (error.message && error.message.startsWith('MP3 encoding failed:')) {
throw error;
}
if (error.message === RATE_LIMIT_ERROR_MESSAGE) {
throw error;
}

157
js/id3-writer.js Normal file
View file

@ -0,0 +1,157 @@
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;
}
}

View file

@ -1,4 +1,5 @@
import { getCoverBlob } from './utils.js';
import { getCoverBlob, detectAudioFormat } from './utils.js';
import { addMp3Metadata } from './id3-writer.js';
const VENDOR_STRING = 'Monochrome';
const DEFAULT_TITLE = 'Unknown Title';
@ -40,7 +41,7 @@ function getFullArtistString(track) {
}
/**
* Adds metadata tags to audio files (FLAC or M4A)
* Adds metadata tags to audio files (FLAC, M4A or MP3)
* @param {Blob} audioBlob - The audio file blob
* @param {Object} track - Track metadata
* @param {Object} api - API instance for fetching album art
@ -52,43 +53,21 @@ export async function addMetadataToAudio(audioBlob, track, api, _quality) {
// DASH Hi-Res streams may return fragmented MP4 instead of raw FLAC
const buffer = await audioBlob.slice(0, 12).arrayBuffer();
const view = new DataView(buffer);
// Check for FLAC signature: "fLaC" (0x66 0x4C 0x61 0x43)
const isFlac =
view.byteLength >= 4 &&
view.getUint8(0) === 0x66 && // f
view.getUint8(1) === 0x4c && // L
view.getUint8(2) === 0x61 && // a
view.getUint8(3) === 0x43; // C
if (isFlac) {
return await addFlacMetadata(audioBlob, track, api);
const format = detectAudioFormat(view, audioBlob.type);
switch (format) {
case 'flac':
return await addFlacMetadata(audioBlob, track, api);
case 'mp4':
return await addM4aMetadata(audioBlob, track, api);
case 'mp3':
return await addMp3Metadata(audioBlob, track, api);
default:
// Unknown format - return original without modification
console.warn(`Unknown audio format (mime: ${audioBlob.type}), returning original blob`);
return audioBlob;
}
// Check for MP4/M4A signature: "ftyp" at offset 4
const isMp4 =
view.byteLength >= 8 &&
view.getUint8(4) === 0x66 && // f
view.getUint8(5) === 0x74 && // t
view.getUint8(6) === 0x79 && // y
view.getUint8(7) === 0x70; // p
if (isMp4) {
return await addM4aMetadata(audioBlob, track, api);
}
// Fallback: check MIME type from blob
const mime = audioBlob.type;
if (mime === 'audio/flac') {
return await addFlacMetadata(audioBlob, track, api);
}
if (mime === 'audio/mp4' || mime === 'audio/x-m4a') {
return await addM4aMetadata(audioBlob, track, api);
}
// Unknown format - return original without modification
console.warn(`Unknown audio format (mime: ${mime}), returning original blob`);
return audioBlob;
}
/**

47
js/mp3-encoder.js Normal file
View file

@ -0,0 +1,47 @@
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);
}
}

70
js/mp3-encoder.worker.js Normal file
View file

@ -0,0 +1,70 @@
import { FFmpeg } from '@ffmpeg/ffmpeg';
import { toBlobURL } from '@ffmpeg/util';
let ffmpeg = null;
let isLoaded = false;
async function loadFFmpeg() {
if (isLoaded) return;
ffmpeg = new FFmpeg();
ffmpeg.on('log', ({ message }) => {
self.postMessage({ type: 'log', message });
});
ffmpeg.on('progress', ({ progress, time }) => {
self.postMessage({
type: 'progress',
stage: 'encoding',
progress: progress * 100,
time
});
});
self.postMessage({ type: 'progress', stage: 'loading', message: 'Loading FFmpeg...' });
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')
});
isLoaded = true;
}
self.onmessage = async (e) => {
const { audioData } = e.data;
try {
await loadFFmpeg();
self.postMessage({ type: 'progress', stage: 'encoding', message: 'Encoding to MP3 320kbps...' });
// Write input file to FFmpeg virtual filesystem
await ffmpeg.writeFile('input', new Uint8Array(audioData));
// Encode to MP3 with 320kbps CBR (FFmpeg auto-detects input format)
await ffmpeg.exec([
'-i', 'input',
'-c:a', 'libmp3lame',
'-b:a', '320k',
'-ar', '44100',
'output.mp3'
]);
self.postMessage({ type: 'progress', stage: 'finalizing', message: 'Finalizing MP3...' });
// Read output file
const data = await ffmpeg.readFile('output.mp3');
const mp3Blob = new Blob([data.buffer], { type: 'audio/mpeg' });
// Cleanup
await ffmpeg.deleteFile('input');
await ffmpeg.deleteFile('output.mp3');
self.postMessage({ type: 'complete', blob: mp3Blob });
} catch (error) {
self.postMessage({ type: 'error', message: error.message });
}
};

View file

@ -91,14 +91,12 @@ export const sanitizeForFilename = (value) => {
};
/**
* Detects actual audio format from blob signature
* @param {Blob} blob - Audio blob to analyze
* @returns {Promise<string>} - Extension: 'flac', 'm4a', or fallback based on mime
* Detects audio format from DataView of first bytes
* @param {DataView} view - DataView of first 12 bytes of audio file
* @param {string} mimeType - MIME type from blob
* @returns {string|null} - Format: 'flac', 'mp4', 'mp3', or null
*/
export const getExtensionFromBlob = async (blob) => {
const buffer = await blob.slice(0, 12).arrayBuffer();
const view = new DataView(buffer);
export const detectAudioFormat = (view, mimeType = '') => {
// Check for FLAC signature: "fLaC" (0x66 0x4C 0x61 0x43)
if (
view.byteLength >= 4 &&
@ -118,14 +116,46 @@ export const getExtensionFromBlob = async (blob) => {
view.getUint8(6) === 0x79 && // y
view.getUint8(7) === 0x70 // p
) {
return 'm4a';
return 'mp4';
}
// Check for MP3 signature: ID3 tag or MPEG frame sync
if (
view.byteLength >= 3 &&
view.getUint8(0) === 0x49 && // I
view.getUint8(1) === 0x44 && // D
view.getUint8(2) === 0x33 // 3
) {
return 'mp3';
}
// Check for MPEG frame sync (0xFF 0xFB or 0xFF 0xFA)
if (view.byteLength >= 2 && view.getUint8(0) === 0xff && (view.getUint8(1) & 0xe0) === 0xe0) {
return 'mp3';
}
// Fallback to MIME type
const mime = blob.type;
if (mime === 'audio/flac') return 'flac';
if (mime === 'audio/mp4' || mime === 'audio/x-m4a') return 'm4a';
if (mimeType === 'audio/flac') return 'flac';
if (mimeType === 'audio/mp4' || mimeType === 'audio/x-m4a') return 'mp4';
if (mimeType === 'audio/mp3' || mimeType === 'audio/mpeg') return 'mp3';
return null;
};
/**
* Detects actual audio format from blob signature
* @param {Blob} blob - Audio blob to analyze
* @returns {Promise<string>} - Extension: 'flac', 'm4a', 'mp3', or fallback based on mime
*/
export const getExtensionFromBlob = async (blob) => {
const buffer = await blob.slice(0, 12).arrayBuffer();
const view = new DataView(buffer);
const format = detectAudioFormat(view, blob.type);
if (format === 'mp4') return 'm4a';
if (format) return format;
// Default fallback
return 'flac';
};
@ -135,6 +165,8 @@ export const getExtensionForQuality = (quality) => {
case 'LOW':
case 'HIGH':
return 'm4a';
case 'MP3_320':
return 'mp3';
default:
return 'flac';
}

10135
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -47,6 +47,8 @@
"source-map": "^0.7.4"
},
"dependencies": {
"@ffmpeg/ffmpeg": "^0.12.10",
"@ffmpeg/util": "^0.12.1",
"@neutralinojs/lib": "^6.5.0",
"butterchurn": "^2.6.7",
"butterchurn-presets": "^2.4.7",

View file

@ -14,7 +14,7 @@ export default defineConfig(({ mode }) => {
},
},
optimizeDeps: {
exclude: ['pocketbase'],
exclude: ['pocketbase', '@ffmpeg/ffmpeg', '@ffmpeg/util'],
},
server: {
fs: {