gemini_adk_fluent_rs/voice/dsp/
stft.rs

1//! STFT engine with WOLA (weighted overlap-add) processing.
2//!
3//! This module provides a spectral processing framework using short-time Fourier
4//! transform with carefully designed windowing to maintain perfect reconstruction.
5//!
6//! # Design: WOLA (Weighted Overlap-Add)
7//!
8//! - **Window**: 20 ms at 16 kHz = 320 samples, hop = 10 ms = 160 samples (50% overlap)
9//! - **Window function**: sqrt-Hann applied on BOTH analysis and synthesis
10//! - **COLA invariant**: When squared windows overlap-add at 50% offset, the sum is
11//!   constant (1.0) everywhere, guaranteeing perfect reconstruction for spectral stages
12//!   that do not modify phase or change the FFT size. After overlap-add and normalization,
13//!   the output exactly reconstructs the input (delayed by window_len).
14//! - **Normalization**: The COLA sum of squared windows is approximately 1.0 at every
15//!   sample position due to 50% overlap and sqrt-Hann windowing. A small normalization
16//!   factor accounts for hop boundaries; we divide each output sample by the sum of
17//!   squared window values at that position.
18//!
19//! # Latency
20//!
21//! The STFT introduces exactly one window length of latency. At construction,
22//! the output FIFO is primed with window_len zeros so that output length always
23//! equals input length.
24//!
25//! # Arbitrary Input Block Sizes
26//!
27//! The STFT accepts blocks of any length via a FIFO input buffer. It processes
28//! every complete hop (forward FFT of the last window, spectral stage, inverse FFT,
29//! overlap-add into the output accumulator), emitting exactly as many output samples
30//! as input samples arrived.
31
32use std::collections::VecDeque;
33
34use realfft::RealFftPlanner;
35use realfft::num_complex::Complex;
36
37use super::{AudioBus, DspStage};
38
39/// Trait for spectral processing stages: one call per hop, operating on
40/// the one-sided FFT spectrum (bins 0..n/2+1) with magnitude and phase.
41pub trait SpectralStage: Send {
42    /// Short stable name for this stage.
43    fn name(&self) -> &'static str;
44
45    /// Process one hop's spectrum in-place.
46    ///
47    /// # Arguments
48    /// - `bins`: One-sided complex spectrum of length n/2+1
49    /// - `bin_hz`: Frequency resolution in Hz (sample_rate / window_len)
50    fn process_spectrum(&mut self, bins: &mut [Complex<f32>], bin_hz: f32);
51}
52
53/// Identity spectral stage: passes the spectrum through unchanged.
54/// Used for testing and latency measurement.
55pub struct Identity;
56
57impl SpectralStage for Identity {
58    fn name(&self) -> &'static str {
59        "stft_identity"
60    }
61
62    fn process_spectrum(&mut self, _bins: &mut [Complex<f32>], _bin_hz: f32) {
63        // No-op
64    }
65}
66
67/// Per-bin spectral floor tracker with magnitude-domain spectral subtraction.
68/// Tracks a slow per-bin minimum-following floor estimate using EWMA with
69/// fast-down/slow-up asymmetry, then subtracts the floor from each bin.
70pub struct SpectralFloor {
71    /// Per-bin magnitude-domain floor estimate.
72    floors: Vec<f32>,
73    /// Floor subtraction multiplier (default 1.5).
74    alpha: f32,
75    /// Spectral floor guard factor (default 0.15).
76    beta: f32,
77    /// EWMA coefficient for floor when magnitude > floor (slow up).
78    ewma_up: f32,
79    /// EWMA coefficient for floor when magnitude < floor (fast down).
80    ewma_down: f32,
81}
82
83impl SpectralFloor {
84    /// Create with default speech settings: alpha=1.5, beta=0.15.
85    pub fn default_speech() -> Self {
86        Self {
87            floors: Vec::new(),
88            alpha: 1.5,
89            beta: 0.15,
90            ewma_up: 0.02,  // slow up (98% memory)
91            ewma_down: 0.2, // fast down (80% memory)
92        }
93    }
94}
95
96impl SpectralStage for SpectralFloor {
97    fn name(&self) -> &'static str {
98        "stft_spectral_floor"
99    }
100
101    fn process_spectrum(&mut self, bins: &mut [Complex<f32>], _bin_hz: f32) {
102        // Initialize floors to the first frame's magnitudes: adapting up
103        // from zero would let the outlier guard below freeze the floor at
104        // zero forever.
105        if self.floors.is_empty() {
106            self.floors = bins.iter().map(|b| b.norm()).collect();
107        }
108
109        for (bin, floor) in bins.iter_mut().zip(self.floors.iter_mut()) {
110            let mag = bin.norm();
111
112            // Decision-directed adaptation: a bin far above its floor is
113            // speech or a tone, not noise — raising the floor on it would
114            // teach the estimator to erase sustained voiced sounds. Only
115            // adapt upward on plausible noise (mag within 4x the floor);
116            // always adapt down fast so pauses re-anchor the estimate.
117            if mag < *floor {
118                *floor = self.ewma_down * mag + (1.0 - self.ewma_down) * *floor;
119            } else if mag < 4.0 * *floor {
120                *floor = self.ewma_up * mag + (1.0 - self.ewma_up) * *floor;
121            }
122
123            // Apply spectral subtraction: subtract alpha * floor from magnitude,
124            // but never go below beta * input magnitude (musical noise guard).
125            let subtracted = (mag - self.alpha * *floor).max(self.beta * mag);
126
127            // Preserve phase.
128            if mag > 1e-8 {
129                let scale = subtracted / mag;
130                bin.re *= scale;
131                bin.im *= scale;
132            } else {
133                bin.re = 0.0;
134                bin.im = 0.0;
135            }
136        }
137    }
138}
139
140/// STFT processor implementing the [`DspStage`] contract.
141///
142/// Accepts arbitrary input block sizes, buffers in an input FIFO,
143/// processes one hop at a time with a SLIDING analysis frame (each hop
144/// consumes `hop_len` new samples and re-uses the previous half — the
145/// overlap in "weighted overlap-add"), and emits exactly as many output
146/// samples as arrived. sqrt-Hann is applied on analysis and synthesis;
147/// with the periodic window at 50% overlap the squared windows sum to
148/// exactly 1 (COLA), so an identity spectral stage reconstructs the
149/// input delayed by one window length.
150pub struct Stft<S: SpectralStage> {
151    stage: S,
152    sample_rate: u32,
153    window_len: usize,
154    hop_len: usize,
155
156    fft: std::sync::Arc<dyn realfft::RealToComplex<f32>>,
157    ifft: std::sync::Arc<dyn realfft::ComplexToReal<f32>>,
158    spectrum: Vec<Complex<f32>>,
159    window_sqrt_hann: Vec<f32>,
160
161    input_fifo: VecDeque<f32>,
162    output_fifo: VecDeque<f32>,
163
164    /// Sliding analysis frame (last `window_len` input samples).
165    frame: Vec<f32>,
166    /// Windowed copy handed to the (in-place-destructive) forward FFT.
167    windowed: Vec<f32>,
168    /// IFFT output scratch.
169    synth: Vec<f32>,
170    /// Overlap-add accumulator for the next `window_len` output samples.
171    acc: Vec<f32>,
172    /// Input samples consumed so far (first window needs a full frame).
173    frame_filled: bool,
174}
175
176impl<S: SpectralStage> Stft<S> {
177    /// Create a new STFT processor: window 20 ms at `sample_rate`, hop
178    /// 10 ms (50% overlap). A sample-rate change re-initializes (flushes).
179    pub fn new(stage: S, sample_rate: u32) -> Self {
180        let window_len = (sample_rate as usize * 20) / 1000;
181        let hop_len = window_len / 2;
182        let mut planner = RealFftPlanner::new();
183        let fft = planner.plan_fft_forward(window_len);
184        let ifft = planner.plan_fft_inverse(window_len);
185        let mut output_fifo = VecDeque::with_capacity(window_len * 4);
186        // Prime with one window of zeros: the engine's declared latency.
187        output_fifo.extend(std::iter::repeat_n(0.0f32, window_len));
188        Self {
189            stage,
190            sample_rate,
191            window_len,
192            hop_len,
193            fft,
194            ifft,
195            spectrum: vec![Complex::new(0.0, 0.0); window_len / 2 + 1],
196            window_sqrt_hann: Self::make_sqrt_hann_window(window_len),
197            input_fifo: VecDeque::with_capacity(window_len * 4),
198            output_fifo,
199            frame: vec![0.0; window_len],
200            windowed: vec![0.0; window_len],
201            synth: vec![0.0; window_len],
202            acc: vec![0.0; window_len],
203            frame_filled: false,
204        }
205    }
206
207    /// Periodic sqrt-Hann: `hann(n) + hann(n + N/2) == 1` exactly, which
208    /// is the COLA condition that makes analysis*synthesis reconstruct.
209    fn make_sqrt_hann_window(len: usize) -> Vec<f32> {
210        (0..len)
211            .map(|i| {
212                let hann = 0.5 * (1.0 - (2.0 * std::f32::consts::PI * i as f32 / len as f32).cos());
213                hann.sqrt()
214            })
215            .collect()
216    }
217
218    /// Samples the input FIFO must hold before the next hop can run.
219    fn need(&self) -> usize {
220        if self.frame_filled {
221            self.hop_len
222        } else {
223            self.window_len
224        }
225    }
226
227    /// Run one hop: slide the frame, FFT, spectral stage, IFFT (normalized
228    /// — realfft's round trip scales by N), synthesis window, overlap-add,
229    /// emit the `hop_len` samples that are now fully summed.
230    fn process_one_hop(&mut self) {
231        let (w, h) = (self.window_len, self.hop_len);
232        if self.frame_filled {
233            self.frame.copy_within(h.., 0);
234            for slot in self.frame[w - h..].iter_mut() {
235                *slot = self.input_fifo.pop_front().unwrap_or(0.0);
236            }
237        } else {
238            for slot in self.frame.iter_mut() {
239                *slot = self.input_fifo.pop_front().unwrap_or(0.0);
240            }
241            self.frame_filled = true;
242        }
243
244        for ((dst, &src), &win) in self
245            .windowed
246            .iter_mut()
247            .zip(&self.frame)
248            .zip(&self.window_sqrt_hann)
249        {
250            *dst = src * win;
251        }
252        self.fft
253            .process(&mut self.windowed, &mut self.spectrum)
254            .expect("forward FFT");
255
256        let bin_hz = self.sample_rate as f32 / w as f32;
257        self.stage.process_spectrum(&mut self.spectrum, bin_hz);
258
259        self.ifft
260            .process(&mut self.spectrum, &mut self.synth)
261            .expect("inverse FFT");
262        let norm = 1.0 / w as f32;
263        for ((acc, &syn), &win) in self
264            .acc
265            .iter_mut()
266            .zip(&self.synth)
267            .zip(&self.window_sqrt_hann)
268        {
269            *acc += syn * norm * win;
270        }
271
272        // The first hop of the accumulator is fully summed: emit and slide.
273        for i in 0..h {
274            self.output_fifo.push_back(self.acc[i]);
275        }
276        self.acc.copy_within(h.., 0);
277        for slot in self.acc[w - h..].iter_mut() {
278            *slot = 0.0;
279        }
280    }
281
282    /// Re-initialize for a new sample rate, keeping the spectral stage.
283    fn reinit(&mut self, sample_rate: u32) {
284        let window_len = (sample_rate as usize * 20) / 1000;
285        let hop_len = window_len / 2;
286        let mut planner = RealFftPlanner::new();
287        self.fft = planner.plan_fft_forward(window_len);
288        self.ifft = planner.plan_fft_inverse(window_len);
289        self.spectrum = vec![Complex::new(0.0, 0.0); window_len / 2 + 1];
290        self.window_sqrt_hann = Self::make_sqrt_hann_window(window_len);
291        self.input_fifo.clear();
292        self.output_fifo.clear();
293        self.output_fifo
294            .extend(std::iter::repeat_n(0.0f32, window_len));
295        self.frame = vec![0.0; window_len];
296        self.windowed = vec![0.0; window_len];
297        self.synth = vec![0.0; window_len];
298        self.acc = vec![0.0; window_len];
299        self.frame_filled = false;
300        self.sample_rate = sample_rate;
301        self.window_len = window_len;
302        self.hop_len = hop_len;
303    }
304}
305
306impl<S: SpectralStage> DspStage for Stft<S> {
307    fn name(&self) -> &'static str {
308        "stft"
309    }
310
311    fn process(&mut self, bus: &mut AudioBus) {
312        if bus.sample_rate != self.sample_rate {
313            self.reinit(bus.sample_rate);
314        }
315        let input_len = bus.samples.len();
316        self.input_fifo.extend(bus.samples.iter().copied());
317
318        while self.input_fifo.len() >= self.need() {
319            self.process_one_hop();
320        }
321
322        bus.samples.clear();
323        for _ in 0..input_len {
324            bus.samples
325                .push(self.output_fifo.pop_front().unwrap_or(0.0));
326        }
327    }
328
329    fn latency_samples(&self) -> usize {
330        self.window_len
331    }
332}
333
334#[cfg(test)]
335#[allow(
336    clippy::needless_range_loop,
337    reason = "sample-index loops read naturally in DSP tests"
338)]
339mod tests {
340    use super::*;
341
342    #[test]
343    fn output_length_tracks_input_length() {
344        let mut stft = Stft::new(Identity, 16_000);
345        let mut samples = vec![0.1; 160];
346        let mut bus = AudioBus {
347            samples: &mut samples,
348            sample_rate: 16_000,
349        };
350        let input_len = bus.samples.len();
351        stft.process(&mut bus);
352        assert_eq!(bus.samples.len(), input_len);
353
354        samples = vec![0.05; 73];
355        let mut bus = AudioBus {
356            samples: &mut samples,
357            sample_rate: 16_000,
358        };
359        let input_len = bus.samples.len();
360        stft.process(&mut bus);
361        assert_eq!(bus.samples.len(), input_len);
362    }
363
364    #[test]
365    fn latency_is_window_length() {
366        let stft = Stft::new(Identity, 16_000);
367        assert_eq!(stft.latency_samples(), 320);
368    }
369
370    #[test]
371    fn dc_and_nyquist_bins_survive_identity() {
372        let mut identity = Identity;
373        let mut bins = vec![
374            Complex::new(1.0, 0.0),
375            Complex::new(0.5, 0.3),
376            Complex::new(0.2, 0.0),
377        ];
378        identity.process_spectrum(&mut bins, 50.0);
379        assert!(bins[0].re > 0.9);
380        assert!(bins[0].im.abs() < 0.01);
381    }
382
383    #[test]
384
385    fn identity_reconstructs_within_60db() {
386        let sample_rate = 16_000u32;
387        let duration = 0.5;
388        let total_samples = (sample_rate as f64 * duration) as usize;
389
390        let mut signal = vec![0.0; total_samples];
391        for i in 0..total_samples {
392            let t = i as f64 / sample_rate as f64;
393            let sine_440 = (2.0 * std::f64::consts::PI * 440.0 * t).sin();
394            let sine_1300 = (2.0 * std::f64::consts::PI * 1300.0 * t).sin();
395            signal[i] = (sine_440 + sine_1300) as f32 * 0.5;
396        }
397
398        let mut stft = Stft::new(Identity, sample_rate);
399
400        let mut output: Vec<f32> = Vec::new();
401        let chunk_sizes = [160, 73, 512, 41];
402        let mut chunk_idx = 0;
403        let mut pos = 0;
404
405        while pos < signal.len() {
406            let size = chunk_sizes[chunk_idx % chunk_sizes.len()].min(signal.len() - pos);
407            chunk_idx += 1;
408
409            let mut chunk = signal[pos..pos + size].to_vec();
410            let mut bus = AudioBus {
411                samples: &mut chunk,
412                sample_rate,
413            };
414            stft.process(&mut bus);
415            output.extend(bus.samples.iter());
416            pos += size;
417        }
418
419        let window_len = 320;
420        // Steady state: skip the primed latency AND the first analysis
421        // window's half-windowed warm-up ramp (inherent to WOLA).
422        let skip = 2 * window_len;
423        if output.len() <= skip {
424            panic!("Output too short");
425        }
426        let output_delayed = &output[skip..];
427        let signal_ref = &signal[skip - window_len..skip - window_len + output_delayed.len()];
428
429        let mut error_sq = 0.0f64;
430        for i in 0..output_delayed.len() {
431            let diff = output_delayed[i] as f64 - signal_ref[i] as f64;
432            error_sq += diff * diff;
433        }
434        let error_rms = (error_sq / output_delayed.len() as f64).sqrt();
435
436        let mut signal_sq = 0.0f64;
437        for &s in signal_ref {
438            signal_sq += s as f64 * s as f64;
439        }
440        let signal_rms = (signal_sq / signal_ref.len() as f64).sqrt();
441
442        let snr_db = 20.0 * (signal_rms / error_rms).log10();
443        eprintln!("SNR: {snr_db:.1} dB");
444        assert!(snr_db > 60.0, "SNR {snr_db:.1} dB is not > 60 dB");
445    }
446
447    #[test]
448    fn spectral_floor_attenuates_stationary_noise() {
449        let sample_rate = 16_000u32;
450        let noise_duration = 1.0;
451        let noise_samples = (sample_rate as f64 * noise_duration) as usize;
452
453        let mut rng = 0u64;
454        let mut noise_signal = vec![0.0; noise_samples];
455        for i in 0..noise_samples {
456            rng = rng.wrapping_mul(1103515245).wrapping_add(12345);
457            let u = ((rng >> 16) & 0x7fff) as f32 / 32768.0;
458            noise_signal[i] = (u - 0.5) * 2.0 * 0.05;
459        }
460
461        let noise_segment_start = (noise_samples as f64 * 0.6) as usize;
462        let noise_segment_end = (noise_samples as f64 * 0.9) as usize;
463        let mut noise_input_sq = 0.0f64;
464        for i in noise_segment_start..noise_segment_end {
465            noise_input_sq += noise_signal[i] as f64 * noise_signal[i] as f64;
466        }
467        let noise_input_rms =
468            (noise_input_sq / (noise_segment_end - noise_segment_start) as f64).sqrt();
469
470        let mut stft = Stft::new(SpectralFloor::default_speech(), sample_rate);
471        let mut output: Vec<f32> = Vec::new();
472        let chunk_size = 160;
473        for chunk in noise_signal.chunks(chunk_size) {
474            let mut chunk = chunk.to_vec();
475            let mut bus = AudioBus {
476                samples: &mut chunk,
477                sample_rate,
478            };
479            stft.process(&mut bus);
480            output.extend(bus.samples.iter());
481        }
482
483        let window_len = 320;
484        let output_segment_start = noise_segment_start + window_len;
485        let output_segment_end = noise_segment_end + window_len;
486        let mut noise_output_sq = 0.0f64;
487        for i in output_segment_start..output_segment_end.min(output.len()) {
488            noise_output_sq += output[i] as f64 * output[i] as f64;
489        }
490        let noise_output_rms =
491            (noise_output_sq / (output_segment_end - output_segment_start) as f64).sqrt();
492
493        let attenuation_db = 20.0 * (noise_input_rms / (noise_output_rms + 1e-10)).log10();
494        eprintln!("Noise attenuation: {attenuation_db:.1} dB");
495        assert!(
496            attenuation_db > 6.0,
497            "Attenuation {attenuation_db:.1} dB is not > 6 dB"
498        );
499
500        // Speech-like tone BURSTS over the same noise, after a noise-only
501        // lead-in that lets the floor converge. A *sustained* tone is
502        // stationary by definition — a stationary-noise suppressor eating
503        // it would be correct — so the retention claim is about bursty,
504        // speech-shaped energy.
505        let mix_samples = (sample_rate as f64 * 1.5) as usize;
506        let lead_in = (sample_rate as f64 * 0.15) as usize;
507        let burst_on = (sample_rate as f64 * 0.2) as usize;
508        let burst_off = (sample_rate as f64 * 0.1) as usize;
509        let mut mix_signal = vec![0.0f32; mix_samples];
510        let mut burst_ranges: Vec<(usize, usize)> = Vec::new();
511        rng = 0;
512        let mut i = 0usize;
513        let mut cursor = lead_in;
514        while cursor < mix_samples {
515            burst_ranges.push((cursor, (cursor + burst_on).min(mix_samples)));
516            cursor += burst_on + burst_off;
517        }
518        while i < mix_samples {
519            rng = rng.wrapping_mul(1103515245).wrapping_add(12345);
520            let u = ((rng >> 16) & 0x7fff) as f32 / 32768.0;
521            let noise = (u - 0.5) * 2.0 * 0.05;
522            let in_burst = burst_ranges.iter().any(|&(a, b)| i >= a && i < b);
523            let t = i as f64 / sample_rate as f64;
524            let sine = if in_burst {
525                (2.0 * std::f64::consts::PI * 440.0 * t).sin() as f32 * 0.2
526            } else {
527                0.0
528            };
529            mix_signal[i] = noise + sine;
530            i += 1;
531        }
532
533        let mut stft = Stft::new(SpectralFloor::default_speech(), sample_rate);
534        let mut mix_output: Vec<f32> = Vec::new();
535        for chunk in mix_signal.chunks(chunk_size) {
536            let mut chunk = chunk.to_vec();
537            let mut bus = AudioBus {
538                samples: &mut chunk,
539                sample_rate,
540            };
541            stft.process(&mut bus);
542            mix_output.extend(bus.samples.iter());
543        }
544
545        // Compare burst energy in vs out (output delayed by window_len).
546        // Skip each burst's first 40 ms (WOLA transient + floor settling).
547        let settle = (sample_rate as f64 * 0.04) as usize;
548        let mut in_sq = 0.0f64;
549        let mut out_sq = 0.0f64;
550        let mut n = 0usize;
551        for &(a, b) in &burst_ranges {
552            for i in (a + settle)..b {
553                let o = i + window_len;
554                if o < mix_output.len() {
555                    in_sq += mix_signal[i] as f64 * mix_signal[i] as f64;
556                    out_sq += mix_output[o] as f64 * mix_output[o] as f64;
557                    n += 1;
558                }
559            }
560        }
561        let sine_amplitude_retention =
562            (out_sq / n as f64).sqrt() / ((in_sq / n as f64).sqrt() + 1e-10);
563        eprintln!(
564            "Sine amplitude retention: {:.1}%",
565            sine_amplitude_retention * 100.0
566        );
567        assert!(
568            sine_amplitude_retention > 0.8,
569            "Sine retention {:.1}% is not > 80%",
570            sine_amplitude_retention * 100.0
571        );
572    }
573}