-
Notifications
You must be signed in to change notification settings - Fork 16
Expand file tree
/
Copy pathtest.js
More file actions
336 lines (301 loc) · 12.3 KB
/
Copy pathtest.js
File metadata and controls
336 lines (301 loc) · 12.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
import { readFileSync } from 'fs'
import decode, { decoder } from './decode-aac.js'
import { parseMeta } from './meta.js'
let pass = 0, fail = 0
function ok(cond, msg) {
if (cond) { pass++; console.log(' ok', msg) }
else { fail++; console.log(' FAIL', msg) }
}
function near(a, b, tol = 0.02) { return Math.abs(a - b) < tol }
function rms(f32) { let s = 0; for (let i = 0; i < f32.length; i++) s += f32[i] * f32[i]; return Math.sqrt(s / f32.length) }
let m4a = readFileSync(new URL('lena.m4a', import.meta.resolve('audio-lena')))
let aac = readFileSync(new URL('lena.aac', import.meta.resolve('audio-lena')))
// ---- M4A decode ----
console.log('M4A decode')
{
let r = await decode(m4a)
ok(r.channelData.length === 2, 'stereo')
ok(r.sampleRate === 44100, 'sampleRate 44100')
ok(near(r.channelData[0].length / r.sampleRate, 12.28, 0.05), 'duration ~12.28s')
ok(rms(r.channelData[0]) > 0.05, 'has audio content')
// verify no NaN/Inf
let bad = 0
for (let ch of r.channelData) for (let i = 0; i < ch.length; i++) if (!isFinite(ch[i])) bad++
ok(bad === 0, 'no NaN/Inf in output')
}
// ---- ADTS decode ----
console.log('ADTS decode')
{
let r = await decode(aac)
ok(r.channelData.length >= 1, 'has channels (' + r.channelData.length + ')')
ok(r.sampleRate === 44100, 'sampleRate 44100')
ok(r.channelData[0].length > 100000, 'has samples (' + r.channelData[0].length + ')')
ok(rms(r.channelData[0]) > 0.05, 'has audio content')
// no NaN
let bad = 0
for (let i = 0; i < r.channelData[0].length; i++) if (!isFinite(r.channelData[0][i])) bad++
ok(bad === 0, 'no NaN/Inf')
}
// ---- Stereo separation ----
console.log('stereo separation')
{
let r = await decode(m4a)
let diff = 0
for (let i = 0; i < Math.min(1000, r.channelData[0].length); i++)
diff += Math.abs(r.channelData[0][i] - r.channelData[1][i])
ok(diff > 0.001, 'L/R channels differ')
}
// ---- Sequential determinism ----
console.log('sequential determinism')
{
let r1 = await decode(m4a)
let r2 = await decode(m4a)
ok(r1.channelData[0].length === r2.channelData[0].length, 'same length')
ok(r1.sampleRate === r2.sampleRate, 'same sampleRate')
let maxDiff = 0
for (let i = 0; i < r1.channelData[0].length; i++)
maxDiff = Math.max(maxDiff, Math.abs(r1.channelData[0][i] - r2.channelData[0][i]))
ok(maxDiff === 0, 'identical output')
}
// ---- Concurrent decoders ----
console.log('concurrent decoders')
{
let [r1, r2] = await Promise.all([decode(m4a), decode(m4a)])
ok(r1.channelData[0].length === r2.channelData[0].length, 'same length')
}
// ---- Lifecycle ----
console.log('lifecycle')
{
// decode after free throws
let d = await decoder()
d.free()
let threw = false
try { d.decode(new Uint8Array(m4a)) } catch { threw = true }
ok(threw, 'decode after free throws')
// double free is safe
d = await decoder()
d.decode(new Uint8Array(m4a))
d.free()
d.free()
ok(true, 'double free ok')
// flush returns EMPTY
d = await decoder()
d.decode(new Uint8Array(m4a))
let f = d.flush()
ok(f.channelData.length === 0, 'flush returns EMPTY')
d.free()
}
// ---- M4A streaming (moov split across chunks, non-faststart layout) ----
// Regression: github #44 — init fired on partial moov → garbage stsz/stco → 0 frames.
console.log('M4A streaming')
{
let ref = await decode(m4a)
for (let chunkSize of [16384, 4096, 1024, 256]) {
let dec = await decoder()
let chunks = []
for (let off = 0; off < m4a.length; off += chunkSize) {
let r = dec.decode(m4a.subarray(off, Math.min(off + chunkSize, m4a.length)))
if (r.channelData.length) chunks.push(r.channelData[0])
}
let f = dec.flush()
if (f.channelData.length) chunks.push(f.channelData[0])
dec.free()
let total = chunks.reduce((s, c) => s + c.length, 0)
ok(total === ref.channelData[0].length, 'chunk=' + chunkSize + ' count matches (' + total + ')')
let stream = new Float32Array(total), pos = 0
for (let c of chunks) { stream.set(c, pos); pos += c.length }
let maxDiff = 0
for (let i = 0; i < total; i++) maxDiff = Math.max(maxDiff, Math.abs(stream[i] - ref.channelData[0][i]))
ok(maxDiff === 0, 'chunk=' + chunkSize + ' content identical')
}
}
// ---- M4A streaming, faststart layout (moov before mdat) ----
// Regression: github #45 — when init parses moov early with no room for first frame,
// _left stayed null and subsequent chunks misaligned, yielding 0 frames.
console.log('M4A streaming (faststart)')
{
// Build faststart variant: ftyp + moov + mdat, with stco offsets rewritten.
let r32 = (b, o) => (b[o] << 24 | b[o + 1] << 16 | b[o + 2] << 8 | b[o + 3]) >>> 0
let w32 = (b, o, v) => { b[o] = (v >>> 24) & 255; b[o + 1] = (v >>> 16) & 255; b[o + 2] = (v >>> 8) & 255; b[o + 3] = v & 255 }
let src = new Uint8Array(m4a), boxes = {}, off = 0
while (off < src.length - 8) {
let sz = r32(src, off)
let t = String.fromCharCode(src[off + 4], src[off + 5], src[off + 6], src[off + 7])
boxes[t] = { off, sz }
off += sz
}
let moov = src.slice(boxes.moov.off, boxes.moov.off + boxes.moov.sz)
let mdat = src.subarray(boxes.mdat.off, boxes.mdat.off + boxes.mdat.sz)
let newMdatOff = boxes.ftyp.sz + moov.length
let delta = newMdatOff - boxes.mdat.off
for (let i = 0; i < moov.length - 4; i++) {
let t = String.fromCharCode(moov[i], moov[i + 1], moov[i + 2], moov[i + 3])
if (t === 'stco') {
let n = r32(moov, i + 8)
for (let k = 0; k < n; k++) w32(moov, i + 12 + k * 4, r32(moov, i + 12 + k * 4) + delta)
}
}
let fast = new Uint8Array(boxes.ftyp.sz + moov.length + mdat.length)
fast.set(src.subarray(0, boxes.ftyp.sz), 0)
fast.set(moov, boxes.ftyp.sz)
fast.set(mdat, newMdatOff)
let ref = await decode(fast)
ok(ref.channelData[0].length > 0, 'faststart whole-file decodes')
// 64-byte chunks: moov spans many chunks AND first frame can't fit at init time.
for (let chunkSize of [16384, 1024, 256, 64]) {
let dec = await decoder()
let chunks = []
for (let off = 0; off < fast.length; off += chunkSize) {
let r = dec.decode(fast.subarray(off, Math.min(off + chunkSize, fast.length)))
if (r.channelData.length) chunks.push(r.channelData[0])
}
let f = dec.flush()
if (f.channelData.length) chunks.push(f.channelData[0])
dec.free()
let total = chunks.reduce((s, c) => s + c.length, 0)
ok(total === ref.channelData[0].length, 'chunk=' + chunkSize + ' count matches (' + total + ')')
let stream = new Float32Array(total), pos = 0
for (let c of chunks) { stream.set(c, pos); pos += c.length }
let maxDiff = 0
for (let i = 0; i < total; i++) maxDiff = Math.max(maxDiff, Math.abs(stream[i] - ref.channelData[0][i]))
ok(maxDiff === 0, 'chunk=' + chunkSize + ' content identical')
}
}
// ---- M4A with a chapter track ----
// Regression: github #48 — a second non-audio trak (QuickTime chapter/text track,
// e.g. ffmpeg-copied Logic Pro markers) clobbered the audio trak's stsz/stco/stsc
// in the flat moov parse → 0 frames. Tables must come from the audio trak only.
console.log('M4A chapter track')
{
let chaptered = readFileSync(new URL('./fixtures/chaptered.m4a', import.meta.url))
// phase-independent pitch estimate (AAC priming shifts the signal): zero-crossing rate
let freq = (ch, sr) => {
let a = ch.subarray(4096, ch.length - 1024), z = 0
for (let i = 1; i < a.length; i++) if ((a[i - 1] < 0) !== (a[i] < 0)) z++
return z * sr / (2 * a.length)
}
let r = await decode(chaptered)
ok(r.channelData.length === 2, 'stereo')
ok(r.sampleRate === 44100, 'sampleRate 44100')
ok(near(r.channelData[0].length / r.sampleRate, 2.0, 0.1), 'duration ~2s (' + (r.channelData[0].length / r.sampleRate).toFixed(2) + 's)')
ok(rms(r.channelData[0]) > 0.05, 'has audio content')
ok(near(freq(r.channelData[0], r.sampleRate) / 440, 1, 0.05), 'ch0 ≈ 440Hz')
ok(near(freq(r.channelData[1], r.sampleRate) / 660, 1, 0.05), 'ch1 ≈ 660Hz')
// chunked feed goes through the same trak selection on the streaming path
let dec = await decoder(), total = 0
for (let off = 0; off < chaptered.length; off += 1024) {
let c = dec.decode(chaptered.subarray(off, Math.min(off + 1024, chaptered.length)))
if (c.channelData.length) total += c.channelData[0].length
}
dec.free()
ok(total === r.channelData[0].length, 'chunked count matches (' + total + ')')
}
// ---- ADTS streaming (partial frame buffering) ----
console.log('ADTS streaming')
{
let ref = await decode(aac)
for (let chunkSize of [100, 300]) {
let dec = await decoder()
let chunks = []
for (let off = 0; off < aac.length; off += chunkSize) {
let r = dec.decode(aac.subarray(off, Math.min(off + chunkSize, aac.length)))
if (r.channelData.length) chunks.push(r.channelData[0])
}
let f = dec.flush()
if (f.channelData.length) chunks.push(f.channelData[0])
dec.free()
let total = chunks.reduce((s, c) => s + c.length, 0)
ok(total === ref.channelData[0].length, 'chunk=' + chunkSize + ' count matches (' + total + ')')
// verify content matches one-shot
let stream = new Float32Array(total), pos = 0
for (let c of chunks) { stream.set(c, pos); pos += c.length }
let maxDiff = 0
for (let i = 0; i < total; i++) maxDiff = Math.max(maxDiff, Math.abs(stream[i] - ref.channelData[0][i]))
ok(maxDiff === 0, 'chunk=' + chunkSize + ' content identical (maxDiff=' + maxDiff + ')')
}
}
// ---- Edge cases ----
console.log('edge cases')
{
// empty input
let d = await decoder()
let r = d.decode(new Uint8Array(0))
let tail = d.flush()
ok(!(r instanceof Promise) && !(tail instanceof Promise), 'decode and flush return values')
ok(r.channelData.length === 0, 'empty input → EMPTY')
d.free()
// null input
d = await decoder()
r = d.decode(null)
ok(r.channelData.length === 0, 'null input → EMPTY')
d.free()
// garbage input
d = await decoder()
let garbage = new Uint8Array(1000)
crypto.getRandomValues(garbage)
try {
r = d.decode(garbage)
ok(r.channelData.length === 0, 'garbage → EMPTY or error')
} catch {
ok(true, 'garbage → threw (ok)')
}
d.free()
// truncated M4A (ftyp header only)
d = await decoder()
r = d.decode(new Uint8Array(m4a.subarray(0, 500)))
ok(r.channelData.length === 0, 'truncated M4A → EMPTY')
d.free()
// ArrayBuffer input
let input = m4a.buffer.slice(m4a.byteOffset, m4a.byteOffset + m4a.length)
r = await decode(input)
ok(r.channelData.length === 2, 'ArrayBuffer input works')
d = await decoder()
r = d.decode(input)
d.free()
ok(r.channelData.length === 2, 'decoder accepts ArrayBuffer')
}
// ---- Performance ----
console.log('performance')
{
// warmup
await decode(m4a)
let t0 = performance.now(), N = 10
for (let i = 0; i < N; i++) await decode(m4a)
let ms = (performance.now() - t0) / N
ok(ms < 200, 'M4A decode < 200ms (' + ms.toFixed(1) + 'ms)')
console.log(' ' + ms.toFixed(1) + 'ms/decode (249KB, 12.3s audio)')
}
// ===== ALAC (Apple Lossless) — afconvert fixtures, 16 + 24 bit =====
console.log('ALAC')
{
let sine = (n, f, sr = 44100) => Array.from({ length: n }, (_, i) => Math.sin(2 * Math.PI * f * i / sr))
let corr = (a, b) => {
let n = Math.min(a.length, b.length), sa = 0, sb = 0, sab = 0
for (let i = 0; i < n; i++) { sa += a[i] * a[i]; sb += b[i] * b[i]; sab += a[i] * b[i] }
return sab / Math.sqrt(sa * sb)
}
for (let [name, nCh] of [['alac_mono', 1], ['alac_stereo', 2], ['alac24_mono', 1], ['alac24_stereo', 2]]) {
let r = await decode(readFileSync(new URL('./fixtures/' + name + '.m4a', import.meta.url)))
ok(r.sampleRate === 44100, name + ': sampleRate 44100')
ok(r.channelData.length === nCh, name + ': ' + nCh + 'ch')
ok(r.channelData[0].length === 22050, name + ': sample count')
ok(corr(r.channelData[0], sine(r.channelData[0].length, 440)) > 0.99, name + ': ch0 ≈ 440Hz')
if (nCh === 2) ok(corr(r.channelData[1], sine(r.channelData[1].length, 660)) > 0.99, name + ': ch1 ≈ 660Hz')
}
}
// ===== metadata (iTunes ilst tags) =====
console.log('M4A metadata')
{
let { meta, sampleRate } = parseMeta(readFileSync(new URL('./fixtures/tagged.m4a', import.meta.url)))
ok(sampleRate === 44100, 'sampleRate from mp4a box')
ok(meta.title === 'Lena Sine', 'title (©nam)')
ok(meta.artist === 'audiojs', 'artist (©ART)')
ok(meta.album === 'Fixtures', 'album (©alb)')
ok(meta.year === '2026', 'year (©day)')
ok(meta.genre === 'Test', 'genre (©gen)')
ok(meta.track === '3', 'track (trkn)')
ok(meta.pictures.length === 1 && meta.pictures[0].mime === 'image/png', 'cover art (covr)')
ok(parseMeta(new Uint8Array([0, 0, 0, 8, 1, 2, 3, 4])) === null, 'non-MP4 → null')
}
console.log(`\n${pass + fail} tests, ${pass} passed, ${fail} failed`)
if (fail) process.exit(1)