|
| 1 | +/** |
| 2 | + * ID3v2 metadata parser for MP3 files. |
| 3 | + * @module @audio/decode-mp3/meta |
| 4 | + */ |
| 5 | + |
| 6 | +const TD_U8 = new TextDecoder('utf-8'), TD_L1 = new TextDecoder('iso-8859-1') |
| 7 | + |
| 8 | +function str(bytes, enc = 'utf-8') { |
| 9 | + let end = bytes.length |
| 10 | + if (enc === 'utf-16') { |
| 11 | + while (end >= 2 && bytes[end - 1] === 0 && bytes[end - 2] === 0) end -= 2 |
| 12 | + return new TextDecoder('utf-16').decode(bytes.subarray(0, end)) |
| 13 | + } |
| 14 | + while (end > 0 && bytes[end - 1] === 0) end-- |
| 15 | + return (enc === 'iso-8859-1' ? TD_L1 : TD_U8).decode(bytes.subarray(0, end)) |
| 16 | +} |
| 17 | + |
| 18 | +// ── Constants ─────────────────────────────────────────────────────────── |
| 19 | + |
| 20 | +export const ID3_MAP = { |
| 21 | + TIT2: 'title', TPE1: 'artist', TALB: 'album', TPE2: 'albumartist', |
| 22 | + TCOM: 'composer', TCON: 'genre', TYER: 'year', TDRC: 'year', |
| 23 | + TRCK: 'track', TPOS: 'disc', TBPM: 'bpm', TKEY: 'key', |
| 24 | + TCOP: 'copyright', TSRC: 'isrc', TPUB: 'publisher', TENC: 'software', |
| 25 | + COMM: 'comment', USLT: 'lyrics' |
| 26 | +} |
| 27 | + |
| 28 | +// ── Binary helpers ────────────────────────────────────────────────────── |
| 29 | + |
| 30 | +function u32be(b, o) { return (b[o] * 0x1000000) + (b[o + 1] << 16) + (b[o + 2] << 8) + b[o + 3] } |
| 31 | +function synchsafe(b, o) { return (b[o] << 21) | (b[o + 1] << 14) | (b[o + 2] << 7) | b[o + 3] } |
| 32 | + |
| 33 | +// ── ID3v2 parsing ─────────────────────────────────────────────────────── |
| 34 | + |
| 35 | +function splitEncodedStrings(b) { |
| 36 | + if (!b.length) return [] |
| 37 | + let enc = b[0], data = b.subarray(1) |
| 38 | + let out = [] |
| 39 | + if (enc === 1 || enc === 2) { |
| 40 | + let start = 0 |
| 41 | + if (enc === 1 && data.length >= 2 && ((data[0] === 0xFF && data[1] === 0xFE) || (data[0] === 0xFE && data[1] === 0xFF))) start = 2 |
| 42 | + let i = start |
| 43 | + for (let j = start; j < data.length - 1; j += 2) { |
| 44 | + if (data[j] === 0 && data[j + 1] === 0) { |
| 45 | + let td = new TextDecoder(enc === 1 ? (start === 2 && data[0] === 0xFE ? 'utf-16be' : 'utf-16le') : 'utf-16be') |
| 46 | + out.push(td.decode(data.subarray(i, j))) |
| 47 | + i = j + 2 |
| 48 | + } |
| 49 | + } |
| 50 | + if (i < data.length) { |
| 51 | + let td = new TextDecoder(enc === 1 ? (start === 2 && data[0] === 0xFE ? 'utf-16be' : 'utf-16le') : 'utf-16be') |
| 52 | + out.push(td.decode(data.subarray(i, data.length - (data[data.length - 1] === 0 && data[data.length - 2] === 0 ? 2 : 0)))) |
| 53 | + } |
| 54 | + } else { |
| 55 | + let i = 0 |
| 56 | + for (let j = 0; j < data.length; j++) { |
| 57 | + if (data[j] === 0) { out.push(str(data.subarray(i, j), enc === 3 ? 'utf-8' : 'iso-8859-1')); i = j + 1 } |
| 58 | + } |
| 59 | + if (i < data.length) out.push(str(data.subarray(i), enc === 3 ? 'utf-8' : 'iso-8859-1')) |
| 60 | + } |
| 61 | + return out |
| 62 | +} |
| 63 | + |
| 64 | +function parseApic(body) { |
| 65 | + let enc = body[0], i = 1 |
| 66 | + let mime = '', desc = '' |
| 67 | + while (i < body.length && body[i] !== 0) i++ |
| 68 | + mime = str(body.subarray(1, i), 'iso-8859-1') |
| 69 | + i++ |
| 70 | + let type = body[i++] |
| 71 | + let descStart = i |
| 72 | + if (enc === 1 || enc === 2) { |
| 73 | + while (i + 1 < body.length && !(body[i] === 0 && body[i + 1] === 0)) i += 2 |
| 74 | + desc = enc === 1 ? new TextDecoder('utf-16le').decode(body.subarray(descStart, i)) : new TextDecoder('utf-16be').decode(body.subarray(descStart, i)) |
| 75 | + i += 2 |
| 76 | + } else { |
| 77 | + while (i < body.length && body[i] !== 0) i++ |
| 78 | + desc = str(body.subarray(descStart, i), enc === 3 ? 'utf-8' : 'iso-8859-1') |
| 79 | + i++ |
| 80 | + } |
| 81 | + let data = body.slice(i) |
| 82 | + return { mime, type, description: desc, data } |
| 83 | +} |
| 84 | + |
| 85 | +/** Parse ID3v2 tag. Returns {meta, size, markers: [], regions: []} or null. */ |
| 86 | +export function parseId3v2(bytes) { |
| 87 | + if (bytes.length < 10 || bytes[0] !== 0x49 || bytes[1] !== 0x44 || bytes[2] !== 0x33) return null |
| 88 | + let ver = bytes[3], flags = bytes[5], size = synchsafe(bytes, 6) |
| 89 | + let end = Math.min(10 + size, bytes.length) |
| 90 | + let meta = {}, raw = { version: ver, frames: [] }, pictures = [] |
| 91 | + let headerSize = ver === 2 ? 6 : 10 |
| 92 | + let sizeFn = ver >= 4 ? synchsafe : (b, o) => u32be(b, o) |
| 93 | + let off = 10 |
| 94 | + if (flags & 0x40) { |
| 95 | + let extSize = ver >= 4 ? synchsafe(bytes, off) : u32be(bytes, off) |
| 96 | + off += extSize + (ver >= 4 ? 0 : 4) |
| 97 | + } |
| 98 | + while (off + headerSize <= end) { |
| 99 | + let id, frameSize |
| 100 | + if (ver === 2) { |
| 101 | + id = String.fromCharCode(bytes[off], bytes[off + 1], bytes[off + 2]) |
| 102 | + frameSize = (bytes[off + 3] << 16) | (bytes[off + 4] << 8) | bytes[off + 5] |
| 103 | + off += 6 |
| 104 | + } else { |
| 105 | + id = String.fromCharCode(bytes[off], bytes[off + 1], bytes[off + 2], bytes[off + 3]) |
| 106 | + frameSize = sizeFn(bytes, off + 4) |
| 107 | + off += 10 |
| 108 | + } |
| 109 | + if (!id.match(/^[A-Z0-9]+$/)) break |
| 110 | + if (frameSize === 0 || off + frameSize > end) break |
| 111 | + let body = bytes.subarray(off, off + frameSize) |
| 112 | + raw.frames.push({ id, body }) |
| 113 | + let key = ID3_MAP[id] |
| 114 | + if (id === 'APIC' && body.length > 0) { |
| 115 | + let p = parseApic(body) |
| 116 | + if (p) pictures.push(p) |
| 117 | + } else if (id === 'COMM' || id === 'USLT') { |
| 118 | + if (body.length >= 4) { |
| 119 | + let lang = str(body.subarray(1, 4), 'iso-8859-1') |
| 120 | + let enc = body[0], rest = body.subarray(4) |
| 121 | + let parts = splitEncodedStrings(Uint8Array.from([enc, ...rest])) |
| 122 | + let text = parts[parts.length - 1] || '' |
| 123 | + if (key) meta[key] = text |
| 124 | + if (id === 'COMM') (meta.comments = meta.comments || []).push({ lang, description: parts[0] || '', text }) |
| 125 | + } |
| 126 | + } else if (key) { |
| 127 | + let parts = splitEncodedStrings(body) |
| 128 | + meta[key] = parts.join('; ') |
| 129 | + } |
| 130 | + off += frameSize |
| 131 | + } |
| 132 | + |
| 133 | + meta.raw = raw |
| 134 | + meta.pictures = pictures |
| 135 | + return { meta, size: 10 + size, markers: [], regions: [] } |
| 136 | +} |
| 137 | + |
| 138 | +/** Parse MP3 metadata. Returns {meta, markers, regions} or null. */ |
| 139 | +export function parseMeta(bytes) { |
| 140 | + if (!bytes?.length) return null |
| 141 | + let r = parseId3v2(bytes) |
| 142 | + return r ? { meta: r.meta, markers: r.markers, regions: r.regions } : null |
| 143 | +} |
0 commit comments