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:
parent
ac8957ada3
commit
8a17bddbc3
11 changed files with 371 additions and 10188 deletions
8
bun.lock
8
bun.lock
|
|
@ -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=="],
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
28
js/api.js
28
js/api.js
|
|
@ -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
157
js/id3-writer.js
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
47
js/mp3-encoder.js
Normal 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
70
js/mp3-encoder.worker.js
Normal 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 });
|
||||
}
|
||||
};
|
||||
54
js/utils.js
54
js/utils.js
|
|
@ -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
10135
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export default defineConfig(({ mode }) => {
|
|||
},
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: ['pocketbase'],
|
||||
exclude: ['pocketbase', '@ffmpeg/ffmpeg', '@ffmpeg/util'],
|
||||
},
|
||||
server: {
|
||||
fs: {
|
||||
|
|
|
|||
Loading…
Reference in a new issue