@audio/neural-denoise 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +26 -0
- package/README.md +125 -20
- package/audio.d.ts +7 -3
- package/audio.js +34 -18
- package/deepfilter.js +62 -4
- package/denoise.js +32 -23
- package/gemm.wat +66 -0
- package/guard.bin +0 -0
- package/guard.js +342 -0
- package/index.d.ts +4 -2
- package/package.json +8 -3
- package/rnnoise.js +5 -3
- package/worklet.js +2 -2
package/NOTICE
CHANGED
|
@@ -41,3 +41,29 @@ The DeepFilterNet3 model is not included. deepfilter.js downloads it on first us
|
|
|
41
41
|
DeepFilterNet repository at the same commit (models/DeepFilterNet3_onnx.tar.gz). Upstream's
|
|
42
42
|
README licenses "all code in this repository" as MIT OR Apache-2.0 and states no separate terms
|
|
43
43
|
for the model files; see README.md, "Licenses".
|
|
44
|
+
|
|
45
|
+
This product includes the speech/music/noise network of inaSpeechSegmenter
|
|
46
|
+
(https://github.com/ina-foss/inaSpeechSegmenter), MIT License: guard.bin holds the weights of its
|
|
47
|
+
keras_speech_music_noise_cnn.hdf5 (release 'models', sha256 f04b5e3c…), each batch norm folded into
|
|
48
|
+
the layer before it, as float16, written by scripts/guard.py; gemm.wat and guard.js run the network.
|
|
49
|
+
guard.js computes the network's input as inaSpeechSegmenter does (its sidekit_mfcc.py, from SIDEKIT by
|
|
50
|
+
Anthony Larcher and Sylvain Meignier, LGPL): the framing, the pre-emphasis, the window, the power
|
|
51
|
+
spectrum and the mel filters' edges and areas, written anew from those definitions.
|
|
52
|
+
|
|
53
|
+
MIT License, Copyright (c) 2018 Ina (David Doukhan - http://www.ina.fr/)
|
|
54
|
+
|
|
55
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this
|
|
56
|
+
software and associated documentation files (the "Software"), to deal in the Software
|
|
57
|
+
without restriction, including without limitation the rights to use, copy, modify, merge,
|
|
58
|
+
publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
|
|
59
|
+
to whom the Software is furnished to do so, subject to the following conditions:
|
|
60
|
+
|
|
61
|
+
The above copyright notice and this permission notice shall be included in all copies or
|
|
62
|
+
substantial portions of the Software.
|
|
63
|
+
|
|
64
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
|
65
|
+
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
|
|
66
|
+
PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
|
|
67
|
+
FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
|
68
|
+
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
|
69
|
+
DEALINGS IN THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
> Neural speech enhancement: RNNoise ported to JS, bit-exact to its C, weights inside; DeepFilterNet3 through `@audio/neural-runtime`, matching the Python original.
|
|
4
4
|
|
|
5
|
-
The neural tier beside [`@audio/denoise`](https://github.com/audiojs/denoise)'s statistical denoisers (`omlsa`, `wiener`, `specsub`). A statistical denoiser tracks a noise floor it assumes to be steady; these models learned what speech is, so they also remove noise that moves: keyboards, traffic, a fan changing speed, a busy room.
|
|
5
|
+
The neural tier beside [`@audio/denoise`](https://github.com/audiojs/denoise)'s statistical denoisers (`omlsa`, `wiener`, `specsub`). A statistical denoiser tracks a noise floor it assumes to be steady; these models learned what speech is, so they also remove noise that moves: keyboards, traffic, a fan changing speed, a busy room. To them everything that isn't speech is noise, music too, so a speech/music classifier stands guard: music, songs included, passes through untouched ([Music](#music)).
|
|
6
6
|
|
|
7
7
|
```js
|
|
8
8
|
import denoise from '@audio/neural-denoise'
|
|
@@ -17,7 +17,8 @@ let better = await denoise(pcm, { sampleRate: 44100, model: 'deepfilternet3' })
|
|
|
17
17
|
|---|---|---|
|
|
18
18
|
| `sampleRate` | | required unless `audio` carries it |
|
|
19
19
|
| `model` | `'rnnoise'` | `'rnnoise'`, `'deepfilternet3'`, or a handle from `load()` |
|
|
20
|
-
| `limit` | `
|
|
20
|
+
| `limit` | `16` RNNoise, `18` DeepFilterNet3 | attenuation limit, dB: the input is mixed back in at 10^(−limit/20), so noise drops by at most `limit`; `0` for none. Each default is the most before the voice itself suffers. RNNoise turns down speech that has nothing to remove (clean word ends 37 dB down unlimited, 12 at 16 dB) and removes the voice on some noisy files; on VoiceBank+DEMAND's training speakers its DNSMOS SIG holds from 14 to 16 dB and falls past it, and at 16 no file loses more than 0.2 STOI (21 of 504 unlimited, 3 at 20, the default before 0.3). DeepFilterNet3's SIG holds from 12 to 18 dB and falls past it, while BAK and PESQ keep rising; unlimited, it takes the pauses of home narrations to digital silence, which ACX Check flags ([Accuracy](#accuracy)) |
|
|
21
|
+
| `music` | `'pass'` | `'pass'`: speech and noise are enhanced, music passes through untouched, segment by segment, the gain ramped over 200 ms at each switch ([Music](#music)); `'enhance'`: everything, as before 0.4 |
|
|
21
22
|
| `weights` | | RNNoise: bytes of an upstream weight blob; DeepFilterNet3: URL or bytes of an upstream ONNX export |
|
|
22
23
|
| `device` | `'auto'` | DeepFilterNet3: `@audio/neural-runtime` backend, `'node'`, `'wasm'` or `'webgpu'` |
|
|
23
24
|
| `sessionOptions` | | DeepFilterNet3: ONNX Runtime session options, e.g. `{ intraOpNumThreads: 4 }` |
|
|
@@ -55,7 +56,7 @@ let node = new AudioWorkletNode(ctx, 'neural-denoise', { processorOptions: { wei
|
|
|
55
56
|
mic.connect(node).connect(ctx.destination)
|
|
56
57
|
```
|
|
57
58
|
|
|
58
|
-
The processor collects 128-sample render quanta into 10 ms frames. Its output queue starts with 480 − gcd(128, 480) = 448 samples of silence, the least that never runs dry, so the node's delay is constant: 1408 samples, 29.3 ms. It allocates nothing per frame. The context must run at 48 kHz; the browser resamples the device. `limit` works as in `denoise()`:
|
|
59
|
+
The processor collects 128-sample render quanta into 10 ms frames. Its output queue starts with 480 − gcd(128, 480) = 448 samples of silence, the least that never runs dry, so the node's delay is constant: 1408 samples, 29.3 ms. It allocates nothing per frame. The context must run at 48 kHz; the browser resamples the device. `limit` works as in `denoise()`: 16 dB unless given, `0` for none. The worklet is for a live voice and has no music guard: it denoises everything.
|
|
59
60
|
|
|
60
61
|
Frame by frame, as `rnnoise_process_frame` works:
|
|
61
62
|
|
|
@@ -68,16 +69,17 @@ let voice = st.process(input480, output480) // int16 scale in and out; voice pr
|
|
|
68
69
|
|
|
69
70
|
## In `audio`: the contract atom
|
|
70
71
|
|
|
71
|
-
`@audio/neural-denoise/audio` is the package's [contract](https://github.com/audiojs/compile/blob/main/CONTRACT.md) manifest: `rnnoise`, a streaming atom at any sample rate, with
|
|
72
|
+
`@audio/neural-denoise/audio` is the package's [contract](https://github.com/audiojs/compile/blob/main/CONTRACT.md) manifest: `rnnoise`, a streaming atom at any sample rate, with two parameters, `limit` (dB, default 16, 0 for none) and `music` (`'pass'`, the default, or `'enhance'`). [`audio`](https://github.com/audiojs/audio) hosts it as its `rnnoise` op, and runs DeepFilterNet3 as its `deepfilter` op (the whole input before rendering, `limit` 18 and `music: 'pass'` by default):
|
|
72
73
|
|
|
73
74
|
```js
|
|
74
75
|
import audio from 'audio'
|
|
75
76
|
|
|
76
77
|
await audio('take.wav').rnnoise().save('clean.wav') // streams
|
|
77
|
-
await audio('take.wav').deepfilter().save('clean.wav') // DeepFilterNet3, room tone kept
|
|
78
|
+
await audio('take.wav').deepfilter().save('clean.wav') // DeepFilterNet3, room tone kept, music passed
|
|
79
|
+
await audio('show.wav').deepfilter({ music: 'enhance' }) // music denoised too
|
|
78
80
|
```
|
|
79
81
|
|
|
80
|
-
Blocks of any size collect into frames; the output queue starts with 479 samples of silence (FRAME − 1, the least that never runs dry for any block size), so the delay is constant: 1439 samples at 48 kHz (30 ms). Other rates go to 48 kHz and back through a streaming form of `@audio/resample-sinc`'s kernel, in its arithmetic, and the delay grows by its lookahead: 1354 samples at 44.1 kHz (30.7 ms), 271 at 8 kHz. The output is `denoise()`'s, delayed: at 48 kHz sample for sample; at other rates, with resample-sinc 1.2.0, sample for sample but in the last 20–30 ms, where the offline resampler sees the end and a stream the host's trailing silence. The default `limit`,
|
|
82
|
+
Blocks of any size collect into frames; the output queue starts with 479 samples of silence (FRAME − 1, the least that never runs dry for any block size), so the delay is constant: 1439 samples at 48 kHz (30 ms). Other rates go to 48 kHz and back through a streaming form of `@audio/resample-sinc`'s kernel, in its arithmetic, and the delay grows by its lookahead: 1354 samples at 44.1 kHz (30.7 ms), 271 at 8 kHz. The output is `denoise()`'s, delayed: at 48 kHz sample for sample; at other rates, with resample-sinc 1.2.0, sample for sample but in the last 20–30 ms, where the offline resampler sees the end and a stream the host's trailing silence. The default `limit`, 16 dB, is `denoise()`'s for RNNoise: unlimited, the model gates clean speech and removes the voice on some noisy files (Accuracy, below). With `music: 'pass'` the atom decides as `denoise()` does for RNNoise, from what has arrived, at no added delay, and mixes the denoised signal with the input itself, delayed by the latency, at the host's rate: what passes is the input, sample for sample.
|
|
81
83
|
|
|
82
84
|
## RNNoise
|
|
83
85
|
|
|
@@ -96,7 +98,7 @@ The float code keeps C's float semantics: each float op is `Math.fround(a op b)`
|
|
|
96
98
|
Upstream's three ONNX graphs (encoder, ERB-gain decoder, deep-filter decoder) run unchanged; the rest is ported from DeepFilterNet at d375b2d, `libDF` and `df.enhance.enhance()`:
|
|
97
99
|
|
|
98
100
|
1. **STFT**: 960-point Vorbis window, 480 hop, the input padded by 960 zeros; the same FFT as RNNoise.
|
|
99
|
-
2. **Features**: power in 32 ERB bands (libDF's `erb_fb`: 2 to 67 bins wide) in dB, mean-normalized with α = 0.99 (τ = 1 s); the lowest 96 bins, unit-normalized with the same α. Both run 2 frames ahead of the output (`conv_lookahead`). `denoise()` computes them as if the input's speech (the louder half of its 50 ms frames) were at −20 dBFS, the median of the VoiceBank+DEMAND test set (−20.2): they are not level-free, the ERB means start at −60 to −90 dB and the complex features scale with the root of the level, and the model was trained at speech gains of −6, 0 and +6 dB (libDF `dataset.rs`). Heard as it is, a take 24 dB quieter lost its pauses' noise by 11 to 14 dB instead of 22 to 24, and the noise after 2.7 s of digital silence by 10 to 24 dB; at −20 dBFS it does as the louder take, at no cost on VoiceBank+DEMAND (PESQ 3.077 against 3.076, 206 files). The spectrum the gains and filters apply to stays as it was.
|
|
101
|
+
2. **Features**: power in 32 ERB bands (libDF's `erb_fb`: 2 to 67 bins wide) in dB, mean-normalized with α = 0.99 (τ = 1 s); the lowest 96 bins, unit-normalized with the same α. Both run 2 frames ahead of the output (`conv_lookahead`). `denoise()` computes them as if the input's speech (the louder half of its 50 ms frames) were at −20 dBFS, the median of the VoiceBank+DEMAND test set (−20.2): they are not level-free, the ERB means start at −60 to −90 dB and the complex features scale with the root of the level, and the model was trained at speech gains of −6, 0 and +6 dB (libDF `dataset.rs`). Heard as it is, a take 24 dB quieter lost its pauses' noise by 11 to 14 dB instead of 22 to 24, and the noise after 2.7 s of digital silence by 10 to 24 dB; at −20 dBFS it does as the louder take, at no cost on VoiceBank+DEMAND (PESQ 3.077 against 3.076, 206 files). The ERB bands wholly above the input's band edge hear white noise 20 dB under that speech: upstream trained on full-band 48 kHz mixtures only (its `config.ini`: `p_bandwidth_ext = 0`, libDF's `BandwidthLimiterAugmentation` off; noise at −5 to 40 dB SNR), so the network never heard a band empty, yet a 16 kHz file leaves everything above 8 kHz empty and a codec's low-pass everything above 16 to 20 kHz (nine of the ten narrations below end between 16.7 and 21 kHz). Such bands reached it as libDF's constant 1e-10 floor, a band without a trace of noise, which no mixture it trained on had, and it cleaned the band below worse: on VoiceBank+DEMAND's training speakers at 16 kHz, unlimited, PESQ 2.26 against 2.57 full-band; over the floor, 2.42 ([Accuracy](#accuracy)). The edge is the highest frequency whose long-term power lies within 60 dB of the 1 to 4 kHz median, measured at the input's own rate before resampling (a resampler's images don't count); a band holding the edge keeps the input's own content, so a 44.1 kHz input, whose top band (20.7 to 24 kHz) still holds 20.7 to 22.05 kHz, is processed as before. Every bin of an empty band draws its power from an exponential distribution in every frame, from a fixed seed, as white noise's STFT does: a constant floor would help only the first seconds, since the mean normalization (τ = 1 s) takes a constant band's feature to zero, while noise keeps moving it. The spectrum the gains and filters apply to stays as it was, and a full-band input (all of VoiceBank+DEMAND at 48 kHz) is processed bit for bit as before.
|
|
100
102
|
3. **Network**: 32 gains (sigmoid) and 96 × 5 complex filter taps per frame.
|
|
101
103
|
4. **Enhance**: every bin takes its band's gain; below bin 96 (4.8 kHz) the output is instead Σₖ spec[t − 2 + k] · coef[t, k], k = 0–4, over the unmasked spectrum; `limit` mixes the noisy spectrum back in. Inverse FFT, overlap-add, the 480-sample STFT delay removed.
|
|
102
104
|
5. **Voice guard** (`denoise()`): the model takes held voicing for noise. A clean held note from VocalSet (Wilkins 2018) comes out 39 dB down, unlimited, its own local-SNR estimate at its floor (−15 dB), while speech keeps its level; sung phrases lose 24 to 34 dB. So where the input holds sustained voicing, runs of 0.3 s or more periodic at RNNoise's pitch (normalized correlation 0.45 or more, Praat's voicing threshold, the pitch searched every 20 ms) that stand 6 dB or more over the quietest 100 ms of the 20 s around (minimum statistics, Martin 2001: a buzz is that background, a note stands over it), each band holding a harmonic keeps at least c² of its input, c its correlation with the frame one and two periods earlier: the periodic share of its power, as RNNoise's pitch filter restores what its band gains would remove. Between the harmonics and in the pauses the model's output stands.
|
|
@@ -105,6 +107,58 @@ The parameters come from the export's `config.ini`, so upstream's low-latency ex
|
|
|
105
107
|
|
|
106
108
|
**Long inputs** run in chunks of `chunk` frames (10 s). Upstream feeds the whole file at once, about 8 MB of activations per second of audio (peak RSS 768 MB for 30 s, 1 GB for 60 s in onnxruntime-node); in 10 s chunks a 180 s file peaks at 563 MB, and any length stays there. The ONNX graphs are exactly causal (outputs on a prefix equal the whole run's) but take no GRU state, so each chunk after the first starts `warmup` frames (3 s) early from zero state, and its first `fade` frames (0.5 s) crossfade from the previous chunk's run, which continued that far. The GRUs remember how they started: 6 s after such a start the gains still differ from the whole-file run's by up to 0.1–0.5. Chunked output is another valid run, not the whole-file one; on VoiceBank+DEMAND in 1 s chunks (1 s warm-up, 0.2 s crossfade, 2 to 3 chunks per utterance) the scores do not move: PESQ 3.164 against 3.162 whole-file, STOI 0.9450 against 0.9452, SI-SDR 18.94 against 18.96 dB, though single files shift by up to 0.31 PESQ either way. The STFT and features stay exact across chunks. Exact chunking needs a stateful re-export (GRU states and convolution buffers as graph inputs and outputs), which is also what live DeepFilterNet3 needs.
|
|
107
109
|
|
|
110
|
+
## Music
|
|
111
|
+
|
|
112
|
+
Both models enhance speech, and to them everything else is noise: at their default limits music lost 8 to 16 dB in every band, songs included (Accuracy, Music). So by default (`music: 'pass'`) a classifier segments the input into speech, music and noise, and the model's output is kept only where it hears speech or noise; where it hears music the input passes through, bit for bit. What counts as what follows the classifier's training, French radio and TV: speech over a music bed is speech, so the bed under a voice-over is cleaned with the noise; a song is music, its singer included, since a speech enhancer would strip the accompaniment from under the voice and dull the voice too. A voice singing alone sits between the two (below). `music: 'enhance'` enhances everything, as before 0.4.
|
|
113
|
+
|
|
114
|
+
1. **Classifier**: inaSpeechSegmenter's speech/music/noise CNN (Doukhan et al., ICASSP 2018; MIT; its `smn` engine), 782,403 weights, in the package as float16 (`guard.bin`, 1.56 MB; `scripts/guard.py` writes it from upstream's Keras export, the batch norms folded). Its input as ina computes it: 16 kHz (here a 31-tap low-pass and every third sample of the 48 kHz signal), pre-emphasis 0.97, 25 ms Hann frames every 10 ms, 24 mel bands from 100 Hz to 8 kHz, log; the lowest 21 bands over 0.68 s make a patch, standardized by its own mean and deviation, so the level does not matter. One patch every 100 ms (ina takes one every 20 ms; on the tuning material every 100 ms decides the same).
|
|
115
|
+
2. **Offline** (DeepFilterNet3, `denoise()` and the `deepfilter` op): segments as ina makes them, Viterbi over the whole input, three states, a switch costing 10⁻⁸⁰ per 20 ms (ina's own constant), so a segment lasts a second or more and a stray patch moves nothing. An input that is music throughout never reaches the model.
|
|
116
|
+
3. **Streaming** (RNNoise: `denoise()`, the contract atom): a stream decides on what has arrived. The forward recursion of the same chain for two states (music, the rest) keeps the log-odds of music, bounded at ±20 nats; a patch moves them by at most ln 100, and by 1 nat less (a prior for speech), and the stream switches to music above 5 and back below 0. Each output frame takes the decision made once the input frame two on is in (RNNoise's own delay), so nothing is added to the latency, and `denoise()` and the atom give the same samples. The price: music is denoised for its first second or so, and each switch comes about a second late.
|
|
117
|
+
4. **The gain** between the enhanced signal and the input moves over 200 ms, raised cosine: offline centered on the segment boundary, streaming from the decision on. It is applied at the input's rate (the 48 kHz gain resampled with the signal), so what passes is the input itself, not its round trip through 48 kHz.
|
|
118
|
+
|
|
119
|
+
It runs in JS: a stream's `process()` can't await ONNX Runtime, and RNNoise needs no runtime. The three upper convolutions (through im2col) and the dense layers run in a 1,074-byte WebAssembly SIMD kernel (`gemm.wat`), the first convolution once per frame and shared by the overlapping patches (the max pooling after it commutes with each patch's standardization); 1.1 s of CPU per minute of audio, 11 s on the JS path where WebAssembly SIMD is missing (same bits).
|
|
120
|
+
|
|
121
|
+
**Accuracy**, on labelled sets (`python scripts/accuracy.py guard-sets`, `node scripts/accuracy.mjs guard train|test`): the share of active 10 ms frames passed (within 40 dB of the loudest). The constants (one patch per 100 ms, the streaming bound, prior and hysteresis) were chosen on the training sets: VoiceBank+DEMAND's training speakers, the MUSDB18 training previews, Slakh2100 mixes 1 to 10, VocalSet singers 1 to 5, the Spoken Wikipedia narrations not in Rooms; the test sets below were run once. Beds: clean utterances over a song's accompaniment (the MUSDB18 stems but the vocals) 20, 15 and 10 dB under the speech; hiss: white and pink Gaussian noise at 20, 10 and 5 dB SNR. Programmes: 8 sequences of six segments, speech (five utterances of one kind, or 15 s of narration) and music (up to 20 s of a song, its accompaniment or a Slakh mix) in turn, scored 0.5 s or more from each cut.
|
|
122
|
+
|
|
123
|
+
| Test set | Frames | Passed, offline (DeepFilterNet3) | Passed, streaming (RNNoise) |
|
|
124
|
+
|---|---|---|---|
|
|
125
|
+
| **Speech, to enhance** | | | |
|
|
126
|
+
| VoiceBank+DEMAND, clean / noisy (824 each) | | 0.0% / 0.0% | 0.0% / 0.0% |
|
|
127
|
+
| the same in white noise, 20 / 10 / 5 dB (275 each) | | 0.0% / 0.0% / 0.0% | 0.0% / 0.0% / 0.0% |
|
|
128
|
+
| the same in pink noise, 20 / 10 / 5 dB | | 0.0% / 0.0% / 0.0% | 0.0% / 0.0% / 0.0% |
|
|
129
|
+
| over a music bed 20 / 15 / 10 dB down (275 each) | | 0.4% / 0.7% / 1.4% | 0.7% / 1.4% / 2.3% |
|
|
130
|
+
| ten home narrations (Rooms) | | 0.0% | 0.0% |
|
|
131
|
+
| VocalSet, spoken excerpts (10) | | 1.8% | 2.1% |
|
|
132
|
+
| DEMAND noise alone (noisy − clean) | | 7.8% | 2.4% |
|
|
133
|
+
| **Music, to pass** | | | |
|
|
134
|
+
| MUSDB18 test previews, songs (50) | | 100% | 87.2% |
|
|
135
|
+
| the same, accompaniment only (50) | | 100% | 87.7% |
|
|
136
|
+
| Slakh2100 mixes 11 to 20, 60 s each | | 100% | 98.6% |
|
|
137
|
+
| the four repair pieces, 60 s each | | 95.7% | 93.8% |
|
|
138
|
+
| **Programmes** (8): speech enhanced / music passed | | 100% / 100% | 98.5% / 92.9%, switches 0.96 s late (median) |
|
|
139
|
+
| **Singing alone** | | | |
|
|
140
|
+
| MUSDB18 vocal stems (50) | | 57.5% | 32.4% |
|
|
141
|
+
| VocalSet sung phrases (20) / long tones (10) | | 66.4% / 67.8% | 34.3% / 40.0% |
|
|
142
|
+
|
|
143
|
+
Offline, every speech set keeps 98.2% or more of its frames enhanced (noisy and hissy speech all of them) and every music set but one passes all of its frames; on the programmes nothing errs 0.5 s from a cut. Streaming, speech is kept as well, and music loses its first second: the previews are 7 s long, so 13% of their frames; the 60 s Slakh mixes, 1.4%. The noise alone that passes is DEMAND's living room (30% of its frames), which has music in it. Of the pieces, `trumpet` (solo) is enhanced for its first 4 s and `brahms` for 5 s of a quiet passage. A voice singing alone is split, half to two thirds of its frames passed offline: held notes and phrases the classifier calls music pass, the rest is enhanced, where DeepFilterNet3's voice guard keeps sustained voicing ([DeepFilterNet3](#deepfilternet3), step 5). Speech over a bed 10 dB down, louder than a podcast's, still counts as speech.
|
|
144
|
+
|
|
145
|
+
What it changes, at the default limits (Accuracy: the same sets, `python scripts/accuracy.py music`; per band the median over the files, the worst in parentheses):
|
|
146
|
+
|
|
147
|
+
| | SI-SDR to the input (median) | 0–250 Hz | 0.25–1 kHz | 1–4 kHz | 4–8 kHz | 8–16 kHz | 16–22 kHz |
|
|
148
|
+
|---|---|---|---|---|---|---|---|
|
|
149
|
+
| DeepFilterNet3, songs (50 MUSDB18 previews): 0.3 | 4.0 dB | −16.9 dB | −13.7 | −16.5 | −13.2 | −12.7 | −16.0 |
|
|
150
|
+
| **0.4** | **the input, bit for bit (all 50)** | **0.0** | **0.0** | **0.0** | **0.0** | **0.0** | **0.0** |
|
|
151
|
+
| DeepFilterNet3, pieces (4): 0.3 | 1.1 dB | −8.6 (−13.0) | −9.0 (−12.1) | −10.9 (−14.8) | −14.4 (−16.6) | −15.5 (−17.4) | −14.0 (−16.1) |
|
|
152
|
+
| **0.4** | **2 of 4 bit for bit** | **0.0 (−0.0)** | **−0.0 (−1.9)** | **−0.1 (−1.7)** | **−0.1 (−4.5)** | **−0.7 (−10.2)** | **−1.0 (−13.0)** |
|
|
153
|
+
| DeepFilterNet3, VocalSet chords (5): 0.3 / **0.4** | 3.5 dB / **bit for bit** | −5.8 / **0.0** | −3.9 / **0.0** | −4.9 / **0.0** | −9.5 / **0.0** | −11.1 / **0.0** | −3.7 / **0.0** |
|
|
154
|
+
| RNNoise, songs: 0.3 | 3.9 dB | −14.4 (−16.0) | −8.0 (−15.1) | −10.1 (−15.8) | −11.4 (−16.0) | −12.2 (−16.0) | −14.2 (−16.0) |
|
|
155
|
+
| **0.4** (the first second denoised) | **11.4 dB** | **−0.6 (−2.3)** | **−0.4 (−2.5)** | **−0.4 (−2.5)** | **−0.5 (−2.2)** | **−0.6 (−2.2)** | **−0.6 (−3.7)** |
|
|
156
|
+
| RNNoise, pieces: 0.3 / **0.4** | 5.8 / **18.3 dB** | −11.5 / **−0.1** | −12.9 / **−0.1** | −13.7 / **−0.1** | −15.3 / **−0.1** | −14.2 / **−0.5** | −11.0 / **−0.6** |
|
|
157
|
+
|
|
158
|
+
Speech is untouched by the guard: on VoiceBank+DEMAND's 824 noisy test utterances both models' default outputs are 0.3's bit for bit (PESQ 2.90 and 2.49, the Accuracy table's rows), and so are those of the ten narrations; lena, a film scene with music under the voice, has its musical opening passed.
|
|
159
|
+
|
|
160
|
+
The classifier was chosen against two others, run in Python on the same training sets, each frame scored as music where its music score beats its speech score (Viterbi-smoothed alike): YAMNet (Google, Apache-2.0; AudioSet's 521 classes, 3.7M weights, 0.96 s frames) passed music as well (songs 100%, accompaniment 100%) but speech over a bed 10 / 15 / 20 dB down 29 / 9.6 / 0.9% of the time (ina, offline: 2.2 / 1.2 / 0.4%); Silero VAD (MIT) tells speech from everything else, noise included, and called 17 to 30% of VoiceBank's clean speech frames non-speech (the pauses). PANNs CNN14 (80M weights) and BEATs (90M) were not tried: a guard should cost a fraction of the model it guards.
|
|
161
|
+
|
|
108
162
|
## Verification
|
|
109
163
|
|
|
110
164
|
| Check | Result |
|
|
@@ -116,8 +170,12 @@ The parameters come from the export's `config.ini`, so upstream's low-latency ex
|
|
|
116
170
|
| DeepFilterNet3, `enhance()` with upstream's settings (no level change, no voice guard, whole file), vs Python `df.enhance.enhance()` (deepfilternet 0.5.6, PyTorch checkpoint), same input, `limit: 0` | 133.5 dB SNR on a 1.7 s utterance, 128.2 dB on 11.3 s (max \|d\| 5.7e-7), 133.8 dB on an utterance led by 2 s of noise, 134.5 dB on a 60 s narration, its first second included; ERB widths and α identical |
|
|
117
171
|
| DeepFilterNet3 voice guard, no model (`test.js`) | a held /a/ (190 Hz, 5 Hz vibrato) 20 dB over white noise through a model that removes everything: kept within 2.8 dB, the noise around it removed; a 120 Hz buzz under the same noise: no frame guarded |
|
|
118
172
|
| DeepFilterNet3 plumbing, no model (`test.js`) | identity gains and taps give the input back to 1.8e-7, whole and chunked (chunks identical to the whole run); a tap one frame ahead advances the band below 4.8 kHz by exactly 480 samples (to 8.9e-8) |
|
|
119
|
-
|
|
|
173
|
+
| DeepFilterNet3 band edge, no model (`test.js`) | the edge of white noise at 48 kHz: 24 kHz; at 16 kHz: 8 kHz; low-passed at 15 kHz: 15 to 16.5 kHz. A 16 kHz input's top band reaches the encoder as the floor (first feature 0.416, as computed; 0.2 fed −0.246, libDF's 1e-10) and moves frame to frame; a full-band input's features are bit for bit those without the floor; identity gains and taps still give the input back |
|
|
174
|
+
| Edge cases (`test.js`), both models, 48 and 16 kHz | empty input, one sample, 100 samples: the same length back, finite; a second of digital silence stays digital silence |
|
|
175
|
+
| Worklet (`test.js`, AudioWorkletGlobalScope simulated) | the frame API's output, delayed by 448 samples, at `limit: 0`; at 12 and at the default 16, that output with the input mixed back, to 1e-7 |
|
|
120
176
|
| Worklet in Chromium 153 (headless, Playwright, `scripts/worklet.mjs`) | runs, no processor error, output non-silent; live underruns in Speed |
|
|
177
|
+
| Music guard (`test.js`) against the float32 Keras model, the same input (`scripts/guard.py` → `fixtures/guard.json`) | lena, 106 patches: probabilities within 1.7e-3 (float16 weights); the wasm kernel and the JS loop give the same bits |
|
|
178
|
+
| Music guard (`test.js`): a synthetic band (chords of plucked tones, a bass, a tick) | DeepFilterNet3, through a stand-in that removes everything: the band back bit for bit at 48 and 44.1 kHz, `music: 'enhance'` removes it; RNNoise: the band itself from 1.1 s on; band, noisy speech, band (with VoiceBank+DEMAND in the data cache): two switches, each within 0.5 s of its cut, gain exactly 0 and 1 between 200 ms raised-cosine ramps; RNNoise's output to frame f − 2 the same whatever follows frame f |
|
|
121
179
|
| Contract atom (`test.js`) against `denoise()`, delayed by its declared latency | 48 kHz: equal sample for sample in blocks of 1, 128, 1024 and 1 to 3000 samples, limits 20 and 0; 44.1 kHz (resample-sinc 1.2.0): equal but in the last 50 ms; with resample-sinc 1.1.2, whose arithmetic differs, 59.5 dB SNR |
|
|
122
180
|
|
|
123
181
|
To rerun: `node scripts/rnnoise-reference.mjs` builds upstream and rewrites `fixtures/rnnoise.json` (and caches the little model); `python scripts/deepfilter-reference.py` (DeepFilterNet 0.5.6 in a Python 3.11 venv) writes the reference `test.js` compares with.
|
|
@@ -136,7 +194,8 @@ The VoiceBank+DEMAND test set (Valentini-Botinhao 2017, CC BY 4.0): 824 utteranc
|
|
|
136
194
|
| "Enhance speech", the whole recipe, with 0.3.11's `omlsa` | 1.63 | 0.830 | 0.84 | 3.06 | 2.37 | 2.18 |
|
|
137
195
|
| RNNoise, `limit: 0` (this package = upstream C) | 2.11 | 0.890 | 12.28 | 3.28 | 3.85 | 2.94 |
|
|
138
196
|
| RNNoise, little model, `limit: 0` | 2.12 | 0.893 | 12.39 | 3.28 | 3.85 | 2.93 |
|
|
139
|
-
| RNNoise, `limit: 20
|
|
197
|
+
| RNNoise, `limit: 20` (the default before 0.3) | 2.46 | 0.918 | 12.86 | 3.40 | 3.71 | 2.97 |
|
|
198
|
+
| RNNoise, `limit: 16`, the default | 2.49 | 0.922 | 13.00 | 3.43 | 3.66 | 2.96 |
|
|
140
199
|
| **DeepFilterNet3**, upstream's settings, `limit: 0` (`enhance()`) | **3.16** | **0.945** | **18.96** | **3.44** | **4.08** | **3.18** |
|
|
141
200
|
| DeepFilterNet3, Python (deepfilternet 0.5.6) | 3.16 | 0.945 | 18.96 | 3.44 | 4.08 | 3.18 |
|
|
142
201
|
| DeepFilterNet3 in 1 s chunks, `limit: 0` | 3.16 | 0.945 | 18.94 | 3.44 | 4.08 | 3.18 |
|
|
@@ -146,9 +205,46 @@ The VoiceBank+DEMAND test set (Valentini-Botinhao 2017, CC BY 4.0): 824 utteranc
|
|
|
146
205
|
|
|
147
206
|
Over the noisy input, paired, with 95% intervals: DeepFilterNet3 adds 1.20 ± 0.03 PESQ, 0.024 ± 0.002 STOI and 10.5 ± 0.3 dB SI-SDR, and beats every classical row's PESQ on 804 of 824 files; RNNoise adds 0.14 ± 0.04 PESQ and 3.8 ± 0.3 dB SI-SDR but loses 0.031 ± 0.004 STOI; `omlsa`, the best classical row, adds 0.43 ± 0.03 PESQ and 6.1 ± 0.2 dB SI-SDR and loses 0.006 STOI. A limit leaves noise in on this set, and spares the voice: paired, on a quarter of the files (206), DNSMOS SIG moves −0.002 ± 0.015 from 12 to 18 dB, then −0.008 ± 0.003 to 20, −0.019 ± 0.006 more to 24 and −0.049 ± 0.017 more unlimited, while BAK rises 0.18, 0.04, 0.07 and 0.16 and PESQ 0.25, 0.06, 0.08 and 0.10. Past 18 dB the speech itself loses, which DNSMOS reads as SIG and a listener as a filtered, phone-call voice; from 8 to 16 kHz the unlimited output carries 1.5 dB less than the clean speech, at 18 dB 0.9 (all 824 files). The default 18 dB still adds 0.93 PESQ over the input and 0.50 over `omlsa`. Pass `limit: 0` where the most noise removal matters more than room tone. The level change and the voice guard cost 0.01 PESQ unlimited and 0.004 at 18 dB. The same scoring gives upstream's published anchors: noisy 1.97 and 0.921 (as the DeepFilterNet2 paper's table), DeepFilterNet3 3.16 and 0.945 against the DeepFilterNet3 paper's 3.17 and 0.944. That DeepFilterNet2 table measured RNNoise's 2022 model at 2.33 and 0.922; the current model scores lower here.
|
|
148
207
|
|
|
149
|
-
- **RNNoise's current model sometimes removes the voice.** STOI drops by more than 0.1 on 68 of 824 files and by more than 0.2 on 12; on p232_354 it goes from 0.870 to 0.154, the output 24 dB under the clean level while RNNoise's own voice probability averages 0.84. Upstream's C does the same, the portable and the NEON build alike (0.154, 0.153). DeepFilterNet3 never loses more than 0.066 STOI, `wiener` 0.065, `omlsa` 0.059. A
|
|
208
|
+
- **RNNoise's current model sometimes removes the voice.** STOI drops by more than 0.1 on 68 of 824 files and by more than 0.2 on 12; on p232_354 it goes from 0.870 to 0.154, the output 24 dB under the clean level while RNNoise's own voice probability averages 0.84. Upstream's C does the same, the portable and the NEON build alike (0.154, 0.153). DeepFilterNet3 never loses more than 0.066 STOI, `wiener` 0.065, `omlsa` 0.059. A `limit` caps it: at the default 16 dB (the input mixed back at 0.16) losses over 0.1 fall to 4 files and over 0.2 to none (p232_354 keeps 0.824), and PESQ rises to 2.49, +0.53 ± 0.02 over the noisy input, at a BAK of 3.66 instead of 3.85. Against 0.2's 20 dB, paired: PESQ +0.029 ± 0.009, STOI +0.004, SIG +0.026 ± 0.004, BAK −0.047 ± 0.005 (6 files over 0.1, p232_354 0.786). `denoise()`, the worklet and the atom apply that limit unless given another.
|
|
150
209
|
- **The recipe's delivery stages are not denoisers.** Its compressor, EQ, de-esser and loudness target change the signal against the clean reference on purpose: the full recipe scores under its own denoising part. SI-SDR also falls with the 80 Hz high-pass: on its own it takes clean speech to 6.2 dB SI-SDR at PESQ 4.45 (21 test utterances). The table says what each stage does to speech in noise, not how finished a podcast sounds.
|
|
151
210
|
|
|
211
|
+
**RNNoise's limit**, chosen on VoiceBank+DEMAND's training speakers (504 utterances: 18 of each of its 28 speakers, none of them in the test set; noise 0 to 15 dB under the voice), scored as above, with the limit-0 output mixed back with the input as `denoise()` mixes it. Clean word ends and quiet syllables: the same utterances' clean takes as input (Clean speech, below).
|
|
212
|
+
|
|
213
|
+
| `limit` | PESQ | STOI | DNSMOS SIG | BAK | STOI down > 0.2 | clean word ends | clean quiet syllables |
|
|
214
|
+
|---|---|---|---|---|---|---|---|
|
|
215
|
+
| noisy input | 1.47 | 0.841 | 3.00 | 2.50 | | | |
|
|
216
|
+
| 0 (upstream) | 1.83 | 0.827 | 3.20 | 3.76 | 21 files | −36.7 dB | −11.4 dB |
|
|
217
|
+
| 12 | 1.84 | 0.858 | 3.33 | 3.19 | 0 | −8.9 | −8.1 |
|
|
218
|
+
| 14 | 1.88 | 0.857 | 3.34 | 3.26 | 0 | −10.3 | −8.8 |
|
|
219
|
+
| **16**, the default | **1.91** | **0.855** | **3.33** | **3.32** | **0** | **−11.7** | **−9.4** |
|
|
220
|
+
| 18 | 1.93 | 0.853 | 3.32 | 3.37 | 1 | −13.0 | −9.9 |
|
|
221
|
+
| 20 (before 0.3) | 1.93 | 0.851 | 3.31 | 3.42 | 3 | −14.4 | −10.2 |
|
|
222
|
+
|
|
223
|
+
The limit trades the noise removed (BAK, and PESQ, which rewards it) against the voice: SIG holds from 14 to 16 dB and falls past it (16 against 20, paired: SIG +0.022 ± 0.011, STOI +0.004 ± 0.001, PESQ −0.018 ± 0.008, BAK −0.10 ± 0.01), and 16 is the most at which no utterance loses more than 0.2 STOI; at 20 three do. The limit only bounds what the model takes from the voice; it never stops taking it: speech with nothing to remove still loses 12 dB at its word ends at 16, 9 at 12.
|
|
224
|
+
|
|
225
|
+
**Clean speech.** A repair with nothing to repair should leave the sound alone. The clean takes of the same utterances as input, against themselves, 10 ms frames: word ends are the last 100 ms before each pause of 100 ms or more, quiet syllables the other frames 20 to 35 dB under the active speech level (ITU-T P.56's, here the mean power of the frames within 35 dB of the 99th-percentile one).
|
|
226
|
+
|
|
227
|
+
| | training speakers (504): word ends | quiet syllables | SI-SDR vs input | test set (824): word ends | quiet syllables | SI-SDR vs input |
|
|
228
|
+
|---|---|---|---|---|---|---|
|
|
229
|
+
| RNNoise, `limit: 0` | −36.7 dB | −11.4 dB | 15.0 dB | −30.8 dB | −10.2 dB | 15.5 dB |
|
|
230
|
+
| RNNoise, `limit: 20` (before 0.3) | −14.4 | −10.2 | 16.0 | −12.9 | −9.5 | 16.4 |
|
|
231
|
+
| **RNNoise, `limit: 16`**, the default | **−11.7** | **−9.4** | **16.6** | **−10.5** | **−8.8** | **17.0** |
|
|
232
|
+
| DeepFilterNet3, `denoise()`, `limit: 0` | −0.9 | −0.7 | 39.0 | −0.9 | −0.8 | 39.2 |
|
|
233
|
+
| **DeepFilterNet3, `denoise()`, `limit: 18`**, the default | **−0.7** | **−0.6** | **40.2** | **−0.8** | **−0.7** | **40.4** |
|
|
234
|
+
|
|
235
|
+
RNNoise gates: its gains close on the ends of words and on quiet syllables even with no noise at all, which a listener hears as clipped word ends; the port is bit-exact to upstream's C, so this is the model. DeepFilterNet3 leaves clean speech within a decibel.
|
|
236
|
+
|
|
237
|
+
**Band-limited input.** The noisy utterances at 16 and at 44.1 kHz (`resample_poly` from 48 kHz), and low-passed at 16 kHz as an MP3 encoder cuts them (a 1023-tap Kaiser FIR, kept at 48 kHz); `denoise()` resamples them to 48 kHz and back; scored at 16 kHz as above. 0.2 heard the empty bands as they are; 0.3 hears the bands wholly above the edge over the floor (DeepFilterNet3, step 2). PESQ / STOI:
|
|
238
|
+
|
|
239
|
+
| DeepFilterNet3 | training speakers (504): 0.2, unlimited | 0.3, unlimited | 0.2, `limit: 18` | **0.3, `limit: 18`** | test set (824): 0.2, `limit: 18` | **0.3, `limit: 18`** |
|
|
240
|
+
|---|---|---|---|---|---|---|
|
|
241
|
+
| 48 kHz, the full band | 2.57 / 0.881 | the same, bit for bit | 2.24 / 0.876 | the same | 2.90 / 0.942 | the same |
|
|
242
|
+
| 44.1 kHz | 2.55 / 0.880 | the same, bit for bit | 2.23 / 0.876 | the same | 2.89 / 0.940 | the same |
|
|
243
|
+
| low-passed at 16 kHz | 2.42 / 0.876 | 2.51 / 0.881 | 2.19 / 0.874 | **2.22 / 0.876** | 2.76 / 0.938 | **2.86 / 0.941** |
|
|
244
|
+
| 16 kHz | 2.26 / 0.863 | 2.42 / 0.878 | 2.13 / 0.866 | **2.17 / 0.874** | 2.72 / 0.934 | **2.83 / 0.940** |
|
|
245
|
+
|
|
246
|
+
On the test set, paired, at the default 18 dB: PESQ +0.11 ± 0.01 at 16 kHz and +0.10 ± 0.01 low-passed, STOI +0.006 and +0.003, DNSMOS within 0.02 (BAK −0.011 at 16 kHz, +0.022 low-passed). Unlimited the gains are larger: 16 kHz from 2.68 to 2.99 PESQ (+0.31 ± 0.02; the full band scores 3.15) and 0.924 to 0.940 STOI, low-passed from 2.90 to 3.07; at 16 kHz the model then leaves a little more noise (DNSMOS BAK −0.06, OVRL −0.04) while keeping more of the voice. A 44.1 kHz input is 0.2's output bit for bit: no band lies wholly above 22.05 kHz (a floor over the top band's empty part, tried first, cost 0.012 ± 0.010 PESQ there on the training speakers). The narrations below, eight of them with empty top bands, move by 0.008 DNSMOS at most.
|
|
247
|
+
|
|
152
248
|
**Real rooms.** Ten amateur narrations from Spoken Wikipedia (volunteers at home, 2007 to 2026, the first 60 s of each; Ogg Vorbis at 44.1 and 48 kHz, decoded and resampled to 48 kHz by ffmpeg), scored without a reference: DNSMOS P.835 as above; the noise floor as the RMS of the quietest 500 ms (ACX Check's measure; one file with pauses cut to digital silence left out); speech level change over the loudest half of 50 ms frames.
|
|
153
249
|
|
|
154
250
|
| System | SIG | BAK | OVRL | noise floor, dBFS (median) | speech level |
|
|
@@ -157,16 +253,20 @@ Over the noisy input, paired, with 95% intervals: DeepFilterNet3 adds 1.20 ± 0.
|
|
|
157
253
|
| `omlsa()` | 3.46 | 4.09 | 3.19 | −98 to −69 (−75) | 0.0 dB |
|
|
158
254
|
| "Enhance speech" denoising | 3.45 | 4.07 | 3.17 | −101 to −71 (−85) | −0.2 dB |
|
|
159
255
|
| RNNoise, `limit: 0` | 3.42 | 4.14 | 3.19 | −127 to −106 (−112) | −0.5 dB |
|
|
160
|
-
|
|
|
161
|
-
| DeepFilterNet3,
|
|
256
|
+
| RNNoise, `limit: 16`, the default | 3.47 | 4.14 | 3.23 | −98 to −70 (−78) | −0.4 dB |
|
|
257
|
+
| DeepFilterNet3, upstream's settings, `limit: 0` | 3.47 | 4.12 | 3.21 | −152 to −61 (−83) | −0.4 dB |
|
|
258
|
+
| DeepFilterNet3, `denoise()`, `limit: 0` | 3.47 | 4.11 | 3.21 | −165 to −60 (−78) | −0.4 dB |
|
|
259
|
+
| DeepFilterNet3, `denoise()` 0.2, `limit: 18` | 3.47 | 4.11 | 3.21 | −93 to −59 (−73) | −0.4 dB |
|
|
162
260
|
| DeepFilterNet3, `denoise()`, `limit: 18`, the default | 3.47 | 4.11 | 3.21 | −93 to −59 (−73) | −0.4 dB |
|
|
163
|
-
| DeepFilterNet3, `limit: 12` | 3.47 | 4.10 | 3.
|
|
261
|
+
| DeepFilterNet3, `limit: 12` | 3.47 | 4.10 | 3.20 | −91 to −58 (−68) | −0.3 dB |
|
|
164
262
|
|
|
165
|
-
These rooms are mostly quiet (raw OVRL 3.17), so the gains are small. OM-LSA keeps SIG and raises BAK a little less than the models (0.3.11's lowered SIG by 0.20); both models raise BAK and keep SIG.
|
|
263
|
+
These rooms are mostly quiet (raw OVRL 3.17), so the gains are small. OM-LSA keeps SIG and raises BAK a little less than the models (0.3.11's lowered SIG by 0.20); both models raise BAK, and at their default limits keep SIG. Unlimited, both can take pauses to dead silence (RNNoise's median floor is −112 dBFS, DeepFilterNet3's lowest −165), which ACX Check flags; the default limits keep the floors between −98 and −59 dBFS at the same OVRL or better, room tone left in. Eight of the narrations end between 16.7 and 19.5 kHz and hear 0.3's floor in their top bands (one ends at 21.1 kHz, inside the top band, one runs to 24): DNSMOS moves by 0.008 at most on any file. On lena (a film scene with music under the voice) RNNoise lowers OVRL from 2.76 to 2.47 unlimited and keeps 2.78 at 16 dB; DeepFilterNet3 raises it to 2.83 upstream, 2.89 at the default limit.
|
|
166
264
|
|
|
167
265
|
**Singing.** VocalSet (Wilkins et al., ISMIR 2018, CC BY 4.0): 40 long tones (straight, forte, pianissimo, messa di voce; 20 singers) and 20 sung phrases (straight and vibrato), alone and in living-room noise (DEMAND, from the VoiceBank+DEMAND test set) at 15 dB SNR; level change of the voice over the frames within 20 dB of the loudest. At the default 18 dB, against 0.1's processing at the same limit (heard as it is, no voice guard): held notes −1.9 dB clean and −3.4 dB in noise, 14% of frames more than 6 dB down, against −14.2 and −16.2 dB, 96% (at 10 dB SNR −4.8 against −15.6); sung phrases −0.9 and −2.0 dB against −10.2 and −14.9; the same singers' spoken phrases −0.3 and −0.8 dB against −1.7 and −3.7 (they are quiet recordings: the −20 dBFS hearing helps them). Upstream's unlimited output takes the held notes 38.7 and 37.4 dB down. The noise in the pauses drops as before (−14.1 dB against −14.3), and a 100 or 120 Hz buzz 20 dB under the voice drops in them as without the guard (−17.2 dB), in quiet and in cafe noise: it is the background, not over it.
|
|
168
266
|
|
|
169
|
-
|
|
267
|
+
**Music.** Both models enhance speech, and to them everything else is noise: before 0.4 music lost at every band, and the limit only capped the loss (DeepFilterNet3, songs: −13 to −17 dB per band, median; unlimited, single bands of single songs lost up to 54 dB, RNNoise's up to 68). From 0.4 the music guard passes it through ([Music](#music), with the before and after per band). The first 60 s of four pieces (`vibeace` jazz, `brahms` string orchestra, `nutcracker` celesta and strings, `trumpet` solo), five chords of three VocalSet singers each, and the 50 MUSDB18 test previews (Rafii et al. 2017, 7 s of a song each, most with vocals), at 44.1 kHz, are the sets of that table.
|
|
268
|
+
|
|
269
|
+
To rerun: `python scripts/accuracy.py prepare` writes the band-limited inputs and decodes the MUSDB18 previews; `python scripts/accuracy.py guard-sets` writes the music guard's labelled sets and `node scripts/accuracy.mjs guard train|test` measures it (systems `rnnoise-guard` and `dfn3-guard` are 0.4's defaults, the other rows take `music: 'enhance'`); `node scripts/accuracy.mjs SET SYSTEMS` (SET: `vbdemand`, `vbtrain`, `vbclean`, `vbtrain-clean`, either noisy set `@16000`, `@44100` or `-lp16k`, `music`) and `node scripts/accuracy.mjs rooms` write the outputs (the data sources and checksums are in its header); `python scripts/accuracy.py SET SYSTEMS`, `limits SET rnnoise 0,12,14,15,16,18,20`, `clean SET SYSTEM [LIMITS]`, `music SYSTEMS`, `presence SYSTEM` and `rooms` score them; `scripts/deepfilter-reference.py --vbdemand` writes the Python row; the classical rows come from `@audio/denoise`'s `node scripts/speech.mjs vbdemand|rooms SYSTEMS` and `python scripts/speech.py score vbdemand|rooms SYSTEMS`. Per-file scores stay in `~/.cache/audiojs/data/` (`vbdemand/scores/neural-*.csv`, `vbdemand-train/scores/neural-*.csv`, `spoken/scores.json`); the scripts regenerate outputs, skipping any already there.
|
|
170
270
|
|
|
171
271
|
## Speed and memory
|
|
172
272
|
|
|
@@ -186,7 +286,9 @@ Apple M4 Max running other jobs (load average 28 to 50 on 14 cores), so wall tim
|
|
|
186
286
|
|
|
187
287
|
A 128-sample render quantum lasts 2.67 ms and a frame is due every 3.75 quanta: the slowest SIMD frame measured (0.94 ms) fits a quantum 2.8 times over, the mean 8 times; the JS path's slowest (up to 4.0 ms) does not, and relies on device buffers of 256 samples or more, the browsers' usual size. The live underruns came with the machine oversubscribed, the control underran too; an idle machine is untested. Memory: 3.5 MB of weights (plus a 2.8 MB copy in the kernel's memory), about 70 KB of state per channel; the Node evaluation processes peaked at 83 MB RSS.
|
|
188
288
|
|
|
189
|
-
**DeepFilterNet3**, onnxruntime-node 1.30, 4 intra-op threads: real-time factor 0.047 in CPU on a 180 s file (8.4 s), 0.087 over the 824 short test utterances (2072 s of audio in 180 s; per-run overhead); Python DeepFilterNet (PyTorch 2.1, 4 threads) 0.067 on the same utterances. Peak RSS 436 MB over the test set, 563 MB for 180 s in 10 s chunks. The voice guard's pitch analysis adds 0.16 s of wall time per minute of audio to the model's 0.6 (one channel at 48 kHz, 4 threads). The browser path (onnxruntime-web, wasm and WebGPU) is untested here.
|
|
289
|
+
**DeepFilterNet3**, onnxruntime-node 1.30, 4 intra-op threads: real-time factor 0.047 in CPU on a 180 s file (8.4 s), 0.087 over the 824 short test utterances (2072 s of audio in 180 s; per-run overhead); Python DeepFilterNet (PyTorch 2.1, 4 threads) 0.067 on the same utterances. Peak RSS 436 MB over the test set, 563 MB for 180 s in 10 s chunks. The voice guard's pitch analysis adds 0.16 s of wall time per minute of audio to the model's 0.6 (one channel at 48 kHz, 4 threads); the band edge, 0.06 s of CPU. The browser path (onnxruntime-web, wasm and WebGPU) is untested here.
|
|
290
|
+
|
|
291
|
+
**Music guard**: 1.1 s of CPU per minute of audio per channel (real-time factor 0.019; WebAssembly SIMD, Node 25, the machine as above), 11 s on the JS path. RNNoise through `denoise()` goes from 3.0 to 4.2 s of CPU per minute; DeepFilterNet3 skips the model where a whole input is music. The guard's state: 0.3 MB per channel, the network 3.1 MB once unpacked to float32.
|
|
190
292
|
|
|
191
293
|
## Licenses
|
|
192
294
|
|
|
@@ -196,6 +298,9 @@ Audited 2026-09-27 against what each project states; a summary, not legal advice
|
|
|
196
298
|
|---|---|---|---|---|
|
|
197
299
|
| **RNNoise**, model 0a8755f8 (January 2025) | BSD-3-Clause (`COPYING`: Valin, Amazon, Mozilla, Xiph.Org, Borgerding) | BSD-3-Clause | `rnnoise_data-0a8755f8….tar.gz` (media.xiph.org, sha256 pinned in upstream's `model_version`): `src/rnnoise_data.c` (default), `src/rnnoise_data_little.c`, two `.pth` checkpoints. No header of their own; upstream's `autogen.sh` downloads them into `src/` and compiles them into the BSD library, and the README calls them "the models distributed with RNNoise". Trained on the corpora in `datasets.txt`: OpenSLR 30–86 (CC BY-SA 4.0) and Hi-Fi TTS (OpenSLR 109, CC BY 4.0) for speech, Xiph's noise collections (donated noise "freely available", terms in that archive, not checked) | bundled: `rnnoise.bin`, the default model through upstream's `dump_weights_blob`; the little model (1.55 MB) loads through `weights` |
|
|
198
300
|
| **DeepFilterNet3**, d375b2d | MIT OR Apache-2.0 ("All code in this repository") | **unconfirmed** | `models/DeepFilterNet3_onnx.tar.gz`, `DeepFilterNet3.zip`, `DeepFilterNet3_ll_onnx.tar.gz`, in the same repository, no terms of their own. Four open issues ask whether "all code" covers them ([#697](https://github.com/Rikorose/DeepFilterNet/issues/697), [#700](https://github.com/Rikorose/DeepFilterNet/issues/700), [#709](https://github.com/Rikorose/DeepFilterNet/issues/709), [#712](https://github.com/Rikorose/DeepFilterNet/issues/712), July to September 2026), none answered; the last push was October 2024. The DeepFilterNet3 paper's abstract says "the framework as well as pretrained weights have been published under an open source license", naming none. Third parties republish them as MIT (Intel/deepfilternet-openvino on Hugging Face) or Apache-2.0 | fetched from upstream at the pinned commit, cached, never bundled or re-hosted |
|
|
301
|
+
| **inaSpeechSegmenter**, speech/music/noise CNN (`smn`) | MIT (`LICENSE`, © 2018 Ina) | MIT: the repository's license; the weight files are its release assets (`models`), no terms of their own. Training data: Ina's annotated French radio and TV, not published | `keras_speech_music_noise_cnn.hdf5` (sha256 `f04b5e3c…`) | bundled: `guard.bin`, its weights folded and in float16 (`scripts/guard.py`); the feature recipe follows ina's `sidekit_mfcc.py` (from SIDEKIT, LGPL), written anew from its definitions |
|
|
302
|
+
| YAMNet (Google) | Apache-2.0 | Apache-2.0 | TF Hub `yamnet/1` | not used: compared, passes more speech over music beds ([Music](#music)) |
|
|
303
|
+
| Silero VAD | MIT | MIT | `silero_vad.onnx` | not used: speech against everything else, noise included |
|
|
199
304
|
| DeepFilterNet 1 and 2 | same | same as DeepFilterNet3 | `models/DeepFilterNet{,2}*.{zip,tar.gz}` | not used; libDF deprecates DeepFilterNet2 |
|
|
200
305
|
| GTCRN (Rong et al., ICASSP 2024) | MIT | MIT (checkpoints in the repository) | 48.2K parameters, trained on DNS3 or VCTK-DEMAND | not used: 16 kHz only, narration would lose everything above 8 kHz |
|
|
201
306
|
| Demucs speech denoiser (facebookresearch/denoiser) | CC BY-NC 4.0 | CC BY-NC 4.0 | | excluded: non-commercial; archived |
|
|
@@ -205,14 +310,14 @@ Audited 2026-09-27 against what each project states; a summary, not legal advice
|
|
|
205
310
|
|
|
206
311
|
In the browser, [shiguredo/rnnoise-wasm](https://github.com/shiguredo/rnnoise-wasm) (Apache-2.0) builds the same RNNoise commit with emscripten, and [sapphi-red/web-noise-suppressor](https://github.com/sapphi-red/web-noise-suppressor) (MIT) wraps its 2022 build next to Speex and GTCRN. This package needs no WebAssembly (it takes 419 bytes of it for speed where it can), is checked bit for bit against the C, and adds DeepFilterNet3.
|
|
207
312
|
|
|
208
|
-
The package is BSD-3-Clause (the RNNoise translation and weights); `deepfilter.js` carries DeepFilterNet's MIT notice ([NOTICE](./NOTICE)).
|
|
313
|
+
The package is BSD-3-Clause (the RNNoise translation and weights); `deepfilter.js` carries DeepFilterNet's MIT notice, `guard.bin` inaSpeechSegmenter's ([NOTICE](./NOTICE)).
|
|
209
314
|
|
|
210
315
|
## Reference
|
|
211
316
|
|
|
212
|
-
J.-M. Valin, "A Hybrid DSP/Deep Learning Approach to Real-Time Full-Band Speech Enhancement", MMSP 2018, [arXiv:1709.08243](https://arxiv.org/abs/1709.08243). · H. Schröter, T. Rosenkranz, A. N. Escalante-B., A. Maier, "DeepFilterNet: Perceptually Motivated Real-Time Speech Enhancement", Interspeech 2023, [arXiv:2305.08227](https://arxiv.org/abs/2305.08227). · C. Valentini-Botinhao, "Noisy speech database for training speech enhancement algorithms and TTS models", University of Edinburgh, 2017, [doi:10.7488/ds/2117](https://doi.org/10.7488/ds/2117). · ITU-T P.862.2 (2007), wideband PESQ. · C. H. Taal, R. C. Hendriks, R. Heusdens, J. Jensen, "An Algorithm for Intelligibility Prediction of Time-Frequency Weighted Noisy Speech", IEEE TASLP 19(7), 2011. · J. Le Roux, S. Wisdom, H. Erdogan, J. R. Hershey, "SDR: Half-baked or Well Done?", ICASSP 2019. · C. K. A. Reddy, V. Gopal, R. Cutler, "DNSMOS P.835: A Non-Intrusive Perceptual Objective Speech Quality Metric to Evaluate Noise Suppressors", ICASSP 2022. · S. A. Figueroa, "When is double rounding innocuous?", SIGNUM Newsletter 30(3), 1995. · [xiph/rnnoise](https://gitlab.xiph.org/xiph/rnnoise) at 70f1d25 · [Rikorose/DeepFilterNet](https://github.com/Rikorose/DeepFilterNet) at d375b2d.
|
|
317
|
+
J.-M. Valin, "A Hybrid DSP/Deep Learning Approach to Real-Time Full-Band Speech Enhancement", MMSP 2018, [arXiv:1709.08243](https://arxiv.org/abs/1709.08243). · H. Schröter, T. Rosenkranz, A. N. Escalante-B., A. Maier, "DeepFilterNet: Perceptually Motivated Real-Time Speech Enhancement", Interspeech 2023, [arXiv:2305.08227](https://arxiv.org/abs/2305.08227). · C. Valentini-Botinhao, "Noisy speech database for training speech enhancement algorithms and TTS models", University of Edinburgh, 2017, [doi:10.7488/ds/2117](https://doi.org/10.7488/ds/2117). · ITU-T P.862.2 (2007), wideband PESQ. · C. H. Taal, R. C. Hendriks, R. Heusdens, J. Jensen, "An Algorithm for Intelligibility Prediction of Time-Frequency Weighted Noisy Speech", IEEE TASLP 19(7), 2011. · J. Le Roux, S. Wisdom, H. Erdogan, J. R. Hershey, "SDR: Half-baked or Well Done?", ICASSP 2019. · C. K. A. Reddy, V. Gopal, R. Cutler, "DNSMOS P.835: A Non-Intrusive Perceptual Objective Speech Quality Metric to Evaluate Noise Suppressors", ICASSP 2022. · S. A. Figueroa, "When is double rounding innocuous?", SIGNUM Newsletter 30(3), 1995. · [xiph/rnnoise](https://gitlab.xiph.org/xiph/rnnoise) at 70f1d25 · [Rikorose/DeepFilterNet](https://github.com/Rikorose/DeepFilterNet) at d375b2d. · D. Doukhan, J. Carrive, F. Vallet, A. Larcher, S. Meignier, "An Open-Source Speaker Gender Detection Framework for Monitoring Gender Equality", ICASSP 2018 · [ina-foss/inaSpeechSegmenter](https://github.com/ina-foss/inaSpeechSegmenter), models release.
|
|
213
318
|
|
|
214
|
-
**Use when:** speech with noise a statistical denoiser leaves or smears (moving noise, a busy room); live voice in a browser (RNNoise, 30 ms); files where quality is worth an 8 MB download (DeepFilterNet3).<br>
|
|
215
|
-
**Not for:** music (both models
|
|
319
|
+
**Use when:** speech with noise a statistical denoiser leaves or smears (moving noise, a busy room); live voice in a browser (RNNoise, 30 ms); files where quality is worth an 8 MB download (DeepFilterNet3); programmes that mix speech and music (the music passes through untouched).<br>
|
|
320
|
+
**Not for:** denoising music itself, a noisy song or concert (music passes untouched; with `music: 'enhance'` both models take 8 to 16 dB from every band); singing alone (half to two thirds of it passes, the rest is enhanced); clean speech with nothing to remove through RNNoise (it gates word ends and quiet syllables, 9 to 12 dB at its default limit); installs that must stay model-free ([`@audio/denoise`](https://github.com/audiojs/denoise)); reverb as such (`dereverb`); live DeepFilterNet3 (upstream's graphs take no state).
|
|
216
321
|
|
|
217
322
|
---
|
|
218
323
|
|
package/audio.d.ts
CHANGED
|
@@ -10,8 +10,10 @@ type Process = (inputs: Float32Array[][], outputs: Float32Array[][], params: Liv
|
|
|
10
10
|
|
|
11
11
|
/** Chainable-host options for 'rnnoise' */
|
|
12
12
|
export interface RnnoiseOptions {
|
|
13
|
-
/** 0..100 dB (default
|
|
13
|
+
/** 0..100 dB (default 16) */
|
|
14
14
|
"limit"?: Auto
|
|
15
|
+
/** default "pass" */
|
|
16
|
+
"music"?: "pass" | "enhance"
|
|
15
17
|
at?: number | string
|
|
16
18
|
duration?: number | string
|
|
17
19
|
}
|
|
@@ -22,7 +24,9 @@ export declare const rnnoise: {
|
|
|
22
24
|
latency: (ctx: { sampleRate: number, params: Live }) => number
|
|
23
25
|
tail: 0
|
|
24
26
|
params: {
|
|
25
|
-
/** 0..100 dB (default
|
|
26
|
-
"limit": { type: "number", default:
|
|
27
|
+
/** 0..100 dB (default 16) */
|
|
28
|
+
"limit": { type: "number", default: 16 }
|
|
29
|
+
/** default "pass" */
|
|
30
|
+
"music": { type: "enum", values: ["pass","enhance"], default: "pass" }
|
|
27
31
|
}
|
|
28
32
|
}
|
package/audio.js
CHANGED
|
@@ -10,19 +10,27 @@
|
|
|
10
10
|
// the end, where the offline resampler sees the end and a stream sees the host's trailing silence.
|
|
11
11
|
//
|
|
12
12
|
// limit (dB): the input is mixed back in at 10^(−limit/20), so noise drops by at most `limit`; 0 lifts it
|
|
13
|
-
// (denoise()'s option, DeepFilterNet's atten_lim_db). The default
|
|
14
|
-
// unlimited, RNNoise's current model removes the voice on
|
|
15
|
-
//
|
|
16
|
-
//
|
|
13
|
+
// (denoise()'s option, DeepFilterNet's atten_lim_db). The default is denoise()'s, 16 dB (rnnoise.js LIMIT):
|
|
14
|
+
// unlimited, RNNoise's current model gates clean speech and removes the voice on some noisy files; 16 is the
|
|
15
|
+
// most before the voice itself suffers (README, Accuracy). The limit is read per block and applies from the
|
|
16
|
+
// next finished frame on.
|
|
17
17
|
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
18
|
+
// music: 'pass' (default) has music pass through untouched, as denoise() does (guard.js): each 48 kHz input frame
|
|
19
|
+
// goes to the guard too, and the output frame two before it, the one RNNoise gives back then, takes the guard's
|
|
20
|
+
// decision so far; the gain between the denoised and the dry signal ramps over 200 ms, at 48 kHz, through the same
|
|
21
|
+
// resampler back, and mixes at the host's rate with the input delayed by the latency, so what passes is the input
|
|
22
|
+
// itself. Nothing is added to the delay: a stream decides on what has arrived, music is denoised for its first second
|
|
23
|
+
// or so, and each switch comes about a second late (README, Music). 'enhance' denoises everything, as before 0.4.
|
|
24
|
+
//
|
|
25
|
+
// Channels are denoised independently; a channel's state (70 KB, and the guard's 0.3 MB) is created on its first
|
|
26
|
+
// block, as the worklet does, since hosts declare up to 32 channels.
|
|
20
27
|
|
|
21
28
|
import { model, create, FRAME, LIMIT } from './rnnoise.js'
|
|
22
29
|
import { weights } from './denoise.js'
|
|
30
|
+
import { guardNet, online, ramp, blend } from './guard.js'
|
|
23
31
|
|
|
24
32
|
const RATE = 48000, DELAY = 2 * FRAME, PRIME = FRAME - 1, HALF = 16
|
|
25
|
-
const net = model(await weights())
|
|
33
|
+
const net = model(await weights()), guard = await guardNet()
|
|
26
34
|
|
|
27
35
|
const sinc = x => x === 0 ? 1 : Math.sin(Math.PI * x) / (Math.PI * x)
|
|
28
36
|
|
|
@@ -89,29 +97,36 @@ function queue(len) {
|
|
|
89
97
|
return q
|
|
90
98
|
}
|
|
91
99
|
|
|
92
|
-
/** One channel at host rate sr: `run(x, y, limit)` takes host samples, gives the denoised ones `latency(sr)` later. */
|
|
100
|
+
/** One channel at host rate sr: `run(x, y, limit, music)` takes host samples, gives the denoised ones `latency(sr)` later. */
|
|
93
101
|
function channel(sr) {
|
|
94
102
|
let st = create(net), frame = new Float32Array(FRAME), den = new Float32Array(FRAME), z = new Float32Array(FRAME)
|
|
95
103
|
// this frame's input and the two before it: RNNoise's output for frame f is frame f − 2's time
|
|
96
|
-
let dry = new Float32Array(3 * FRAME), fill = 0, frames = 0, lim = 0,
|
|
97
|
-
let
|
|
104
|
+
let dry = new Float32Array(3 * FRAME), fill = 0, frames = 0, lim = 0, pass = false, D = latency(sr)
|
|
105
|
+
let q = queue(D), gq = queue(D), dq = queue(D), decide = online(guard), next = ramp(), gz = new Float32Array(FRAME)
|
|
106
|
+
let to = q => sr === RATE ? x => { for (let k = 0; k < x.length; k++) q.push(x[k]) } : resampler(RATE, sr, q.push)
|
|
107
|
+
let back = to(q), gback = to(gq), g = new Float32Array(0), d = new Float32Array(0)
|
|
98
108
|
let push = v => {
|
|
99
109
|
frame[fill] = v * 32768; dry[(frames % 3) * FRAME + fill] = v
|
|
100
110
|
if (++fill < FRAME) return
|
|
101
111
|
st.process(frame, den)
|
|
102
|
-
|
|
112
|
+
let on = decide(dry.subarray((frames % 3) * FRAME, (frames % 3 + 1) * FRAME))
|
|
113
|
+
// as denoise()'s offline path: the output 960 samples on, the input mixed back at `lim`, the guard's gain beside it
|
|
103
114
|
if (frames >= 2) {
|
|
104
|
-
let
|
|
105
|
-
for (let k = 0; k < FRAME; k++) z[k] = (den[k] / 32768) * (1 - lim) + dry[
|
|
106
|
-
back(z)
|
|
115
|
+
let o = ((frames - 2) % 3) * FRAME
|
|
116
|
+
for (let k = 0; k < FRAME; k++) { z[k] = (den[k] / 32768) * (1 - lim) + dry[o + k] * lim; gz[k] = next(!(on && pass)) }
|
|
117
|
+
back(z); gback(gz)
|
|
107
118
|
}
|
|
108
119
|
frames++; fill = 0
|
|
109
120
|
}
|
|
110
121
|
let into = sr === RATE ? x => { for (let i = 0; i < x.length; i++) push(x[i]) } : resampler(sr, RATE, push)
|
|
111
|
-
return (x, y, limit) => {
|
|
122
|
+
return (x, y, limit, music) => {
|
|
112
123
|
lim = limit ? 10 ** (-Math.abs(limit) / 20) : 0
|
|
124
|
+
pass = music !== 'enhance'
|
|
113
125
|
into(x)
|
|
114
|
-
|
|
126
|
+
for (let i = 0; i < x.length; i++) dq.push(x[i])
|
|
127
|
+
if (g.length < y.length) { g = new Float32Array(y.length); d = new Float32Array(y.length) }
|
|
128
|
+
q.pull(y); gq.pull(g.subarray(0, y.length)); dq.pull(d.subarray(0, y.length))
|
|
129
|
+
for (let i = 0; i < y.length; i++) y[i] = blend(g[i], y[i], d[i])
|
|
115
130
|
}
|
|
116
131
|
}
|
|
117
132
|
|
|
@@ -120,8 +135,8 @@ export const rnnoise = (ctx) => {
|
|
|
120
135
|
return (inputs, outputs, params) => {
|
|
121
136
|
let inp = inputs[0], out = outputs[0]
|
|
122
137
|
if (!inp || !inp.length) return
|
|
123
|
-
let limit = params.limit[0]
|
|
124
|
-
for (let c = 0; c < inp.length; c++) (chans[c] ??= channel(sr))(inp[c], out[c], limit)
|
|
138
|
+
let limit = params.limit[0], music = params.music
|
|
139
|
+
for (let c = 0; c < inp.length; c++) (chans[c] ??= channel(sr))(inp[c], out[c], limit, music)
|
|
125
140
|
}
|
|
126
141
|
}
|
|
127
142
|
rnnoise.channels = 'any'
|
|
@@ -129,4 +144,5 @@ rnnoise.latency = ctx => latency(ctx.sampleRate)
|
|
|
129
144
|
rnnoise.tail = 0
|
|
130
145
|
rnnoise.params = {
|
|
131
146
|
limit: { type: 'number', min: 0, max: 100, default: LIMIT, unit: 'dB' },
|
|
147
|
+
music: { type: 'enum', values: ['pass', 'enhance'], default: 'pass' },
|
|
132
148
|
}
|
package/deepfilter.js
CHANGED
|
@@ -20,7 +20,9 @@
|
|
|
20
20
|
//
|
|
21
21
|
// Added, for denoise(): the network can hear its input at another level (`gain`, its features only: they are
|
|
22
22
|
// not level-free, the ERB means start at fixed dB and the complex features scale with the root of the level),
|
|
23
|
-
//
|
|
23
|
+
// its features can hear a floor of white noise above the input's band edge (`edge`: a band-limited input leaves
|
|
24
|
+
// bands empty that the model, trained on full-band mixtures only, never heard empty), and a voice guard (`voice`)
|
|
25
|
+
// keeps the sustained voicing it takes for noise: held sung notes, chant.
|
|
24
26
|
|
|
25
27
|
import { load, tensor, fetchModel } from '@audio/neural-runtime'
|
|
26
28
|
import { WINDOW, rfft, irfft, N as FFT_N, HOP as FFT_HOP, BINS } from './fft.js'
|
|
@@ -36,6 +38,20 @@ export const MODEL = 'https://raw.githubusercontent.com/Rikorose/DeepFilterNet/d
|
|
|
36
38
|
// rise; unlimited, the pauses of home narrations fall to digital silence (README, Accuracy).
|
|
37
39
|
export const LIMIT = 18
|
|
38
40
|
|
|
41
|
+
// The level, dBFS, at which denoise() has the network hear the speech (`gain`): the median of the VoiceBank+DEMAND
|
|
42
|
+
// test set it scores its published PESQ on (level(), -20.2); its features are not level-free (README, DeepFilterNet3).
|
|
43
|
+
export const LEVEL = -20
|
|
44
|
+
|
|
45
|
+
// The floor the features hear in the bands above the input's band edge, dB under the speech: white noise, as the
|
|
46
|
+
// model always heard there. It trained on full-band 48 kHz mixtures only (config.ini: p_bandwidth_ext 0, libDF's
|
|
47
|
+
// BandwidthLimiterAugmentation off; noise at -5 to 40 dB SNR), so a band a 16 kHz file or a codec's low-pass left
|
|
48
|
+
// empty reached it as libDF's constant 1e-10 floor, without the trace of noise every mixture it knew had, and it
|
|
49
|
+
// cleaned the band below worse: on the VoiceBank+DEMAND training subset at 16 kHz, unlimited, PESQ 2.26 against
|
|
50
|
+
// 2.57 full-band. Over a floor at -40, -30, -20 and -10 dB: 2.30, 2.37, 2.42, 2.44; low-passed at 16 kHz, 2.42 and
|
|
51
|
+
// over -20 dB 2.51; at 8 kHz, 1.73 and 2.09. -20 dB, noise in the middle of the SNRs it trained on, takes most of
|
|
52
|
+
// the gain (README, Accuracy).
|
|
53
|
+
export const FILL = -20
|
|
54
|
+
|
|
39
55
|
const isNode = typeof process !== 'undefined' && !!process.versions?.node
|
|
40
56
|
|
|
41
57
|
// ------------------------------------------------ model archive
|
|
@@ -118,6 +134,35 @@ function normAlpha(sr, hop, tau) {
|
|
|
118
134
|
// ndarray's linspace, in f32 as libDF builds its initial normalization states
|
|
119
135
|
const linspace = (a, b, n) => { let step = f(f(f(b) - f(a)) / (n - 1)); return Float32Array.from({ length: n }, (_, i) => f(f(a) + f(step * i))) }
|
|
120
136
|
|
|
137
|
+
// ------------------------------------------------ input analysis
|
|
138
|
+
|
|
139
|
+
// level(x) → the speech level, dBFS: mean power of the louder half of 50 ms frames at 48 kHz (LEVEL where there is none)
|
|
140
|
+
export function level(x) {
|
|
141
|
+
let F = 2400, p = []
|
|
142
|
+
for (let i = 0; i + F <= x.length; i += F) { let s = 0; for (let j = i; j < i + F; j++) s += x[j] * x[j]; p.push(s / F) }
|
|
143
|
+
p.sort((a, b) => b - a)
|
|
144
|
+
let k = Math.max(1, p.length >> 1), s = 0
|
|
145
|
+
for (let i = 0; i < k && i < p.length; i++) s += p[i]
|
|
146
|
+
return s > 0 ? 10 * Math.log10(s / k) : LEVEL
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// bandEdge(x, rate) → Hz: the input's band edge, the highest frequency whose long-term power lies within 60 dB of the
|
|
150
|
+
// 1 to 4 kHz median: the power of 960-point frames end to end, averaged, at the input's own rate, before any
|
|
151
|
+
// resampling, so a resampler's images don't count; rate/2 for a full band, and where nothing is heard from 1 to 4 kHz.
|
|
152
|
+
export function bandEdge(x, rate) {
|
|
153
|
+
let P = new Float64Array(BINS), buf = new Float32Array(FFT_N), re = new Float32Array(BINS), im = new Float32Array(BINS)
|
|
154
|
+
for (let a = 0; a + FFT_N <= x.length; a += FFT_N) {
|
|
155
|
+
for (let i = 0; i < FFT_N; i++) buf[i] = x[a + i] * WINDOW[i]
|
|
156
|
+
rfft(buf, re, im)
|
|
157
|
+
for (let k = 0; k < BINS; k++) P[k] += re[k] * re[k] + im[k] * im[k]
|
|
158
|
+
}
|
|
159
|
+
let k0 = Math.ceil(1000 * FFT_N / rate), k1 = Math.min(BINS - 1, Math.floor(4000 * FFT_N / rate))
|
|
160
|
+
let mid = Array.from(P.subarray(k0, k1 + 1)).sort((a, b) => a - b)[(k1 - k0 + 1) >> 1], e = BINS - 1
|
|
161
|
+
if (!(mid > 0)) return rate / 2
|
|
162
|
+
while (e > 0 && !(P[e] >= mid * 1e-6)) e--
|
|
163
|
+
return e * rate / FFT_N
|
|
164
|
+
}
|
|
165
|
+
|
|
121
166
|
// ------------------------------------------------ enhance
|
|
122
167
|
|
|
123
168
|
// voicing(x, T, hop) → { period, on }: per frame, RNNoise's pitch period over the frame's 20 ms window, and `on`
|
|
@@ -149,9 +194,10 @@ export function voicing(x, T, hop) {
|
|
|
149
194
|
|
|
150
195
|
// enhance(x, net, opts) → Float32Array: one channel at 48 kHz, as df.enhance.enhance(model, state, x, pad=True).
|
|
151
196
|
// `gain` scales the input the network hears (its features), not the spectrum its gains and filters apply to;
|
|
197
|
+
// `edge` (Hz, the input's band edge) has the ERB bands wholly above it hear white noise FILL dB under speech at LEVEL;
|
|
152
198
|
// `voice` keeps sustained voicing (voicing()) the network would remove.
|
|
153
|
-
export async function enhance(x, net, { limit, chunk = 1000, warmup = 300, fade = 50, gain = 1, voice = false } = {}) {
|
|
154
|
-
let { hop, n, nbErb, nbDf, order, dfLa, convLa, widths, alpha } = net.params
|
|
199
|
+
export async function enhance(x, net, { limit, chunk = 1000, warmup = 300, fade = 50, gain = 1, edge = Infinity, voice = false } = {}) {
|
|
200
|
+
let { sr, hop, n, nbErb, nbDf, order, dfLa, convLa, widths, alpha } = net.params
|
|
155
201
|
let L = x.length, T = Math.floor((L + n) / hop), lead = order - 1 - dfLa, look = Math.max(convLa, dfLa)
|
|
156
202
|
let lim = limit != null && Math.abs(limit) > 0 ? 10 ** (-Math.abs(limit) / 20) : 0, g1 = f(gain), g2 = f(gain * gain)
|
|
157
203
|
let vo = voice && voicing(x, T, hop)
|
|
@@ -163,6 +209,18 @@ export async function enhance(x, net, { limit, chunk = 1000, warmup = 300, fade
|
|
|
163
209
|
let mem = new Float32Array(hop), buf = new Float32Array(n), made = 0
|
|
164
210
|
let erbStart = new Int32Array(nbErb), erbK = new Float32Array(nbErb)
|
|
165
211
|
for (let b = 0, s = 0; b < nbErb; s += widths[b++]) { erbStart[b] = s; erbK[b] = f(1 / widths[b]) }
|
|
212
|
+
// the floor, in the bands from `empty` on, the first wholly above the edge (a band holding the edge has the input's
|
|
213
|
+
// own content): white Gaussian noise of power 10^((LEVEL + FILL)/10) per sample has |X_k|² = that / 2n · E per bin,
|
|
214
|
+
// E exponential of mean 1 (the window's Σw² is n/2, the FFT scaled by 1/n), drawn for every bin in every frame from
|
|
215
|
+
// a fixed seed (32-bit LCG): a floor the network hears move, as noise does, not a constant its mean normalization
|
|
216
|
+
// takes to zero. fill(b) → its power per bin in band b this frame
|
|
217
|
+
let empty = erbStart.findIndex(s => s * sr / n > edge), floor = 10 ** ((LEVEL + FILL) / 10) / (2 * n), seed = 1
|
|
218
|
+
let fill = b => {
|
|
219
|
+
if (empty < 0 || b < empty) return 0
|
|
220
|
+
let p = 0
|
|
221
|
+
for (let j = 0; j < widths[b]; j++) p -= Math.log(((seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0) + .5) / 2 ** 32)
|
|
222
|
+
return f(floor * p / widths[b])
|
|
223
|
+
}
|
|
166
224
|
|
|
167
225
|
// frame_analysis + feat_erb + feat_cplx for frames up to t (sequential: the normalizations carry state)
|
|
168
226
|
function make(upto) {
|
|
@@ -174,7 +232,7 @@ export async function enhance(x, net, { limit, chunk = 1000, warmup = 300, fade
|
|
|
174
232
|
for (let b = 0; b < nbErb; b++) {
|
|
175
233
|
let s = 0, k = erbK[b]
|
|
176
234
|
for (let j = erbStart[b], e = j + widths[b]; j < e; j++) s = f(s + f(f(f(Sr[o + j] * Sr[o + j]) + f(Si[o + j] * Si[o + j])) * k))
|
|
177
|
-
let v = f(f(Math.log10(f(f(s * g2) + f(1e-10)))) * 10)
|
|
235
|
+
let v = f(f(Math.log10(f(f(f(s * g2) + fill(b)) + f(1e-10)))) * 10)
|
|
178
236
|
mean[b] = f(f(v * a1) + f(mean[b] * alpha))
|
|
179
237
|
FE[slot * nbErb + b] = f(f(v - mean[b]) / 40)
|
|
180
238
|
}
|
package/denoise.js
CHANGED
|
@@ -1,19 +1,17 @@
|
|
|
1
1
|
// Neural speech enhancement: RNNoise (pure JS, weights bundled) and DeepFilterNet3 (ONNX through
|
|
2
2
|
// @audio/neural-runtime, weights fetched from upstream and cached). Both run at 48 kHz on 10 ms hops;
|
|
3
3
|
// other rates are resampled in and out with @audio/resample-sinc. Offline results are aligned with the
|
|
4
|
-
// input: the models' delay is compensated.
|
|
4
|
+
// input: the models' delay is compensated. Music passes through untouched (guard.js) unless music: 'enhance'.
|
|
5
5
|
|
|
6
6
|
import resample from '@audio/resample-sinc'
|
|
7
7
|
import { model as parseRNNoise, create, FRAME, LIMIT as RNNOISE_LIMIT } from './rnnoise.js'
|
|
8
|
-
import { loadDeepFilter, enhance, MODEL, LIMIT as DFN_LIMIT } from './deepfilter.js'
|
|
8
|
+
import { loadDeepFilter, enhance, level, bandEdge, MODEL, LEVEL, LIMIT as DFN_LIMIT } from './deepfilter.js'
|
|
9
|
+
import { guardNet, offline, online, gains, blend } from './guard.js'
|
|
9
10
|
|
|
10
11
|
export { create as rnnoise, FRAME } from './rnnoise.js'
|
|
11
12
|
export { MODEL } from './deepfilter.js'
|
|
12
13
|
|
|
13
14
|
const RATE = 48000
|
|
14
|
-
// DeepFilterNet3 hears its input with the speech at -20 dBFS, the median of the VoiceBank+DEMAND test set it scores
|
|
15
|
-
// its published PESQ on (level(), -20.2): its features are not level-free (README, DeepFilterNet3)
|
|
16
|
-
const LEVEL = -20
|
|
17
15
|
// the attenuation limit (dB) each model gets when opts.limit is not given; 0 is upstream's output
|
|
18
16
|
const LIMITS = { rnnoise: RNNOISE_LIMIT, deepfilternet3: DFN_LIMIT }
|
|
19
17
|
|
|
@@ -49,14 +47,21 @@ export default async function denoise(audio, opts = {}) {
|
|
|
49
47
|
else if (audio?.channelData) { channels = audio.channelData; rate = audio.sampleRate ?? rate }
|
|
50
48
|
else throw new TypeError('neural-denoise: audio must be a Float32Array, Float32Array[] or { channelData, sampleRate }')
|
|
51
49
|
if (!(rate > 0)) throw new TypeError('neural-denoise: sampleRate is required (opts.sampleRate, or audio.sampleRate)')
|
|
50
|
+
let music = opts.music ?? 'pass'
|
|
51
|
+
if (music !== 'pass' && music !== 'enhance') throw new TypeError(`neural-denoise: music is 'pass' or 'enhance', not ${music}`)
|
|
52
52
|
let handle = typeof opts.model === 'object' && opts.model ? opts.model : await load(opts.model, opts)
|
|
53
53
|
try {
|
|
54
|
-
let out = [], limit = opts.limit ?? LIMITS[handle.model]
|
|
54
|
+
let out = [], limit = opts.limit ?? LIMITS[handle.model], nn = music === 'pass' ? await guardNet() : null
|
|
55
55
|
for (let ch of channels) {
|
|
56
|
-
let x = rate === RATE ? Float32Array.from(ch) : resample(Float32Array.from(ch), { from: rate, to: RATE })
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
56
|
+
let x = rate === RATE ? Float32Array.from(ch) : resample(Float32Array.from(ch), { from: rate, to: RATE }), y, pass
|
|
57
|
+
// RNNoise decides as its stream does (guard.js online()), DeepFilterNet3 with the whole input in view
|
|
58
|
+
if (handle.model === 'rnnoise') ({ y, pass } = rnnoiseOffline(x, handle.net, limit, nn))
|
|
59
|
+
else {
|
|
60
|
+
pass = nn && offline(x, nn)
|
|
61
|
+
y = pass?.every(Boolean) ? x : await enhance(x, handle.net, { ...opts, limit, gain: 10 ** ((LEVEL - level(x)) / 20), edge: bandEdge(ch, rate), voice: true })
|
|
62
|
+
}
|
|
63
|
+
if (rate !== RATE) y = fit(resample(y, { from: RATE, to: rate }), ch.length)
|
|
64
|
+
out.push(pass ? mix(y, ch, gains(pass, x.length, handle.model !== 'rnnoise'), rate) : y)
|
|
60
65
|
}
|
|
61
66
|
return audio instanceof Float32Array ? out[0] : Array.isArray(audio) ? out : { ...audio, channelData: out, sampleRate: rate }
|
|
62
67
|
} finally {
|
|
@@ -64,25 +69,29 @@ export default async function denoise(audio, opts = {}) {
|
|
|
64
69
|
}
|
|
65
70
|
}
|
|
66
71
|
|
|
67
|
-
//
|
|
68
|
-
function
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
for (let i = 0; i < k && i < p.length; i++) s += p[i]
|
|
74
|
-
return s > 0 ? 10 * Math.log10(s / k) : LEVEL
|
|
72
|
+
// the enhanced y where the gain g (at 48 kHz) is 1, the input x where it is 0: music passes untouched
|
|
73
|
+
function mix(y, x, g, rate) {
|
|
74
|
+
if (rate !== RATE) g = fit(resample(g, { from: RATE, to: rate }), x.length)
|
|
75
|
+
let out = new Float32Array(x.length)
|
|
76
|
+
for (let i = 0; i < x.length; i++) out[i] = blend(g[i], y[i], x[i])
|
|
77
|
+
return out
|
|
75
78
|
}
|
|
76
79
|
|
|
77
80
|
const fit = (y, n) => y.length === n ? y : (() => { let o = new Float32Array(n); o.set(y.subarray(0, n)); return o })()
|
|
78
81
|
|
|
79
|
-
// One channel through RNNoise at int16 scale, delay removed: out[i] lines up with x[i]
|
|
80
|
-
|
|
82
|
+
// One channel through RNNoise at int16 scale, delay removed: out[i] lines up with x[i]. With the guard's network,
|
|
83
|
+
// also `pass` per 480 samples of the output, as the stream decides it: each output frame takes the decision made
|
|
84
|
+
// once the input frame two on (RNNoise's delay) is in, from the input so far
|
|
85
|
+
function rnnoiseOffline(x, net, limit, nn) {
|
|
81
86
|
let st = create(net), delay = 2 * FRAME, L = x.length, n = Math.ceil((L + delay) / FRAME) * FRAME
|
|
82
|
-
let pad = new Float32Array(n), y = new Float32Array(n)
|
|
87
|
+
let pad = new Float32Array(n), y = new Float32Array(n), raw = new Float32Array(n), decide = nn && online(nn), on = []
|
|
83
88
|
for (let i = 0; i < L; i++) pad[i] = x[i] * 32768
|
|
84
|
-
|
|
89
|
+
raw.set(x)
|
|
90
|
+
for (let i = 0; i < n; i += FRAME) {
|
|
91
|
+
st.process(pad.subarray(i, i + FRAME), y.subarray(i, i + FRAME))
|
|
92
|
+
if (decide) on.push(decide(raw.subarray(i, i + FRAME)))
|
|
93
|
+
}
|
|
85
94
|
let lim = limit ? 10 ** (-Math.abs(limit) / 20) : 0, out = new Float32Array(L)
|
|
86
95
|
for (let i = 0; i < L; i++) out[i] = (y[i + delay] / 32768) * (1 - lim) + x[i] * lim
|
|
87
|
-
return out
|
|
96
|
+
return { y: out, pass: decide && Uint8Array.from(on.slice(2)) }
|
|
88
97
|
}
|
package/gemm.wat
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
;; The music guard's float32 products (guard.js: the classifier's upper convolutions through im2col, and its dense
|
|
2
|
+
;; layers) in WebAssembly SIMD, embedded in guard.js as bytes. Rebuild: wat2wasm gemm.wat -o gemm.wasm (WABT).
|
|
3
|
+
(module
|
|
4
|
+
(memory (export "memory") 1)
|
|
5
|
+
;; gemm(w, x, b, y, rows, k, n): y[p, r] = max(0, b[r] + Σc w[r, c] · x[p, c]) for p < n, r < rows; w row-major
|
|
6
|
+
;; rows × k, x n × k, y n × rows; rows and k multiples of 4. Four rows and two positions at a time, lane l of a row
|
|
7
|
+
;; summing the columns c ≡ l (mod 4), the lanes then added (l0 + l1) + (l2 + l3), then the bias: guard.js's JS loop
|
|
8
|
+
;; does the same float ops in the same order
|
|
9
|
+
(func (export "gemm") (param $w i32) (param $x i32) (param $b i32) (param $y i32) (param $rows i32) (param $k i32) (param $n i32)
|
|
10
|
+
(local $r i32) (local $c i32) (local $p i32) (local $x0 i32) (local $x1 i32) (local $w0 i32) (local $kb i32)
|
|
11
|
+
(local $a0 v128) (local $a1 v128) (local $a2 v128) (local $a3 v128)
|
|
12
|
+
(local $b0 v128) (local $b1 v128) (local $b2 v128) (local $b3 v128)
|
|
13
|
+
(local $u v128) (local $v v128) (local $s v128) (local $t v128)
|
|
14
|
+
(local.set $kb (i32.shl (local.get $k) (i32.const 2)))
|
|
15
|
+
(loop $pairs
|
|
16
|
+
(local.set $x0 (i32.add (local.get $x) (i32.mul (local.get $p) (local.get $kb))))
|
|
17
|
+
;; the second of the pair: itself again when n is odd (computed, not stored)
|
|
18
|
+
(local.set $x1 (select (i32.add (local.get $x0) (local.get $kb)) (local.get $x0) (i32.lt_u (i32.add (local.get $p) (i32.const 1)) (local.get $n))))
|
|
19
|
+
(local.set $r (i32.const 0))
|
|
20
|
+
(loop $rowblk
|
|
21
|
+
(local.set $a0 (v128.const i32x4 0 0 0 0)) (local.set $a1 (v128.const i32x4 0 0 0 0))
|
|
22
|
+
(local.set $a2 (v128.const i32x4 0 0 0 0)) (local.set $a3 (v128.const i32x4 0 0 0 0))
|
|
23
|
+
(local.set $b0 (v128.const i32x4 0 0 0 0)) (local.set $b1 (v128.const i32x4 0 0 0 0))
|
|
24
|
+
(local.set $b2 (v128.const i32x4 0 0 0 0)) (local.set $b3 (v128.const i32x4 0 0 0 0))
|
|
25
|
+
(local.set $w0 (i32.add (local.get $w) (i32.mul (local.get $r) (local.get $kb))))
|
|
26
|
+
(local.set $c (i32.const 0))
|
|
27
|
+
(loop $cols
|
|
28
|
+
(local.set $u (v128.load (i32.add (local.get $x0) (local.get $c))))
|
|
29
|
+
(local.set $v (v128.load (i32.add (local.get $x1) (local.get $c))))
|
|
30
|
+
(local.set $s (v128.load (i32.add (local.get $w0) (local.get $c))))
|
|
31
|
+
(local.set $a0 (f32x4.add (local.get $a0) (f32x4.mul (local.get $s) (local.get $u))))
|
|
32
|
+
(local.set $b0 (f32x4.add (local.get $b0) (f32x4.mul (local.get $s) (local.get $v))))
|
|
33
|
+
(local.set $s (v128.load (i32.add (i32.add (local.get $w0) (local.get $kb)) (local.get $c))))
|
|
34
|
+
(local.set $a1 (f32x4.add (local.get $a1) (f32x4.mul (local.get $s) (local.get $u))))
|
|
35
|
+
(local.set $b1 (f32x4.add (local.get $b1) (f32x4.mul (local.get $s) (local.get $v))))
|
|
36
|
+
(local.set $s (v128.load (i32.add (i32.add (local.get $w0) (i32.shl (local.get $kb) (i32.const 1))) (local.get $c))))
|
|
37
|
+
(local.set $a2 (f32x4.add (local.get $a2) (f32x4.mul (local.get $s) (local.get $u))))
|
|
38
|
+
(local.set $b2 (f32x4.add (local.get $b2) (f32x4.mul (local.get $s) (local.get $v))))
|
|
39
|
+
(local.set $s (v128.load (i32.add (i32.add (local.get $w0) (i32.mul (local.get $kb) (i32.const 3))) (local.get $c))))
|
|
40
|
+
(local.set $a3 (f32x4.add (local.get $a3) (f32x4.mul (local.get $s) (local.get $u))))
|
|
41
|
+
(local.set $b3 (f32x4.add (local.get $b3) (f32x4.mul (local.get $s) (local.get $v))))
|
|
42
|
+
(local.set $c (i32.add (local.get $c) (i32.const 16)))
|
|
43
|
+
(br_if $cols (i32.lt_u (local.get $c) (local.get $kb))))
|
|
44
|
+
;; transpose-add the four row sums: lane q = Σ a_q
|
|
45
|
+
(local.set $t (v128.load (i32.add (local.get $b) (i32.shl (local.get $r) (i32.const 2)))))
|
|
46
|
+
(local.set $s (f32x4.add
|
|
47
|
+
(f32x4.add (i8x16.shuffle 0 1 2 3 16 17 18 19 0 1 2 3 16 17 18 19 (local.get $a0) (local.get $a1)) (i8x16.shuffle 4 5 6 7 20 21 22 23 4 5 6 7 20 21 22 23 (local.get $a0) (local.get $a1)))
|
|
48
|
+
(f32x4.add (i8x16.shuffle 8 9 10 11 24 25 26 27 8 9 10 11 24 25 26 27 (local.get $a0) (local.get $a1)) (i8x16.shuffle 12 13 14 15 28 29 30 31 12 13 14 15 28 29 30 31 (local.get $a0) (local.get $a1)))))
|
|
49
|
+
(local.set $u (f32x4.add
|
|
50
|
+
(f32x4.add (i8x16.shuffle 0 1 2 3 16 17 18 19 0 1 2 3 16 17 18 19 (local.get $a2) (local.get $a3)) (i8x16.shuffle 4 5 6 7 20 21 22 23 4 5 6 7 20 21 22 23 (local.get $a2) (local.get $a3)))
|
|
51
|
+
(f32x4.add (i8x16.shuffle 8 9 10 11 24 25 26 27 8 9 10 11 24 25 26 27 (local.get $a2) (local.get $a3)) (i8x16.shuffle 12 13 14 15 28 29 30 31 12 13 14 15 28 29 30 31 (local.get $a2) (local.get $a3)))))
|
|
52
|
+
(v128.store (i32.add (local.get $y) (i32.shl (i32.add (i32.mul (local.get $p) (local.get $rows)) (local.get $r)) (i32.const 2)))
|
|
53
|
+
(f32x4.max (v128.const f32x4 0 0 0 0) (f32x4.add (local.get $t) (i8x16.shuffle 0 1 2 3 4 5 6 7 16 17 18 19 20 21 22 23 (local.get $s) (local.get $u)))))
|
|
54
|
+
(if (i32.lt_u (i32.add (local.get $p) (i32.const 1)) (local.get $n)) (then
|
|
55
|
+
(local.set $s (f32x4.add
|
|
56
|
+
(f32x4.add (i8x16.shuffle 0 1 2 3 16 17 18 19 0 1 2 3 16 17 18 19 (local.get $b0) (local.get $b1)) (i8x16.shuffle 4 5 6 7 20 21 22 23 4 5 6 7 20 21 22 23 (local.get $b0) (local.get $b1)))
|
|
57
|
+
(f32x4.add (i8x16.shuffle 8 9 10 11 24 25 26 27 8 9 10 11 24 25 26 27 (local.get $b0) (local.get $b1)) (i8x16.shuffle 12 13 14 15 28 29 30 31 12 13 14 15 28 29 30 31 (local.get $b0) (local.get $b1)))))
|
|
58
|
+
(local.set $u (f32x4.add
|
|
59
|
+
(f32x4.add (i8x16.shuffle 0 1 2 3 16 17 18 19 0 1 2 3 16 17 18 19 (local.get $b2) (local.get $b3)) (i8x16.shuffle 4 5 6 7 20 21 22 23 4 5 6 7 20 21 22 23 (local.get $b2) (local.get $b3)))
|
|
60
|
+
(f32x4.add (i8x16.shuffle 8 9 10 11 24 25 26 27 8 9 10 11 24 25 26 27 (local.get $b2) (local.get $b3)) (i8x16.shuffle 12 13 14 15 28 29 30 31 12 13 14 15 28 29 30 31 (local.get $b2) (local.get $b3)))))
|
|
61
|
+
(v128.store (i32.add (local.get $y) (i32.shl (i32.add (i32.mul (i32.add (local.get $p) (i32.const 1)) (local.get $rows)) (local.get $r)) (i32.const 2)))
|
|
62
|
+
(f32x4.max (v128.const f32x4 0 0 0 0) (f32x4.add (local.get $t) (i8x16.shuffle 0 1 2 3 4 5 6 7 16 17 18 19 20 21 22 23 (local.get $s) (local.get $u)))))))
|
|
63
|
+
(local.set $r (i32.add (local.get $r) (i32.const 4)))
|
|
64
|
+
(br_if $rowblk (i32.lt_u (local.get $r) (local.get $rows))))
|
|
65
|
+
(local.set $p (i32.add (local.get $p) (i32.const 2)))
|
|
66
|
+
(br_if $pairs (i32.lt_u (local.get $p) (local.get $n))))))
|
package/guard.bin
ADDED
|
Binary file
|
package/guard.js
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
// Music guard: speech and noise are enhanced, music passes through untouched, segment by segment.
|
|
2
|
+
//
|
|
3
|
+
// Both models enhance speech, and to them everything else is noise: music loses 8 to 16 dB in every band at their
|
|
4
|
+
// default limits. What tells music from speech here is inaSpeechSegmenter's speech/music/noise CNN (D. Doukhan,
|
|
5
|
+
// J. Carrive, F. Vallet, A. Larcher, S. Meignier, "An Open-Source Speaker Gender Detection Framework for Monitoring
|
|
6
|
+
// Gender Equality", ICASSP 2018; ina-foss/inaSpeechSegmenter, MIT; its 'smn' engine, keras_speech_music_noise_cnn.hdf5),
|
|
7
|
+
// trained on French radio and TV, where it labels speech over a music bed as speech and songs as music. Its input as ina
|
|
8
|
+
// computes it (sidekit's mfcc() at ina's settings): 16 kHz audio, pre-emphasis 0.97 within each frame, 25 ms Hann
|
|
9
|
+
// frames every 10 ms, a 512-point power spectrum, 24 triangular HTK-mel filters from 100 Hz to 8 kHz, natural log. The
|
|
10
|
+
// lowest 21 bands over 68 frames (0.68 s) make a patch, standardized by its own mean and deviation, so the level
|
|
11
|
+
// doesn't matter. Four convolutions and four dense layers give speech, music and noise; 782,403 weights, in guard.bin
|
|
12
|
+
// as float16 (1.56 MB, scripts/guard.py).
|
|
13
|
+
//
|
|
14
|
+
// A patch every STEP frames (ina takes one every 2; on the training material every 10 decides the same, at a fifth of
|
|
15
|
+
// the work). Offline, the classes become segments as ina makes them, by Viterbi over the whole input: three states,
|
|
16
|
+
// a switch costing 10^-80 per 20 ms (ina's SpeechMusicNoise, diag_trans_exp(80)). A stream can only use what has
|
|
17
|
+
// arrived: the forward recursion of the same chain, for two states (music, the rest), keeps the log-odds of music,
|
|
18
|
+
// bounded at ±BOUND, a patch moving them by at most ln(1/FLOOR), each patch PRIOR nats in favour of speech; it
|
|
19
|
+
// switches to music above HYST and back below 0 (hysteresis). Either way, music passes (the enhanced signal's gain 0),
|
|
20
|
+
// speech and noise are enhanced (gain 1), and the gain moves over RAMP samples, raised cosine: offline centered on the
|
|
21
|
+
// segment boundary, streaming from the decision on.
|
|
22
|
+
//
|
|
23
|
+
// The products of the three upper convolutions and the dense layers run in a WebAssembly SIMD kernel (gemm.wat) or
|
|
24
|
+
// its JS form, the same float ops in the same order, so the same bits either way.
|
|
25
|
+
|
|
26
|
+
const f = Math.fround
|
|
27
|
+
|
|
28
|
+
export const STEP = 10, RAMP = 9600, BOUND = 20, PRIOR = 1, HYST = 5, FLOOR = 1e-2
|
|
29
|
+
const COST = 80 * Math.LN10 * 2 / STEP
|
|
30
|
+
|
|
31
|
+
// ------------------------------------------------ the network
|
|
32
|
+
|
|
33
|
+
// conv: [out, kh, kw, in], dense: [out, in], each followed by its bias, batch norms folded into the layer before
|
|
34
|
+
const LAYERS = [[64, 7, 4, 1], [128, 3, 3, 64], [128, 3, 3, 128], [256, 3, 3, 128], [256, 256], [256, 256], [256, 256], [256, 256], [3, 256]]
|
|
35
|
+
export const WEIGHTS = LAYERS.reduce((n, s) => n + s.reduce((a, b) => a * b) + s[0], 0)
|
|
36
|
+
|
|
37
|
+
// IEEE 754 binary16 → number
|
|
38
|
+
const half = h => { let e = (h >> 10) & 31, m = h & 1023, v = e ? (1 + m / 1024) * 2 ** (e - 15) : m * 2 ** -24; return h >> 15 ? -v : v }
|
|
39
|
+
|
|
40
|
+
// gemm.wat, compiled by wat2wasm (1,074 bytes): y[p, r] = max(0, b[r] + Σc w[r, c] · x[p, c]), four rows and two
|
|
41
|
+
// positions at a time, each row in four float32 lanes summed (l0 + l1) + (l2 + l3)
|
|
42
|
+
const GEMM = new Uint8Array([
|
|
43
|
+
0,97,115,109,1,0,0,0,1,11,1,96,7,127,127,127,127,127,127,127,0,3,2,1,0,5,3,1,0,1,7,17,2,6,109,101,109,111,114,121,2,0,
|
|
44
|
+
4,103,101,109,109,0,0,10,254,7,1,251,7,2,7,127,12,123,32,5,65,2,116,33,13,3,64,32,1,32,9,32,13,108,106,33,10,32,10,32,
|
|
45
|
+
13,106,32,10,32,9,65,1,106,32,6,73,27,33,11,65,0,33,7,3,64,253,12,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,33,14,253,12,0,0,0,
|
|
46
|
+
0,0,0,0,0,0,0,0,0,0,0,0,0,33,15,253,12,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,33,16,253,12,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
|
|
47
|
+
33,17,253,12,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,33,18,253,12,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,33,19,253,12,0,0,0,0,0,0,0,
|
|
48
|
+
0,0,0,0,0,0,0,0,0,33,20,253,12,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,33,21,32,0,32,7,32,13,108,106,33,12,65,0,33,8,3,64,32,
|
|
49
|
+
10,32,8,106,253,0,4,0,33,22,32,11,32,8,106,253,0,4,0,33,23,32,12,32,8,106,253,0,4,0,33,24,32,14,32,24,32,22,253,230,1,
|
|
50
|
+
253,228,1,33,14,32,18,32,24,32,23,253,230,1,253,228,1,33,18,32,12,32,13,106,32,8,106,253,0,4,0,33,24,32,15,32,24,32,
|
|
51
|
+
22,253,230,1,253,228,1,33,15,32,19,32,24,32,23,253,230,1,253,228,1,33,19,32,12,32,13,65,1,116,106,32,8,106,253,0,4,0,
|
|
52
|
+
33,24,32,16,32,24,32,22,253,230,1,253,228,1,33,16,32,20,32,24,32,23,253,230,1,253,228,1,33,20,32,12,32,13,65,3,108,
|
|
53
|
+
106,32,8,106,253,0,4,0,33,24,32,17,32,24,32,22,253,230,1,253,228,1,33,17,32,21,32,24,32,23,253,230,1,253,228,1,33,21,
|
|
54
|
+
32,8,65,16,106,33,8,32,8,32,13,73,13,0,11,32,2,32,7,65,2,116,106,253,0,4,0,33,25,32,14,32,15,253,13,0,1,2,3,16,17,18,
|
|
55
|
+
19,0,1,2,3,16,17,18,19,32,14,32,15,253,13,4,5,6,7,20,21,22,23,4,5,6,7,20,21,22,23,253,228,1,32,14,32,15,253,13,8,9,10,
|
|
56
|
+
11,24,25,26,27,8,9,10,11,24,25,26,27,32,14,32,15,253,13,12,13,14,15,28,29,30,31,12,13,14,15,28,29,30,31,253,228,1,253,
|
|
57
|
+
228,1,33,24,32,16,32,17,253,13,0,1,2,3,16,17,18,19,0,1,2,3,16,17,18,19,32,16,32,17,253,13,4,5,6,7,20,21,22,23,4,5,6,7,
|
|
58
|
+
20,21,22,23,253,228,1,32,16,32,17,253,13,8,9,10,11,24,25,26,27,8,9,10,11,24,25,26,27,32,16,32,17,253,13,12,13,14,15,
|
|
59
|
+
28,29,30,31,12,13,14,15,28,29,30,31,253,228,1,253,228,1,33,22,32,3,32,9,32,4,108,32,7,106,65,2,116,106,253,12,0,0,0,0,
|
|
60
|
+
0,0,0,0,0,0,0,0,0,0,0,0,32,25,32,24,32,22,253,13,0,1,2,3,4,5,6,7,16,17,18,19,20,21,22,23,253,228,1,253,233,1,253,11,4,
|
|
61
|
+
0,32,9,65,1,106,32,6,73,4,64,32,18,32,19,253,13,0,1,2,3,16,17,18,19,0,1,2,3,16,17,18,19,32,18,32,19,253,13,4,5,6,7,20,
|
|
62
|
+
21,22,23,4,5,6,7,20,21,22,23,253,228,1,32,18,32,19,253,13,8,9,10,11,24,25,26,27,8,9,10,11,24,25,26,27,32,18,32,19,253,
|
|
63
|
+
13,12,13,14,15,28,29,30,31,12,13,14,15,28,29,30,31,253,228,1,253,228,1,33,24,32,20,32,21,253,13,0,1,2,3,16,17,18,19,0,
|
|
64
|
+
1,2,3,16,17,18,19,32,20,32,21,253,13,4,5,6,7,20,21,22,23,4,5,6,7,20,21,22,23,253,228,1,32,20,32,21,253,13,8,9,10,11,
|
|
65
|
+
24,25,26,27,8,9,10,11,24,25,26,27,32,20,32,21,253,13,12,13,14,15,28,29,30,31,12,13,14,15,28,29,30,31,253,228,1,253,
|
|
66
|
+
228,1,33,22,32,3,32,9,65,1,106,32,4,108,32,7,106,65,2,116,106,253,12,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,32,25,32,24,32,
|
|
67
|
+
22,253,13,0,1,2,3,4,5,6,7,16,17,18,19,20,21,22,23,253,228,1,253,233,1,253,11,4,0,11,32,7,65,4,106,33,7,32,7,32,4,73,
|
|
68
|
+
13,0,11,32,9,65,2,106,33,9,32,9,32,6,73,13,0,11,11,
|
|
69
|
+
])
|
|
70
|
+
|
|
71
|
+
/** net(bytes, { simd }) → the classifier over guard.bin; rest(p1) takes conv1's pooled output, gives [speech, music, noise]. */
|
|
72
|
+
export function net(bytes, { simd = true } = {}) {
|
|
73
|
+
let u = new Uint16Array(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)), o = 0
|
|
74
|
+
if (u.length !== WEIGHTS) throw new Error(`neural-denoise: guard weights are ${u.length} values, want ${WEIGHTS}`)
|
|
75
|
+
let take = n => { let a = new Float32Array(n); for (let i = 0; i < n; i++) a[i] = half(u[o++]); return a }
|
|
76
|
+
let [c1, c2, c3, c4, ...dense] = LAYERS.map(s => ({ s, w: take(s.reduce((a, b) => a * b)), b: take(s[0]) }))
|
|
77
|
+
let out = dense.pop(), mm = matmul([c2, c3, c4, ...dense], simd), col = new Float32Array(12 * 5 * 1152)
|
|
78
|
+
// conv1's kernel sums: a standardized patch (x − μ)/σ convolves to (conv(x) − μ·Σk)/σ
|
|
79
|
+
let ksum = new Float32Array(64)
|
|
80
|
+
for (let a = 0; a < 64; a++) for (let j = 0; j < 28; j++) ksum[a] += c1.w[a * 28 + j]
|
|
81
|
+
// valid convolution, channels last, through im2col: x [H][W][C] → [H − kh + 1][W − kw + 1][co], ReLU
|
|
82
|
+
function conv(x, H, W, C, l) {
|
|
83
|
+
let [co, kh, kw] = l.s, oh = H - kh + 1, ow = W - kw + 1, q = 0
|
|
84
|
+
for (let i = 0; i < oh; i++) for (let j = 0; j < ow; j++) for (let h = 0; h < kh; h++) {
|
|
85
|
+
let a = ((i + h) * W + j) * C
|
|
86
|
+
col.set(x.subarray(a, a + kw * C), q); q += kw * C
|
|
87
|
+
}
|
|
88
|
+
return [mm(l, col, oh * ow), oh, ow, co]
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
c1, ksum,
|
|
92
|
+
rest(p1) {
|
|
93
|
+
for (let i = 0; i < p1.length; i++) if (p1[i] < 0) p1[i] = 0
|
|
94
|
+
let t = conv(p1, 16, 9, 64, c2)
|
|
95
|
+
t = pool(conv(...t, c3), 2, 2, 0)
|
|
96
|
+
let v = pool(conv(...t, c4), 4, 1, 0)[0]
|
|
97
|
+
for (let l of dense) v = mm(l, v, 1)
|
|
98
|
+
let z = [0, 1, 2].map(a => { let s = out.b[a]; for (let k = 0; k < 256; k++) s += out.w[a * 256 + k] * v[k]; return s })
|
|
99
|
+
let m = Math.max(...z), e = z.map(q => Math.exp(q - m)), s = e[0] + e[1] + e[2]
|
|
100
|
+
return e.map(q => q / s)
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// matmul(layers) → mm(l, x, n): the kernel over layer l's weights for n rows of x; wasm where WebAssembly SIMD runs
|
|
106
|
+
function matmul(layers, simd = true) {
|
|
107
|
+
let mod = null
|
|
108
|
+
try { mod = simd && typeof WebAssembly === 'object' ? new WebAssembly.Module(GEMM) : null } catch {}
|
|
109
|
+
if (!mod) return (l, x, n) => gemm(l.w, x, l.b, l.s[0], l.w.length / l.s[0], n)
|
|
110
|
+
let inst = new WebAssembly.Instance(mod), mem = inst.exports.memory, top = 0, at = new Map()
|
|
111
|
+
let alloc = n => { let p = top; top += n * 4; return p }
|
|
112
|
+
for (let l of layers) at.set(l, [alloc(l.w.length), alloc(l.b.length)])
|
|
113
|
+
let X = alloc(12 * 5 * 1152), Y = alloc(14 * 7 * 128)
|
|
114
|
+
mem.grow(Math.ceil(top / 65536))
|
|
115
|
+
let F = new Float32Array(mem.buffer)
|
|
116
|
+
for (let l of layers) { F.set(l.w, at.get(l)[0] / 4); F.set(l.b, at.get(l)[1] / 4) }
|
|
117
|
+
return (l, x, n) => {
|
|
118
|
+
let rows = l.s[0], k = l.w.length / rows, [w, b] = at.get(l)
|
|
119
|
+
F.set(x.subarray(0, n * k), X / 4)
|
|
120
|
+
inst.exports.gemm(w, X, b, Y, rows, k, n)
|
|
121
|
+
return F.slice(Y / 4, Y / 4 + n * rows)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// gemm.wat in JS
|
|
126
|
+
function gemm(w, x, b, rows, k, n) {
|
|
127
|
+
let y = new Float32Array(n * rows)
|
|
128
|
+
for (let p = 0; p < n; p++) for (let r = 0; r < rows; r++) {
|
|
129
|
+
let a0 = 0, a1 = 0, a2 = 0, a3 = 0, wo = r * k, xo = p * k
|
|
130
|
+
for (let c = 0; c < k; c += 4) {
|
|
131
|
+
a0 = f(a0 + f(w[wo + c] * x[xo + c])); a1 = f(a1 + f(w[wo + c + 1] * x[xo + c + 1]))
|
|
132
|
+
a2 = f(a2 + f(w[wo + c + 2] * x[xo + c + 2])); a3 = f(a3 + f(w[wo + c + 3] * x[xo + c + 3]))
|
|
133
|
+
}
|
|
134
|
+
let s = f(b[r] + f(f(a0 + a1) + f(a2 + a3)))
|
|
135
|
+
y[p * rows + r] = s > 0 ? s : 0
|
|
136
|
+
}
|
|
137
|
+
return y
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// max pooling, window = stride = ph × pw, `pt` rows of padding on top (keras 'same' pads the first pool's rows 1 + 1)
|
|
141
|
+
function pool([x, H, W, C], ph, pw, pt) {
|
|
142
|
+
let oh = Math.ceil(H / ph), ow = Math.ceil(W / pw), y = new Float32Array(oh * ow * C).fill(-Infinity)
|
|
143
|
+
for (let i = 0; i < H; i++) for (let j = 0; j < W; j++) {
|
|
144
|
+
let a = Math.floor((i + pt) / ph), c = Math.floor(j / pw), xo = (i * W + j) * C, yo = (a * ow + c) * C
|
|
145
|
+
for (let k = 0; k < C; k++) if (x[xo + k] > y[yo + k]) y[yo + k] = x[xo + k]
|
|
146
|
+
}
|
|
147
|
+
return [y, oh, ow, C]
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ------------------------------------------------ features
|
|
151
|
+
|
|
152
|
+
// 48 → 16 kHz: a 31-tap low-pass at 8 kHz (sinc under a Kaiser window, β = 6), causal, every third sample kept. Its
|
|
153
|
+
// stop band (60 dB down from 10.6 kHz, whose alias lands at 5.4 kHz) covers the 21 bands used, which end at 5.4 kHz.
|
|
154
|
+
const TAPS = 31, LP = new Float32Array(TAPS)
|
|
155
|
+
{
|
|
156
|
+
let I0 = x => { let s = 1, t = 1; for (let k = 1; k < 30; k++) { t *= (x / 2 / k) ** 2; s += t } return s }, sum = 0
|
|
157
|
+
for (let i = 0; i < TAPS; i++) {
|
|
158
|
+
let t = i - (TAPS - 1) / 2, r = 2 * t / (TAPS - 1)
|
|
159
|
+
LP[i] = (t ? Math.sin(Math.PI * t / 3) / (Math.PI * t) : 1 / 3) * I0(6 * Math.sqrt(1 - r * r)) / I0(6); sum += LP[i]
|
|
160
|
+
}
|
|
161
|
+
for (let i = 0; i < TAPS; i++) LP[i] /= sum
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// sidekit's trfbank(16000, 512, 100, 8000, 0, 24): triangles evenly spaced in HTK mel, each of area 1 (the lowest 21)
|
|
165
|
+
const MELS = 24, BANDS = 21, NFFT = 512, WIN = 400, HOP = 160, PATCH = 68
|
|
166
|
+
const mel = hz => 2595 * Math.log10(1 + hz / 700), hz = m => 700 * (10 ** (m / 2595) - 1)
|
|
167
|
+
const FB = Array.from({ length: BANDS }, (_, i) => {
|
|
168
|
+
let edge = k => hz(mel(100) + k * (mel(8000) - mel(100)) / (MELS + 1)), [lo, c, hi] = [edge(i), edge(i + 1), edge(i + 2)]
|
|
169
|
+
let h = 2 / (hi - lo), w = [], start = Math.floor(lo * NFFT / 16000) + 1, mid = Math.floor(c * NFFT / 16000)
|
|
170
|
+
for (let k = start; k <= mid; k++) w.push(h / (c - lo) * (k * 16000 / NFFT - lo))
|
|
171
|
+
for (let k = mid + 1; k < Math.min(Math.floor(hi * NFFT / 16000) + 1, NFFT) - 1; k++) w.push(h / (hi - c) * (hi - k * 16000 / NFFT))
|
|
172
|
+
return { start, w: Float64Array.from(w) }
|
|
173
|
+
})
|
|
174
|
+
// numpy.hanning(400)
|
|
175
|
+
const HANN = Float64Array.from({ length: WIN }, (_, n) => .5 - .5 * Math.cos(2 * Math.PI * n / (WIN - 1)))
|
|
176
|
+
|
|
177
|
+
// radix-2 FFT of 512 points, the frame zero-padded → power of bins 0…256
|
|
178
|
+
const REV = new Uint16Array(NFFT), COS = new Float64Array(NFFT / 2), SIN = new Float64Array(NFFT / 2)
|
|
179
|
+
for (let i = 0; i < NFFT; i++) { let r = 0; for (let b = 0; b < 9; b++) r |= ((i >> b) & 1) << (8 - b); REV[i] = r }
|
|
180
|
+
for (let i = 0; i < NFFT / 2; i++) { COS[i] = Math.cos(2 * Math.PI * i / NFFT); SIN[i] = -Math.sin(2 * Math.PI * i / NFFT) }
|
|
181
|
+
function power(x, re, im, P) {
|
|
182
|
+
for (let i = 0; i < NFFT; i++) { re[REV[i]] = i < WIN ? x[i] : 0; im[i] = 0 }
|
|
183
|
+
for (let size = 2; size <= NFFT; size <<= 1) {
|
|
184
|
+
let half = size >> 1, step = NFFT / size
|
|
185
|
+
for (let s = 0; s < NFFT; s += size) for (let j = 0; j < half; j++) {
|
|
186
|
+
let a = s + j, b = a + half, c = COS[j * step], d = SIN[j * step]
|
|
187
|
+
let tr = re[b] * c - im[b] * d, ti = re[b] * d + im[b] * c
|
|
188
|
+
re[b] = re[a] - tr; im[b] = im[a] - ti; re[a] += tr; im[a] += ti
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
for (let k = 0; k <= NFFT / 2; k++) P[k] = re[k] * re[k] + im[k] * im[k]
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* analyzer(nn) → push(x): 48 kHz samples in, the [speech, music, noise] of each patch completed out, in order (null
|
|
196
|
+
* for a patch with digital silence in it: its log-mel is −∞, what ina calls not finite and scores as neither class).
|
|
197
|
+
* Patch k covers the 16 kHz frames STEP·k … STEP·k + 67, frame j the 16 kHz samples 160j … 160j + 399; at 48 kHz
|
|
198
|
+
* (the low-pass delays 15 samples) it is centered on sample 480·(STEP·k + 34.716) and complete once sample
|
|
199
|
+
* 480·(STEP·k + 67) + 1197 is in, in 48 kHz frame STEP·k + 69. push.end() → for an input shorter than a patch, its
|
|
200
|
+
* one patch, padded with its least log-mel value as ina pads.
|
|
201
|
+
*/
|
|
202
|
+
export function analyzer(nn) {
|
|
203
|
+
let hist = new Float32Array(TAPS), hn = 0, n48 = 0, s16 = new Float32Array(WIN), n16 = 0
|
|
204
|
+
let frame = new Float64Array(WIN), re = new Float64Array(NFFT), im = new Float64Array(NFFT), P = new Float64Array(NFFT / 2 + 1)
|
|
205
|
+
// the last 68 frames' log-mel, and conv1 over the 7 frames from each: [18][64], no bias
|
|
206
|
+
let M = new Float32Array(PATCH * BANDS), C = new Float32Array(PATCH * 1152), mels = new Float32Array(BANDS)
|
|
207
|
+
let p1 = new Float32Array(16 * 9 * 64), nf = 0, low = Infinity, out = [], { c1, ksum } = nn
|
|
208
|
+
let row = j => (j % PATCH) * BANDS
|
|
209
|
+
function addFrame() {
|
|
210
|
+
// pre-emphasis within the frame, its first sample against itself
|
|
211
|
+
for (let i = 0; i < WIN; i++) frame[i] = (s16[(n16 + i) % WIN] - .97 * s16[(n16 + Math.max(i - 1, 0)) % WIN]) * HANN[i]
|
|
212
|
+
power(frame, re, im, P)
|
|
213
|
+
for (let b = 0; b < BANDS; b++) {
|
|
214
|
+
let { start, w } = FB[b], s = 0
|
|
215
|
+
for (let k = 0; k < w.length; k++) s += P[start + k] * w[k]
|
|
216
|
+
mels[b] = Math.log(s); low = Math.min(low, mels[b])
|
|
217
|
+
}
|
|
218
|
+
addRow(mels)
|
|
219
|
+
}
|
|
220
|
+
function addRow(v) {
|
|
221
|
+
M.set(v, row(nf++))
|
|
222
|
+
if (nf >= 7) {
|
|
223
|
+
let o = ((nf - 7) % PATCH) * 1152
|
|
224
|
+
for (let j = 0; j < 18; j++) for (let a = 0; a < 64; a++) {
|
|
225
|
+
let s = 0
|
|
226
|
+
for (let h = 0; h < 7; h++) {
|
|
227
|
+
let m = row(nf - 7 + h) + j, w = (a * 7 + h) * 4
|
|
228
|
+
s += M[m] * c1.w[w] + M[m + 1] * c1.w[w + 1] + M[m + 2] * c1.w[w + 2] + M[m + 3] * c1.w[w + 3]
|
|
229
|
+
}
|
|
230
|
+
C[o + j * 64 + a] = s
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (nf >= PATCH && (nf - PATCH) % STEP === 0) out.push(patch(nf - PATCH))
|
|
234
|
+
}
|
|
235
|
+
// the patch from frame t: standardized by the mean and deviation of its 68 × 21 values; conv1 from the shared rows,
|
|
236
|
+
// pooled 4 × 2 (the max commutes with the patch's increasing affine map), then the rest of the network
|
|
237
|
+
function patch(t) {
|
|
238
|
+
let s = 0, ss = 0
|
|
239
|
+
for (let j = t; j < t + PATCH; j++) for (let b = 0, r = row(j); b < BANDS; b++) s += M[r + b]
|
|
240
|
+
let mu = s / (PATCH * BANDS)
|
|
241
|
+
if (!isFinite(mu)) return null
|
|
242
|
+
for (let j = t; j < t + PATCH; j++) for (let b = 0, r = row(j); b < BANDS; b++) ss += (M[r + b] - mu) ** 2
|
|
243
|
+
let sd = Math.sqrt(ss / (PATCH * BANDS))
|
|
244
|
+
if (!(sd > 0)) return null
|
|
245
|
+
for (let m = 0; m < 16; m++) for (let c = 0; c < 9; c++) for (let a = 0; a < 64; a++) {
|
|
246
|
+
let v = -Infinity
|
|
247
|
+
for (let i = Math.max(0, 4 * m - 1); i <= Math.min(61, 4 * m + 2); i++) {
|
|
248
|
+
let q = ((t + i) % PATCH) * 1152 + 2 * c * 64 + a
|
|
249
|
+
v = Math.max(v, C[q], C[q + 64])
|
|
250
|
+
}
|
|
251
|
+
p1[(m * 9 + c) * 64 + a] = (v - mu * ksum[a]) / sd + c1.b[a]
|
|
252
|
+
}
|
|
253
|
+
return nn.rest(p1)
|
|
254
|
+
}
|
|
255
|
+
function push(x) {
|
|
256
|
+
out = []
|
|
257
|
+
for (let i = 0; i < x.length; i++) {
|
|
258
|
+
hist[hn = (hn + 1) % TAPS] = x[i]
|
|
259
|
+
if (n48++ % 3) continue
|
|
260
|
+
let v = 0
|
|
261
|
+
for (let k = 0; k < TAPS; k++) v += LP[k] * hist[(hn - k + TAPS) % TAPS]
|
|
262
|
+
s16[n16++ % WIN] = v
|
|
263
|
+
if (n16 >= WIN && (n16 - WIN) % HOP === 0) addFrame()
|
|
264
|
+
}
|
|
265
|
+
return out
|
|
266
|
+
}
|
|
267
|
+
push.end = () => {
|
|
268
|
+
out = []
|
|
269
|
+
if (nf && nf < PATCH) { mels.fill(low); while (nf < PATCH) addRow(mels) }
|
|
270
|
+
return out
|
|
271
|
+
}
|
|
272
|
+
return push
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ------------------------------------------------ decisions
|
|
276
|
+
|
|
277
|
+
const lp = (p, floor) => Math.log(Math.max(p, floor))
|
|
278
|
+
|
|
279
|
+
/** offline(x, nn) → per 48 kHz frame of x (480 samples), 1 where it is music: ina's Viterbi over the whole input. */
|
|
280
|
+
export function offline(x, nn) {
|
|
281
|
+
let a = analyzer(nn), P = [...a(x), ...a.end()], T = P.length, n = Math.ceil(x.length / 480), pass = new Uint8Array(n)
|
|
282
|
+
if (!T) return pass
|
|
283
|
+
// V: the best path's log-likelihood ending in each state (speech, music, noise); B: the state it came from. Every
|
|
284
|
+
// switch costs COST; a patch of silence scores all states alike. Ties go to the lower state, as numpy's argmax.
|
|
285
|
+
let V = [0, 0, 0], B = new Uint8Array(T * 3)
|
|
286
|
+
for (let t = 0; t < T; t++) {
|
|
287
|
+
let e = P[t] ? P[t].map(p => lp(p, 1e-10)) : [0, 0, 0], j = V.indexOf(Math.max(...V))
|
|
288
|
+
V = V.map((v, s) => { let b = V[j] - COST > v ? j : s; B[t * 3 + s] = b; return (b === s ? v : V[j] - COST) + e[s] })
|
|
289
|
+
}
|
|
290
|
+
let music = new Uint8Array(T)
|
|
291
|
+
for (let t = T - 1, s = V.indexOf(Math.max(...V)); t >= 0; s = B[t * 3 + s], t--) music[t] = +(s === 1)
|
|
292
|
+
// each frame takes the patch centered nearest it
|
|
293
|
+
for (let g = 0; g < n; g++) pass[g] = music[Math.min(T - 1, Math.max(0, Math.round((g + .5 - 34.716) / STEP)))]
|
|
294
|
+
return pass
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* online(nn) → frame(x480): the stream's decision, one 48 kHz frame at a time: true once it holds music, from the
|
|
299
|
+
* patches complete in the frames so far. The output frame two before (RNNoise's delay) takes it.
|
|
300
|
+
*/
|
|
301
|
+
export function online(nn) {
|
|
302
|
+
let a = analyzer(nn), l = 0, on = false
|
|
303
|
+
return x => {
|
|
304
|
+
for (let p of a(x)) {
|
|
305
|
+
if (!p) continue
|
|
306
|
+
l = Math.min(BOUND, Math.max(-BOUND, l + lp(p[1], FLOOR) - lp(p[0] + p[2], FLOOR) - PRIOR))
|
|
307
|
+
on = l > (on ? 0 : HYST)
|
|
308
|
+
}
|
|
309
|
+
return on
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** ramp(enhance) → next(enhance): the enhanced signal's gain for the next sample, toward 1 (enhance) or 0 (pass) over
|
|
314
|
+
* RAMP, starting at 1 or 0. */
|
|
315
|
+
export function ramp(start = true) {
|
|
316
|
+
let phi = +start
|
|
317
|
+
return enhance => (phi = Math.min(1, Math.max(0, phi + (enhance ? 1 : -1) / RAMP)), .5 - .5 * Math.cos(Math.PI * phi))
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** blend(g, y, x): the enhanced y at gain g, the input x at 1 − g; at 1 and 0 the one signal itself, bit for bit. */
|
|
321
|
+
export const blend = (g, y, x) => g === 1 ? y : g === 0 ? x : g * y + (1 - g) * x
|
|
322
|
+
|
|
323
|
+
/** gains(pass, n, centered) → Float32Array(n), the enhanced signal's gain per sample from a pass flag per 480: offline,
|
|
324
|
+
* from the first frame's and the ramps centered on each change; streaming, from 1 (nothing decided yet), the ramps
|
|
325
|
+
* starting at each change. */
|
|
326
|
+
export function gains(pass, n, centered) {
|
|
327
|
+
let g = new Float32Array(n), next = ramp(!(centered && pass[0])), lead = centered ? RAMP / 2 : 0
|
|
328
|
+
for (let i = 0; i < n; i++) g[i] = next(!pass[Math.min(pass.length - 1, Math.floor((i + lead) / 480))])
|
|
329
|
+
return g
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// guard.bin, the network's weights (scripts/guard.py writes them from upstream's export)
|
|
333
|
+
let bundled
|
|
334
|
+
export async function guardNet() {
|
|
335
|
+
return bundled ??= (async () => {
|
|
336
|
+
let url = new URL('./guard.bin', import.meta.url)
|
|
337
|
+
if (url.protocol === 'file:') return net(new Uint8Array((await import('node:fs')).readFileSync(url)))
|
|
338
|
+
let res = await fetch(url)
|
|
339
|
+
if (!res.ok) throw new Error(`neural-denoise: can't fetch ${url}: ${res.status}`)
|
|
340
|
+
return net(new Uint8Array(await res.arrayBuffer()))
|
|
341
|
+
})()
|
|
342
|
+
}
|
package/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** Neural speech enhancement: RNNoise (pure JS, weights bundled) and DeepFilterNet3 (ONNX, fetched and cached). */
|
|
1
|
+
/** Neural speech enhancement: RNNoise (pure JS, weights bundled) and DeepFilterNet3 (ONNX, fetched and cached); music passes through untouched. */
|
|
2
2
|
|
|
3
3
|
export type Audio = Float32Array | Float32Array[] | { channelData: Float32Array[], sampleRate: number }
|
|
4
4
|
|
|
@@ -28,8 +28,10 @@ export interface DenoiseOptions extends LoadOptions {
|
|
|
28
28
|
sampleRate?: number
|
|
29
29
|
/** 'rnnoise' (default), 'deepfilternet3', or a handle from load() to reuse across calls */
|
|
30
30
|
model?: 'rnnoise' | 'deepfilternet3' | Model
|
|
31
|
-
/** attenuation limit in dB, the most the noise drops: mixes the input back in at 10^(−limit/20); default
|
|
31
|
+
/** attenuation limit in dB, the most the noise drops: mixes the input back in at 10^(−limit/20); default 16 for RNNoise, 18 for DeepFilterNet3 (README, API); 0 for none. DeepFilterNet3 also hears its input with the speech at −20 dBFS and, above a band-limited input's edge, a white noise floor 20 dB under it, and keeps sustained voicing it would remove (held sung notes) */
|
|
32
32
|
limit?: number
|
|
33
|
+
/** 'pass' (default): speech and noise are enhanced, music (songs included) passes through untouched, as inaSpeechSegmenter's speech/music/noise CNN segments the input: DeepFilterNet3 with the whole input in view, RNNoise as its stream decides (from what has arrived: music's first second or so is denoised); the gain ramps over 200 ms at each switch. 'enhance': everything, as before 0.4 */
|
|
34
|
+
music?: 'pass' | 'enhance'
|
|
33
35
|
/** DeepFilterNet3: frames (10 ms) per model run, default 1000 */
|
|
34
36
|
chunk?: number
|
|
35
37
|
/** DeepFilterNet3: frames of context run before each chunk after the first, default 300 */
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@audio/neural-denoise",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Neural speech enhancement: RNNoise (Valin 2018) in pure JS, bit-exact to its C, weights bundled; DeepFilterNet3 through ONNX Runtime, matching the Python original",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Neural speech enhancement: RNNoise (Valin 2018) in pure JS, bit-exact to its C, weights bundled; DeepFilterNet3 through ONNX Runtime, matching the Python original; music passes through untouched (inaSpeechSegmenter's speech/music/noise CNN)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
7
7
|
"main": "denoise.js",
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
},
|
|
18
18
|
"./worklet": "./worklet.js",
|
|
19
19
|
"./rnnoise.bin": "./rnnoise.bin",
|
|
20
|
+
"./guard.bin": "./guard.bin",
|
|
20
21
|
"./package.json": "./package.json"
|
|
21
22
|
},
|
|
22
23
|
"files": [
|
|
@@ -28,6 +29,9 @@
|
|
|
28
29
|
"worklet.js",
|
|
29
30
|
"rnnoise.bin",
|
|
30
31
|
"gemv.wat",
|
|
32
|
+
"guard.js",
|
|
33
|
+
"guard.bin",
|
|
34
|
+
"gemm.wat",
|
|
31
35
|
"index.d.ts",
|
|
32
36
|
"LICENSE",
|
|
33
37
|
"NOTICE",
|
|
@@ -61,7 +65,8 @@
|
|
|
61
65
|
"author": "Dmitry Iv. <dfcreative@gmail.com>",
|
|
62
66
|
"contributors": [
|
|
63
67
|
"Jean-Marc Valin, Xiph.Org Foundation, Mozilla, Amazon and the RNNoise authors (RNNoise)",
|
|
64
|
-
"Hendrik Schröter (DeepFilterNet)"
|
|
68
|
+
"Hendrik Schröter (DeepFilterNet)",
|
|
69
|
+
"David Doukhan, Ina (inaSpeechSegmenter)"
|
|
65
70
|
],
|
|
66
71
|
"audio": "./audio.js",
|
|
67
72
|
"engines": {
|
package/rnnoise.js
CHANGED
|
@@ -33,9 +33,11 @@ const F = { c06: f(.6), c07: f(.7), c085: f(.85), c09: f(.9), c03: f(.3), c04: f
|
|
|
33
33
|
|
|
34
34
|
export const FRAME = 480
|
|
35
35
|
// The attenuation limit, dB, that denoise(), the worklet and the audio atom apply unless given one (0:
|
|
36
|
-
// none, upstream's output)
|
|
37
|
-
//
|
|
38
|
-
|
|
36
|
+
// none, upstream's output): the most before the voice itself suffers. Unlimited, the model gates speech
|
|
37
|
+
// that has nothing to remove (clean word ends 37 dB down) and removes the voice on some noisy files. On the
|
|
38
|
+
// VoiceBank+DEMAND training subset (504 files) DNSMOS SIG holds from 14 to 16 dB and falls past it, and 16
|
|
39
|
+
// is the most at which no file loses more than 0.2 STOI (21 files unlimited, 3 at 20) (README, Accuracy).
|
|
40
|
+
export const LIMIT = 16
|
|
39
41
|
const WIN = 960, FREQ = 481, NB = 32, NF = 65
|
|
40
42
|
const PMIN = 60, PMAX = 768, PFRAME = 960, PBUF = PMAX + PFRAME
|
|
41
43
|
|
package/worklet.js
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
// Render quanta (128 samples) are collected into 10 ms frames (480); the output queue starts with
|
|
7
7
|
// 480 − gcd(quantum, 480) samples of silence, the least that never runs dry, so the node's latency is
|
|
8
8
|
// constant: that plus RNNoise's 960 samples (1408 samples, 29.3 ms, at the 128-sample quantum).
|
|
9
|
-
// processorOptions.limit (dB) caps the attenuation by mixing the input back in:
|
|
10
|
-
// none (upstream's output).
|
|
9
|
+
// processorOptions.limit (dB) caps the attenuation by mixing the input back in: 16 unless given (LIMIT),
|
|
10
|
+
// 0 for none (upstream's output).
|
|
11
11
|
|
|
12
12
|
import { model, create, FRAME, LIMIT } from './rnnoise.js'
|
|
13
13
|
|