Skip to main content

ferraus_denoise/
engine.rs

1//! The spectral gating engine shared by profile and adaptive modes. It follows the PRD's
2//! seven steps:
3//!
4//! 1. STFT with a periodic Hann window (2048 samples at 44.1/48 kHz, scaled with the
5//!    sample rate), 75% overlap, `f32`.
6//! 2. Noise statistics per bin: from the profile (mean and std of the magnitude in dB), or
7//!    in adaptive mode from a minimum-statistics tracker (Martin 2001) with no profile.
8//! 3. Threshold `mean + sensitivity × std`. The mean is put on the power scale (mean of dB
9//!    plus 2.51 dB for Gaussian noise, less for steadier noise) and the std is divided by
10//!    5.57 dB, the spread of Gaussian noise in dB (with a 2.5 dB floor so steady hum still
11//!    gets a margin). So `--sensitivity 6` puts the threshold 6 dB above the mean power of
12//!    steady hiss, and proportionally higher for noise that fluctuates more. Each bin's
13//!    level is compared with its threshold and mapped through a soft sigmoid (centred
14//!    1.5 dB above the threshold, 1.5 dB wide) to a gain between 0 dB and `-reduction`.
15//! 4. The mask is smoothed across frequency (a 3-bin morphological opening removes isolated
16//!    open bins narrower than a window main lobe, then a Hann-weighted average over
17//!    `±smoothing` bins adds soft skirts without pulling partials down) and in time (a
18//!    lookahead maximum over `attack` worth of frames, then slew limits: the gate opens
19//!    through the full range in `attack` and closes in `release`), so onsets are never
20//!    clipped.
21//! 5. `--artifact-control` blends a 3×3 (time × frequency) median of the mask into it and
22//!    fills spectral troughs up to a steady floor `-reduction` below the minimum-statistics
23//!    noise estimate. Both remove the random flicker of gated noise ("musical noise") at
24//!    the cost of a little more residual noise.
25//! 6. `--link on` computes one mask from the channel-mean power; `--preserve-transients`
26//!    detects onsets (spectral flux of the above-threshold bins, adaptive threshold, local
27//!    maximum) and holds the gate open around them with the threshold lowered to the noise
28//!    mean and instant attack.
29//! 7. Inverse STFT with overlap-add (Hann² sums to exactly 1.5 at 75% overlap). With
30//!    `--reduction 0dB` every gain is exactly 1.
31//!
32//! The engine streams: it holds a constant number of frames (input ring, lookahead queue,
33//! overlap-add accumulator) regardless of input length.
34
35use crate::profile::NoiseProfile;
36use crate::stft::{hop_for, pow_db, Fft, FLOOR_POW};
37use ferraus_core::{AudioBuffer, Result};
38use realfft::num_complex::Complex32;
39use std::collections::VecDeque;
40
41/// Mean of 10·log10 of an exponential variable is 2.507 dB below 10·log10 of its mean.
42pub const RAYLEIGH_LOG_BIAS_DB: f32 = 2.507;
43/// Standard deviation of 10·log10 of an exponential variable (Rayleigh magnitude in dB).
44pub const RAYLEIGH_STD_DB: f32 = 5.570;
45/// Width of the soft sigmoid, in dB of excess over threshold.
46const SIGMOID_WIDTH_DB: f32 = 1.5;
47/// The sigmoid is centred this far above the threshold, so bins at the threshold are
48/// already mostly attenuated and bins a few dB above it pass.
49const SIGMOID_OFFSET_DB: f32 = 1.5;
50/// Lower bound on the noise spread used for the threshold, so near-constant noise such as
51/// hum still gets a threshold a few dB above its level.
52const SPREAD_FLOOR_DB: f32 = 2.5;
53/// Bins this far above the threshold survive the frequency opening even when narrow.
54const OPENING_KEEP_DB: f32 = 6.0;
55/// Excess values are clamped (keeps the sigmoid in range).
56const EXCESS_CLAMP_DB: f32 = 40.0;
57/// The artifact-control floor may lift a bin at most this far above `-reduction`.
58const FLOOR_MAX_LIFT: f32 = 4.0; // linear (≈ +12 dB)
59/// Onset detection.
60const ONSET_MIN_FLUX_DB: f32 = 1.0;
61const ONSET_RATIO: f32 = 3.0;
62const ONSET_HOLD_S: f32 = 0.04;
63
64/// Mean-of-log bias for a noise whose dB spread is `std_db`: 2.507 dB for Gaussian noise
65/// (Rayleigh magnitudes, spread 5.57 dB), shrinking towards 0 for steady tones.
66pub fn log_bias_db(std_db: f32) -> f32 {
67    let r = (std_db / RAYLEIGH_STD_DB).min(1.0);
68    RAYLEIGH_LOG_BIAS_DB * r * r
69}
70
71/// Parameters of the gate (PRD defaults in `Default`).
72#[derive(Clone, Debug, PartialEq)]
73pub struct GateConfig {
74    pub reduction_db: f32,
75    pub sensitivity: f32,
76    pub smoothing: usize,
77    pub attack_s: f32,
78    pub release_s: f32,
79    /// 0..1
80    pub artifact_control: f32,
81    pub link: bool,
82    pub preserve_transients: bool,
83}
84
85impl Default for GateConfig {
86    fn default() -> Self {
87        GateConfig {
88            reduction_db: 12.0,
89            sensitivity: 6.0,
90            smoothing: 3,
91            attack_s: 0.020,
92            release_s: 0.120,
93            artifact_control: 0.5,
94            link: true,
95            preserve_transients: true,
96        }
97    }
98}
99
100/// Where the noise estimate comes from.
101#[derive(Clone, Debug)]
102pub enum NoiseModel {
103    Profile(Box<NoiseProfile>),
104    /// Minimum-statistics tracking with no profile. `noisiness` 0..1 raises the threshold
105    /// above the tracked floor (20 dB × noisiness); `floor_db` shifts the tracked floor.
106    Adaptive {
107        noisiness: f32,
108        floor_db: f32,
109    },
110}
111
112/// Minimum-statistics noise power tracker (after Martin 2001): smoothed periodogram,
113/// minimum over a ~1.5 s window kept as U sub-window minima, and bias compensation.
114#[derive(Clone, Debug)]
115pub struct MinStats {
116    alpha: f32,
117    smoothed: Vec<f32>,
118    sub_min: Vec<f32>,
119    ring: Vec<Vec<f32>>,
120    ring_len: usize,
121    ring_pos: usize,
122    sub_count: usize,
123    sub_len: usize,
124    bias: f32,
125    pub noise: Vec<f32>,
126    init: bool,
127}
128
129/// Bias compensation for the minimum of the smoothed periodogram (calibrated on Gaussian
130/// noise for the window and smoothing used here).
131const MINSTATS_BIAS: f32 = 2.2;
132const MINSTATS_SUBWINDOWS: usize = 8;
133
134impl MinStats {
135    /// `frame_s` is the hop duration; the window spans about 1.5 s.
136    pub fn new(bins: usize, frame_s: f32) -> MinStats {
137        let window_frames = (1.5 / frame_s).round().max(8.0) as usize;
138        let sub_len = (window_frames / MINSTATS_SUBWINDOWS).max(1);
139        // ~ 70 ms smoothing time constant
140        let alpha = (-frame_s / 0.07).exp().clamp(0.0, 0.95);
141        MinStats {
142            alpha,
143            smoothed: vec![0.0; bins],
144            sub_min: vec![f32::INFINITY; bins],
145            ring: vec![vec![f32::INFINITY; bins]; MINSTATS_SUBWINDOWS],
146            ring_len: 0,
147            ring_pos: 0,
148            sub_count: 0,
149            sub_len,
150            bias: MINSTATS_BIAS,
151            noise: vec![FLOOR_POW; bins],
152            init: false,
153        }
154    }
155
156    /// Start from a known noise power (profile mode).
157    pub fn seed(&mut self, noise_pow: &[f32]) {
158        self.smoothed.copy_from_slice(noise_pow);
159        for r in &mut self.ring {
160            for (m, n) in r.iter_mut().zip(noise_pow) {
161                *m = n / self.bias;
162            }
163        }
164        self.ring_len = self.ring.len();
165        self.sub_min.iter_mut().for_each(|m| *m = f32::INFINITY);
166        self.noise.copy_from_slice(noise_pow);
167        self.init = true;
168    }
169
170    pub fn update(&mut self, p: &[f32]) {
171        if !self.init {
172            self.smoothed.copy_from_slice(p);
173            self.init = true;
174        } else {
175            let a = self.alpha;
176            for (s, x) in self.smoothed.iter_mut().zip(p) {
177                *s = a * *s + (1.0 - a) * x;
178            }
179        }
180        for (m, s) in self.sub_min.iter_mut().zip(&self.smoothed) {
181            if *s < *m {
182                *m = *s;
183            }
184        }
185        self.sub_count += 1;
186        if self.sub_count >= self.sub_len {
187            self.ring[self.ring_pos].copy_from_slice(&self.sub_min);
188            self.ring_pos = (self.ring_pos + 1) % self.ring.len();
189            self.ring_len = (self.ring_len + 1).min(self.ring.len());
190            self.sub_min.iter_mut().for_each(|m| *m = f32::INFINITY);
191            self.sub_count = 0;
192        }
193        for k in 0..self.noise.len() {
194            let mut m = self.sub_min[k];
195            for r in self.ring.iter().take(self.ring_len) {
196                m = m.min(r[k]);
197            }
198            if !m.is_finite() {
199                m = self.smoothed[k];
200            }
201            self.noise[k] = (m * self.bias).max(FLOOR_POW);
202        }
203    }
204}
205
206struct Frame {
207    spec: Vec<Vec<Complex32>>,
208    pow: Vec<Vec<f32>>,
209    noise: Vec<Vec<f32>>,
210    raw: Vec<Vec<f32>>,
211    open: Vec<Vec<f32>>,
212    blended: Vec<Vec<f32>>,
213    flux: f32,
214    onset: bool,
215}
216
217impl Frame {
218    fn new(channels: usize, groups: usize, bins: usize) -> Frame {
219        let g = || vec![vec![0f32; bins]; groups];
220        Frame {
221            spec: vec![vec![Complex32::new(0.0, 0.0); bins]; channels],
222            pow: g(),
223            noise: g(),
224            raw: g(),
225            open: g(),
226            blended: g(),
227            flux: 0.0,
228            onset: false,
229        }
230    }
231}
232
233enum Thresholds {
234    /// Per group: main threshold, lowered (transient) threshold, in dB.
235    Profile {
236        main: Vec<Vec<f32>>,
237        open: Vec<Vec<f32>>,
238    },
239    Adaptive {
240        offset_db: f32,
241        floor_db: f32,
242    },
243}
244
245/// Statistics gathered while processing (for metrics).
246#[derive(Clone, Debug, Default)]
247pub struct EngineStats {
248    pub frames: usize,
249    pub onsets: usize,
250    /// Mean applied gain in dB over all bins and frames.
251    pub mean_gain_db: f64,
252}
253
254/// Streaming spectral gate.
255pub struct Engine {
256    cfg: GateConfig,
257    channels: usize,
258    groups: usize,
259    n: usize,
260    hop: usize,
261    bins: usize,
262    fft: Fft,
263    scale2: f32,
264    inbuf: Vec<Vec<f32>>,
265    ola: Vec<Vec<f32>>,
266    thresholds: Thresholds,
267    trackers: Vec<MinStats>,
268    queue: VecDeque<Frame>,
269    pool: Vec<Frame>,
270    blended_count: usize,
271    blend_prev: Vec<Vec<f32>>,
272    prev_level: Vec<Vec<f32>>,
273    prev_flux: f32,
274    smooth_db: Vec<Vec<f32>>,
275    flux_avg: f32,
276    flux_alpha: f32,
277    hold: usize,
278    hold_frames: usize,
279    lookahead: usize,
280    a_att: f32,
281    a_rel: f32,
282    gmin: f32,
283    kernel: Vec<f32>,
284    frame_index: usize,
285    // scratch
286    level: Vec<f32>,
287    excess: Vec<f32>,
288    scratch: Vec<f32>,
289    scratch2: Vec<f32>,
290    target: Vec<f32>,
291    gain: Vec<Vec<f32>>,
292    pub stats: EngineStats,
293    gain_sum: f64,
294}
295
296impl Engine {
297    pub fn new(cfg: GateConfig, noise: &NoiseModel, sample_rate: u32, channels: usize) -> Engine {
298        let channels = channels.max(1);
299        let n = match noise {
300            NoiseModel::Profile(p) => p.fft_size,
301            NoiseModel::Adaptive { .. } => crate::stft::fft_size_for(sample_rate),
302        };
303        let hop = hop_for(n);
304        let fft = Fft::new(n);
305        let bins = fft.bins();
306        let groups = if cfg.link { 1 } else { channels };
307        let frame_s = hop as f32 / sample_rate.max(1) as f32;
308        let mut trackers = vec![MinStats::new(bins, frame_s); groups];
309        let thresholds = match noise {
310            NoiseModel::Profile(p) => {
311                let mut main = Vec::new();
312                let mut open = Vec::new();
313                for (g, tracker) in trackers.iter_mut().enumerate() {
314                    let (mean, std) = p.stats_for(g, cfg.link, channels);
315                    let mean_pow_db: Vec<f32> = mean.iter().zip(std).map(|(m, s)| m + log_bias_db(*s)).collect();
316                    main.push(
317                        mean_pow_db
318                            .iter()
319                            .zip(std)
320                            .map(|(m, s)| m + cfg.sensitivity * s.max(SPREAD_FLOOR_DB) / RAYLEIGH_STD_DB)
321                            .collect(),
322                    );
323                    let seed: Vec<f32> = mean_pow_db.iter().map(|d| 10f32.powf(d / 10.0).max(FLOOR_POW)).collect();
324                    tracker.seed(&seed);
325                    open.push(mean_pow_db);
326                }
327                Thresholds::Profile { main, open }
328            }
329            NoiseModel::Adaptive { noisiness, floor_db } => {
330                Thresholds::Adaptive { offset_db: 20.0 * noisiness.clamp(0.0, 1.0), floor_db: *floor_db }
331            }
332        };
333        // attack/release as slew rates: the full reduction range per attack/release time
334        let slew = |t: f32| {
335            if t <= 0.0 {
336                f32::INFINITY
337            } else {
338                cfg.reduction_db.abs() * frame_s / t
339            }
340        };
341        let lookahead = ((cfg.attack_s / frame_s).ceil() as usize).clamp(1, 8);
342        let r = cfg.smoothing.min(64);
343        // Hann-shaped kernel over ±r bins (outer taps light, so peaks keep their level)
344        let kernel: Vec<f32> = (0..=2 * r)
345            .map(|i| {
346                let x = std::f32::consts::PI * 2.0 * (i + 1) as f32 / (2 * r + 2) as f32;
347                0.5 - 0.5 * x.cos()
348            })
349            .collect();
350        let flux_tau = 0.5f32;
351        Engine {
352            channels,
353            groups,
354            n,
355            hop,
356            bins,
357            scale2: fft.mag_scale * fft.mag_scale,
358            fft,
359            inbuf: vec![vec![0.0; n]; channels],
360            ola: vec![vec![0.0; n]; channels],
361            thresholds,
362            trackers,
363            queue: VecDeque::new(),
364            pool: Vec::new(),
365            blended_count: 0,
366            blend_prev: vec![vec![0.0; bins]; groups],
367            prev_level: vec![vec![f32::NEG_INFINITY; bins]; groups],
368            prev_flux: 0.0,
369            smooth_db: vec![vec![-cfg.reduction_db.abs(); bins]; groups],
370            flux_avg: 0.0,
371            flux_alpha: (-frame_s / flux_tau).exp(),
372            hold: 0,
373            hold_frames: (ONSET_HOLD_S / frame_s).ceil() as usize,
374            lookahead,
375            a_att: slew(cfg.attack_s),
376            a_rel: slew(cfg.release_s),
377            gmin: 10f32.powf(-cfg.reduction_db.abs() / 20.0),
378            kernel,
379            frame_index: 0,
380            level: vec![0.0; bins],
381            excess: vec![0.0; bins],
382            scratch: vec![0.0; bins],
383            scratch2: vec![0.0; bins],
384            target: vec![0.0; bins],
385            gain: vec![vec![1.0; bins]; groups],
386            stats: EngineStats::default(),
387            gain_sum: 0.0,
388            cfg,
389        }
390    }
391
392    pub fn fft_size(&self) -> usize {
393        self.n
394    }
395
396    pub fn hop(&self) -> usize {
397        self.hop
398    }
399
400    /// Samples of output to discard at the start (the output stream starts `n - hop`
401    /// samples before the input).
402    pub fn latency(&self) -> usize {
403        self.n - self.hop
404    }
405
406    /// Push `hop` samples per channel; appends any completed output samples to `out`.
407    pub fn push_hop(&mut self, input: &[&[f32]], out: &mut [Vec<f32>]) {
408        let (n, hop) = (self.n, self.hop);
409        for c in 0..self.channels {
410            let buf = &mut self.inbuf[c];
411            buf.copy_within(hop.., 0);
412            let src = input[c.min(input.len() - 1)];
413            buf[n - hop..].copy_from_slice(&src[..hop]);
414        }
415        let mut frame = self.pool.pop().unwrap_or_else(|| Frame::new(self.channels, self.groups, self.bins));
416        self.analyze(&mut frame);
417        self.queue.push_back(frame);
418        // stage B: blend (median) the frame before the newest
419        let len = self.queue.len();
420        if len >= 2 && self.blended_count < len - 1 {
421            self.blend(len - 2);
422            self.blended_count = len - 1;
423        }
424        // stage C: output when the lookahead window is blended
425        if self.blended_count > self.lookahead {
426            self.output_front(out);
427        }
428    }
429
430    fn analyze(&mut self, frame: &mut Frame) {
431        let idx = self.frame_index;
432        self.frame_index += 1;
433        let full = idx + 1 >= self.n / self.hop; // frame contains no initial zero padding
434        for c in 0..self.channels {
435            self.fft.forward(&self.inbuf[c], &mut frame.spec[c]);
436        }
437        let scale2 = self.scale2;
438        let bins = self.bins;
439        let rdb = self.cfg.reduction_db.abs();
440        let mut max_flux = 0f32;
441        for g in 0..self.groups {
442            // group power
443            let pow = &mut frame.pow[g];
444            if self.cfg.link {
445                pow.iter_mut().for_each(|p| *p = 0.0);
446                let inv = 1.0 / self.channels as f32;
447                for c in 0..self.channels {
448                    for (p, z) in pow.iter_mut().zip(&frame.spec[c]) {
449                        *p += z.norm_sqr() * scale2 * inv;
450                    }
451                }
452            } else {
453                for (p, z) in pow.iter_mut().zip(&frame.spec[g]) {
454                    *p = z.norm_sqr() * scale2;
455                }
456            }
457            // detector level: the bin's own power in dB (same statistic as the profile)
458            for (l, p) in self.level.iter_mut().zip(pow.iter()) {
459                *l = pow_db(*p);
460            }
461            if full {
462                self.trackers[g].update(pow);
463            }
464            frame.noise[g].copy_from_slice(&self.trackers[g].noise);
465            // thresholds → raw gains
466            let (main_off, open_off) = match &self.thresholds {
467                Thresholds::Profile { .. } => (0.0, 0.0),
468                Thresholds::Adaptive { offset_db, floor_db } => (offset_db + floor_db, *floor_db),
469            };
470            // main gate
471            for k in 0..bins {
472                let thr = match &self.thresholds {
473                    Thresholds::Profile { main, .. } => main[g][k],
474                    Thresholds::Adaptive { .. } => pow_db(frame.noise[g][k]) + main_off,
475                };
476                self.excess[k] = (self.level[k] - thr).clamp(-EXCESS_CLAMP_DB, EXCESS_CLAMP_DB);
477            }
478            // spectral flux of above-threshold bins (onset detector)
479            let mut flux = 0f32;
480            if full {
481                let prev = &self.prev_level[g];
482                for ((e, l), p) in self.excess.iter().zip(&self.level).zip(prev) {
483                    if *e > 0.0 && p.is_finite() {
484                        flux += (l - p).max(0.0);
485                    }
486                }
487                flux /= bins as f32;
488            }
489            self.prev_level[g].copy_from_slice(&self.level);
490            max_flux = max_flux.max(flux);
491            smooth_to_gain(&self.excess, &self.kernel, rdb, &mut frame.raw[g], &mut self.scratch, &mut self.scratch2);
492            if self.cfg.preserve_transients {
493                for k in 0..bins {
494                    let thr = match &self.thresholds {
495                        Thresholds::Profile { open, .. } => open[g][k],
496                        Thresholds::Adaptive { .. } => pow_db(frame.noise[g][k]) + open_off,
497                    };
498                    self.excess[k] = (self.level[k] - thr).clamp(-EXCESS_CLAMP_DB, EXCESS_CLAMP_DB);
499                }
500                smooth_to_gain(
501                    &self.excess,
502                    &self.kernel,
503                    rdb,
504                    &mut frame.open[g],
505                    &mut self.scratch,
506                    &mut self.scratch2,
507                );
508            }
509        }
510        frame.flux = max_flux;
511        frame.onset = false;
512    }
513
514    /// Median-blend frame `i` (needs frames i-1 via `blend_prev` and i+1 in the queue) and
515    /// confirm onsets as local maxima of the flux.
516    fn blend(&mut self, i: usize) {
517        let alpha = self.cfg.artifact_control.clamp(0.0, 1.0);
518        let bins = self.bins;
519        let (head, tail) = self.queue.make_contiguous().split_at_mut(i + 1);
520        let cur = &mut head[i];
521        let next = &tail[0];
522        for g in 0..self.groups {
523            let prev = &self.blend_prev[g];
524            let raw = &cur.raw[g];
525            let nxt = &next.raw[g];
526            let out = &mut cur.blended[g];
527            if alpha <= 0.0 {
528                out.copy_from_slice(raw);
529            } else {
530                for k in 0..bins {
531                    let lo = k.saturating_sub(1);
532                    let hi = (k + 1).min(bins - 1);
533                    let mut v = [0f32; 9];
534                    let mut m = 0;
535                    for j in lo..=hi {
536                        v[m] = prev[j];
537                        v[m + 1] = raw[j];
538                        v[m + 2] = nxt[j];
539                        m += 3;
540                    }
541                    let med = median(&mut v[..m]);
542                    out[k] = raw[k] + alpha * (med - raw[k]);
543                }
544            }
545            self.blend_prev[g].copy_from_slice(&cur.raw[g]);
546        }
547        // onset: flux above an adaptive threshold and a local maximum
548        if self.cfg.preserve_transients {
549            let f = cur.flux;
550            let thr = (ONSET_RATIO * self.flux_avg).max(ONSET_MIN_FLUX_DB);
551            cur.onset = f > thr && f >= self.prev_flux && f > next.flux;
552            self.prev_flux = f;
553            self.flux_avg = self.flux_alpha * self.flux_avg + (1.0 - self.flux_alpha) * f;
554        }
555    }
556
557    fn output_front(&mut self, out: &mut [Vec<f32>]) {
558        let la = self.lookahead;
559        let bins = self.bins;
560        let transient_ahead = self.queue.iter().take(la + 1).any(|f| f.onset);
561        if self.queue[0].onset {
562            self.hold = self.hold_frames;
563            self.stats.onsets += 1;
564        }
565        let transient = self.cfg.preserve_transients && (transient_ahead || self.hold > 0);
566        self.hold = self.hold.saturating_sub(1);
567        let alpha = self.cfg.artifact_control.clamp(0.0, 1.0);
568        let gmin = self.gmin;
569        let floor_cap = (gmin * FLOOR_MAX_LIFT).min(1.0);
570        let mut target = std::mem::take(&mut self.target);
571        for g in 0..self.groups {
572            // lookahead maximum of the blended mask
573            target.copy_from_slice(&self.queue[0].blended[g]);
574            for f in self.queue.iter().skip(1).take(la) {
575                for (t, b) in target.iter_mut().zip(&f.blended[g]) {
576                    if *b > *t {
577                        *t = *b;
578                    }
579                }
580            }
581            let f0 = &self.queue[0];
582            let smooth = &mut self.smooth_db[g];
583            let gain = &mut self.gain[g];
584            for k in 0..bins {
585                let target = target[k];
586                let s = smooth[k];
587                let ns = if transient {
588                    let t = target.max(f0.open[g][k]);
589                    if t > s {
590                        t
591                    } else {
592                        t.max(s - self.a_rel)
593                    }
594                } else if target > s {
595                    target.min(s + self.a_att)
596                } else {
597                    target.max(s - self.a_rel)
598                };
599                smooth[k] = ns;
600                let mut gl = if ns == 0.0 { 1.0 } else { 10f32.powf(ns / 20.0) };
601                if alpha > 0.0 {
602                    let p = f0.pow[g][k].max(FLOOR_POW);
603                    let gf = (gmin * (f0.noise[g][k] / p).sqrt()).min(floor_cap);
604                    if gf > gl {
605                        gl += alpha * (gf - gl);
606                    }
607                }
608                gain[k] = gl;
609                self.gain_sum += (20.0 * gl.max(1e-6).log10()) as f64;
610            }
611        }
612        self.target = target;
613        self.stats.frames += 1;
614        let mut frame = self.queue.pop_front().expect("queue not empty");
615        self.blended_count -= 1;
616        let (n, hop) = (self.n, self.hop);
617        for (c, (spec, o)) in frame.spec.iter_mut().zip(out.iter_mut()).enumerate() {
618            let g = if self.cfg.link { 0 } else { c };
619            for (z, gl) in spec.iter_mut().zip(&self.gain[g]) {
620                *z *= *gl;
621            }
622            let t = self.fft.inverse_windowed(spec);
623            let acc = &mut self.ola[c];
624            for (a, x) in acc.iter_mut().zip(t) {
625                *a += x;
626            }
627            o.extend_from_slice(&acc[..hop]);
628            acc.copy_within(hop.., 0);
629            acc[n - hop..].iter_mut().for_each(|v| *v = 0.0);
630        }
631        self.pool.push(frame);
632    }
633
634    /// Process a whole buffer in hop-sized chunks (constant engine memory). `progress` is
635    /// called periodically with the fraction done and may abort by returning an error.
636    pub fn process_buffer(
637        &mut self,
638        buf: &AudioBuffer,
639        progress: &mut dyn FnMut(f64) -> Result<()>,
640    ) -> Result<AudioBuffer> {
641        let total = buf.frames();
642        let ch = self.channels;
643        let hop = self.hop;
644        let skip = self.latency();
645        let mut out: Vec<Vec<f32>> = (0..ch).map(|_| Vec::with_capacity(total + self.n)).collect();
646        let mut chunk = vec![vec![0f32; hop]; ch];
647        let mut pos = 0usize;
648        let mut tick = 0usize;
649        while out[0].len() < skip + total {
650            for (c, dst) in chunk.iter_mut().enumerate() {
651                let src = &buf.data[c.min(buf.channels() - 1)];
652                for (i, d) in dst.iter_mut().enumerate() {
653                    *d = src.get(pos + i).copied().unwrap_or(0.0);
654                }
655            }
656            pos += hop;
657            let refs: Vec<&[f32]> = chunk.iter().map(|c| c.as_slice()).collect();
658            self.push_hop(&refs, &mut out);
659            tick += 1;
660            if tick.is_multiple_of(256) {
661                progress((pos as f64 / total.max(1) as f64).min(1.0))?;
662            }
663        }
664        if self.stats.frames > 0 {
665            self.stats.mean_gain_db = self.gain_sum / (self.stats.frames * self.bins * self.groups) as f64;
666        }
667        let data = out
668            .into_iter()
669            .map(|mut c| {
670                c.drain(..skip);
671                c.truncate(total);
672                c
673            })
674            .collect();
675        Ok(AudioBuffer::from_planar(buf.sample_rate, data))
676    }
677}
678
679/// Map `excess` through the soft sigmoid to a gain between `-reduction` and 0 dB, then
680/// smooth the mask across frequency:
681///
682/// * a morphological opening over 3 bins removes isolated open bins narrower than a
683///   window main lobe (random noise peaks, the seeds of musical noise) while leaving real
684///   partials, which are at least a main lobe wide, untouched;
685/// * a Hann-weighted average over `±smoothing` bins then lifts the bins next to open
686///   regions (soft skirts instead of hard spectral edges); it never pulls an open bin down,
687///   so partials keep their level.
688fn smooth_to_gain(
689    excess: &[f32],
690    kernel: &[f32],
691    reduction_db: f32,
692    out: &mut [f32],
693    lin: &mut [f32],
694    tmp: &mut [f32],
695) {
696    let bins = excess.len();
697    let r = kernel.len() / 2;
698    if reduction_db == 0.0 {
699        out.iter_mut().for_each(|g| *g = 0.0);
700        return;
701    }
702    let to_lin = std::f32::consts::LN_10 / 20.0;
703    for (l, e) in lin.iter_mut().zip(excess) {
704        let sig = 1.0 / (1.0 + (-(e - SIGMOID_OFFSET_DB) / SIGMOID_WIDTH_DB).exp());
705        *l = (-reduction_db * (1.0 - sig) * to_lin).exp();
706    }
707    if r == 0 {
708        for (o, l) in out.iter_mut().zip(lin.iter()) {
709            *o = 20.0 * l.max(1e-9).log10();
710        }
711        return;
712    }
713    // opening = erosion then dilation, 3-bin structuring element; bins well above the
714    // threshold are kept as they are (noise peaks never get there, weak narrow partials do)
715    for (k, t) in tmp.iter_mut().enumerate() {
716        let lo = k.saturating_sub(1);
717        let hi = (k + 1).min(bins - 1);
718        *t = lin[lo..=hi].iter().copied().fold(f32::INFINITY, f32::min);
719    }
720    for (k, o) in out.iter_mut().enumerate() {
721        let lo = k.saturating_sub(1);
722        let hi = (k + 1).min(bins - 1);
723        *o = tmp[lo..=hi].iter().copied().fold(0.0, f32::max);
724    }
725    for ((t, e), (l, o)) in tmp.iter_mut().zip(excess).zip(lin.iter().zip(out.iter())) {
726        *t = if *e > OPENING_KEEP_DB { *l } else { *o };
727    }
728    for k in 0..bins {
729        let lo = k.saturating_sub(r);
730        let hi = (k + r).min(bins - 1);
731        let mut acc = 0f32;
732        let mut wsum = 0f32;
733        for (j, g) in tmp.iter().enumerate().take(hi + 1).skip(lo) {
734            let w = kernel[j + r - k];
735            acc += w * g;
736            wsum += w;
737        }
738        out[k] = 20.0 * (acc / wsum).max(tmp[k]).max(1e-9).log10();
739    }
740}
741
742fn median(v: &mut [f32]) -> f32 {
743    v.sort_unstable_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
744    let m = v.len();
745    if m % 2 == 1 {
746        v[m / 2]
747    } else {
748        0.5 * (v[m / 2 - 1] + v[m / 2])
749    }
750}
751
752#[cfg(test)]
753mod tests {
754    use super::*;
755    use ferraus_core::testing::{noise, sine};
756
757    #[test]
758    fn unity_gain_reconstructs() {
759        let x = ferraus_core::testing::add(&sine(48_000, 440.0, 0.5, 0.5), &noise(48_000, 0.5, 0.1, 3));
760        let cfg = GateConfig { reduction_db: 0.0, ..Default::default() };
761        let mut e = Engine::new(cfg, &NoiseModel::Adaptive { noisiness: 0.3, floor_db: 0.0 }, 48_000, 1);
762        let y = e.process_buffer(&x, &mut |_| Ok(())).unwrap();
763        assert_eq!(y.frames(), x.frames());
764        let err = ferraus_core::testing::rms_diff(&x, &y);
765        assert!(20.0 * err.log10() < -120.0, "error {} dB", 20.0 * err.log10());
766    }
767
768    #[test]
769    fn minstats_tracks_white_noise_power() {
770        let sr = 48_000;
771        let x = noise(sr, 3.0, 0.3, 9);
772        let n = 2048;
773        let mut fft = Fft::new(n);
774        let bins = fft.bins();
775        let mut ms = MinStats::new(bins, 512.0 / sr as f32);
776        let mut spec = vec![Complex32::new(0.0, 0.0); bins];
777        let mut p = vec![0f32; bins];
778        let mut true_mean = vec![0f64; bins];
779        let mut frames = 0;
780        let mut s = 0;
781        while s + n <= x.frames() {
782            fft.forward(&x.data[0][s..s + n], &mut spec);
783            for (pp, z) in p.iter_mut().zip(&spec) {
784                *pp = z.norm_sqr() * fft.mag_scale * fft.mag_scale;
785            }
786            for (t, v) in true_mean.iter_mut().zip(&p) {
787                *t += *v as f64;
788            }
789            frames += 1;
790            ms.update(&p);
791            s += 512;
792        }
793        let est: f64 = ms.noise[20..1000].iter().map(|v| *v as f64).sum::<f64>();
794        let tru: f64 = true_mean[20..1000].iter().sum::<f64>() / frames as f64;
795        let ratio_db = 10.0 * (est / tru).log10();
796        assert!(ratio_db.abs() < 1.5, "min-stats bias {ratio_db:.2} dB");
797    }
798}