Skip to content

Commit 3d52dc2

Browse files
committed
Add metadata handling
1 parent a303979 commit 3d52dc2

9 files changed

Lines changed: 409 additions & 0 deletions

File tree

meta.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/**
2+
* Meta parsers — re-export from codec packages.
3+
* @module audio-decode/meta
4+
*
5+
* import { wav, mp3, flac } from 'audio-decode/meta'
6+
* let result = wav(bytes) // { meta, markers, regions } | null
7+
*/
8+
9+
export { parseMeta as wav } from '@audio/decode-wav/meta'
10+
export { parseMeta as mp3, parseId3v2 } from '@audio/decode-mp3/meta'
11+
export { parseMeta as flac } from '@audio/decode-flac/meta'

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"default": "./audio-decode.js"
1414
},
1515
"./stream": "./stream.js",
16+
"./meta": "./meta.js",
1617
"./package.json": "./package.json"
1718
},
1819
"workspaces": [

packages/decode-flac/meta.js

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/**
2+
* FLAC metadata parser — Vorbis comments, pictures, cue sheets.
3+
* @module @audio/decode-flac/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+
while (end > 0 && bytes[end - 1] === 0) end--
11+
return (enc === 'iso-8859-1' ? TD_L1 : TD_U8).decode(bytes.subarray(0, end))
12+
}
13+
14+
// ── Constants ───────────────────────────────────────────────────────────
15+
16+
const STREAMINFO = 0, PADDING = 1, VORBIS_COMMENT = 4, CUESHEET = 5, PICTURE = 6
17+
18+
export const VORBIS_MAP = {
19+
TITLE: 'title', ARTIST: 'artist', ALBUM: 'album', ALBUMARTIST: 'albumartist',
20+
COMPOSER: 'composer', GENRE: 'genre', DATE: 'year', TRACKNUMBER: 'track',
21+
DISCNUMBER: 'disc', BPM: 'bpm', KEY: 'key', COMMENT: 'comment', DESCRIPTION: 'comment',
22+
COPYRIGHT: 'copyright', ISRC: 'isrc', PUBLISHER: 'publisher', ENCODER: 'software',
23+
LYRICS: 'lyrics'
24+
}
25+
26+
// ── Binary helpers ──────────────────────────────────────────────────────
27+
28+
function u32be(b, o) { return (b[o] * 0x1000000) + (b[o + 1] << 16) + (b[o + 2] << 8) + b[o + 3] }
29+
function u32le(b, o) { return b[o] | (b[o + 1] << 8) | (b[o + 2] << 16) | (b[o + 3] * 0x1000000) }
30+
function u24be(b, o) { return (b[o] << 16) | (b[o + 1] << 8) | b[o + 2] }
31+
function fourcc(b, o) { return String.fromCharCode(b[o], b[o + 1], b[o + 2], b[o + 3]) }
32+
33+
// ── Block parsers ───────────────────────────────────────────────────────
34+
35+
function parseVorbisComment(b) {
36+
let off = 0
37+
let vendorLen = u32le(b, off); off += 4
38+
off += vendorLen
39+
let n = u32le(b, off); off += 4
40+
let out = {}
41+
for (let i = 0; i < n; i++) {
42+
let len = u32le(b, off); off += 4
43+
let s = str(b.subarray(off, off + len), 'utf-8')
44+
off += len
45+
let eq = s.indexOf('=')
46+
if (eq < 0) continue
47+
let key = s.slice(0, eq).toUpperCase(), val = s.slice(eq + 1)
48+
let norm = VORBIS_MAP[key]
49+
if (norm) out[norm] = val
50+
}
51+
return out
52+
}
53+
54+
function parseFlacPicture(b) {
55+
let off = 0
56+
let type = u32be(b, off); off += 4
57+
let mimeLen = u32be(b, off); off += 4
58+
let mime = str(b.subarray(off, off + mimeLen), 'iso-8859-1'); off += mimeLen
59+
let descLen = u32be(b, off); off += 4
60+
let desc = str(b.subarray(off, off + descLen), 'utf-8'); off += descLen
61+
off += 16 // width, height, depth, colors
62+
let dataLen = u32be(b, off); off += 4
63+
let data = b.slice(off, off + dataLen)
64+
return { mime, type, description: desc, data }
65+
}
66+
67+
function parseCueSheet(b) {
68+
let markers = [], regions = []
69+
let off = 395 // media catalog (128) + leadin (8) + flags (1) + reserved (258)
70+
if (b.length < off + 1) return { markers, regions }
71+
let numTracks = b[off]; off += 1
72+
for (let t = 0; t < numTracks; t++) {
73+
if (off + 36 > b.length) break
74+
let trackOffLow = u32be(b, off + 4), trackOffHigh = u32be(b, off)
75+
let trackOffset = trackOffHigh * 0x100000000 + trackOffLow
76+
let trackNum = b[off + 8]
77+
let nIdx = b[off + 35]
78+
off += 36
79+
if (nIdx > 0 && trackNum < 170) markers.push({ sample: trackOffset, label: `Track ${trackNum}` })
80+
off += nIdx * 12
81+
}
82+
return { markers, regions }
83+
}
84+
85+
/** Parse FLAC metadata blocks. Returns {meta, sampleRate, markers, regions} or null. */
86+
export function parseMeta(bytes) {
87+
if (!bytes?.length || bytes.length < 4 || fourcc(bytes, 0) !== 'fLaC') return null
88+
let meta = {}, raw = { blocks: [] }, pictures = []
89+
let sampleRate = 0, markers = [], regions = []
90+
let off = 4
91+
while (off + 4 <= bytes.length) {
92+
let hdr = bytes[off]
93+
let last = !!(hdr & 0x80), type = hdr & 0x7f
94+
let size = u24be(bytes, off + 1)
95+
let body = bytes.subarray(off + 4, off + 4 + size)
96+
if (type !== STREAMINFO && type !== PADDING) raw.blocks.push({ type, body })
97+
if (type === STREAMINFO && body.length >= 18) {
98+
sampleRate = (body[10] << 12) | (body[11] << 4) | (body[12] >> 4)
99+
} else if (type === VORBIS_COMMENT) {
100+
Object.assign(meta, parseVorbisComment(body))
101+
} else if (type === PICTURE) {
102+
let p = parseFlacPicture(body)
103+
if (p) pictures.push(p)
104+
} else if (type === CUESHEET) {
105+
let c = parseCueSheet(body)
106+
markers.push(...c.markers); regions.push(...c.regions)
107+
}
108+
off += 4 + size
109+
if (last) break
110+
}
111+
112+
meta.raw = raw
113+
meta.pictures = pictures
114+
return { meta, sampleRate, markers, regions }
115+
}

packages/decode-flac/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@
77
"types": "decode-flac.d.ts",
88
"exports": {
99
".": "./decode-flac.js",
10+
"./meta": "./meta.js",
1011
"./package.json": "./package.json"
1112
},
1213
"files": [
1314
"decode-flac.js",
1415
"decode-flac.d.ts",
16+
"meta.js",
1517
"LICENSE"
1618
],
1719
"scripts": {

packages/decode-mp3/meta.js

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
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+
}

packages/decode-mp3/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@
77
"types": "decode-mp3.d.ts",
88
"exports": {
99
".": "./decode-mp3.js",
10+
"./meta": "./meta.js",
1011
"./package.json": "./package.json"
1112
},
1213
"files": [
1314
"decode-mp3.js",
1415
"decode-mp3.d.ts",
16+
"meta.js",
1517
"LICENSE"
1618
],
1719
"scripts": {

0 commit comments

Comments
 (0)