Repository navigation
Expand file tree
/
Copy pathtest.js
More file actions
2485 lines (2274 loc) · 161 KB
/
Copy pathtest.js
File metadata and controls
2485 lines (2274 loc) · 161 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
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// Test suite for noise-reduction methods.
// Synthetic generators for deterministic property tests + audio-lena for real speech.
import test, { almost, ok, is } from 'tst'
import raw from 'audio-lena/raw'
import {
gate, dehum, specsub, wiener, omlsa, declick, decrackle, declip,
dewind, deplosive, deesser, debreath, dereverb, denoise, classify
} from './index.js'
import { vad, spp, ddSnr } from '@audio/vad'
import { vad as vadUmbrella, minStats as minStatsUmbrella } from './index.js'
import { noiseProfile, minStats, imcra, known } from '@audio/noise-estimate'
import { processor as omlsaProcessor, frame as omlsaFrame } from '@audio/denoise-omlsa'
import { snr, segSnr, lsd, nrr, speechAttenuation } from '@audio/quality'
import { stftBatch, stftStream, stftAnalyse } from '@audio/stft'
let fs = 44100
let lena = new Float32Array(raw) // 12.27s mono speech
// --- generators ---
function sine(freq, n, amp = 1, phase = 0) {
let d = new Float32Array(n)
for (let i = 0; i < n; i++) d[i] = amp * Math.sin(2 * Math.PI * freq * i / fs + phase)
return d
}
function noise(n, amp = 1) {
let d = new Float32Array(n)
for (let i = 0; i < n; i++) d[i] = amp * (Math.random() * 2 - 1)
return d
}
function add(...arrays) {
let n = Math.max(...arrays.map(a => a.length))
let d = new Float32Array(n)
for (let a of arrays) for (let i = 0; i < a.length; i++) d[i] += a[i]
return d
}
function copy(a) { return new Float32Array(a) }
function rms(d) { let s = 0; for (let i = 0; i < d.length; i++) s += d[i] * d[i]; return Math.sqrt(s / d.length) }
function peak(d) { let p = 0; for (let i = 0; i < d.length; i++) { let a = Math.abs(d[i]); if (a > p) p = a } return p }
function mix(speech, noise, snrDb) {
let sR = rms(speech), nR = rms(noise)
let target = sR / Math.pow(10, snrDb / 20)
let scale = target / Math.max(nR, 1e-30)
let n = Math.max(speech.length, noise.length)
let d = new Float32Array(n)
for (let i = 0; i < n; i++) d[i] = (speech[i] || 0) + (noise[i] || 0) * scale
return d
}
function clicks(n, count, amp = 0.9) {
let d = new Float32Array(n)
for (let k = 0; k < count; k++) d[(k + 1) * Math.floor(n / (count + 1))] = (k & 1 ? -1 : 1) * amp
return d
}
// =================== Phase 0 — utilities ===================
test('stft — round-trip preserves signal', () => {
let x = sine(440, 8192)
let out = stftBatch(x, (mag, phase) => ({ mag, phase }), { frameSize: 1024, hopSize: 256 })
let n = Math.min(x.length, out.length) - 2048
let err = 0
for (let i = 1024; i < 1024 + n; i++) err += (out[i] - x[i]) ** 2
ok(Math.sqrt(err / n) < 0.01, 'reconstruction error < 1%')
})
test('stft — streaming identity reconstructs mid-region across chunk boundaries', () => {
// Pins the stream-buffer bookkeeping (appendIn/compactIn/take): irregular chunk
// sizes force ring growth + input compaction; mid-region must reconstruct exactly.
let N = 2048, hop = 512
let x = sine(440, 12000)
let s = stftStream((mag, phase) => ({ mag, phase }), { frameSize: N, hopSize: hop })
let chunks = [], pos = 0
for (let size of [700, 4096, 33, 2048, 5000, 123]) {
chunks.push(s.write(x.subarray(pos, Math.min(pos + size, x.length))))
pos = Math.min(pos + size, x.length)
}
chunks.push(s.flush())
let out = new Float32Array(chunks.reduce((a, c) => a + c.length, 0)), o = 0
for (let c of chunks) { out.set(c, o); o += c.length }
ok(out.length >= 10000, 'stream emits the signal body')
let err = 0, n = 10000 - N
for (let i = N; i < 10000; i++) err += (out[i] - x[i]) ** 2
ok(Math.sqrt(err / n) < 0.01, 'mid-region reconstruction error < 1%')
})
test('stft — analyse visits frames in order', () => {
let positions = []
stftAnalyse(noise(4096), (mag, phase, pos) => positions.push(pos), { frameSize: 1024, hopSize: 512 })
ok(positions.length >= 6, 'gets ≥6 frames')
for (let i = 1; i < positions.length; i++) is(positions[i] - positions[i - 1], 512)
})
test('vad — flags speech-only frames active', () => {
let speech = lena.subarray(0, fs * 4)
let { active } = vad(speech, { fs })
let activeCount = active.reduce((a, b) => a + b, 0)
ok(activeCount > 0 && activeCount < active.length, 'some active, not all')
})
test('vad — silence yields no active frames', () => {
let { active } = vad(new Float32Array(fs), { fs })
is(active.reduce((a, b) => a + b, 0), 0)
})
// lena under pink noise (Voss-McCartney filter bank, seeded) at `snr` dB, and her loud 10 ms frames: within 20 dB of
// the loudest, speech beyond doubt. The fixture for what the speech ops must keep under noise.
function pinkNoise(n, seed = 7) {
let s = seed >>> 0, r = () => (s = (Math.imul(s, 1664525) + 1013904223) >>> 0) / 4294967296, x = new Float32Array(n), b = [0, 0, 0, 0, 0, 0, 0]
for (let i = 0; i < n; i++) {
let w = r() * 2 - 1
b[0] = 0.99886 * b[0] + w * 0.0555179; b[1] = 0.99332 * b[1] + w * 0.0750759; b[2] = 0.969 * b[2] + w * 0.153852
b[3] = 0.8665 * b[3] + w * 0.3104856; b[4] = 0.55 * b[4] + w * 0.5329522; b[5] = -0.7616 * b[5] - w * 0.016898
x[i] = b[0] + b[1] + b[2] + b[3] + b[4] + b[5] + b[6] + w * 0.5362; b[6] = w * 0.115926
}
return x
}
function lenaInNoise(snr) {
let x = mix(lena, pinkNoise(lena.length), snr), n = fs / 100, m = Math.floor(lena.length / n), e = new Float64Array(m)
for (let k = 0; k < m; k++) for (let i = k * n; i < (k + 1) * n; i++) e[k] += lena[i] * lena[i]
let top = Math.max(...e)
return { x, n, loud: Array.from(e, v => v > top / 100) }
}
test('vad — speech under noise is speech, the noise alone is not', () => {
// 1.x took the 10th-percentile frame energy of the whole input for the floor and wanted 11 dB over it: under noise
// the 10th percentile is the noise, and at 5 dB SNR it heard 11 % of lena's loud frames
let { x, n, loud } = lenaInNoise(5), { active, hop, frameSize } = vad(x, { fs }), on = 0, all = 0
loud.forEach((l, k) => { if (!l) return; all++; on += active[Math.min(active.length - 1, Math.max(0, Math.round((k * n + n / 2 - frameSize / 2) / hop)))] })
ok(on / all > 0.95, `loud speech frames active at 5 dB SNR: ${(100 * on / all).toFixed(1)} %`)
let quiet = vad(pinkNoise(fs * 5, 3).map(v => 0.01 * v), { fs }).active
is(quiet.reduce((a, b) => a + b, 0), 0, 'pink noise alone: no speech')
})
test('vad — noise alone is no speech: white, pink, brown, steady or stepping, at 16, 44.1 and 48 kHz', () => {
// 2.0.0 took voicing from the Wiener estimate: on noise it keeps a few random bins, a sparse spectrum whose
// autocorrelation peaks high (median 0.8). Where a level step left the floor low, the noise was present, voiced,
// speech: on the loud side of each step down, 6-8 % of those clips
let s = 21, r = () => (s = (Math.imul(s, 1664525) + 1013904223) >>> 0) / 4294967296
let kinds = {
white: n => Float32Array.from({ length: n }, () => r() * 2 - 1),
pink: n => pinkNoise(n, s++),
brown: n => { let x = new Float32Array(n), y = 0; for (let i = 0; i < n; i++) x[i] = y = 0.998 * y + (r() * 2 - 1) * 0.05; return x }
}, bad = []
for (let sr of [16000, 44100, 48000]) for (let [kind, make] of Object.entries(kinds)) for (let [a, b] of [[0.02, 0.02], [0.2, 0.2], [0.002, 0.002], [0.02, 0.002], [0.002, 0.02]]) {
let n = 2 * sr, x = make(2 * n), p = 0
for (let i = 0; i < x.length; i++) p = Math.max(p, Math.abs(x[i]))
for (let i = 0; i < x.length; i++) x[i] *= (i < n ? a : b) / p
let on = vad(x, { fs: sr }).active.reduce((u, v) => u + v, 0)
if (on) bad.push(`${kind} ${a} → ${b} at ${sr}: ${on} frames`)
}
is(bad, [], 'no frame of noise is speech')
})
test('vad — a note held past the floor window is not the floor', () => {
// the floor is a minimum over 1.5 s: a steady sound longer than that is its own minimum, unless tones stay out of it
let x = pinkNoise(fs * 4, 5).map(v => 0.0003 * v)
for (let i = fs / 2, ph = 0; i < fs * 3.5; i++) { ph += 2 * Math.PI * 150 / fs; for (let h = 1; h <= 10; h++) x[i] += 0.2 * Math.sin(h * ph) / h }
let { active, hop, frameSize } = vad(x, { fs }), mid = active.slice(Math.round((fs - frameSize / 2) / hop), Math.round((fs * 3 - frameSize / 2) / hop))
ok(mid.every(v => v), `the held note is speech-like sound throughout its middle: ${mid.reduce((a, b) => a + b, 0)} of ${mid.length} frames`)
})
test('vad — empty, shorter than a frame, a single sample: no frames, no throw', () => {
for (let x of [new Float32Array(0), new Float32Array(1), new Float32Array(1000).fill(0.1)]) {
let r = vad(x, { fs })
is(r.active.length, 0); is(r.voiced.length, 0); is(r.times.length, 0)
}
is(vad(lena.subarray(0, 2048), { fs }).active.length, 1, 'exactly a frame: one decision')
})
test('@audio/denoise umbrella forwards vad + noise estimation to their promoted atoms (single impl)', () => {
is(vadUmbrella, vad, '@audio/denoise re-exports @audio/vad — one implementation')
is(minStatsUmbrella, minStats, '@audio/denoise re-exports @audio/noise-estimate — one implementation')
let { active } = vad(lena.subarray(0, fs * 2), { fs })
ok(active.reduce((a, b) => a + b, 0) > 0, 'standalone @audio/vad flags speech active')
})
test('spp — pure tone gets high probability', () => {
let mag = new Float64Array(513)
for (let k = 0; k < 513; k++) mag[k] = 0.01
mag[100] = 1.0
let np = new Float64Array(513).fill(0.0001)
let p = spp(mag, np)
ok(p[100] > 0.99, 'tone bin is speech')
})
test('noiseProfile — averages first frames', () => {
let np = noiseProfile(noise(8192, 0.1), { frameSize: 512, fs })
ok(np.length > 0, 'has profile')
ok(np[10] > 0, 'has energy')
})
test('minStats — tracks decreasing floor', () => {
let est = minStats(513)
let mag = new Float64Array(513).fill(1)
for (let i = 0; i < 50; i++) est.update(mag)
for (let k = 0; k < 513; k++) mag[k] = 0.1
for (let i = 0; i < 100; i++) est.update(mag)
ok(est.psd[100] < 0.5, 'floor follows minimum')
})
test('quality — snr inf for identical', () => {
let x = sine(440, 4096)
ok(snr(x, x) > 100, 'matched signal scores >100 dB')
})
test('quality — lsd zero for identical', () => {
let x = sine(440, 4096)
almost(lsd(x, x), 0, 0.01)
})
test('quality — nrr positive after attenuation', () => {
let n = noise(4096, 0.5)
let q = new Float32Array(n.length)
for (let i = 0; i < n.length; i++) q[i] = n[i] * 0.1
ok(nrr(n, q) > 15, 'nrr ≥ 15 dB for 10× attenuation')
})
// =================== gate ===================
test('gate — silences below threshold', () => {
let x = noise(8192, 0.001)
let out = gate(copy(x), { threshold: -40, fs })
ok(rms(out) < rms(x) * 0.5, 'attenuates quiet noise')
})
test('gate — preserves signal above threshold', () => {
let x = sine(440, 8192, 0.5)
let out = gate(copy(x), { threshold: -40, fs })
almost(rms(out), rms(x), rms(x) * 0.1, 'loud signal passes')
})
// =================== dehum ===================
test('dehum — removes 60Hz tone', () => {
let hum = sine(60, fs, 0.3)
let speech = lena.subarray(0, fs)
let dirty = add(speech, hum)
let clean = dehum(copy(dirty), { freq: 60, fs })
ok(narrowEnergy(clean, 60) < narrowEnergy(dirty, 60) * 0.2, 'hum reduced ≥5×')
})
test('dehum — removes harmonics', () => {
let hum = add(sine(60, fs, 0.3), sine(120, fs, 0.15), sine(180, fs, 0.075))
let clean = dehum(hum, { freq: 60, harmonics: 3, fs })
ok(narrowEnergy(clean, 120) < 0.05, 'second harmonic gone')
})
test('dehum — preserves nearby content', () => {
let mix = add(sine(60, fs, 0.3), sine(440, fs, 0.3))
let clean = dehum(mix, { freq: 60, fs })
ok(narrowEnergy(clean, 440) > 0.1, '440Hz content preserved')
})
// mains hum: 12 harmonics at 1/h, the fundamental wandering ±dev Hz (a 20 s sine), scaled to rms r
function mainsHum(n, f0, dev, r) {
let y = new Float32Array(n), ph = 0
for (let i = 0; i < n; i++) { ph += 2 * Math.PI * (f0 + dev * Math.sin(2 * Math.PI * i / fs / 20)) / fs; for (let h = 1; h <= 12; h++) y[i] += Math.cos(h * ph + h) / h }
let g = r / rms(y)
return y.map(v => v * g)
}
function sumsq(d) { let s = 0; for (let v of d) s += v * v; return s }
// 0.2.0 notched at Q 30 the few harmonics that stood out from speech: hum 6 dB down, speech SDR 23 dB here
test('dehum — wandering hum under speech: removed, the speech kept', () => {
let x = lena.subarray(0, fs * 8), h = mainsHum(x.length, 50.05, 0.02, rms(x) / 10)
// phase inversion (Hagerman & Olofsson 2004): what became of the hum and of the speech, apart
let yp = dehum(x.map((v, i) => v + h[i]), { fs }), ym = dehum(x.map((v, i) => v - h[i]), { fs })
let down = 10 * Math.log10(sumsq(h) / sumsq(yp.map((v, i) => (v - ym[i]) / 2)))
let sdr = 10 * Math.log10(sumsq(x) / sumsq(yp.map((v, i) => (v + ym[i]) / 2 - x[i])))
ok(down > 35, `hum 20 dB under, ±0.02 Hz: ${down.toFixed(1)} dB down`)
ok(sdr > 33, `speech SDR ${sdr.toFixed(1)} dB`)
})
// a Q 30 notch at 100.1 Hz is 3.3 Hz wide: it took 2 dB of a G2 2 Hz away
test('dehum — a note 2 Hz from a line is left to the program', () => {
let n = fs * 6, x = new Float32Array(n)
for (let i = 0; i < n; i++) for (let k = 1; k <= 4; k++) x[i] += 0.2 / k * Math.sin(2 * Math.PI * 98 * k * i / fs + k)
let h = mainsHum(n, 50.05, 0, rms(x) / 10), y = dehum(x.map((v, i) => v + h[i]), { fs })
// Hann-windowed DFT over the middle 4 s: the note's leakage stays off the line
let at = (d, f) => { let re = 0, im = 0; for (let i = fs; i < n - fs; i++) { let w = 1 - Math.cos(2 * Math.PI * (i - fs) / (n - 2 * fs)); re += w * d[i] * Math.cos(2 * Math.PI * f * i / fs); im += w * d[i] * Math.sin(2 * Math.PI * f * i / fs) } return Math.hypot(re, im) }
let kept = 20 * Math.log10(at(y, 98) / at(x, 98)), down = 20 * Math.log10(at(h, 100.1) / at(y, 100.1))
ok(Math.abs(kept) < 0.3, `G2 at 98 Hz: ${kept.toFixed(2)} dB`)
ok(down > 25, `the 100.1 Hz line beside it: ${down.toFixed(1)} dB down`)
})
// cuts jump the hum's phase and spread its line: measured 0.13 Hz low here, the hum 14 dB down away from the cuts (0.3.0)
test('dehum — an edited take: the frequency its cuts blur corrected from the tracked phase', () => {
let n = fs * 12, cuts = [1.7, 3.1, 4.9, 6.2, 8.4, 10.3].map(t => Math.round(t * fs)), x = Float32Array.from({ length: n }, (_, i) => lena[i % lena.length])
let h = new Float32Array(n), ph = 0
for (let i = 0; i < n; i++) { let j = cuts.indexOf(i); if (j >= 0) ph += 2 * Math.PI * 50.05 * 0.2137 * (j + 1); ph += 2 * Math.PI * 50.05 / fs; for (let k = 1; k <= 12; k++) h[i] += Math.cos(k * ph + k) / k }
let g = rms(x) / 10 / rms(h), yp = dehum(x.map((v, i) => v + g * h[i]), { fs }), ym = dehum(x.map((v, i) => v - g * h[i]), { fs }), a = 0, b = 0
for (let i = 0; i < n; i++) if (cuts.every(c => Math.abs(i - c) > fs / 2)) a += (g * h[i]) ** 2, b += ((yp[i] - ym[i]) / 2) ** 2
ok(10 * Math.log10(a / b) > 20, `hum ${(10 * Math.log10(a / b)).toFixed(1)} dB down over half a second from the cuts`)
})
test('dehum — hum alone, wandering ±0.05 Hz: tracked', () => {
let h = mainsHum(fs * 20, 59.95, 0.05, 0.05), down = 10 * Math.log10(sumsq(h) / sumsq(dehum(copy(h), { fs })))
ok(down > 35, `${down.toFixed(1)} dB down (0.2.0: 22)`)
})
// buzz (rectifier and ground-loop: odd harmonics at h^−½, even at 0.3·h^−½, rolling off over 3 kHz) reaches 8 kHz;
// 0.4.0 took the lines under 1 kHz and left the rest: 6.5 dB down
test('dehum — buzz to 8 kHz under speech: its lines above 1 kHz go too', () => {
let x = lena.subarray(0, fs * 8), n = x.length, h = new Float32Array(n), ph = 0
for (let i = 0; i < n; i++) { ph += 2 * Math.PI * (59.95 + 0.02 * Math.sin(2 * Math.PI * i / fs / 20)) / fs; for (let k = 1; k * 59.95 < 8000; k++) h[i] += (k % 2 ? 1 : 0.3) / Math.sqrt(k) / Math.hypot(1, k * 59.95 / 3000) * Math.cos(k * ph + k * k) }
let g = rms(x) / 10 / rms(h), yp = dehum(x.map((v, i) => v + g * h[i]), { fs }), ym = dehum(x.map((v, i) => v - g * h[i]), { fs })
let down = 10 * Math.log10(g * g * sumsq(h) / sumsq(yp.map((v, i) => (v - ym[i]) / 2))), sdr = 10 * Math.log10(sumsq(x) / sumsq(yp.map((v, i) => (v + ym[i]) / 2 - x[i])))
ok(down > 14, `buzz 20 dB under: ${down.toFixed(1)} dB down`)
ok(sdr > 35, `speech SDR ${sdr.toFixed(1)} dB`)
})
// a dense mix after 2 s of room tone: a bass line and chords, each note held over the next; and a buzz (59.95 Hz, odd-heavy
// to 8 kHz), its frequency wandering by `wob` Hz over 7 s
function denseMix(wob = 0) {
let n = 10 * fs, x = new Float32Array(n), s = 5, r = () => (s = (Math.imul(s, 1664525) + 1013904223) >>> 0) / 4294967296
let notes = [41.2, 49, 55, 61.7, 73.4, 82.4, 98, 110, 123.5, 146.8, 164.8, 196, 220, 246.9, 293.7, 329.6, 392, 440]
for (let t = 2; t < 10;) {
let d = 0.5 + 0.5 * r(), f = notes[Math.floor(r() * notes.length)], i0 = Math.round(t * fs), L = Math.min(n - i0, Math.round((d + 0.3) * fs)), vr = 4 + 2 * r()
for (let k = 1; k * f < 4000; k++) {
let a = 0.1 / k, ph = r() * 6.28
for (let i = 0; i < L; i++) { let u = i / fs; x[i0 + i] += a * Math.min(1, u / 0.02, (L - i) / (0.05 * fs)) * Math.exp(-u / 1.5) * Math.sin(2 * Math.PI * k * f * (u + 0.002 * Math.sin(2 * Math.PI * vr * u) / (2 * Math.PI * vr)) + ph) }
}
t += d / 3
}
let h = new Float32Array(n), ph = 0
for (let i = 0; i < n; i++) { ph += 2 * Math.PI * (59.95 + wob * Math.sin(2 * Math.PI * i / fs / 7)) / fs; for (let k = 1; k * 59.95 < 8000; k++) h[i] += (k % 2 ? 1 : 0.3) / Math.sqrt(k) / Math.hypot(1, k * 59.95 / 3000) * Math.cos(k * ph + k * k) }
return { x, h, pre: 2 * fs }
}
// Do no harm: buzz 30 dB under a dense mix (a bass line and chords, each note held over the next, after 2 s of room
// tone). A fit over 2 s takes the music near every line, more of it than the buzz it removes (0.5.0: 30.0 dB in, 22.6
// out); the hum bridged through the music from where it stands alone does not
test('dehum — faint buzz under a dense mix: the music comes out no worse than it went in', () => {
let { x, h, pre } = denseMix()
let g = rms(x.subarray(pre)) / rms(h) / Math.sqrt(1000), y = dehum(x.map((v, i) => v + g * h[i]), { fs }), xm = x.subarray(pre)
let inp = 10 * Math.log10(sumsq(xm) / (g * g * sumsq(h.subarray(pre)))), out = 10 * Math.log10(sumsq(xm) / sumsq(y.subarray(pre).map((v, i) => v - xm[i])))
ok(out >= inp, `SDR ${inp.toFixed(1)} dB in, ${out.toFixed(1)} out`)
})
// steady: the same buzz, wandering ±0.02 Hz, held through the take as one phasor per line, the mains phase refined
// against them. 0.5.2 (and the default, a fit over 2 s): 30.0 dB in, 28.3 out, the buzz 3.7 dB down, the music's SDR
// 32.6 dB by phase inversion
test('dehum — steady: a buzz held through a dense mix goes, the music stays', () => {
let { x, h, pre } = denseMix(0.02), xm = x.subarray(pre)
let g = rms(xm) / rms(h) / Math.sqrt(1000), yp = dehum(x.map((v, i) => v + g * h[i]), { fs, steady: true }), ym = dehum(x.map((v, i) => v - g * h[i]), { fs, steady: true })
let down = 10 * Math.log10(g * g * sumsq(h.subarray(pre)) / sumsq(yp.subarray(pre).map((v, i) => (v - ym[pre + i]) / 2)))
let sdr = 10 * Math.log10(sumsq(xm) / sumsq(yp.subarray(pre).map((v, i) => (v + ym[pre + i]) / 2 - xm[i])))
ok(down > 10, `buzz ${down.toFixed(1)} dB down`)
ok(sdr > 50, `music SDR ${sdr.toFixed(1)} dB`)
})
// a bar repeated exactly is a comb of lines 1/bar apart: at 120 bpm every 2 Hz, 50 and 60 Hz among them
test('dehum — a loop is no hum, nor a chord beside the series; speech passes untouched', async () => {
let { measure } = await import('@audio/denoise-dehum')
let r = 1, rnd = () => (r = r * 16807 % 2147483647) / 2147483647, bar = new Float32Array(fs / 2)
for (let i = 0; i < bar.length; i++) bar[i] = 0.3 * Math.exp(-i / 2000) * Math.sin(2 * Math.PI * 52 * i / fs) + 0.05 * (rnd() - 0.5)
let loop = Float32Array.from({ length: fs * 30 }, (_, i) => bar[i % bar.length])
let chord = new Float32Array(fs * 4)
for (let i = 0; i < chord.length; i++) for (let f of [98, 146.83, 196]) chord[i] += 0.2 * Math.sin(2 * Math.PI * f * i / fs)
for (let [name, x] of [['loop', loop], ['G2 D3 G3', chord], ['speech', lena.subarray(0, fs * 4)]])
ok(!measure(x, fs) && dehum(copy(x), { fs }).every((v, i) => v === x[i]), `${name}: untouched`)
})
// a kick at 200 bpm, its body falling to 50 Hz: its pattern, every 0.3 s, is a comb of lines 3.33 Hz apart with teeth
// on 50 and 60 Hz (15 and 18 × 3.33), the 50 Hz tooth over its neighbours where the kick rings; under chords and hats
// with no pause persists() can't tell them from hum (0.5.1: SDR 1.6 dB; the MUSDB18 preview of Dark Ride's "Burning
// Bridges", 6.0)
test('dehum – a kick at 200 bpm, its comb on 50 and 60 Hz, under a mix with no pause: untouched', () => {
let n = 7 * fs, x = new Float32Array(n), s = 1, r = () => (s = (Math.imul(s, 1664525) + 1013904223) >>> 0) / 4294967296, P = Math.round(0.3 * fs)
for (let i0 = 0; i0 < n; i0 += P) for (let i = 0, ph = 0; i < P && i0 + i < n; i++) { // each hit cut by the next
let u = i / fs; ph += 2 * Math.PI * (50 + 60 * Math.exp(-u / 0.02)) / fs; x[i0 + i] += 0.5 * Math.exp(-u / 0.4) * Math.sin(ph) * Math.min(1, (P - i) / 64)
}
let chords = [[98, 123.5, 146.8], [110, 130.8, 164.8], [87.3, 110, 130.8], [98, 116.5, 146.8]]
for (let c = 0; c * 1.2 * fs < n; c++) for (let f of chords[c % 4]) for (let k = 1; k * f < 3000; k++) {
let a = 0.04 / k, p0 = r() * 6.28, i0 = Math.round(c * 1.2 * fs), L = Math.min(n - i0, Math.round(1.25 * fs))
for (let i = 0; i < L; i++) x[i0 + i] += a * Math.min(1, i / 400, (L - i) / 400) * Math.sin(2 * Math.PI * k * f * i / fs + p0)
}
for (let i0 = Math.round(0.15 * fs); i0 < n; i0 += P / 2) for (let i = 0; i < 0.05 * fs && i0 + i < n; i++) x[i0 + i] += 0.05 * Math.exp(-i / 400) * (r() * 2 - 1)
ok(dehum(copy(x), { fs }).every((v, i) => v === x[i]), 'untouched')
})
test('dehum — edges: empty, a sample, silence, half a second as told, NaN', async () => {
let { measure } = await import('@audio/denoise-dehum')
is(dehum(new Float32Array(0), { fs }).length, 0, 'empty')
is(dehum(new Float32Array([0.5]), { fs, freq: 50 })[0], 0.5, 'one sample: too short to fit, untouched')
ok(dehum(new Float32Array(fs * 2), { fs }).every(v => v === 0), 'silence')
let s = mainsHum(fs / 2, 60, 0, 0.1), down = 10 * Math.log10(sumsq(s) / sumsq(dehum(copy(s), { fs, freq: 60 })))
ok(down > 20, `0.5 s, freq 60 as told: ${down.toFixed(1)} dB down`)
let t = mainsHum(fs * 3, 50, 0, 0.1); t[fs] = NaN
ok(dehum(copy(t), { fs, freq: 50, harmonics: 12 }).every((v, i) => i === fs || Number.isFinite(v)), 'a NaN stays one sample')
is(measure(t, fs), null, 'measure over a NaN: null')
})
// =================== specsub ===================
test('specsub — improves SNR on white-noisy speech', () => {
let speech = lena.subarray(0, fs * 4)
let dirty = mix(speech, noise(speech.length), 5)
let clean = specsub(copy(dirty), { fs })
ok(rms(clean) > 0, 'has output')
let nIn = nrr(dirty.subarray(0, fs / 2), dirty.subarray(0, fs / 2)) // proxy
ok(rms(clean) < rms(dirty), 'noise floor reduced')
})
// =================== wiener ===================
test('wiener — improves segSNR on noisy speech', () => {
let speech = lena.subarray(0, fs * 4)
let n = noise(speech.length, 1)
let dirty = mix(speech, n, 5)
let clean = wiener(copy(dirty), { fs })
ok(segSnr(clean, speech) > segSnr(dirty, speech), 'segSNR improved')
})
test('wiener — mmse-lsa rule runs', () => {
let dirty = mix(lena.subarray(0, fs * 2), noise(fs * 2), 5)
let clean = wiener(copy(dirty), { fs, rule: 'mmse-lsa' })
ok(rms(clean) > 0, 'produces output')
})
// α = 0.98 is Ephraim & Malah's for an 8 ms frame step (1984 §VI) and was applied per frame, 10.7 ms at 48 kHz. On a
// learned profile the gain is the LSA rule on ξ = α^(Δt/8 ms) ²(l−1)/λ + (1 − α^(Δt/8 ms)) max(γ − 1, 0), written out
test('wiener: the decision-directed α is per 8 ms, rescaled to the frame step', () => {
let sr = 48000, N = 2048, hop = 512, o = { fs: sr, frameSize: N, hopSize: hop }, a = 0.98 ** (hop / sr / 0.008)
let x = gauss(sr * 2, 0.01).map((v, i) => v + 0.1 * Math.sin(2 * Math.PI * 440 * i / sr) * (i > sr)), profile = noiseProfile(x, { to: sr, frameSize: N, hopSize: hop })
// E1 by its series (Abramowitz & Stegun 5.1.11), apart from the kernel's rational approximation
let e1 = v => { if (v > 30) return 0; let s = 0, t = 1; for (let k = 1; k < 200; k++) { t *= -v / k; s += t / k } return -0.5772156649015329 - Math.log(v) - s }
let eta = new Float64Array(N / 2 + 1).fill(1)
let ref = stftBatch(x, (mag, phase) => {
for (let k = 0; k <= N / 2; k++) {
let g = mag[k] ** 2 / profile[k], xi = Math.max(10 ** -1.5, a * eta[k] + (1 - a) * Math.max(g - 1, 0))
let G = xi / (1 + xi) * Math.exp(0.5 * e1(xi * g / (1 + xi)))
eta[k] = G * G * g; mag[k] *= G
}
return { mag, phase }
}, o)
let y = wiener(x, { ...o, profile }), d = 0
for (let i = 0; i < x.length; i++) d = Math.max(d, Math.abs(y[i] - ref[i]))
ok(d < 1e-6, `α = 0.98^(Δt/8 ms) = ${a.toFixed(4)}: max deviation ${d.toExponential(1)} (signal peak 0.15)`)
})
// =================== omlsa ===================
test('omlsa — improves segSNR on noisy speech', () => {
let speech = lena.subarray(0, fs * 4)
let dirty = mix(speech, noise(speech.length), 5)
let clean = omlsa(copy(dirty), { fs })
ok(segSnr(clean, speech) > segSnr(dirty, speech), 'segSNR improved')
})
// Syllables after pauses: 120 Hz pulses through formants at 700 and 1200 Hz, rising and falling over 50 ms, 250 ms
// each, 300 ms apart, from 1 s; white Gaussian noise `snrDb` under the syllables' level
function afterPauses(sr, snrDb = 0) {
let n = 4 * sr, s = new Float32Array(n), on = [], y1 = 0, y2 = 0, z1 = 0, z2 = 0, P = Math.round(sr / 120)
for (let t = sr; t + 0.25 * sr < n; t += Math.round(0.55 * sr)) on.push(t)
for (let i = 0; i < n; i++) {
let t0 = on.find(t => i >= t && i < t + 0.25 * sr), e = t0 != null && i % P === 0 ? Math.min(1, (i - t0) / (0.05 * sr), (t0 + 0.25 * sr - i) / (0.05 * sr)) : 0
let y = e + 2 * Math.cos(2 * Math.PI * 700 / sr) * 0.97 * y1 - 0.9409 * y2; y2 = y1; y1 = y
let z = y + 2 * Math.cos(2 * Math.PI * 1200 / sr) * 0.95 * z1 - 0.9025 * z2; z2 = z1; z1 = z
s[i] = 0.02 * z
}
let p = 0; for (let v of s) p += v * v
let nz = gauss(n, Math.sqrt(p / (on.length * 0.25 * sr) / 10 ** (snrDb / 10)), 3)
return { s, on, y: s.map((v, i) => v + nz[i]) }
}
// a frame process's gain on y, frame by frame, on @audio/stft's frames
function frameGains(proc, y, o) { let G = []; stftBatch(y, (m, p) => { let m0 = Float64Array.from(m), r = proc(m, p); G.push(Float64Array.from(m0, (v, k) => v > 0 ? r.mag[k] / v : 1)); return r }, o); return G }
// The decision-directed α is quoted per 8 ms frame (Cohen & Berdugo 2001 Table 1, Ephraim & Malah 1984 §VI) and was
// applied per frame: at 48 kHz (10.7 ms frames) the a priori SNR's memory ran 1.8× longer than at 44.1 kHz (5.8 ms)
test('known, imcra: the decision-directed α is per 8 ms, rescaled to the frame step as the other time constants', () => {
let sr = 48000, N = 2048, hop = 512, x = gauss(sr, 0.01).map((v, i) => v + 0.2 * Math.sin(2 * Math.PI * 440 * i / sr) * (i > sr / 2))
let profile = noiseProfile(gauss(sr, 0.01, 11), { frameSize: N, hopSize: hop, to: sr }), a = 0.92 ** (hop / sr / 0.008)
let est = known(profile, { fs: sr, hop, alphaDD: 0.92 }), eta = new Float64Array(N / 2 + 1).fill(1), worst = 0
stftAnalyse(x, mag => {
est.update(mag)
for (let k = 0; k <= N / 2; k++) {
let g = mag[k] ** 2 / profile[k], xi = Math.max(a * eta[k] + (1 - a) * Math.max(g - 1, 0), 10 ** -2.5)
worst = Math.max(worst, Math.abs(est.xi[k] - xi) / xi)
eta[k] = est.gain[k] ** 2 * g
}
}, { frameSize: N, hopSize: hop })
ok(worst < 1e-9, `known: ξ with α = 0.92^(Δt/8 ms) = ${a.toFixed(4)}, worst relative error ${worst.toExponential(1)}`)
let m = imcra(N / 2, { fs: sr, hop, alphaDD: 0.92 }), worst2 = 0, e2 = null
stftAnalyse(x, mag => {
let prev = Float64Array.from(m.psd)
m.update(mag)
if (e2) for (let k = 0; k <= N / 2; k++) { // ξ on the previous estimate, for this frame's p
let g = mag[k] ** 2 / prev[k], xi = Math.max(a * e2[k] + (1 - a) * Math.max(g - 1, 0), 10 ** -2.5)
worst2 = Math.max(worst2, Math.abs(m.xi0[k] - xi) / xi)
}
e2 = Float64Array.from(m.gain, (G, k) => G * G * m.gamma[k])
}, { frameSize: N, hopSize: hop })
ok(worst2 < 1e-9, `imcra: the same α, worst relative error ${worst2.toExponential(1)}`)
})
// G = G_H1^p G_min^(1−p) (eq. 16) dips below G_min where G_H1 does (to −18 dB at G_min −12). G_H1 floored at G_min:
// a bin where speech may be present goes no further down than one where it is absent
test('omlsa: G_min is the floor, tracked and on a learned noise', () => {
let sr = 48000, N = omlsaFrame(sr), o = { fs: sr, frameSize: N, hopSize: N / 4 }, { y } = afterPauses(sr, 5)
for (let [name, opts, gMin] of [['tracked', {}, -15], ['learned', { profile: noiseProfile(y, { to: sr, frameSize: N, hopSize: N / 4 }), gMin: -12 }, -12]]) {
let lo = Infinity
for (let g of frameGains(omlsaProcessor({ ...o, ...opts }), y, o)) for (let v of g) lo = Math.min(lo, v)
ok(20 * Math.log10(lo) > gMin - 1e-6, `${name}: lowest gain ${(20 * Math.log10(lo)).toFixed(2)} dB, G_min ${gMin} dB`)
}
})
// ξ trails a word's start after a pause; with α 0.98 per frame (per 10.7 ms at 48 kHz) a syllable's first 50 ms lost
// 6.3 dB tracked and 8.5 on the learned noise, at 0 dB SNR
test('omlsa: a syllable\'s first 50 ms after a pause is kept, tracked and on a learned noise', () => {
let sr = 48000, N = omlsaFrame(sr), o = { fs: sr, frameSize: N, hopSize: N / 4 }, { s, y, on } = afterPauses(sr, 0)
for (let [name, opts, least] of [['tracked', {}, -4], ['learned', { profile: noiseProfile(y, { to: sr, frameSize: N, hopSize: N / 4 }), gMin: -12 }, -2.5]]) {
let G = frameGains(omlsaProcessor({ ...o, ...opts }), y, o), l = 0, a = 0, b = 0
stftBatch(s, (mag, phase, st, ctx) => {
let c = ctx.pos + N / 2 // the frame's centre
if (on.some(t => c >= t && c < t + 0.05 * sr)) for (let k = 0; k <= N / 2; k++) { a += mag[k] ** 2; b += (mag[k] * G[l][k]) ** 2 }
l++
return { mag, phase }
}, o)
let kept = 10 * Math.log10(b / a)
ok(kept > least, `${name}: speech kept in the first 50 ms, ${kept.toFixed(2)} dB`)
}
})
// Short narrowband noise bursts the tracker cannot follow (40 ms sine blips at random frequencies, a few dB over the
// noise in their bins) in steady white noise. q from the decision-directed ξ took them for speech and passed them,
// tones in the residue: −2.6 dB at 48 kHz, −4.0 at 16 kHz (0.3). From the cepstrally smoothed ξ (Breithaupt, Gerkmann &
// Martin 2008) a lone spectral peak is not speech's structure, and they go down with the noise
test('omlsa: lone narrowband noise bursts are not taken for speech (q from the cepstro-temporally smoothed a priori SNR)', () => {
for (let sr of [48000, 16000]) {
let N = omlsaFrame(sr), o = { fs: sr, frameSize: N, hopSize: N / 4 }, n = 6 * sr, nz = gauss(n, 0.01), b = new Float32Array(n)
let seed = 9, rnd = () => (seed = seed * 16807 % 2147483647) / 2147483647
for (let t = sr; t + 0.04 * sr < n; t += Math.round((0.08 + 0.1 * rnd()) * sr)) {
let f = 300 + rnd() * (Math.min(8000, sr / 2 - 500) - 300), L = Math.round(0.04 * sr)
for (let i = 0; i < L; i++) b[t + i] += 0.02 * Math.sin(2 * Math.PI * f * i / sr) * Math.sin(Math.PI * i / L) ** 2
}
let G = frameGains(omlsaProcessor(o), b.map((v, i) => v + nz[i]), o)
let kept = x => { let a = 0, c = 0, l = 0; stftBatch(x, (m, p) => { if (l > 2 * sr / (N / 4)) for (let k = 0; k <= N / 2; k++) { a += m[k] ** 2; c += (m[k] * G[l][k]) ** 2 } l++; return { mag: m, phase: p } }, o); return 10 * Math.log10(c / a) }
let kb = kept(b), kn = kept(nz)
ok(kb < -5, `${sr} Hz: bursts ${kb.toFixed(2)} dB (0.3: −2.6 at 48 kHz, −4.0 at 16), the noise ${kn.toFixed(2)} dB`)
}
})
// On a learned noise, p read γ in each bin alone at ξ_H1 15 dB (Gerkmann & Hendriks 2012): speech 0–5 dB over the
// noise lost 9.7 dB at 0 dB SNR (the MMSE Wiener gain keeps −3.9 there). Averaged over 5 bins (105.5 Hz) and 23 (543 Hz)
// at fixed priors (Gerkmann, Breithaupt & Martin 2008), weak speech reads as present
test('omlsa on a learned noise: speech 0–5 dB over the noise is kept; the noise alone still goes G_min down, no musical noise', () => {
let sr = 48000, N = omlsaFrame(sr), o = { fs: sr, frameSize: N, hopSize: N / 4 }
for (let snr of [0, 10]) {
let { s, y } = afterPauses(sr, snr), nz = y.map((v, i) => v - s[i]), lam = noiseProfile(nz, { to: nz.length, frameSize: N, hopSize: N / 4 })
let G = frameGains(omlsaProcessor({ ...o, profile: noiseProfile(y, { to: sr, frameSize: N, hopSize: N / 4 }), gMin: -12 }), y, o), l = 0, a = 0, b = 0
stftBatch(s, (mag, phase) => {
for (let k = 0; k <= N / 2; k++) { let r = mag[k] ** 2 / lam[k]; if (r >= 1 && r < 10 ** 0.5) { a += mag[k] ** 2; b += (mag[k] * G[l][k]) ** 2 } }
l++
return { mag, phase }
}, o)
let kept = 10 * Math.log10(b / a)
ok(kept > -7.5, `${snr} dB SNR: speech 0–5 dB over the noise kept ${kept.toFixed(2)} dB (0.3: −9.7 at 0 dB, −9.4 at 10)`)
}
let x = gauss(sr * 6, 0.01, 5), y = omlsa(copy(x), { fs: sr, profileFrom: 0, profileTo: sr, gMin: -12 })
let st = z => { let s1 = 0, s2 = 0, c = 0; stftAnalyse(z.subarray(sr), m => { for (let k = 4; k < 700; k++) { let p = m[k] * m[k]; s1 += p; s2 += p * p; c++ } }, { frameSize: N, hopSize: N / 4 }); return { k: s2 * c / (s1 * s1), p: s1 / c } }
let a = st(x), b = st(y), lk = Math.log(b.k / a.k), down = 10 * Math.log10(a.p / b.p)
ok(Math.abs(lk) < 0.02 && Math.abs(down - 12) < 0.2, `white noise alone: ${down.toFixed(2)} dB down, log kurtosis ratio ${lk.toFixed(3)}`)
// a chord (4 notes, 8 harmonics at 1/h) over white noise 20 dB under it stops: in the half second after, what is left
// of the noise is the noise, not tones (0.3: log kurtosis ratio 0.25 at G_min −12 dB, 1.68 at −20; averaged over the
// paper's 64 ms of frames as well, presence outlasted the chord: 0.71 and 2.80)
let c = new Float32Array(4 * sr)
for (let f0 of [220, 277.2, 329.6, 440]) for (let h = 1; h <= 8; h++) for (let i = sr; i < 3 * sr; i++) c[i] += 0.05 / h * Math.sin(2 * Math.PI * f0 * h * i / sr + h)
let pc = 0; for (let i = sr; i < 3 * sr; i++) pc += c[i] * c[i] / (2 * sr)
let nc = gauss(c.length, Math.sqrt(pc / 100), 13), yc = c.map((v, i) => v + nc[i])
let tail = z => { let s1 = 0, s2 = 0, n = 0; stftAnalyse(z.subarray(3 * sr, 3.5 * sr), m => { for (let k = 4; k < 340; k++) { let p = m[k] * m[k]; s1 += p; s2 += p * p; n++ } }, { frameSize: N, hopSize: N / 4 }); return s2 * n / (s1 * s1) }
for (let [gMin, most] of [[-12, 0.1], [-20, 1]]) {
let lt = Math.log(tail(omlsa(copy(yc), { fs: sr, profileFrom: 0, profileTo: sr, gMin })) / tail(yc))
ok(lt < most, `the half second after a chord stops, G_min ${gMin} dB: log kurtosis ratio ${lt.toFixed(2)}`)
}
})
// Clean speech (syllables 60 dB over a room tone) comes back as it went in; degenerate input comes back finite, its length
test('omlsa, wiener, specsub: clean speech kept; empty, one-sample, sub-frame, silent input', () => {
let ops = sr => [['omlsa', x => omlsa(x, { fs: sr })], ['omlsa learned', x => omlsa(x, { fs: sr, profileFrom: 0, profileTo: sr / 2, gMin: -12 })],
['wiener', x => wiener(x, { fs: sr })], ['specsub', x => specsub(x, { fs: sr })]]
for (let sr of [44100, 48000]) {
let { s, y } = afterPauses(sr, 60)
for (let [name, op] of ops(sr)) {
let out = op(copy(y)), e = 0, p = 0
for (let i = 0; i < y.length; i++) { e += (out[i] - y[i]) ** 2; p += y[i] ** 2 }
ok(10 * Math.log10(p / e) > 45, `${name} at ${sr} Hz: clean speech back at ${(10 * Math.log10(p / e)).toFixed(1)} dB SNR`)
}
}
for (let [name, op] of ops(fs)) for (let [what, x] of [['empty', new Float32Array(0)], ['one sample', Float32Array.of(0.5)],
['100 samples', Float32Array.from({ length: 100 }, (_, i) => Math.sin(i))], ['silence', new Float32Array(fs)]]) {
let out = op(copy(x))
ok(out.length === x.length && out.every(Number.isFinite) && (what !== 'silence' || out.every(v => v === 0)), `${name}, ${what}`)
}
})
// minStats keeps each bin's minimum over the last D frames by monotonic deque; the rescan it replaced is the reference,
// and once the window is full its mean the cap (a running sum, re-added once a window). Windows of 1, 3 and 96 frames,
// fewer frames than the window, repeated values, minima falling and rising. Frames of digital silence (every 37th
// frame starts five) are skipped; the smoother starts as the mean of the frames with sound so far, and enters the
// minimum from its 5th frame (its memory, 1/(1−α) = 3.3 frames, full), the mean standing for the estimate until then.
test('minStats — the D-frame minimum equals a rescan of the last D frames', () => {
let seed = 5, rnd = () => (seed = (seed * 16807) % 2147483647) / 2147483647
for (let [half, D, frames] of [[64, 96, 300], [16, 1, 50], [16, 3, 200], [64, 96, 20]]) {
let est = minStats(half, { D }), smoothed = new Float64Array(half + 1), hist = [], seen = 0, bad = 0
for (let f = 0; f < frames; f++) {
let mag = Float64Array.from({ length: half + 1 }, () => f % 37 < 5 ? 0 : rnd() < 0.1 ? 1 : rnd() * (1 + (f % 300) / 30))
est.update(mag)
if (f % 37 >= 5) { let a = Math.min(0.7, seen / (seen + 1)); seen++; hist.push(Float64Array.from(mag, (m, k) => smoothed[k] = a * smoothed[k] + (1 - a) * (m * m))) }
if (hist.length > D) hist.shift()
let mins = hist.slice(Math.max(0, hist.length - (seen - 4))) // the frames that entered the minimum
for (let k = 0; k <= half; k++) {
let m = mins.length ? Math.min(...mins.map(p => p[k])) * est.bias : smoothed[k], mean = hist.reduce((a, p) => a + p[k], 0) / D
let ref = mins.length && hist.length === D ? Math.min(m, mean) : m // a full window caps it at its mean
if (Math.abs(est.psd[k] - ref) > 1e-12 * ref) bad++
}
}
is(bad, 0, `${half + 1} bins, D ${D}, ${frames} frames`)
}
})
// =================== speech denoisers: references and properties ===================
import { readFileSync, readdirSync, existsSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
// scripts/reference.py's signal(): 'speech in noise' at 8 kHz in arithmetic only, so both languages make the same
// doubles. Park–Miller uniforms summed 12 at a time; noise ×3 from 1.5 s; four syllables of a 125 Hz pulse train
// through two resonators (formants near 600 Hz and 1.6 kHz).
function refSignal(n = 20000) {
let seed = 1, x = new Float32Array(n), y1 = 0, y2 = 0, z1 = 0, z2 = 0
let seg = [[2000, 5000], [7000, 10000], [13000, 15000], [16500, 19000]]
for (let i = 0; i < n; i++) {
let g = 0
for (let j = 0; j < 12; j++) { seed = seed * 16807 % 2147483647; g += seed / 2147483647 }
let e = 0
for (let [s, t] of seg) if (s <= i && i < t && i % 64 === 0) e = Math.min(1, (i - s) / 400, (t - i) / 400)
let y = e + 1.6 * y1 - 0.81 * y2; y2 = y1; y1 = y
let z = y + 0.5 * z1 - 0.64 * z2; z2 = z1; z1 = z
x[i] = 0.1 * z + (g - 6) * (i < 12000 ? 0.01 : 0.03)
}
return x
}
// white Gaussian (Irwin–Hall) noise, reproducible
function gauss(n, amp = 1, seed = 7) {
let d = new Float32Array(n), rnd = () => (seed = seed * 16807 % 2147483647) / 2147483647
for (let i = 0; i < n; i++) { let s = -6; for (let j = 0; j < 12; j++) s += rnd(); d[i] = amp * s }
return d
}
// the installed @audio/stft's first frame: hop − N over the input's mirror image (2.0.0 on), or 0 before
function stftFirst(N, hop) {
let p0; stftBatch(new Float32Array(N), (mag, phase, s, c) => (p0 ??= c.pos, { mag, phase }), { frameSize: N, hopSize: hop })
return p0 ?? 0
}
// fixtures/reference.json: numpy references written from the papers (scripts/reference.py). Its IMCRA reproduces
// Cohen's own omlsa.m to the last bit on 16 kHz VoiceBank frames, so these hold the JS to the published algorithms.
test('imcra, omlsa, wiener, specsub: equal their numpy references (Cohen 2003, Cohen & Berdugo 2001, Ephraim & Malah 1984/85, Berouti 1979, Martin 2001)', () => {
let fx = JSON.parse(readFileSync(new URL('./fixtures/reference.json', import.meta.url)))
let x = refSignal(), { fs: sr, frameSize: N, hopSize: hop, step, rms: r } = fx
let est = imcra(N / 2), f = 0, worst = 0
stftAnalyse(x, mag => {
est.update(mag)
let i = fx.imcra.frames.indexOf(f++)
if (i >= 0) fx.imcra.bins.forEach((k, j) => { worst = Math.max(worst, Math.abs(est.psd[k] / fx.imcra.psd[i][j] - 1)) })
}, { frameSize: N, hopSize: hop })
ok(worst < 1e-6, `imcra: noise track within ${worst.toExponential(1)} of the reference`)
let dev = (y, ref) => ref.reduce((m, v, i) => Math.max(m, Math.abs(y[i * step] - v)), 0) / r
// the references are the papers' trackers: without noise-estimate's `partials` guard
let o = { fs: sr, frameSize: N, hopSize: hop }, ms = { ...o, estimator: { D: 96, partials: false } }, bx = fx.batch[stftFirst(N, hop) < 0 ? 'reflect' : 'zero']
for (let [name, y, ref] of [
['omlsa', omlsa(x, { ...o, ...fx.omlsaOpts, qFrom: 'dd', estimator: { partials: false } }), bx.omlsa], // q from the decision-directed ξ, as omlsa.m
['wiener (LSA rule)', wiener(x, ms), bx.wiener],
['wiener (Wiener rule)', wiener(x, { ...ms, rule: 'wiener' }), bx.wienerRule],
['specsub', specsub(x, ms), bx.specsub],
]) { let d = dev(y, ref); ok(d < 1e-5, `${name}: output within ${d.toExponential(1)} of the RMS`) }
for (let [key, v] of Object.entries(fx.biasMin)) {
let [D, alpha] = key.split('/').map(Number)
almost(minStats(8, { D, alpha }).bias, v, 1e-12, `B_min(${D}, ${alpha}) = ${v.toFixed(3)}, Martin 2001 eq. (17)`)
}
})
test('minStats: unbiased on white noise; starts at the first frame; skips digital silence', () => {
// B_min puts the mean estimate 0.2 dB under the noise power (the constant 1.5 it replaced left it 3.8 dB under); the cap
// at the window's mean takes 0.35 dB more, where P_min·B_min spreads over the mean
let x = gauss(48000 * 20), est = minStats(1024), f = 0, sum = 0, cnt = 0
stftAnalyse(x, mag => { est.update(mag); if (f++ > 200) { for (let k = 10; k < 1000; k++) sum += est.psd[k]; cnt += 990 } }, { frameSize: 2048, hopSize: 512 })
let db = 10 * Math.log10(sum / cnt / 768) // E|Y|² = σ² Σw² = 768 for unit white noise, Hann 2048
ok(Math.abs(db) < 0.7, `mean estimate ${db.toFixed(2)} dB re the noise power`)
let e = minStats(8), m = new Float64Array(9).fill(2)
e.update(m)
almost(e.psd[3], 4, 1e-9, 'first frame: its own power, no warm-up from zero')
e.update(new Float64Array(9)); e.update(new Float64Array(9))
almost(e.psd[3], 4, 1e-9, 'digital silence leaves the estimate as it was')
})
// A lone periodogram value swings over 2 degrees of freedom: started on it, the smoother put 1 % of bins 20 dB low, and
// the minimum held them there for the window's length (white noise, wiener's 1.5 s window at 48 kHz: 2.6 % of bins
// 10 dB under over the first 1.5 s). Started on the mean of the frames so far, it enters the minimum once its memory is full
test('minStats: the first frames do not hold the estimate down for a window', () => {
let sr = 48000, N = 2048, hop = 512, D = Math.round(1.5 * sr / hop), low = 0, all = 0
for (let seed of [1, 2, 3]) {
let est = minStats(N / 2, { D }), l = 0
stftAnalyse(gauss(sr * 2, 1, seed), mag => { est.update(mag); if (l++ < D) for (let k = 10; k < 1000; k++) { all++; if (est.psd[k] < 768 / 10) low++ } }, { frameSize: N, hopSize: hop })
}
ok(low / all < 0.005, `${(100 * low / all).toFixed(2)} % of bins 10 dB under the noise power over the first ${D} frames`)
})
// A steady line (a whine, a pilot tone) hardly swings: its minimum is its mean, and B_min put it 6.5 dB over in
// wiener's window (1024/256 frames at 44.1 kHz, D 258). The LSA gain's floor passes √(ξ_min λ), so the line came
// through 6 dB louder than on its learned profile, and denoise() on speech + 7 kHz line + white noise fell from
// 13.3 dB SNR (wiener 0.1's learned profile) to 7.8. The window's mean caps the estimate: the line as if learned.
test('minStats: a steady line is estimated at its power, not B_min over it; wiener takes it down as if learned', () => {
let N = 1024, hop = 256, D = Math.round(1.5 * fs / hop), n = fs * 4, k0 = Math.round(7000 * N / fs), o = { frameSize: N, hopSize: hop }
let speech = lena.subarray(0, n), line = sine(7000, n, 0.3), x = add(speech, line, gauss(n, 0.01))
let psd = y => { let p = new Float64Array(N / 2 + 1), c = 0; stftAnalyse(y, m => { for (let k = 0; k <= N / 2; k++) p[k] += m[k] ** 2; c++ }, o); return p.map(v => v / c) }
let est = minStats(N / 2, { D }), p = psd(line), worst = 0
stftAnalyse(x, m => est.update(m), o)
for (let k = k0 - 2; k <= k0 + 2; k++) worst = Math.max(worst, Math.abs(10 * Math.log10(est.psd[k] / p[k])))
ok(worst < 0.5, `the line's 5 bins within ${worst.toFixed(2)} dB of its power (B_min put them 6.5 over)`)
let amp = y => { let re = 0, im = 0; for (let i = 2 * fs; i < n; i++) { re += y[i] * Math.cos(2 * Math.PI * 7000 * i / fs); im += y[i] * Math.sin(2 * Math.PI * 7000 * i / fs) } return 20 * Math.log10(2 * Math.hypot(re, im) / (n - 2 * fs)) }
let tracked = amp(wiener(copy(x), { fs })), learned = amp(wiener(copy(x), { fs, profile: psd(add(line, gauss(n, 0.01, 99))) }))
ok(Math.abs(tracked - learned) < 0.5, `line left ${tracked.toFixed(1)} dBFS tracked, ${learned.toFixed(1)} on its learned profile (was 6 dB over)`)
// forced: the line holds through the voice's pauses, as a held note would, so classify() can't call it a defect
let { out, plan } = denoise(copy(x), { fs, force: 'wiener', returnPlan: true }), s = snr(speech, out)
ok(plan.method === 'wiener' && s > 10, `denoise({ force: 'wiener' }): SNR ${s.toFixed(1)} dB (7.8 before)`)
})
test('imcra: unbiased on white noise; digital silence is skipped; a 12 dB step is followed within two windows', () => {
let sr = 16000, N = 512, hop = 128, x = gauss(sr * 8, 0.01)
for (let i = sr * 3; i < sr * 3.5; i++) x[i] = 0 // an edited-out pause
for (let i = sr * 5; i < x.length; i++) x[i] *= 4 // the room gets 12 dB louder
let est = imcra(N / 2, { fs: sr, hop }), track = []
stftAnalyse(x, mag => { est.update(mag); let s = 0; for (let k = 10; k < 250; k++) s += est.psd[k]; track.push(s / 240) }, { frameSize: N, hopSize: hop })
let at = t => 10 * Math.log10(track[Math.round(t * sr / hop)] / (0.01 ** 2 * 3 * N / 8)) // E|Y|² = σ² · 3N/8
ok(Math.abs(at(2.9)) < 1, `before the pause: ${at(2.9).toFixed(2)} dB re the noise`)
ok(Math.abs(at(3.6)) < 1, `right after the pause: ${at(3.6).toFixed(2)} dB (digital silence learned nothing)`)
ok(Math.abs(at(7.9) - 12) < 1, `2.9 s after the step: ${at(7.9).toFixed(2)} dB (+12; IMCRA lags up to two ~1 s minimum windows)`)
})
// dehum measures before it notches: nothing without hum, the measured series and frequency with it
test('dehum: speech without hum comes back untouched; 50 and 60 Hz hum with harmonics is measured and removed', async () => {
let { measure } = await import('@audio/denoise-dehum')
let speech = lena.subarray(0, fs * 4), out = dehum(copy(speech), { fs })
ok(out.every((v, i) => v === speech[i]), 'lena, no hum: output equals input')
// 12 harmonics at −6 dB per octave, 0.05 Hz off nominal
let hum = (f0, n) => { let y = new Float32Array(n); for (let i = 0; i < n; i++) for (let h = 1; h <= 12; h++) y[i] += Math.sin(2 * Math.PI * h * f0 * i / fs + h) / h; return y }
for (let f0 of [50.05, 59.95]) {
let h = hum(f0, fs * 4), m = measure(add(speech, h.map(v => v * 0.005)), fs)
ok(m && Math.abs(m.f0 - f0) < 0.02, `under speech: ${f0} Hz hum measured at ${m?.f0.toFixed(3)} Hz, harmonics ${m?.harmonics}`)
let y = dehum(copy(h), { fs }), after1s = a => rms(a.subarray(fs))
let db = 20 * Math.log10(after1s(h) / after1s(y))
ok(db > 30, `${f0} Hz hum alone: ${db.toFixed(1)} dB down`)
}
})
// Musical noise is isolated spectral peaks left in the residual: a heavier-tailed power distribution. Its measure is
// the kurtosis ratio of the power spectral values, out over in (Uemura et al., IWAENC 2008; Miyazaki et al., IEEE
// TASLP 20(7), 2012); a gain that only scales the noise keeps it at 1.
test('omlsa: stationary noise alone: brought down by G_min, no musical noise', () => {
let n = gauss(fs * 6, 0.01), y = omlsa(copy(n), { fs })
let stats = x => { let s1 = 0, s2 = 0, c = 0; stftAnalyse(x.subarray(fs * 2), mag => { for (let k = 4; k < 1000; k++) { let p = mag[k] * mag[k]; s1 += p; s2 += p * p; c++ } }, { frameSize: 2048, hopSize: 512 }); return { k: s2 * c / (s1 * s1), p: s1 / c } }
let a = stats(n), b = stats(y), lk = Math.log(b.k / a.k), down = 10 * Math.log10(a.p / b.p)
ok(Math.abs(lk) < 0.1, `log kurtosis ratio ${lk.toFixed(3)} (2 s on, trackers settled)`)
ok(Math.abs(down - 15) < 0.5, `noise down ${down.toFixed(1)} dB, G_min 15 dB`)
})
// Minimum statistics and IMCRA learn whatever holds a bin for a second: a chord or a sung tone held 6 s over a quieter
// bin came out as the noise, and the gain took 62–76 % of their energy down by more than 3 dB (noise-estimate 2.2).
// noise-estimate's `partials` holds the noise under a partial at what the bin held before it came.
import { processor as wienerProcessor } from '@audio/denoise-wiener'
import { processor as specsubProcessor } from '@audio/denoise-spectral'
import { partials } from '@audio/noise-estimate'
test('omlsa, wiener, specsub tracking the noise: a held chord and a sung tone are not learned as the noise', () => {
let sr = 16000, o = { fs: sr, frameSize: 512, hopSize: 128 }, n = 9 * sr, nz = gauss(n, 0.001)
let chord = new Float32Array(n), sung = new Float32Array(n) // 2 s of the noise alone, then 6 s held
for (let f0 of [220, 261.63, 329.63]) for (let h = 1; h <= 8; h++) for (let i = 2 * sr; i < 8 * sr; i++) chord[i] += 0.03 / h * Math.sin(2 * Math.PI * f0 * h * i / sr + h)
for (let i = 2 * sr, ph = 0; i < 8 * sr; i++) { // 5.5 Hz vibrato, ±40 cents
ph += 2 * Math.PI * 220 * 2 ** (40 / 1200 * Math.sin(2 * Math.PI * 5.5 * i / sr)) / sr
for (let h = 1; h <= 8; h++) sung[i] += 0.05 / h * Math.sin(h * ph)
}
// the share of the program's time-frequency energy the gain, computed on the mix, cuts by more than 3 dB
let cut = (p, s) => {
let G = [], a = 0, b = 0, i = 0
stftBatch(s.map((v, j) => v + nz[j]), (m, ph) => { let m0 = Float64Array.from(m), r = p(m, ph); G.push(m0.map((v, k) => v > 0 ? r.mag[k] / v : 1)); return r }, o)
stftBatch(s, (m, ph) => { let g = G[i++]; for (let k = 1; k < m.length; k++) { b += m[k] ** 2; if (g[k] ** 2 < 0.5) a += m[k] ** 2 } return { mag: m, phase: ph } }, o)
return 100 * a / b
}
for (let [name, make] of [['omlsa', omlsaProcessor], ['wiener', wienerProcessor], ['specsub', specsubProcessor]]) for (let [what, s] of [['chord', chord], ['sung tone', sung]]) {
let c = cut(make(o), s)
ok(c < 1, `${name}, ${what}: ${c.toFixed(1)} % cut by more than 3 dB (62–76 % before)`)
}
ok(omlsa(add(chord, nz), o).every(Number.isFinite), 'omlsa: output finite')
})
test('partials: noise alone passes untouched; a line from the start is noise at once, one that starts mid-take after T', () => {
let sr = 16000, N = 512, hop = 128, x = gauss(sr * 8, 0.01), o = { frameSize: N, hopSize: hop }
let a = imcra(N / 2, { fs: sr, hop }), b = imcra(N / 2, { fs: sr, hop, partials: true }), same = 0, all = 0
stftAnalyse(x, m => { a.update(m); b.update(m); for (let k = 0; k <= N / 2; k++) { all++; if (a.psd[k] === b.psd[k]) same++ } }, o)
ok(same === all, `white noise: ${all - same} of ${all} estimates changed`)
// a 1 kHz line 39 dB over its bin, from the start and from 2 s on; T 4 s (60 by default)
let k0 = Math.round(1000 * N / sr), at = t => Math.round(t * sr / hop)
let line = t0 => { let y = gauss(sr * 10, 0.01, 3); for (let i = t0 * sr; i < y.length; i++) y[i] += 0.1 * Math.sin(2 * Math.PI * 1000 * i / sr); return y }
let level = (y, ts) => {
let e = minStats(N / 2, { D: Math.round(1.5 * sr / hop), fs: sr, hop, partials: { T: 4 } }), r = [], l = 0
stftAnalyse(y, m => { e.update(m); if (ts.includes(l++)) r.push(10 * Math.log10(e.psd[k0] / (0.01 ** 2 * 3 * N / 8))) }, o)
return { r, lines: e.partials.lines[k0] }
}
let s = level(line(0), [at(3)]), m = level(line(2), [at(4), at(9.5)])
ok(s.r[0] > 15 && s.lines === 1, `from the start: the noise at 3 s ${s.r[0].toFixed(1)} dB over the floor, a line`)
ok(m.r[0] > -4 && m.r[0] < 2, `from 2 s: held as a partial at 4 s, ${m.r[0].toFixed(1)} dB (the bin's memory: the least of its subwindows' means)`)
ok(m.r[1] > 15, `no free frame for T: learned by 9.5 s, ${m.r[1].toFixed(1)} dB`)
let p = partials(0), q = partials(9, { dt: 0.008 }), psd = new Float64Array(9).fill(1)
p.update(new Float64Array(0), new Float64Array(0)); q.update(new Float64Array(9), psd)
ok(psd.every(v => v === 1), 'no bins, and a silent frame: nothing changed')
})
// 10 VoiceBank+DEMAND test utterances (Valentini-Botinhao 2017, CC BY 4.0) when ~/.cache/audiojs/data/vbdemand holds
// them (scripts/speech.mjs says where from); not committed. Guards what scripts/speech.py measured on all 824.
const VB = path.join(os.homedir(), '.cache', 'audiojs', 'data', 'vbdemand')
test('omlsa, wiener, specsub: 10 VoiceBank+DEMAND utterances: SI-SDR up from the noisy input, clean speech kept', { skip: !existsSync(path.join(VB, 'clean_testset_wav')), timeout: 300000 }, () => {
let read = f => { let b = readFileSync(f), o = 12; while (b.toString('ascii', o, o + 4) !== 'data') o += 8 + b.readUInt32LE(o + 4); let n = b.readUInt32LE(o + 4) / 2, x = new Float32Array(n); for (let i = 0; i < n; i++) x[i] = b.readInt16LE(o + 8 + 2 * i) / 32768; return x }
let sisdr = (r, e) => { // Le Roux et al., ICASSP 2019, eq. 3, zero-mean
let mr = 0, me = 0, d = 0, rr = 0, t = 0, u = 0
for (let i = 0; i < r.length; i++) { mr += r[i] / r.length; me += e[i] / r.length }
for (let i = 0; i < r.length; i++) { d += (e[i] - me) * (r[i] - mr); rr += (r[i] - mr) ** 2 }
for (let i = 0; i < r.length; i++) { let a = d / rr * (r[i] - mr), b = e[i] - me - a; t += a * a; u += b * b }
return 10 * Math.log10(t / u)
}
let names = readdirSync(path.join(VB, 'clean_testset_wav')).filter(f => f.endsWith('.wav')).sort().filter((_, i) => i % 82 === 0)
for (let [name, fn, gain] of [['omlsa', omlsa, 4], ['wiener', wiener, 4], ['specsub', specsub, 3]]) {
let up = 0, kept = Infinity
for (let f of names) {
let c = read(path.join(VB, 'clean_testset_wav', f)), x = read(path.join(VB, 'noisy_testset_wav', f))
up += (sisdr(c, fn(x, { fs: 48000 })) - sisdr(c, x)) / names.length
kept = Math.min(kept, sisdr(c, fn(c, { fs: 48000 })))
}
ok(up > gain, `${name}: SI-SDR +${up.toFixed(2)} dB over the noisy input`)
ok(kept > 14, `${name}: clean speech through it, worst SI-SDR ${kept.toFixed(1)} dB`)
}
})
// =================== declick ===================
test('declick — removes injected clicks', () => {
let speech = lena.subarray(0, fs * 2)
let dirty = add(speech, clicks(speech.length, 8, 0.9))
let clean = declick(copy(dirty), { fs })
ok(peak(clean) < peak(dirty) * 0.9, 'peak click reduced')
})
// a tick as a stylus reads one: an impulse ringing at 5 kHz, decaying in 0.2 ms, at 5× the speech around it
// (scripts/declick.js measures the kinds and sizes)
function ticks(x, every = 0.3) {
let d = copy(x), at = []
for (let t = Math.round(0.25 * fs); t < x.length - 0.25 * fs; t += Math.round(every * fs)) {
let s = 0; for (let i = t - 441; i < t + 441; i++) s += x[i] * x[i]
let level = Math.sqrt(s / 882), tau = 0.2 * fs / 1000
for (let n = 0; n < 5 * tau; n++) d[t + n] += 5 * level * Math.exp(-n / tau) * Math.cos(2 * Math.PI * 5000 * n / fs)
at.push(t)
}
return { d, at }
}
const err = (x, y, a, b) => { let s = 0; for (let i = a; i < b; i++) s += (x[i] - y[i]) ** 2; return s }
test('declick — a ringing tick, not only its onset', () => {
let speech = lena.subarray(0, fs * 3), { d, at } = ticks(speech)
let out = declick(copy(d), { fs }), before = 0, after = 0
for (let t of at) before += err(d, speech, t - 44, t + 88), after += err(out, speech, t - 44, t + 88)
ok(10 * Math.log10(before / after) > 12, `the ticks' error ${(10 * Math.log10(before / after)).toFixed(1)} dB down`)
})
test('declick — leaves clean speech alone', () => {
let speech = lena.subarray(0, fs * 2)
let out = declick(copy(speech), { fs })
is([...out].findIndex((v, i) => v !== speech[i]), -1, 'not a sample changed')
})
test('declick — regions: the clicks there and nothing else', () => {
let speech = lena.subarray(0, fs * 3), { d, at } = ticks(speech)
let t = at[2], out = declick(copy(d), { fs, regions: [{ at: (t - 200) / fs, duration: 600 / fs }] })
ok(err(out, speech, t - 44, t + 88) < err(d, speech, t - 44, t + 88) / 10, 'the tick in the region gone')
let moved = []; for (let i = 0; i < d.length; i++) if (out[i] !== d[i]) moved.push(i)
ok(moved[0] >= t - 200 - 64 && moved.at(-1) < t + 400 + 64, `changed only around it (${moved[0]}–${moved.at(-1)})`)
is([...declick(copy(d), { fs, regions: [] })].findIndex((v, i) => v !== d[i]), -1, 'no region: none touched')
})
test('declick — a sound shorter than its window', () => {
let x = new Float32Array(600)
for (let i = 0; i < x.length; i++) x[i] = 0.3 * Math.sin(2 * Math.PI * 440 * i / fs)
x[300] += 0.8
let out = declick(copy(x), { fs })
ok(out.every(Number.isFinite) && Math.abs(out[300] - 0.3 * Math.sin(2 * Math.PI * 440 * 300 / fs)) < 0.1, 'repaired')
is(declick(new Float32Array(8), { fs }).length, 8, 'shorter than the model')
})
// a pop (a 600 Hz resonance decaying in 0.6 ms, at 5× the speech around it) or a mouth click (2 ms of differenced
// noise, at 2×) every 0.3 s; its span
function struck(x, kind, k) {
let d = copy(x), at = [], s = 5, r = () => (s = (Math.imul(s, 1664525) + 1013904223) >>> 0) / 4294967296
for (let t = Math.round(0.25 * fs); t < x.length - 0.25 * fs; t += Math.round(0.3 * fs)) {
let e = 0; for (let i = t - 441; i < t + 441; i++) e += x[i] * x[i]
let level = Math.sqrt(e / 882), h, q = 0, tau = 0.6 * fs / 1000
if (kind === 'pop') h = Array.from({ length: Math.ceil(5 * tau) }, (_, n) => Math.exp(-n / tau) * Math.sin(2 * Math.PI * 600 * n / fs + 0.3))
else h = Array.from({ length: 88 }, (_, n) => { let w = r() * 2 - 1, y = w - q; q = w; return y * Math.sin(Math.PI * n / 88) ** 2 })
let pk = Math.max(...h.map(Math.abs))
for (let n = 0; n < h.length; n++) d[t + n] += k * level * h[n] / pk
at.push([t, t + h.length])
}
return { d, at }
}
test('declick — a pop rings on, its tail taken too; a faint mouth click rebuilt toward the sound under it', () => {
// 0.2.0 (a gap as far as the AR(32) error showed, least squares): the pops 12.9 dB down, the mouth clicks 12.4
let speech = lena.subarray(0, fs * 3)
for (let [kind, k, want, name] of [['pop', 5, 22, 'pops'], ['mouth', 2, 16, 'mouth clicks']]) {
let { d, at } = struck(speech, kind, k), out = declick(copy(d), { fs }), before = 0, after = 0
for (let [a, b] of at) before += err(d, speech, a - 44, b + 44), after += err(out, speech, a - 44, b + 44)
ok(10 * Math.log10(before / after) > want, `${name} at ${k}×: their error ${(10 * Math.log10(before / after)).toFixed(1)} dB down`)
}
})
test('declick — empty, one sample, silence, clicks at the ends, no NaN', () => {
is(declick(new Float32Array(0), { fs }).length, 0, 'empty')
is([...declick(Float32Array.of(0.5), { fs })], [0.5], 'one sample')
ok(declick(new Float32Array(fs), { fs }).every(v => v === 0), 'silence stays silent')
let x = lena.slice(fs, 2 * fs), d = copy(x)
d[200] += 0.5, d[x.length - 300] += 0.5
let out = declick(d, { fs })
ok(Math.abs(out[200] - x[200]) < 0.02 && Math.abs(out[x.length - 300] - x[x.length - 300]) < 0.1, 'near the start and the end, rebuilt')
let many = copy(x)
for (let i = 500; i < many.length; i += 397) many[i] += i & 1 ? 0.9 : -0.9
ok(declick(many, { fs }).every(Number.isFinite), 'no NaN')
})
// =================== decrackle ===================
test('decrackle — reduces high-rate impulse noise', () => {
let n = lena.length > fs * 2 ? lena.subarray(0, fs * 2) : lena
let crack = new Float32Array(n.length)
for (let i = 0; i < n.length; i += 256) crack[i] = (i & 1 ? -1 : 1) * 0.4
let dirty = add(n, crack)
let clean = decrackle(copy(dirty), { fs })
ok(peak(clean) < peak(dirty), 'peaks reduced')
})
// crackle as scripts/decrackle.js makes it: impulses at Poisson times, `rate` a second, half 1–3 samples off, half ticks
// ringing at 2–8 kHz for 0.03–0.15 ms, peaking at A/5–A × the sound's RMS
function crackle(x, rate, A, seed = 1) {
let s = seed, r = () => (s = (Math.imul(s, 1664525) + 1013904223) >>> 0) / 4294967296, d = copy(x), g = rms(x)
for (let at = 0; ; ) {
at += Math.max(1, Math.round(-Math.log(r() || 1e-9) / rate * fs))
if (at > x.length - 64) break
let amp = g * A * 5 ** -r() * (r() < 0.5 ? -1 : 1), h
if (r() < 0.5) h = Array.from({ length: 1 + Math.floor(r() * 3) }, () => 1 - r() * 0.3)
else { let f = 2000 + r() * 6000, t = (0.03 + r() * 0.12) * fs / 1000; h = Array.from({ length: Math.ceil(5 * t) }, (_, n) => Math.exp(-n / t) * Math.cos(2 * Math.PI * f * n / fs)) }
for (let j = 0; j < h.length; j++) d[at + j] += amp * h[j]
}
return d
}
const sdr = (c, y) => { let s = 0, e = 0; for (let i = 0; i < c.length; i++) s += c[i] ** 2, e += (y[i] - c[i]) ** 2; return 10 * Math.log10(s / e) }
const moved = (x, y) => { let c = 0, e = 0, s = 0; for (let i = 0; i < x.length; i++) { if (y[i] !== x[i]) c++; e += (y[i] - x[i]) ** 2; s += x[i] ** 2 } return { share: c / x.length, db: 10 * Math.log10(e / s) } }
test('decrackle — small crackle on speech taken out, the speech not made worse', () => {
// 0.1.7 (2.5 × the window's MAD, Gauss-Seidel fill) took this from 32.5 to 20.5 dB
let speech = lena.subarray(0, fs * 3), d = crackle(speech, 200, 0.5)
let before = sdr(speech, d), after = sdr(speech, decrackle(d, { fs }))
ok(after > before + 5, `SDR ${before.toFixed(1)} → ${after.toFixed(1)} dB`)
})
test('decrackle — dense crackle, 1000/s at 0.4–2× the speech', () => {
let speech = lena.subarray(0, fs * 2), d = crackle(speech, 1000, 2, 7)
let before = sdr(speech, d), after = sdr(speech, decrackle(d, { fs }))
ok(after > before + 6, `SDR ${before.toFixed(1)} → ${after.toFixed(1)} dB`)
})
test('decrackle — loud ticks: their ringing and the small ones beside them taken too', () => {
// 0.2.0 (the rebuild bent to the tails under the threshold, taking them for sound): 14.7 → 28.2 dB
let speech = lena.subarray(0, fs * 3), d = crackle(speech, 50, 8, 7)
let before = sdr(speech, d), after = sdr(speech, decrackle(d, { fs }))
ok(after > 30, `SDR ${before.toFixed(1)} → ${after.toFixed(1)} dB`)
})
test('decrackle — clean speech and a sung-like tone left nearly as they are', () => {
// 0.1.7 changed 7% of these samples, the speech to −20 dB; 0.3 softens 0.6 ms around each of the few it takes for
// crackle, so more samples move, each by less
let speech = lena.subarray(0, fs * 3), m = moved(speech, decrackle(speech, { fs }))
ok(m.share < 0.003 && m.db < -60, `speech: ${(100 * m.share).toFixed(3)}% of samples, ${m.db.toFixed(1)} dB`)
let tone = new Float32Array(fs * 2), ph = 0 // 220 Hz, 8 harmonics at 1/k, ±30 cents at 5.5 Hz
for (let i = 0; i < tone.length; i++) {
ph += 2 * Math.PI * 220 * 2 ** (0.3 / 12 * Math.sin(2 * Math.PI * 5.5 * i / fs)) / fs
for (let k = 1; k <= 8; k++) tone[i] += 0.3 * Math.sin(k * ph) / k
}
m = moved(tone, decrackle(tone, { fs }))
ok(m.share < 0.015 && m.db < -60, `tone: ${(100 * m.share).toFixed(3)}% of samples, ${m.db.toFixed(1)} dB`)
let hiss = noise(fs, 0.1)
is([...decrackle(hiss, { fs })].findIndex((v, i) => v !== hiss[i]), -1, 'noise: not a sample changed')
})