1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
//! Noise-power-spectral-density estimation.
//!
//! Implements a robust *minima-controlled recursive averaging* (MCRA / IMCRA
//! style) estimator, optionally seeded by a *noise profile* learned from a
//! leading-silence (or explicitly provided) segment. This is the classical
//! state-of-the-art approach to tracking non-stationary noise without needing
//! a separate noise-only recording, while a profile gives an excellent
//! cold-start.
//!
//! Per frequency bin `k`, every frame:
//! 1. Recursively smooth the noisy power: `S = a_s*S + (1-a_s)*|Y|^2`.
//! 2. Track the running minimum `S_min` over a window of `L` frames using a
//! two-tracker (Dpledged) scheme that avoids the minimum "sticking".
//! 3. Estimate the speech-presence probability `p` from the ratio
//! `zeta = S / (B_min * S_min)` via a sigmoid.
//! 4. Update the noise PSD with a smoothing factor that *freezes* when
//! speech is present: `a_d_eff = a_d + (1-a_d)*p`,
//! `lambda_d = a_d_eff*lambda_d + (1-a_d_eff)*|Y|^2`.
/// Tunable parameters for the noise estimator.
#[derive(Clone, Copy, Debug)]
pub struct NoiseConfig {
/// Power-spectrum smoothing factor (close to 1 = slow).
pub alpha_s: f64,
/// Base noise-PSD smoothing factor used in speech absence.
pub alpha_d: f64,
/// Minima-tracking window length, in frames (~1.5 s of audio by default).
pub window_frames: usize,
/// Bias compensation applied to the running minimum.
pub b_min: f64,
/// Sigmoid midpoint: speech declared around `zeta == zeta0`.
pub zeta0: f64,
/// Sigmoid steepness (smaller = sharper transition).
pub sigma: f64,
/// Maximum amount (in dB) the noise PSD may rise *above a learned noise
/// profile*. This anchors the estimate so a long, sustained signal cannot
/// drag `lambda_d` up and suppress itself. `f64::INFINITY` disables the
/// anchor.
pub anchor_cap_db: f64,
/// Maximum upward slew rate of the noise PSD, in dB per second. Limits how
/// fast `lambda_d` can rise in the blind (no-profile) case. `f64::INFINITY`
/// disables the rate limiter.
pub up_rate_dbps: f64,
/// Learn and refresh the profile from confidently noise-only frames found
/// throughout the signal, rather than only from a leading segment.
pub adaptive_profile: bool,
}
impl Default for NoiseConfig {
fn default() -> Self {
NoiseConfig {
alpha_s: 0.9,
alpha_d: 0.95,
window_frames: 0, // 0 => computed from sample rate / hop (~1.5 s)
b_min: 2.0,
zeta0: 2.0,
sigma: 0.25,
anchor_cap_db: 15.0,
up_rate_dbps: 6.0,
adaptive_profile: false,
}
}
}
pub struct NoiseEstimator {
cfg: NoiseConfig,
nbins: usize,
s: Vec<f64>, // smoothed noisy power
s_min: Vec<f64>, // fast-forgetting minimum (SPP decision)
s_tmp: Vec<f64>, // slow-forgetting minimum (long-term floor)
lambda_d: Vec<f64>, // noise PSD
p: Vec<f64>, // speech-presence probability
initialized: bool,
/// Whether the noise PSD is allowed to adapt over time.
pub adapt: bool,
/// Learned noise profile used as an upward anchor (None if not profiled).
profile_psd: Vec<f64>,
has_profile: bool,
/// Per-frame multiplicative cap for the upward rate limiter.
up_ratio: f64,
/// Multiplicative anchor cap (10^(anchor_cap_db/10)); `INFINITY` disables.
anchor_ratio: f64,
/// Per-frame forgetting factor for s_min (~+1.5 dB/s).
min_forget_fast: f64,
/// Per-frame forgetting factor for s_tmp (~+0.5 dB/s).
min_forget_slow: f64,
}
impl NoiseEstimator {
/// Create a new estimator for `nbins` unique bins. `window_frames == 0`
/// selects a ~1.5 s window from `sample_rate` and `hop`.
pub fn new(cfg: NoiseConfig, nbins: usize, sample_rate: u32, hop: usize) -> Self {
let window_frames = if cfg.window_frames == 0 {
((1.5 * sample_rate as f64 / hop as f64).round() as usize).max(8)
} else {
cfg.window_frames
};
let anchor_ratio = 10f64.powf(cfg.anchor_cap_db / 10.0);
let up_ratio = 10f64.powf(cfg.up_rate_dbps * hop as f64 / sample_rate as f64 / 10.0);
let dt = hop as f64 / sample_rate as f64; // seconds per frame
// Fast tracker: rises ~9 dB/s so it converges to the current noise level
// within ~1 s (giving zeta ≈ 1 for stationary noise → low SPP → noise
// attenuated), while staying well below a sustained signal (which is
// typically >20 dB above noise, so zeta stays high → SPP ≈ 1 → signal
// preserved). Slow tracker: +0.5 dB/s, a long-term floor reference.
let min_forget_fast = 10f64.powf(9.0 * dt / 10.0);
let min_forget_slow = 10f64.powf(0.5 * dt / 10.0);
NoiseEstimator {
cfg: NoiseConfig {
window_frames,
..cfg
},
nbins,
s: vec![0.0; nbins],
s_min: vec![f64::MAX; nbins],
s_tmp: vec![f64::MAX; nbins],
lambda_d: vec![1e-10; nbins],
p: vec![0.0; nbins],
initialized: false,
adapt: true,
profile_psd: vec![0.0; nbins],
has_profile: false,
up_ratio,
anchor_ratio,
min_forget_fast,
min_forget_slow,
}
}
#[inline]
pub fn nbins(&self) -> usize {
self.nbins
}
/// Seed the estimator from a noise profile: per-frame power spectra
/// `|Y|^2` (length `nbins` each) averaged into `lambda_d`.
pub fn seed_from_profile(&mut self, profile_frames: &[Vec<f64>]) {
if profile_frames.is_empty() {
return;
}
for k in 0..self.nbins {
let mut acc = 0.0;
let mut n = 0.0;
for fr in profile_frames {
if k < fr.len() {
acc += fr[k];
n += 1.0;
}
}
let val = (if n > 0.0 { acc / n } else { 1e-10 }).max(1e-12);
self.lambda_d[k] = val;
self.s[k] = val;
self.s_min[k] = val;
self.s_tmp[k] = val;
self.profile_psd[k] = val;
}
self.initialized = true;
self.has_profile = true;
}
/// Current noise PSD estimate (length `nbins`).
#[inline]
pub fn noise_psd(&self) -> &[f64] {
&self.lambda_d
}
/// Current per-bin speech-presence probability (length `nbins`).
#[inline]
pub fn speech_presence(&self) -> &[f64] {
&self.p
}
#[inline]
pub fn has_profile(&self) -> bool {
self.has_profile
}
/// Update with one frame of noisy power `y2[k] = |Y[k]|^2` (length `nbins`).
pub fn update(&mut self, y2: &[f64]) {
debug_assert_eq!(y2.len(), self.nbins);
let cfg = self.cfg;
// Skip near-silent (zero-padding) frames before bootstrap so they do not
// corrupt the initial noise estimate. We treat a frame as padding when
// its total energy is more than 60 dB below the loudest bin seen so far
// (or, pre-bootstrap, simply when it is effectively zero).
let frame_energy: f64 = y2.iter().sum();
if !self.initialized {
if frame_energy < 1e-9 {
self.p.fill(0.0);
return;
}
for (k, &y2_k) in y2.iter().enumerate().take(self.nbins) {
let v = y2_k.max(1e-12);
self.s[k] = v;
self.s_min[k] = v;
self.s_tmp[k] = v;
self.lambda_d[k] = v;
}
self.initialized = true;
self.p.fill(0.0);
return;
}
// 1. Smoothed noisy power.
for (k, &y2_k) in y2.iter().enumerate().take(self.nbins) {
self.s[k] = cfg.alpha_s * self.s[k] + (1.0 - cfg.alpha_s) * y2_k;
}
// 2. Running minimum with exponential forgetting.
//
// s_min = min(s, s_min * gamma) where gamma > 1 lets s_min rise slowly
// to track genuine noise-level changes. When a noise PROFILE is
// available, the profile anchor caps s_min, so we use a SLOW forgetting
// rate (the anchor does the heavy lifting and s_min stays near the true
// floor → maximum SPP contrast). Without a profile, we use a FAST rate
// so s_min converges to the current noise level within ~1 s, giving
// zeta ≈ 1 for stationary noise (→ low SPP → noise attenuated) while
// staying well below any sustained signal (→ high SPP → signal kept).
let gamma_fast = if self.has_profile {
self.min_forget_slow // slow (+0.5 dB/s) when anchored
} else {
self.min_forget_fast // fast (+9 dB/s) when blind
};
let gamma_slow = self.min_forget_slow;
for k in 0..self.nbins {
let f = self.s_min[k] * gamma_fast;
self.s_min[k] = if self.s[k] < f { self.s[k] } else { f };
let g = self.s_tmp[k] * gamma_slow;
self.s_tmp[k] = if self.s[k] < g { self.s[k] } else { g };
}
// Anchor the running minima to the profile (when available): never let
// s_min rise far above a learned noise floor. This is a tighter cap
// (half the dB of the lambda_d anchor) to keep SPP sensitive to speech.
if self.has_profile {
let cap = self.anchor_ratio.sqrt();
for k in 0..self.nbins {
let m = self.profile_psd[k] * cap;
if self.s_min[k] > m {
self.s_min[k] = m;
}
if self.s_tmp[k] > m {
self.s_tmp[k] = m;
}
}
}
// 3. SPP and 4. noise-PSD update.
for (k, &y2_k) in y2.iter().enumerate().take(self.nbins) {
let denom = (cfg.b_min * self.s_min[k]).max(1e-12);
let zeta = self.s[k] / denom;
let arg = (zeta - cfg.zeta0) / cfg.sigma;
let p = if arg >= 0.0 {
1.0 / (1.0 + (-arg).exp())
} else {
let e = arg.exp();
e / (1.0 + e)
};
self.p[k] = p;
if self.adapt {
let old = self.lambda_d[k];
let a_d_eff = cfg.alpha_d + (1.0 - cfg.alpha_d) * p;
let mut new_ld = a_d_eff * old + (1.0 - a_d_eff) * y2_k;
// Upward rate limiter: limit how fast lambda_d can rise, so a
// misclassified sustained signal cannot drag the estimate up
// quickly. Downward movement is unrestricted.
let cap_up = old * self.up_ratio;
if new_ld > cap_up {
new_ld = cap_up;
}
// Profile anchor: never let lambda_d rise far above a learned
// noise profile. This is the decisive guard against long
// sustained signals being absorbed as noise.
if self.has_profile {
let anchor = self.profile_psd[k] * self.anchor_ratio;
if new_ld > anchor {
new_ld = anchor;
}
}
self.lambda_d[k] = new_ld;
}
}
if cfg.adaptive_profile && self.adapt && self.is_noise_only_frame(y2) {
// About five seconds to move 63% toward a changed stationary floor.
let alpha = 0.995;
for (profile, power) in self.profile_psd.iter_mut().zip(y2) {
if self.has_profile {
*profile = alpha * *profile + (1.0 - alpha) * power.max(1e-12);
} else {
*profile = power.max(1e-12);
}
}
self.has_profile = true;
}
}
fn is_noise_only_frame(&self, y2: &[f64]) -> bool {
let mean_speech = self.p.iter().sum::<f64>() / self.p.len().max(1) as f64;
if mean_speech >= 0.2 {
return false;
}
let arithmetic = y2.iter().sum::<f64>() / y2.len().max(1) as f64;
if arithmetic <= 1e-12 {
return false;
}
let geometric = (y2.iter().map(|power| power.max(1e-20).ln()).sum::<f64>()
/ y2.len().max(1) as f64)
.exp();
geometric / arithmetic >= 0.2
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn adaptive_profile_learns_noise_only_frames() {
let cfg = NoiseConfig {
adaptive_profile: true,
..NoiseConfig::default()
};
let mut estimator = NoiseEstimator::new(cfg, 8, 48_000, 480);
let flat_noise = vec![0.01; 8];
estimator.update(&flat_noise);
estimator.update(&flat_noise);
assert!(estimator.has_profile());
}
#[test]
fn adaptive_profile_rejects_tonal_frames() {
let cfg = NoiseConfig {
adaptive_profile: true,
..NoiseConfig::default()
};
let mut estimator = NoiseEstimator::new(cfg, 8, 48_000, 480);
let tonal = vec![1.0, 1e-20, 1e-20, 1e-20, 1e-20, 1e-20, 1e-20, 1e-20];
estimator.update(&tonal);
estimator.update(&tonal);
assert!(!estimator.has_profile());
}
}