@audio/decode-mp4 1.1.1 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/decode-mp4.js +115 -21
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -33,7 +33,7 @@ dec.free()
|
|
|
33
33
|
| `dtsc`, `dtsh`, `dtsl`, `dtse`, `mp4a` (0xA9) | DTS (core) | [@audio/decode-dts](../decode-dts) |
|
|
34
34
|
| `ec-3`, `mp4a` (0xA6) | E-AC-3 (Dolby Digital Plus) | [@audio/decode-eac3](../decode-eac3) |
|
|
35
35
|
|
|
36
|
-
TrueHD tracks throw an error naming the codec. Fragmented MP4 (`moof`)
|
|
36
|
+
TrueHD tracks throw an error naming the codec. Fragmented MP4 (ISO/IEC 14496-12 §8.8, `moof`+`mdat` after an empty-table `moov`) streams fragment by fragment, holding only the bytes not yet read. QuickTime sound descriptions v0, v1 and v2 are handled, including `wave`-wrapped configs and `enda` endianness flags. When `moov` follows `mdat` (typical of camera and QuickTime output) the file is buffered until the header arrives.
|
|
37
37
|
|
|
38
38
|
Codec packages are imported dynamically, which is unavailable inside an AudioWorklet — decode in the main thread or a Worker.
|
|
39
39
|
|
package/decode-mp4.js
CHANGED
|
@@ -41,6 +41,7 @@ class MP4Decoder {
|
|
|
41
41
|
this.pending = null // codec load in flight
|
|
42
42
|
this.codec = null // { feed(frames), flush(), free() }
|
|
43
43
|
this.st = null // sample-table walker: { sizes, stco, stsc, idx, ci, sInC, spc, nextOff }
|
|
44
|
+
this.fr = null // fragment walker (fragmented file): { id, trex, pos, queue, qi }
|
|
44
45
|
this.left = null // unconsumed bytes
|
|
45
46
|
this.fileOff = 0 // absolute file offset of left[0]
|
|
46
47
|
this.skip = 0 // bytes to discard before the next sample
|
|
@@ -52,7 +53,7 @@ class MP4Decoder {
|
|
|
52
53
|
if (!data || !data.byteLength) return EMPTY
|
|
53
54
|
let buf = data instanceof Uint8Array ? data : new Uint8Array(data)
|
|
54
55
|
if (this.pending) return this.pending.then(() => this.decode(buf))
|
|
55
|
-
if (this.st) return this.feed(buf)
|
|
56
|
+
if (this.st || this.fr) return this.feed(buf)
|
|
56
57
|
|
|
57
58
|
this.accum.push(buf); this.accumLen += buf.length
|
|
58
59
|
let all = this.accum.length === 1 ? this.accum[0] : concat(this.accum, this.accumLen)
|
|
@@ -64,8 +65,10 @@ class MP4Decoder {
|
|
|
64
65
|
if (this.freed) { codec.free(); return EMPTY }
|
|
65
66
|
this.codec = codec
|
|
66
67
|
let { sizes, stco, stsc } = track
|
|
67
|
-
this.st = { sizes, stco, stsc, idx: 0, ci: 0, sInC: 0, spc: spcAt(0, stsc), nextOff: stco[0] }
|
|
68
68
|
this.left = all; this.fileOff = 0; this.skip = 0
|
|
69
|
+
// fragmented (ISO/IEC 14496-12 §8.8): samples come in moof+mdat pairs after the moov
|
|
70
|
+
if (track.fragmented) { this.fr = { id: track.id, trex: track.trex, pos: 0, queue: [], qi: 0 }; return this.fragments() }
|
|
71
|
+
this.st = { sizes, stco, stsc, idx: 0, ci: 0, sInC: 0, spc: spcAt(0, stsc), nextOff: stco[0] }
|
|
69
72
|
return this.extract()
|
|
70
73
|
})
|
|
71
74
|
}
|
|
@@ -78,7 +81,37 @@ class MP4Decoder {
|
|
|
78
81
|
if (!buf.length) return EMPTY
|
|
79
82
|
}
|
|
80
83
|
this.left = append(this.left, buf)
|
|
81
|
-
return this.extract()
|
|
84
|
+
return this.fr ? this.fragments() : this.extract()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Top-level boxes in order: each complete moof queues its samples (absolute offsets), which are
|
|
88
|
+
// read out of the following mdat as its bytes arrive; bytes nobody needs any more are dropped.
|
|
89
|
+
fragments() {
|
|
90
|
+
let fr = this.fr, frames = []
|
|
91
|
+
for (;;) {
|
|
92
|
+
let q = fr.queue
|
|
93
|
+
for (; fr.qi < q.length; fr.qi++) {
|
|
94
|
+
let [off, sz] = q[fr.qi], b = off - this.fileOff
|
|
95
|
+
if (!this.left || b + sz > this.left.length) break
|
|
96
|
+
frames.push(this.left.subarray(b, b + sz))
|
|
97
|
+
}
|
|
98
|
+
if (fr.qi === q.length) { q.length = 0; fr.qi = 0 }
|
|
99
|
+
let b = fr.pos - this.fileOff, left = this.left
|
|
100
|
+
if (fr.pos === Infinity || !left || b < 0 || b + 8 > left.length) break
|
|
101
|
+
let size = r32(left, b), type = str4(left, b + 4)
|
|
102
|
+
if (size === 1) { if (b + 16 > left.length) break; size = r32(left, b + 8) * 0x100000000 + r32(left, b + 12) }
|
|
103
|
+
else if (size === 0) size = Infinity // to the end of the file
|
|
104
|
+
if (type === 'moof') {
|
|
105
|
+
if (b + size > left.length) break // wait for the whole moof
|
|
106
|
+
moofSamples(left, b, size, fr.pos, fr)
|
|
107
|
+
}
|
|
108
|
+
fr.pos += size
|
|
109
|
+
}
|
|
110
|
+
// keep bytes from the next queued sample or the next box, whichever comes first
|
|
111
|
+
let keep = Math.min(fr.pos, fr.qi < fr.queue.length ? fr.queue[fr.qi][0] : Infinity), end = this.fileOff + (this.left?.length || 0)
|
|
112
|
+
if (keep >= end && keep !== Infinity) { this.skip = keep - end; this.fileOff = end; this.left = null }
|
|
113
|
+
else if (keep > this.fileOff && keep !== Infinity) { this.left = this.left.subarray(keep - this.fileOff).slice(); this.fileOff = keep }
|
|
114
|
+
return frames.length ? this.codec.feed(frames) : EMPTY
|
|
82
115
|
}
|
|
83
116
|
|
|
84
117
|
// Walk sample tables by absolute file offset so chunk boundaries are irrelevant.
|
|
@@ -104,7 +137,6 @@ class MP4Decoder {
|
|
|
104
137
|
if (this.pending) return this.pending.then(() => this.flush())
|
|
105
138
|
this.freed = true
|
|
106
139
|
if (!this.codec) {
|
|
107
|
-
if (this.accumLen && findBox(concat(this.accum, this.accumLen), 'moof')) throw Error('Fragmented MP4 is not supported')
|
|
108
140
|
throw Error(this.accumLen ? 'No audio track found in MP4' : 'Not an MP4 file')
|
|
109
141
|
}
|
|
110
142
|
try { return this.codec.flush() } finally { this.codec.free(); this.codec = null; this.left = null }
|
|
@@ -114,14 +146,14 @@ class MP4Decoder {
|
|
|
114
146
|
if (this.freed) return
|
|
115
147
|
this.freed = true
|
|
116
148
|
this.codec?.free(); this.codec = null
|
|
117
|
-
this.accum = []; this.left = null; this.st = null
|
|
149
|
+
this.accum = []; this.left = null; this.st = null; this.fr = null
|
|
118
150
|
}
|
|
119
151
|
}
|
|
120
152
|
|
|
121
153
|
|
|
122
154
|
// ===== ISO BMFF demuxer =====
|
|
123
155
|
|
|
124
|
-
const CONTAINERS = new Set(['moov', 'trak', 'mdia', 'minf', 'stbl', 'udta', 'edts', 'sinf', 'wave'])
|
|
156
|
+
const CONTAINERS = new Set(['moov', 'trak', 'mdia', 'minf', 'stbl', 'udta', 'edts', 'sinf', 'wave', 'mvex', 'traf'])
|
|
125
157
|
|
|
126
158
|
/** Walk boxes in [start, end), calling cb(type, body, bodyOff) for leaves and cb(type, null) at container boundaries. */
|
|
127
159
|
function walk(buf, start, end, cb) {
|
|
@@ -144,10 +176,37 @@ function walk(buf, start, end, cb) {
|
|
|
144
176
|
return true
|
|
145
177
|
}
|
|
146
178
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
179
|
+
/** Queue the samples a moof's track fragments place (ISO/IEC 14496-12 §8.8.7 tfhd, §8.8.8 trun):
|
|
180
|
+
* absolute [offset, size] pairs, sizes from trun, else tfhd's default, else trex's. */
|
|
181
|
+
function moofSamples(buf, start, size, abs, fr) {
|
|
182
|
+
let traf = null
|
|
183
|
+
walk(buf, start + 8, start + size, (type, d) => {
|
|
184
|
+
if (type === 'traf') traf = { base: abs, size: fr.trex.size || 0, mine: false, next: null }
|
|
185
|
+
else if (type === 'tfhd') {
|
|
186
|
+
let flags = (d[1] << 16) | (d[2] << 8) | d[3], p = 8
|
|
187
|
+
traf.mine = r32(d, 4) === fr.id
|
|
188
|
+
if (flags & 1) { traf.base = r32(d, p) * 0x100000000 + r32(d, p + 4); p += 8 } // else: default-base-is-moof, or the first traf
|
|
189
|
+
if (flags & 2) p += 4
|
|
190
|
+
if (flags & 8) p += 4
|
|
191
|
+
if (flags & 0x10) traf.size = r32(d, p)
|
|
192
|
+
}
|
|
193
|
+
else if (type === 'trun' && traf?.mine) {
|
|
194
|
+
let flags = (d[1] << 16) | (d[2] << 8) | d[3], n = r32(d, 4), p = 8
|
|
195
|
+
let off = traf.next ?? traf.base
|
|
196
|
+
if (flags & 1) { off = traf.base + (r32(d, p) | 0); p += 4 }
|
|
197
|
+
if (flags & 4) p += 4
|
|
198
|
+
for (let i = 0; i < n; i++) {
|
|
199
|
+
if (flags & 0x100) p += 4
|
|
200
|
+
let sz = traf.size
|
|
201
|
+
if (flags & 0x200) { sz = r32(d, p); p += 4 }
|
|
202
|
+
if (flags & 0x400) p += 4
|
|
203
|
+
if (flags & 0x800) p += 4
|
|
204
|
+
fr.queue.push([off, sz])
|
|
205
|
+
off += sz
|
|
206
|
+
}
|
|
207
|
+
traf.next = off
|
|
208
|
+
}
|
|
209
|
+
})
|
|
151
210
|
}
|
|
152
211
|
|
|
153
212
|
// QuickTime sound sample description: v0 = 36-byte header, v1 adds 16 bytes, v2 declares its own size.
|
|
@@ -179,13 +238,17 @@ function parseStsd(buf, off, end, cb) {
|
|
|
179
238
|
|
|
180
239
|
/** Parse the first audio track out of a complete moov. Returns null while moov is incomplete or absent. */
|
|
181
240
|
function parseTrack(buf) {
|
|
182
|
-
let traks = [], t = null, moov = false
|
|
241
|
+
let traks = [], t = null, moov = false, trex = new Map(), movie = {}
|
|
183
242
|
walk(buf, 0, buf.length, (type, data, off) => {
|
|
184
243
|
if (type === 'moov') moov = true
|
|
244
|
+
else if (type === 'mvhd' || type === 'ilst' && !t) movie[type] = data // movie timescale; iTunes tags
|
|
185
245
|
else if (type === 'trak') traks.push(t = { children: {} })
|
|
246
|
+
else if (type === '/trak') t = null
|
|
247
|
+
else if (type === 'trex') trex.set(r32(data, 4), { size: r32(data, 16) })
|
|
186
248
|
else if (!t) return
|
|
249
|
+
else if (type === 'tkhd') t.id = r32(data, data[0] === 1 ? 20 : 12)
|
|
187
250
|
else if (type === 'hdlr') t.handler = str4(data, 8)
|
|
188
|
-
else if (type === 'mdhd') t.timescale = r32(data, data[0] === 1 ? 20 : 12)
|
|
251
|
+
else if (type === 'mdhd') { t.timescale = r32(data, data[0] === 1 ? 20 : 12); t.children.mdhd = data }
|
|
189
252
|
else if (type === 'entry') t.entry ??= data
|
|
190
253
|
else if (type === 'stsz') t.sizes = parseStsz(data)
|
|
191
254
|
else if (type === 'stco') t.stco = parseStco(data)
|
|
@@ -196,8 +259,11 @@ function parseTrack(buf) {
|
|
|
196
259
|
if (!moov) return null
|
|
197
260
|
let track = traks.find(t => t.handler === 'soun' && t.entry) ?? traks.find(t => t.entry && AUDIO_TYPES.has(t.entry.type))
|
|
198
261
|
if (!track) throw Error('No audio track found in MP4')
|
|
199
|
-
|
|
262
|
+
// movie fragments carry the samples when mvex is there and the tables are empty
|
|
263
|
+
if (trex.size && !track.sizes?.length) { track.fragmented = true; track.trex = trex.get(track.id) || {} }
|
|
264
|
+
else if (!track.sizes || !track.stco?.length) throw Error('Audio track has no sample tables')
|
|
200
265
|
if (!track.entry.sampleRate && track.timescale) track.entry.sampleRate = track.timescale
|
|
266
|
+
track.movie = movie
|
|
201
267
|
return track
|
|
202
268
|
}
|
|
203
269
|
|
|
@@ -252,25 +318,27 @@ const MP3_OTI = new Set([0x69, 0x6B]), AAC_OTI = new Set([0x40, 0x66, 0x67, 0x68
|
|
|
252
318
|
const UNSUPPORTED = { 0xE1: 'QCELP' }
|
|
253
319
|
const DTS_TYPES = new Set(['dtsc', 'dtsh', 'dtsl', 'dtse']) // DTS-HD variants carry a decodable core
|
|
254
320
|
|
|
255
|
-
async function createCodec({ entry, children }) {
|
|
321
|
+
async function createCodec({ entry, children, movie }) {
|
|
256
322
|
let { type } = entry
|
|
323
|
+
// AAC and ALAC trim the encoder's priming and padding by the track's edit list or iTunSMPB, MP3 by the edit list (gapless)
|
|
324
|
+
let boxes = { elst: children.elst, mdhd: children.mdhd, stts: children.stts, mvhd: movie?.mvhd, ilst: movie?.ilst ?? children.ilst }
|
|
257
325
|
if (type === 'mp4a') {
|
|
258
326
|
let { oti, dsi } = children.esds ? parseEsds(children.esds) : {}
|
|
259
|
-
if (MP3_OTI.has(oti)) return
|
|
327
|
+
if (MP3_OTI.has(oti)) return mp3(boxes)
|
|
260
328
|
if (oti === 0xA5) return frames(import('@audio/decode-ac3'))
|
|
261
329
|
if (oti === 0xA9) return frames(import('@audio/decode-dts'))
|
|
262
330
|
if (oti === 0xA6) return frames(import('@audio/decode-eac3'))
|
|
263
331
|
if (!oti || AAC_OTI.has(oti)) {
|
|
264
332
|
if (!dsi) throw Error('MP4 AAC track has no AudioSpecificConfig')
|
|
265
|
-
return aac({ asc: dsi })
|
|
333
|
+
return aac({ asc: dsi }, boxes)
|
|
266
334
|
}
|
|
267
335
|
throw unsupported(UNSUPPORTED[oti] || 'esds object type 0x' + oti.toString(16))
|
|
268
336
|
}
|
|
269
337
|
if (type === 'alac') {
|
|
270
338
|
if (!children.alac) throw Error('MP4 ALAC track has no magic cookie')
|
|
271
|
-
return aac({ alac: children.alac })
|
|
339
|
+
return aac({ alac: children.alac }, boxes)
|
|
272
340
|
}
|
|
273
|
-
if (type === '.mp3') return
|
|
341
|
+
if (type === '.mp3') return mp3(boxes)
|
|
274
342
|
if (type === 'fLaC') {
|
|
275
343
|
if (!children.dfLa) throw Error('MP4 FLAC track has no dfLa box')
|
|
276
344
|
return flac(children.dfLa.subarray(4))
|
|
@@ -319,8 +387,9 @@ function pcmFormat(e, ch) {
|
|
|
319
387
|
|
|
320
388
|
// --- adapters: { feed(frames: Uint8Array[]), flush(), free() } ---
|
|
321
389
|
|
|
322
|
-
async function aac(opts) {
|
|
323
|
-
let
|
|
390
|
+
async function aac(opts, boxes) {
|
|
391
|
+
let { decoder, gapless } = await import('@audio/decode-aac')
|
|
392
|
+
let dec = await decoder({ ...opts, gapless: gapless?.({ ...boxes, ...opts }) })
|
|
324
393
|
return { feed: frames => dec.decode(frames), flush: () => dec.flush(), free: () => dec.free() }
|
|
325
394
|
}
|
|
326
395
|
|
|
@@ -330,6 +399,26 @@ async function frames(load) {
|
|
|
330
399
|
return { feed: frames => dec.decode(concat(frames)), flush: () => dec.flush?.() ?? EMPTY, free: () => dec.free() }
|
|
331
400
|
}
|
|
332
401
|
|
|
402
|
+
// MP3 samples carry no LAME tag, so mpg123 decodes from the media's first sample: the edit list's window drops the
|
|
403
|
+
// encoder and decoder delay (1105 for LAME, as ffmpeg writes it) and the padding. decode-aac reads the window.
|
|
404
|
+
async function mp3(boxes) {
|
|
405
|
+
let [codec, { gapless }] = await Promise.all([frames(import('@audio/decode-mp3')), import('@audio/decode-aac')])
|
|
406
|
+
let g = gapless?.(boxes)
|
|
407
|
+
if (!g) return codec
|
|
408
|
+
let skip = null, left = null
|
|
409
|
+
return {
|
|
410
|
+
feed(frames) {
|
|
411
|
+
let r = codec.feed(frames), n = r.channelData?.[0]?.length
|
|
412
|
+
if (!n) return r
|
|
413
|
+
skip ??= Math.round(g.start * r.sampleRate); left ??= g.duration == null ? Infinity : Math.round(g.duration * r.sampleRate)
|
|
414
|
+
let a = Math.min(n, skip), b = Math.min(n, a + left)
|
|
415
|
+
skip -= a; left -= b - a
|
|
416
|
+
return b <= a ? EMPTY : a || b < n ? { ...r, channelData: r.channelData.map(c => c.subarray(a, b)) } : r
|
|
417
|
+
},
|
|
418
|
+
flush: codec.flush, free: codec.free
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
333
422
|
async function flac(blocks) {
|
|
334
423
|
let dec = await (await import('@audio/decode-flac')).decoder()
|
|
335
424
|
let head = concat([new Uint8Array([0x66, 0x4C, 0x61, 0x43]), blocks]) // "fLaC" + METADATA_BLOCKs
|
|
@@ -341,12 +430,17 @@ async function flac(blocks) {
|
|
|
341
430
|
}
|
|
342
431
|
}
|
|
343
432
|
|
|
433
|
+
// Mapping family 1 orders 3–8 channels as Vorbis does (RFC 7845 §5.1.1.2): out in SMPTE/WAV order (L R C LFE Ls Rs …)
|
|
434
|
+
// as decode-opus reorders Ogg Opus: output channel j takes the coded channel of Vorbis position OPUS_ORDER[n][j]
|
|
435
|
+
const OPUS_ORDER = { 3: [0, 2, 1], 5: [0, 2, 1, 3, 4], 6: [0, 2, 1, 5, 3, 4], 7: [0, 2, 1, 6, 5, 3, 4], 8: [0, 2, 1, 7, 5, 6, 3, 4] }
|
|
436
|
+
|
|
344
437
|
async function opus(dOps) {
|
|
345
438
|
let channels = dOps[1]
|
|
346
439
|
let opts = { channels, sampleRate: 48000, preSkip: r16(dOps, 2), outputGain: (r16(dOps, 8) << 16) >> 16 }
|
|
347
440
|
if (dOps[10] > 0) {
|
|
348
441
|
opts.streamCount = dOps[11]; opts.coupledStreamCount = dOps[12]
|
|
349
|
-
|
|
442
|
+
let table = Array.from(dOps.subarray(13, 13 + channels))
|
|
443
|
+
opts.channelMappingTable = dOps[10] === 1 && OPUS_ORDER[channels] ? OPUS_ORDER[channels].map(i => table[i]) : table
|
|
350
444
|
}
|
|
351
445
|
let dec = await (await import('@audio/decode-opus/core')).createOpusDecoder()
|
|
352
446
|
dec.configure(opts)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@audio/decode-mp4",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Decode the audio track of MP4, MOV, M4A, M4V and 3GP files (AAC, ALAC, MP3, FLAC, Opus, AMR, PCM) to PCM samples",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "decode-mp4.js",
|
|
@@ -27,13 +27,13 @@
|
|
|
27
27
|
"LICENSE"
|
|
28
28
|
],
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@audio/decode-aac": "^1.
|
|
30
|
+
"@audio/decode-aac": "^1.6.0",
|
|
31
31
|
"@audio/decode-ac3": "^1.0.0",
|
|
32
32
|
"@audio/decode-amr": "^1.3.0",
|
|
33
33
|
"@audio/decode-dts": "^1.0.0",
|
|
34
34
|
"@audio/decode-flac": "^1.3.0",
|
|
35
35
|
"@audio/decode-mp3": "^1.3.0",
|
|
36
|
-
"@audio/decode-opus": "^1.3.
|
|
36
|
+
"@audio/decode-opus": "^1.3.3",
|
|
37
37
|
"@audio/decode-eac3": "^1.0.0"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|