@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.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/decode-mp4.js +115 -21
  3. 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`) is not supported. 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.
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
- function findBox(buf, name) {
148
- let found = false
149
- walk(buf, 0, buf.length, type => { if (type === name) found = true })
150
- return found
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
- if (!track.sizes || !track.stco?.length) throw Error('Audio track has no sample tables')
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 frames(import('@audio/decode-mp3'))
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 frames(import('@audio/decode-mp3'))
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 dec = await (await import('@audio/decode-aac')).decoder(opts)
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
- opts.channelMappingTable = Array.from(dOps.subarray(13, 13 + channels))
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.1.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.5.0",
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.0",
36
+ "@audio/decode-opus": "^1.3.3",
37
37
  "@audio/decode-eac3": "^1.0.0"
38
38
  },
39
39
  "devDependencies": {