@audio/neural-denoise 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,7 +17,7 @@ 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` 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. Unlimited, RNNoise removes the voice on 12 of the 824 VoiceBank+DEMAND files (STOI down by more than 0.2), on none at 20 dB, where PESQ rises from 2.11 to 2.46. DeepFilterNet3's 18 dB is the most before the voice itself suffers: on VoiceBank+DEMAND its DNSMOS 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)) |
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
21
  | `weights` | | RNNoise: bytes of an upstream weight blob; DeepFilterNet3: URL or bytes of an upstream ONNX export |
22
22
  | `device` | `'auto'` | DeepFilterNet3: `@audio/neural-runtime` backend, `'node'`, `'wasm'` or `'webgpu'` |
23
23
  | `sessionOptions` | | DeepFilterNet3: ONNX Runtime session options, e.g. `{ intraOpNumThreads: 4 }` |
@@ -55,7 +55,7 @@ let node = new AudioWorkletNode(ctx, 'neural-denoise', { processorOptions: { wei
55
55
  mic.connect(node).connect(ctx.destination)
56
56
  ```
57
57
 
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()`: 20 dB unless given, `0` for none.
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()`: 16 dB unless given, `0` for none.
59
59
 
60
60
  Frame by frame, as `rnnoise_process_frame` works:
61
61
 
@@ -68,7 +68,7 @@ let voice = st.process(input480, output480) // int16 scale in and out; voice pr
68
68
 
69
69
  ## In `audio`: the contract atom
70
70
 
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 one parameter, `limit` (dB, default 20, 0 for none). [`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 by default):
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 one parameter, `limit` (dB, default 16, 0 for none). [`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 by default):
72
72
 
73
73
  ```js
74
74
  import audio from 'audio'
@@ -77,7 +77,7 @@ await audio('take.wav').rnnoise().save('clean.wav') // streams
77
77
  await audio('take.wav').deepfilter().save('clean.wav') // DeepFilterNet3, room tone kept
78
78
  ```
79
79
 
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`, 20 dB, is `denoise()`'s for RNNoise: unlimited, the model removes the voice on some files (Accuracy, below).
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`, 16 dB, is `denoise()`'s for RNNoise: unlimited, the model gates clean speech and removes the voice on some noisy files (Accuracy, below).
81
81
 
82
82
  ## RNNoise
83
83
 
@@ -96,7 +96,7 @@ The float code keeps C's float semantics: each float op is `Math.fround(a op b)`
96
96
  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
97
 
98
98
  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.
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 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
100
  3. **Network**: 32 gains (sigmoid) and 96 × 5 complex filter taps per frame.
101
101
  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
102
  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.
@@ -116,7 +116,9 @@ The parameters come from the export's `config.ini`, so upstream's low-latency ex
116
116
  | 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
117
  | 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
118
  | 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
- | Worklet (`test.js`, AudioWorkletGlobalScope simulated) | the frame API's output, delayed by 448 samples, at `limit: 0`; at 12 and at the default 20, that output with the input mixed back, to 1e-7 |
119
+ | 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 |
120
+ | 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 |
121
+ | 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
122
  | Worklet in Chromium 153 (headless, Playwright, `scripts/worklet.mjs`) | runs, no processor error, output non-silent; live underruns in Speed |
121
123
  | 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
124
 
@@ -136,7 +138,8 @@ The VoiceBank+DEMAND test set (Valentini-Botinhao 2017, CC BY 4.0): 824 utteranc
136
138
  | "Enhance speech", the whole recipe, with 0.3.11's `omlsa` | 1.63 | 0.830 | 0.84 | 3.06 | 2.37 | 2.18 |
137
139
  | RNNoise, `limit: 0` (this package = upstream C) | 2.11 | 0.890 | 12.28 | 3.28 | 3.85 | 2.94 |
138
140
  | RNNoise, little model, `limit: 0` | 2.12 | 0.893 | 12.39 | 3.28 | 3.85 | 2.93 |
139
- | RNNoise, `limit: 20`, the default | 2.46 | 0.918 | 12.86 | 3.40 | 3.71 | 2.97 |
141
+ | RNNoise, `limit: 20` (the default before 0.3) | 2.46 | 0.918 | 12.86 | 3.40 | 3.71 | 2.97 |
142
+ | RNNoise, `limit: 16`, the default | 2.49 | 0.922 | 13.00 | 3.43 | 3.66 | 2.96 |
140
143
  | **DeepFilterNet3**, upstream's settings, `limit: 0` (`enhance()`) | **3.16** | **0.945** | **18.96** | **3.44** | **4.08** | **3.18** |
141
144
  | DeepFilterNet3, Python (deepfilternet 0.5.6) | 3.16 | 0.945 | 18.96 | 3.44 | 4.08 | 3.18 |
142
145
  | DeepFilterNet3 in 1 s chunks, `limit: 0` | 3.16 | 0.945 | 18.94 | 3.44 | 4.08 | 3.18 |
@@ -146,9 +149,46 @@ The VoiceBank+DEMAND test set (Valentini-Botinhao 2017, CC BY 4.0): 824 utteranc
146
149
 
147
150
  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
151
 
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 20 dB `limit` (the input mixed back at 0.1) caps it: losses over 0.1 fall to 6 files and over 0.2 to none (p232_354 keeps 0.785), and PESQ rises to 2.46, +0.50 ± 0.03 over the noisy input and +0.07 ± 0.02 over `omlsa`, at a BAK of 3.71 instead of 3.85. `denoise()`, the worklet and the atom apply that limit unless given another.
152
+ - **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
153
  - **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
154
 
155
+ **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).
156
+
157
+ | `limit` | PESQ | STOI | DNSMOS SIG | BAK | STOI down > 0.2 | clean word ends | clean quiet syllables |
158
+ |---|---|---|---|---|---|---|---|
159
+ | noisy input | 1.47 | 0.841 | 3.00 | 2.50 | | | |
160
+ | 0 (upstream) | 1.83 | 0.827 | 3.20 | 3.76 | 21 files | −36.7 dB | −11.4 dB |
161
+ | 12 | 1.84 | 0.858 | 3.33 | 3.19 | 0 | −8.9 | −8.1 |
162
+ | 14 | 1.88 | 0.857 | 3.34 | 3.26 | 0 | −10.3 | −8.8 |
163
+ | **16**, the default | **1.91** | **0.855** | **3.33** | **3.32** | **0** | **−11.7** | **−9.4** |
164
+ | 18 | 1.93 | 0.853 | 3.32 | 3.37 | 1 | −13.0 | −9.9 |
165
+ | 20 (before 0.3) | 1.93 | 0.851 | 3.31 | 3.42 | 3 | −14.4 | −10.2 |
166
+
167
+ 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.
168
+
169
+ **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).
170
+
171
+ | | training speakers (504): word ends | quiet syllables | SI-SDR vs input | test set (824): word ends | quiet syllables | SI-SDR vs input |
172
+ |---|---|---|---|---|---|---|
173
+ | RNNoise, `limit: 0` | −36.7 dB | −11.4 dB | 15.0 dB | −30.8 dB | −10.2 dB | 15.5 dB |
174
+ | RNNoise, `limit: 20` (before 0.3) | −14.4 | −10.2 | 16.0 | −12.9 | −9.5 | 16.4 |
175
+ | **RNNoise, `limit: 16`**, the default | **−11.7** | **−9.4** | **16.6** | **−10.5** | **−8.8** | **17.0** |
176
+ | DeepFilterNet3, `denoise()`, `limit: 0` | −0.9 | −0.7 | 39.0 | −0.9 | −0.8 | 39.2 |
177
+ | **DeepFilterNet3, `denoise()`, `limit: 18`**, the default | **−0.7** | **−0.6** | **40.2** | **−0.8** | **−0.7** | **40.4** |
178
+
179
+ 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.
180
+
181
+ **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:
182
+
183
+ | 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`** |
184
+ |---|---|---|---|---|---|---|
185
+ | 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 |
186
+ | 44.1 kHz | 2.55 / 0.880 | the same, bit for bit | 2.23 / 0.876 | the same | 2.89 / 0.940 | the same |
187
+ | 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** |
188
+ | 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** |
189
+
190
+ 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.
191
+
152
192
  **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
193
 
154
194
  | System | SIG | BAK | OVRL | noise floor, dBFS (median) | speech level |
@@ -157,16 +197,31 @@ Over the noisy input, paired, with 95% intervals: DeepFilterNet3 adds 1.20 ± 0.
157
197
  | `omlsa()` | 3.46 | 4.09 | 3.19 | −98 to −69 (−75) | 0.0 dB |
158
198
  | "Enhance speech" denoising | 3.45 | 4.07 | 3.17 | −101 to −71 (−85) | −0.2 dB |
159
199
  | RNNoise, `limit: 0` | 3.42 | 4.14 | 3.19 | −127 to −106 (−112) | −0.5 dB |
160
- | DeepFilterNet3, upstream's settings, `limit: 0` | 3.47 | 4.12 | 3.21 | −152 to −61 (−89) | −0.5 dB |
161
- | DeepFilterNet3, `denoise()`, `limit: 0` | 3.47 | 4.11 | 3.21 | −132 to −60 (−84) | −0.4 dB |
200
+ | RNNoise, `limit: 16`, the default | 3.47 | 4.14 | 3.23 | −98 to −70 (−78) | −0.4 dB |
201
+ | DeepFilterNet3, upstream's settings, `limit: 0` | 3.47 | 4.12 | 3.21 | −152 to −61 (−83) | −0.4 dB |
202
+ | DeepFilterNet3, `denoise()`, `limit: 0` | 3.47 | 4.11 | 3.21 | −165 to −60 (−78) | −0.4 dB |
203
+ | DeepFilterNet3, `denoise()` 0.2, `limit: 18` | 3.47 | 4.11 | 3.21 | −93 to −59 (−73) | −0.4 dB |
162
204
  | 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.21 | −91 to −57 (−67) | −0.4 dB |
205
+ | DeepFilterNet3, `limit: 12` | 3.47 | 4.10 | 3.20 | −91 to −58 (−68) | −0.3 dB |
164
206
 
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. Both can also take pauses to dead silence (RNNoise's median floor is −112 dBFS, DeepFilterNet3's lowest −152), which ACX Check flags; a `limit` of 18 dB keeps DeepFilterNet3's floors between −93 and −59 dBFS at the same OVRL, room tone left in. On lena (a film scene with music under the voice) RNNoise lowers OVRL from 2.76 to 2.47, DeepFilterNet3 raises it to 2.83, 2.90 with the limit.
207
+ 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
208
 
167
209
  **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
210
 
169
- To rerun: `node scripts/accuracy.mjs vbdemand SYSTEMS` and `node scripts/accuracy.mjs rooms` write the outputs (the data sources and checksums are in its header), `python scripts/accuracy.py vbdemand SYSTEMS` and `python scripts/accuracy.py rooms` score them, and `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/`, `spoken/scores.json`); the scripts regenerate outputs, skipping any already there.
211
+ **Music.** Both models enhance speech, and to them everything else is noise: music loses at every band, and the limit only caps the loss. 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; level change per band against the input, the median over the files:
212
+
213
+ | at the default limit | 0–250 Hz | 0.25–1 kHz | 1–4 kHz | 4–8 kHz | 8–16 kHz | 16–22 kHz |
214
+ |---|---|---|---|---|---|---|
215
+ | RNNoise, pieces | −11.5 dB | −12.9 | −13.7 | −15.3 | −14.2 | −11.0 |
216
+ | RNNoise, chords | −7.2 | −15.4 | −15.6 | −15.4 | −14.7 | −15.3 |
217
+ | RNNoise, songs | −14.4 | −8.0 | −10.1 | −11.4 | −12.2 | −14.2 |
218
+ | DeepFilterNet3, pieces | −8.6 | −9.0 | −10.9 | −14.4 | −15.5 | −14.0 |
219
+ | DeepFilterNet3, chords | −5.8 | −3.9 | −4.9 | −9.5 | −11.1 | −3.7 |
220
+ | DeepFilterNet3, songs | −16.9 | −13.7 | −16.5 | −13.2 | −12.7 | −16.0 |
221
+
222
+ Unlimited, single bands of single songs lose up to 54 dB (DeepFilterNet3) and 68 dB (RNNoise). The voice guard keeps one held voice: it follows one pitch, not a chord or an orchestra. Neither model passes music through, because nothing at hand tells music from noisy speech reliably enough. The guard that suggests itself, passing the input through where the unlimited model takes much of what stands 10 dB or more over each band's noise floor (minimum statistics), can't be set: noise that moves stands over the floor too. DeepFilterNet3 takes more than 9 dB of it from 17 of the 504 noisy training utterances, as from 45 of the 50 songs and 3 of the 4 pieces, but from none of the chords (5 to 8.4 dB); at 12 dB, 4 utterances would pass through untouched while 10 songs and every chord would still lose; at 6 dB, 32 utterances would. RNNoise takes more than 9 dB from 32 utterances (its voice removals among them) as from every piece and chord and 40 songs. Telling music from speech takes a classifier trained for it; none ships here.
223
+
224
+ To rerun: `python scripts/accuracy.py prepare` writes the band-limited inputs and decodes the MUSDB18 previews; `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
225
 
171
226
  ## Speed and memory
172
227
 
@@ -186,7 +241,7 @@ Apple M4 Max running other jobs (load average 28 to 50 on 14 cores), so wall tim
186
241
 
187
242
  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
243
 
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.
244
+ **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.
190
245
 
191
246
  ## Licenses
192
247
 
@@ -212,7 +267,7 @@ The package is BSD-3-Clause (the RNNoise translation and weights); `deepfilter.j
212
267
  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.
213
268
 
214
269
  **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 keep speech and drop the rest; DeepFilterNet3's voice guard keeps held sung notes, RNNoise drops them); 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).
270
+ **Not for:** music, songs included (both take 8 to 16 dB from every band at their default limits, and nothing in them tells music from noisy speech; DeepFilterNet3's voice guard keeps one held voice, RNNoise drops it); 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
271
 
217
272
  ---
218
273
 
package/audio.d.ts CHANGED
@@ -10,7 +10,7 @@ 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 20) */
13
+ /** 0..100 dB (default 16) */
14
14
  "limit"?: Auto
15
15
  at?: number | string
16
16
  duration?: number | string
@@ -22,7 +22,7 @@ export declare const rnnoise: {
22
22
  latency: (ctx: { sampleRate: number, params: Live }) => number
23
23
  tail: 0
24
24
  params: {
25
- /** 0..100 dB (default 20) */
26
- "limit": { type: "number", default: 20 }
25
+ /** 0..100 dB (default 16) */
26
+ "limit": { type: "number", default: 16 }
27
27
  }
28
28
  }
package/audio.js CHANGED
@@ -10,10 +10,10 @@
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 departs from denoise()'s (none) on purpose:
14
- // unlimited, RNNoise's current model removes the voice on 12 of the 824 VoiceBank+DEMAND test files (STOI
15
- // down by more than 0.2); limited to 20 dB, on none, and PESQ rises from 2.11 to 2.46 (README, Accuracy).
16
- // The limit is read per block and applies from the next finished frame on.
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
18
  // Channels are denoised independently; a channel's state (70 KB) is created on its first block, as the
19
19
  // worklet does, since hosts declare up to 32 channels.
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
- // and a voice guard (`voice`) keeps the sustained voicing it takes for noise: held sung notes, chant.
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
@@ -5,15 +5,12 @@
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
9
 
10
10
  export { create as rnnoise, FRAME } from './rnnoise.js'
11
11
  export { MODEL } from './deepfilter.js'
12
12
 
13
13
  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
14
  // the attenuation limit (dB) each model gets when opts.limit is not given; 0 is upstream's output
18
15
  const LIMITS = { rnnoise: RNNOISE_LIMIT, deepfilternet3: DFN_LIMIT }
19
16
 
@@ -55,7 +52,7 @@ export default async function denoise(audio, opts = {}) {
55
52
  for (let ch of channels) {
56
53
  let x = rate === RATE ? Float32Array.from(ch) : resample(Float32Array.from(ch), { from: rate, to: RATE })
57
54
  let y = handle.model === 'rnnoise' ? rnnoiseOffline(x, handle.net, limit)
58
- : await enhance(x, handle.net, { ...opts, limit, gain: 10 ** ((LEVEL - level(x)) / 20), voice: true })
55
+ : await enhance(x, handle.net, { ...opts, limit, gain: 10 ** ((LEVEL - level(x)) / 20), edge: bandEdge(ch, rate), voice: true })
59
56
  out.push(rate === RATE ? y : fit(resample(y, { from: RATE, to: rate }), ch.length))
60
57
  }
61
58
  return audio instanceof Float32Array ? out[0] : Array.isArray(audio) ? out : { ...audio, channelData: out, sampleRate: rate }
@@ -64,16 +61,6 @@ export default async function denoise(audio, opts = {}) {
64
61
  }
65
62
  }
66
63
 
67
- // speech level, dBFS: mean power of the louder half of 50 ms frames (LEVEL where there is none)
68
- function level(x) {
69
- let F = 2400, p = []
70
- 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) }
71
- p.sort((a, b) => b - a)
72
- let k = Math.max(1, p.length >> 1), s = 0
73
- for (let i = 0; i < k && i < p.length; i++) s += p[i]
74
- return s > 0 ? 10 * Math.log10(s / k) : LEVEL
75
- }
76
-
77
64
  const fit = (y, n) => y.length === n ? y : (() => { let o = new Float32Array(n); o.set(y.subarray(0, n)); return o })()
78
65
 
79
66
  // One channel through RNNoise at int16 scale, delay removed: out[i] lines up with x[i]
package/index.d.ts CHANGED
@@ -28,7 +28,7 @@ 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 20 for RNNoise, 18 for DeepFilterNet3 (README, API); 0 for none. DeepFilterNet3 also hears its input with the speech at −20 dBFS and keeps sustained voicing it would remove (held sung notes) */
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
33
  /** DeepFilterNet3: frames (10 ms) per model run, default 1000 */
34
34
  chunk?: number
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@audio/neural-denoise",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
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",
5
5
  "type": "module",
6
6
  "sideEffects": false,
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). Unlimited, this model removes the voice on 12 of the 824 VoiceBank+DEMAND
37
- // test files (STOI down by more than 0.2); limited to 20 dB, on none (README, Accuracy).
38
- export const LIMIT = 20
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: 20 unless given, 0 for
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