-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtest.js
More file actions
422 lines (377 loc) · 17.8 KB
/
Copy pathtest.js
File metadata and controls
422 lines (377 loc) · 17.8 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
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
import test, { almost, ok, is } from 'tst'
import * as audio from './index.js'
import { freqz, mag2db } from 'digital-filter'
import { magDB, EPSILON, impulse } from './test/util.js'
// IEC 61672-1:2013 Table 2 — A-weighting nominal values (dB)
const IEC_A = {
31.5: -39.4, 63: -26.2, 125: -16.1, 250: -8.6, 500: -3.2,
1000: 0.0, 2000: 1.2, 4000: 1.0, 8000: -1.1, 10000: -2.5,
16000: -6.6, 20000: -9.3
}
// IEC 61672-1:2013 Table 2 — C-weighting nominal values (dB)
const IEC_C = {
31.5: -3.0, 63: -0.8, 125: -0.2, 250: 0.0, 500: 0.0,
1000: 0.0, 2000: -0.2, 4000: -0.8, 8000: -3.0
}
// Rec. ITU-R BS.468-4 Annex 1 table, dB rel 1kHz (reproduced in Wikipedia
// "ITU-R 468 noise weighting"; digits also embedded in itu468-poles.mjs
// used to derive the analog pole positions in weighting/itu468.js)
const BS468 = {
31.5: -29.9, 63: -23.9, 100: -19.8, 200: -13.8, 400: -7.8, 800: -1.9,
1000: 0, 2000: 5.6, 3150: 9.0, 4000: 10.5, 5000: 11.7, 6300: 12.2,
7100: 12.0, 8000: 11.4, 9000: 10.1, 10000: 8.1, 12500: 0, 14000: -5.3,
16000: -11.7, 20000: -22.2
}
// Matched-z discretization of the itu468 analog prototype (0.05dB max error,
// see itu468-poles.mjs) is near-exact away from Nyquist and degrades close to
// it; tolerances below are measured envelopes (script:
// scratchpad/fix-weighting/verify-itu468.mjs), not arbitrary slack.
function itu468Tol(fs, f) {
if (f <= 800) return 0.15
if (f <= 6300) return fs >= 96000 ? 0.6 : fs >= 48000 ? 1.5 : 1.8
if (f <= 10000) return fs >= 96000 ? 1.0 : fs >= 48000 ? 3.5 : 4.2
return fs >= 96000 ? 3.5 : fs >= 48000 ? 14.5 : 17.5
}
test('aWeighting — IEC 61672 table values at 96kHz', () => {
// matched z-transform: ≤10kHz excellent; 16–20kHz within IEC Class 1 (±2 dB)
let sos = audio.aWeighting.coefs(96000)
for (let [f, expected] of Object.entries(IEC_A)) {
let tol = +f >= 16000 ? 1.5 : 0.5
let got = magDB(sos, +f, 96000)
ok(Math.abs(got - expected) < tol, `A-weighting ${f}Hz: expected ${expected}, got ${got.toFixed(2)} dB`)
}
})
test('aWeighting — IEC 61672 table values at 48kHz', () => {
// matched z-transform: ≤8kHz < 1 dB; 10kHz ~1.1 dB (IEC Class 1 boundary);
// 16–20kHz: better than bilinear (4 dB vs 12 dB error) but 48kHz is insufficient there
let sos = audio.aWeighting.coefs(48000)
for (let [f, expected] of Object.entries(IEC_A)) {
let tol = +f >= 16000 ? 5.0 : +f >= 10000 ? 1.2 : 1.0
let got = magDB(sos, +f, 48000)
ok(Math.abs(got - expected) < tol, `A-weighting ${f}Hz@48kHz: expected ${expected}, got ${got.toFixed(2)} dB`)
}
})
test('cWeighting — IEC 61672 table values at 96kHz', () => {
let sos = audio.cWeighting.coefs(96000)
for (let [f, expected] of Object.entries(IEC_C)) {
let got = magDB(sos, +f, 96000)
ok(Math.abs(got - expected) < 0.5, `C-weighting ${f}Hz: expected ${expected}, got ${got.toFixed(2)} dB`)
}
})
test('aWeighting — 3 SOS sections via coefs', () => {
let sos = audio.aWeighting.coefs(44100)
is(sos.length, 3, '3 sections')
ok(sos[0].b0 !== undefined, 'has coefficients')
})
test('aWeighting — 0dB at 1kHz (analytic normalization exactness)', () => {
// matchedZ's normalize() sets gain to exactly 0dB at normFreq by construction;
// evaluated directly (magDB) rather than via freqz's rounded FFT bin, whose
// nearest-bin frequency error alone (~1.3Hz off at 44100/2048) already exceeds 1e-3dB
let sos = audio.aWeighting.coefs(44100)
let db = magDB(sos, 1000, 44100)
almost(db, 0, 1e-9, 'A-weighting exactly 0dB at 1kHz, got ' + db)
})
test('aWeighting — in-place processing', () => {
let data = impulse(512)
let p = { fs: 44100 }
audio.aWeighting(data, p)
let hasOutput = false
for (let v of data) if (Math.abs(v) > 1e-10) { hasOutput = true; break }
ok(hasOutput, 'aWeighting produces output')
})
test('cWeighting — 2 SOS sections via coefs', () => {
let sos = audio.cWeighting.coefs(44100)
is(sos.length, 2, '2 sections')
})
test('cWeighting — 0dB at 1kHz (analytic normalization exactness)', () => {
let sos = audio.cWeighting.coefs(44100)
let db = magDB(sos, 1000, 44100)
almost(db, 0, 1e-9, 'C-weighting exactly 0dB at 1kHz, got ' + db)
})
test('cWeighting — in-place processing', () => {
let data = impulse(512)
let p = { fs: 44100 }
audio.cWeighting(data, p)
let hasOutput = false
for (let v of data) if (Math.abs(v) > 1e-10) { hasOutput = true; break }
ok(hasOutput, 'cWeighting produces output')
})
test('kWeighting 48kHz — exact BS.1770-4 Annex 1 coefficients (all 5 coefs, both stages)', () => {
// ITU-R BS.1770-4 Annex 1: pre-filter stage 1 (high shelf) and stage 2 (RLB highpass)
let sos = audio.kWeighting.coefs(48000)
is(sos.length, 2, '2 stages')
almost(sos[0].b0, 1.53512485958697, EPSILON)
almost(sos[0].b1, -2.69169618940638, EPSILON)
almost(sos[0].b2, 1.19839281085285, EPSILON)
almost(sos[0].a1, -1.69065929318241, EPSILON)
almost(sos[0].a2, 0.73248077421585, EPSILON)
almost(sos[1].b0, 1.0, EPSILON)
almost(sos[1].b1, -2.0, EPSILON)
almost(sos[1].b2, 1.0, EPSILON)
almost(sos[1].a1, -1.99004745483398, EPSILON)
almost(sos[1].a2, 0.99007225036621, EPSILON)
})
test('kWeighting other rate — still returns 2 sections', () => {
let sos = audio.kWeighting.coefs(44100)
is(sos.length, 2, '2 stages at 44100')
})
test('kWeighting 44100Hz — shelf matches analog design gain at DC/Nyquist', () => {
// Independent reference check at a non-48kHz rate: for ANY correctly
// bilinear-transformed 2nd-order analog shelf, z=1 (DC) maps exactly to
// s=0 and z=-1 (Nyquist) maps exactly to s=∞ (Zölzer, "Digital Audio
// Signal Processing", bilinear transform frequency mapping) — so the
// digital shelf's gain at DC must equal the analog low-frequency
// asymptote (0dB, unity) and at Nyquist must equal the analog high-shelf
// gain G exactly, for every sample rate, independent of the specific
// coefficient formula used to reach it.
let G = 3.999843853973347 // weighting/k-weighting.js shelf gain constant
for (let fs of [44100, 96000, 192000]) {
let shelf = [audio.kWeighting.coefs(fs)[0]]
almost(magDB(shelf, 1e-6, fs), 0, 1e-6, `shelf@${fs} ≈0dB at DC`)
almost(magDB(shelf, fs / 2 - 1e-6, fs), G, 1e-6, `shelf@${fs} ≈${G}dB at Nyquist`)
}
})
test('kWeighting 44100Hz — RLB stage keeps unnormalized {1,-2,1} numerator at any fs', () => {
// ITU-R BS.1770-4 Annex 1: RLB highpass b-coefficients are the fixed
// double-zero-at-DC numerator (1 - z^-1)^2, unnormalized, independent of fs
for (let fs of [44100, 48000, 96000]) {
let rlb = audio.kWeighting.coefs(fs)[1]
is(rlb.b0, 1, `rlb.b0@${fs}`)
is(rlb.b1, -2, `rlb.b1@${fs}`)
is(rlb.b2, 1, `rlb.b2@${fs}`)
}
})
test('itu468 — exact 3 SOS sections (6 matched-z poles paired into 3 biquads)', () => {
let sos = audio.itu468.coefs(48000)
is(sos.length, 3, '3 sections')
})
test('itu468 — in-place processing', () => {
let data = impulse(512)
let p = { fs: 48000 }
audio.itu468(data, p)
let hasOutput = false
for (let v of data) if (Math.abs(v) > 1e-10) { hasOutput = true; break }
ok(hasOutput, 'itu468 produces output')
})
test('riaa — returns 1 section', () => {
let sos = audio.riaa.coefs(44100)
is(sos.length, 1, '1 section')
})
test('riaa — bass boost at 20Hz', () => {
let sos = audio.riaa.coefs(44100)
let resp = freqz(sos, 4096, 44100)
let idx20 = Math.round(20 / (44100 / 2) * 4096)
let idx1k = Math.round(1000 / (44100 / 2) * 4096)
let db20 = mag2db(resp.magnitude[idx20])
let db1k = mag2db(resp.magnitude[idx1k])
ok(db20 > db1k + 10, 'RIAA boosts bass (20Hz > 1kHz by >10dB)')
})
test('riaa — in-place processing', () => {
let data = impulse(512)
let p = { fs: 44100 }
audio.riaa(data, p)
let hasOutput = false
for (let v of data) if (Math.abs(v) > 1e-10) { hasOutput = true; break }
ok(hasOutput, 'riaa produces output')
})
test('itu468 — peaked response near 6.3kHz', () => {
let sos = audio.itu468.coefs(48000)
is(sos.length, 3, '3 sections')
let resp = freqz(sos, 4096, 48000)
let db = mag2db(resp.magnitude)
let idx2k = Math.round(2000 / (48000 / 2) * 4096)
let idx6k = Math.round(6300 / (48000 / 2) * 4096)
// ITU-468 peaks near 6.3kHz, significantly above 2kHz level
ok(db[idx6k] > db[idx2k] + 3, 'ITU-468 peaks near 6.3kHz (6.3kHz: ' + db[idx6k].toFixed(1) + 'dB, 2kHz: ' + db[idx2k].toFixed(1) + 'dB)')
})
// ═══════════════════════════════════════════════════════════════════════════
// Standards calibration
// ═══════════════════════════════════════════════════════════════════════════
for (let fs of [96000, 48000, 44100]) {
test(`itu468 ${fs}Hz — full BS.468-4 table, 31.5Hz–20kHz (rel 1kHz)`, () => {
let sos = audio.itu468.coefs(fs)
is(sos.length, 3, '3 sections')
let ref = magDB(sos, 1000, fs)
for (let [f, expected] of Object.entries(BS468)) {
let got = magDB(sos, +f, fs) - ref
let tol = itu468Tol(fs, +f)
ok(Math.abs(got - expected) < tol, `ITU-468@${fs} ${f}Hz: expected ${expected}dB rel 1kHz, got ${got.toFixed(2)}dB (tol ${tol})`)
}
})
}
test('riaa 44100Hz — IEC 98 reference values relative to 1kHz', () => {
let sos = audio.riaa.coefs(44100)
let db1k = magDB(sos, 1000, 44100)
let db20 = magDB(sos, 20, 44100)
let db10k = magDB(sos, 10000, 44100)
let rel20 = db20 - db1k
ok(Math.abs(rel20 - 19.274) < 1.0, `RIAA 20Hz: expected +19.274dB rel 1kHz, got ${rel20.toFixed(3)}dB`)
let rel10k = db10k - db1k
ok(Math.abs(rel10k - (-13.734)) < 1.0, `RIAA 10kHz: expected -13.734dB rel 1kHz, got ${rel10k.toFixed(3)}dB`)
})
test('kWeighting 44100Hz — shelving boost near 2kHz, HPF rolls off below 100Hz', () => {
let sos = audio.kWeighting.coefs(44100)
let db200 = magDB(sos, 200, 44100)
let db2k = magDB(sos, 2000, 44100)
ok(db2k > db200, `K-weighting 2kHz (${db2k.toFixed(2)}dB) boosted vs 200Hz (${db200.toFixed(2)}dB)`)
let db20 = magDB(sos, 20, 44100)
ok(db20 < db200 - 3, `K-weighting 20Hz (${db20.toFixed(2)}dB) rolls off vs 200Hz (${db200.toFixed(2)}dB)`)
})
test('cWeighting 48kHz — IEC 61672 table values', () => {
let sos = audio.cWeighting.coefs(48000)
let table = { 31.5: -3.0, 1000: 0, 8000: -3.0 }
for (let [f, expected] of Object.entries(table)) {
let got = magDB(sos, +f, 48000)
let tol = +f >= 8000 ? 1.0 : 0.5
ok(Math.abs(got - expected) < tol, `C-weighting@48kHz ${f}Hz: expected ${expected}dB, got ${got.toFixed(2)}dB`)
}
})
test('aWeighting 44100Hz — IEC 61672 table values', () => {
let sos = audio.aWeighting.coefs(44100)
let table = {
31.5: -39.4, 63: -26.2, 125: -16.1, 250: -8.6, 500: -3.2,
1000: 0.0, 2000: 1.2, 4000: 1.0, 8000: -1.1
}
for (let [f, expected] of Object.entries(table)) {
let tol = +f <= 63 ? 2.0 : 1.5
let got = magDB(sos, +f, 44100)
ok(Math.abs(got - expected) < tol, `A-weighting@44100 ${f}Hz: expected ${expected}dB, got ${got.toFixed(2)}dB`)
}
})
// ═══════════════════════════════════════════════════════════════════════════
// Cross-cutting API contract (shared by all 5 weighting filters)
// ═══════════════════════════════════════════════════════════════════════════
const FILTERS = [
['aWeighting', audio.aWeighting, 44100, 48000],
['cWeighting', audio.cWeighting, 44100, 96000],
['kWeighting', audio.kWeighting, 48000, 44100],
['itu468', audio.itu468, 48000, 96000],
['riaa', audio.riaa, 44100, 48000]
]
test('weighting filters — all five expose .coefs as a function', () => {
for (let [name, fn] of FILTERS)
is(typeof fn.coefs, 'function', `${name}.coefs is a function`)
})
test('weighting filters — recompute _sos/_fs when params.fs changes mid-stream', () => {
for (let [name, fn, fs1, fs2] of FILTERS) {
let p = { fs: fs1 }
fn(impulse(32), p)
let sos1 = p._sos
is(p._fs, fs1, `${name} caches initial fs`)
p.fs = fs2
fn(impulse(32), p)
ok(p._sos !== sos1, `${name} recomputes sos on fs change ${fs1}->${fs2}`)
is(p._fs, fs2, `${name} updates cached fs to ${fs2}`)
}
})
test('weighting filters — dfFilter reads only .coefs/.state (params.coefs is set)', () => {
for (let [name, fn] of FILTERS) {
let p = {}
fn(impulse(8), p)
ok(p.coefs === p._sos, `${name} params.coefs aliases params._sos`)
}
})
test('aWeighting/cWeighting — dense freqz sweep matches magDB reference (cross-check util.js vs digital-filter\'s own freqz/mag2db)', () => {
// Both magDB (test/util.js) and freqz+mag2db (digital-filter) evaluate the
// same biquad transfer function in closed form; comparing them at freqz's
// own bin frequencies across a dense sweep guards against a formula
// transcription bug in either implementation, beyond the handful of
// discrete spec-table points checked elsewhere in this file.
for (let fs of [44100, 48000, 96000]) {
for (let name of ['aWeighting', 'cWeighting']) {
let sos = audio[name].coefs(fs)
let resp = freqz(sos, 512, fs)
for (let i = 1; i < 512; i += 23) {
let f = resp.frequencies[i]
let dbFreqz = mag2db(resp.magnitude[i])
let dbLocal = magDB(sos, f, fs)
almost(dbFreqz, dbLocal, 1e-9, `${name}@${fs} ${f.toFixed(1)}Hz: freqz ${dbFreqz} vs magDB ${dbLocal}`)
}
}
}
})
// B-weighting: no IEC 61672 table (superseded by A/C in the modern standard) — the
// only citable reference for the retained ANSI S1.4 curve is a-weighting's own b(f)
// closed form (independently published npm package), differential-tested directly.
import { b as bRef } from 'a-weighting'
test('bWeighting — differential vs a-weighting reference (matched-Z, same bound as A/C near Nyquist)', () => {
let sos = audio.bWeighting.coefs(44100)
for (let f of [31.5, 63, 125, 250, 500, 1000, 2000, 4000, 8000]) {
let ref = 20 * Math.log10(bRef(f) / bRef(1000))
let got = magDB(sos, f, 44100)
let tol = f >= 8000 ? 1.5 : 0.5 // same matched-Z tolerance A/C already use at these bands
ok(Math.abs(got - ref) < tol, `B-weighting ${f}Hz: ref ${ref.toFixed(2)}, got ${got.toFixed(2)} dB`)
}
})
test('bWeighting — 3 SOS sections, 0dB at 1kHz, produces output', () => {
let sos = audio.bWeighting.coefs(44100)
is(sos.length, 3, 'double pole f1 + single pole f2 + double pole f4 = 3 sections')
almost(magDB(sos, 1000, 44100), 0, 1e-6)
let data = impulse(4096)
let p = { fs: 44100 }
audio.bWeighting(data, p)
ok(data.some(x => Math.abs(x) > EPSILON), 'bWeighting produces output')
})
// D-weighting: withdrawn from the modern standard (IEC 537 aircraft-noise curve) — no IEC
// 61672 table to check against, so differential-test the realized filter against a-weighting's
// own d(f) closed form, relative to 1 kHz (same pattern as B). Matched-z rolloff widens the
// gap near Nyquist exactly as it does for A/C, hence the looser bound at 8 kHz.
import { d as dRef } from 'a-weighting'
test('dWeighting — differential vs a-weighting reference (relative to 1kHz)', () => {
let sos = audio.dWeighting.coefs(44100)
let ref1k = 20 * Math.log10(dRef(1000)), got1k = magDB(sos, 1000, 44100)
for (let f of [31.5, 63, 125, 250, 500, 1000, 2000, 4000, 6300, 8000]) {
let ref = 20 * Math.log10(dRef(f)) - ref1k
let got = magDB(sos, f, 44100) - got1k
let tol = f >= 8000 ? 1.5 : 0.5
ok(Math.abs(got - ref) < tol, `D-weighting ${f}Hz: ref ${ref.toFixed(2)}, got ${got.toFixed(2)} dB`)
}
})
test('dWeighting — 2 SOS sections, 0dB at 1kHz, resonant hump ~+11.5dB near 3.15kHz', () => {
let sos = audio.dWeighting.coefs(44100)
is(sos.length, 2, 'complex zero pair + complex pole pair fold with the real poles / DC zero into 2 sections')
almost(magDB(sos, 1000, 44100), 0, 1e-6)
// standard D-weighting is +11.5 dB at the 3.15 kHz third-octave band (rel 1 kHz)
let hump = magDB(sos, 3150, 44100)
ok(Math.abs(hump - 11.5) < 0.3, `D-weighting hump ~+11.5dB at 3.15kHz, got ${hump.toFixed(2)}dB`)
ok(hump > magDB(sos, 1000, 44100) && hump > magDB(sos, 8000, 44100), 'hump exceeds the 1kHz and 8kHz shoulders')
let data = impulse(4096)
audio.dWeighting(data, { fs: 44100 })
ok(data.some(x => Math.abs(x) > EPSILON), 'dWeighting produces output')
})
// .response(f, fs) — self-consistency: must equal the atom's own coefs()' magnitude,
// not an independent reimplementation (that's the whole point: it describes what the
// shipped filter actually does, not a parallel analog approximation that could drift).
test('response() is exactly the atom\'s own filter magnitude, for A/B/C/D, at several fs', () => {
for (let fs of [44100, 48000, 96000]) {
for (let name of ['aWeighting', 'bWeighting', 'cWeighting', 'dWeighting']) {
let sos = audio[name].coefs(fs)
for (let f of [100, 1000, 8000]) {
almost(audio[name].response(f, fs), Math.pow(10, magDB(sos, f, fs) / 20), 1e-9,
`${name}.response(${f}, ${fs}) matches its own coefs()`)
}
}
}
})
test('response(1000) is unity for A/B/C/D/itu468 (0dB normalization)', () => {
for (let name of ['aWeighting', 'bWeighting', 'cWeighting', 'dWeighting', 'itu468']) almost(audio[name].response(1000), 1, 1e-9, name)
})
test('kWeighting/riaa response() matches their own coefs()\' magnitude', () => {
for (let fs of [44100, 48000, 96000]) {
let sos = audio.kWeighting.coefs(fs)
for (let f of [100, 1000, 8000]) {
almost(audio.kWeighting.response(f, fs), Math.pow(10, magDB(sos, f, fs) / 20), 1e-9,
`kWeighting.response(${f}, ${fs}) matches its own coefs()`)
}
}
for (let fs of [44100, 48000, 96000]) {
let sos = audio.riaa.coefs(fs)
for (let f of [100, 1000, 8000]) {
almost(audio.riaa.response(f, fs), Math.pow(10, magDB(sos, f, fs) / 20), 1e-9,
`riaa.response(${f}, ${fs}) matches its own coefs()`)
}
}
})
test('riaa.response(1000) is unity (0dB normalization at 1kHz)', () => {
for (let fs of [44100, 48000, 96000]) almost(audio.riaa.response(1000, fs), 1, 1e-9, `riaa@${fs}`)
})