feat(library): estimate mp3 files duration

This commit is contained in:
Timur Cravtov 2026-03-06 21:51:05 +02:00
parent 808d48c2e7
commit 8c7a7547c9
2 changed files with 1545 additions and 1 deletions

View file

@ -323,6 +323,7 @@ async function readMp3Metadata(file, metadata) {
if (frameId === 'TALB') metadata.album.title = readID3Text(frameData);
if (frameId === 'TSRC') metadata.isrc = readID3Text(frameData);
if (frameId === 'TCOP') metadata.copyright = readID3Text(frameData);
if (frameId === 'TLEN') metadata.duration = parseInt(readID3Text(frameData)) / 1000; // usually not present
if (frameId === 'TYER' || frameId === 'TDRC') {
const year = readID3Text(frameData);
if (year) metadata.album.releaseDate = year;
@ -366,6 +367,10 @@ async function readMp3Metadata(file, metadata) {
if (artistStr) {
metadata.artists = artistStr.split('/').map((name) => ({ name: name.trim() }));
}
if (!metadata.duration || metadata.duration === 0) {
metadata.duration = await calculateMp3Duration(file, tagSize);
}
}
if (file.size > 128) {
@ -393,6 +398,57 @@ async function readMp3Metadata(file, metadata) {
}
}
// since mp3 file don't have metadata about duration, estimating it
// uses evil bitwise magic
async function calculateMp3Duration(file, startOffset) {
const buffer = await file.slice(startOffset, startOffset + 32768).arrayBuffer();
const view = new DataView(buffer);
const uint8 = new Uint8Array(buffer);
let offset = 0;
// finding sync word
while (offset < view.byteLength - 4 && !(uint8[offset] === 0xff && (uint8[offset + 1] & 0xe0) === 0xe0)) {
offset++;
}
if (offset >= view.byteLength - 4) return 0;
const header = view.getUint32(offset, false);
// header info
const mpegVer = (header >> 19) & 3;
const brIdx = (header >> 12) & 15;
const srIdx = (header >> 10) & 3;
// Reject invalid headers
if (mpegVer === 1 || brIdx === 0 || brIdx === 15 || srIdx === 3) return 0;
const sampleRates = [[11025, 12000, 8000], null, [22050, 24000, 16000], [44100, 48000, 32000]];
const brMpeg1 = [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0];
const brMpeg2 = [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0];
const sampleRate = sampleRates[mpegVer][srIdx];
const bitrate = mpegVer === 3 ? brMpeg1[brIdx] : brMpeg2[brIdx];
// this xing header is present in many mp3 files and contains total frame count, which allows for accurate duration calculation
const channelMode = (header >> 6) & 3; // mono or stereo
const xingOffset = offset + 4 + (mpegVer === 3 ? (channelMode === 3 ? 17 : 32) : (channelMode === 3 ? 9 : 17)); // the position of xing header
if (xingOffset + 8 <= view.byteLength) {
const sig = view.getUint32(xingOffset, false);
if ((sig === 0x58696e67 || sig === 0x496e666f) && (view.getUint32(xingOffset + 4, false) & 1)) {
const frames = view.getUint32(xingOffset + 8, false);
// basically, duration = frames * samples per frame / sample rate
return (frames * (mpegVer === 3 ? 1152 : 576)) / sampleRate;
}
}
// if no Xing header, estimate duration from file size and bitrate
return ((file.size - startOffset) * 8) / (bitrate * 1000);
}
function readSynchsafeInteger32(view, offset) {
return (
((view.getUint8(offset) & 0x7f) << 21) |

1490
package-lock.json generated

File diff suppressed because it is too large Load diff