@audio/decode-mp4 1.1.0 → 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.
- package/README.md +2 -1
- package/decode-mp4.js +81 -15
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -31,8 +31,9 @@ dec.free()
|
|
|
31
31
|
| `ulaw`, `alaw` | G.711 | built in |
|
|
32
32
|
| `ac-3`, `mp4a` (0xA5) | AC-3 | [@audio/decode-ac3](../decode-ac3) |
|
|
33
33
|
| `dtsc`, `dtsh`, `dtsl`, `dtse`, `mp4a` (0xA9) | DTS (core) | [@audio/decode-dts](../decode-dts) |
|
|
34
|
+
| `ec-3`, `mp4a` (0xA6) | E-AC-3 (Dolby Digital Plus) | [@audio/decode-eac3](../decode-eac3) |
|
|
34
35
|
|
|
35
|
-
|
|
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.
|
|
36
37
|
|
|
37
38
|
Codec packages are imported dynamically, which is unavailable inside an AudioWorklet — decode in the main thread or a Worker.
|
|
38
39
|
|
package/decode-mp4.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* MP4 / MOV / M4A / M4V / 3GP audio decoder — ISO BMFF demuxer that routes the audio track to a codec:
|
|
3
3
|
* AAC, ALAC → @audio/decode-aac · MP3 → @audio/decode-mp3 · FLAC → @audio/decode-flac
|
|
4
|
-
* Opus → @audio/decode-opus/core · AMR → @audio/decode-amr · AC-3 → @audio/decode-ac3 ·
|
|
5
|
-
* PCM / µ-law / A-law inline.
|
|
4
|
+
* Opus → @audio/decode-opus/core · AMR → @audio/decode-amr · AC-3 → @audio/decode-ac3 · E-AC-3 → @audio/decode-eac3
|
|
5
|
+
* DTS → @audio/decode-dts · PCM / µ-law / A-law inline.
|
|
6
6
|
*
|
|
7
7
|
* let { channelData, sampleRate } = await decode(mp4buf)
|
|
8
8
|
* let dec = await decoder(); let result = await dec.decode(chunk)
|
|
@@ -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,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
|
-
|
|
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
|
}
|
|
@@ -249,7 +313,7 @@ function advance(st) {
|
|
|
249
313
|
// ===== codec routing =====
|
|
250
314
|
|
|
251
315
|
const MP3_OTI = new Set([0x69, 0x6B]), AAC_OTI = new Set([0x40, 0x66, 0x67, 0x68])
|
|
252
|
-
const UNSUPPORTED = {
|
|
316
|
+
const UNSUPPORTED = { 0xE1: 'QCELP' }
|
|
253
317
|
const DTS_TYPES = new Set(['dtsc', 'dtsh', 'dtsl', 'dtse']) // DTS-HD variants carry a decodable core
|
|
254
318
|
|
|
255
319
|
async function createCodec({ entry, children }) {
|
|
@@ -259,6 +323,7 @@ async function createCodec({ entry, children }) {
|
|
|
259
323
|
if (MP3_OTI.has(oti)) return frames(import('@audio/decode-mp3'))
|
|
260
324
|
if (oti === 0xA5) return frames(import('@audio/decode-ac3'))
|
|
261
325
|
if (oti === 0xA9) return frames(import('@audio/decode-dts'))
|
|
326
|
+
if (oti === 0xA6) return frames(import('@audio/decode-eac3'))
|
|
262
327
|
if (!oti || AAC_OTI.has(oti)) {
|
|
263
328
|
if (!dsi) throw Error('MP4 AAC track has no AudioSpecificConfig')
|
|
264
329
|
return aac({ asc: dsi })
|
|
@@ -280,6 +345,7 @@ async function createCodec({ entry, children }) {
|
|
|
280
345
|
}
|
|
281
346
|
if (type === 'samr' || type === 'sawb') return amr(type === 'sawb')
|
|
282
347
|
if (type === 'ac-3') return frames(import('@audio/decode-ac3'))
|
|
348
|
+
if (type === 'ec-3') return frames(import('@audio/decode-eac3'))
|
|
283
349
|
if (DTS_TYPES.has(type)) return frames(import('@audio/decode-dts'))
|
|
284
350
|
let fmt = pcmFormat(entry, children)
|
|
285
351
|
if (fmt) return pcm(fmt)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@audio/decode-mp4",
|
|
3
|
-
"version": "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",
|
|
@@ -33,7 +33,8 @@
|
|
|
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.0",
|
|
37
|
+
"@audio/decode-eac3": "^1.0.0"
|
|
37
38
|
},
|
|
38
39
|
"devDependencies": {
|
|
39
40
|
"tst": "^9.2.2"
|