@audio/decode-mp4 1.1.1 → 1.2.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 +76 -12
  3. package/package.json +1 -1
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,11 +238,14 @@ 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()
183
242
  walk(buf, 0, buf.length, (type, data, off) => {
184
243
  if (type === 'moov') moov = true
185
244
  else if (type === 'trak') traks.push(t = { children: {} })
245
+ else if (type === '/trak') t = null
246
+ else if (type === 'trex') trex.set(r32(data, 4), { size: r32(data, 16) })
186
247
  else if (!t) return
248
+ else if (type === 'tkhd') t.id = r32(data, data[0] === 1 ? 20 : 12)
187
249
  else if (type === 'hdlr') t.handler = str4(data, 8)
188
250
  else if (type === 'mdhd') t.timescale = r32(data, data[0] === 1 ? 20 : 12)
189
251
  else if (type === 'entry') t.entry ??= data
@@ -196,7 +258,9 @@ function parseTrack(buf) {
196
258
  if (!moov) return null
197
259
  let track = traks.find(t => t.handler === 'soun' && t.entry) ?? traks.find(t => t.entry && AUDIO_TYPES.has(t.entry.type))
198
260
  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')
261
+ // movie fragments carry the samples when mvex is there and the tables are empty
262
+ if (trex.size && !track.sizes?.length) { track.fragmented = true; track.trex = trex.get(track.id) || {} }
263
+ else if (!track.sizes || !track.stco?.length) throw Error('Audio track has no sample tables')
200
264
  if (!track.entry.sampleRate && track.timescale) track.entry.sampleRate = track.timescale
201
265
  return track
202
266
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@audio/decode-mp4",
3
- "version": "1.1.1",
3
+ "version": "1.2.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",