Skip to main content

rill_fft/effects/
spectral_gate.rs

1// rill-fft/src/effects/spectral_gate.rs
2//! Spectral gate — frequency-domain noise gate.
3//!
4//! Transforms the signal into the frequency domain, silences bins whose
5//! magnitude falls below a threshold, then transforms back. Useful for
6//! noise reduction and creative spectral effects.
7
8use num_complex::Complex;
9use rill_core::traits::algorithm::{Algorithm, AlgorithmCategory, AlgorithmMetadata};
10use rill_core::traits::ProcessResult;
11use rill_core::Transcendental;
12
13use crate::real_fft::RealFft;
14
15/// Spectral gate effect using overlap-add FFT processing.
16///
17/// # Type parameters
18///
19/// - `T` — sample type (`f32` or `f64`)
20/// - `BUF_SIZE` — processing block size in samples
21pub struct SpectralGate<T: Transcendental, const BUF_SIZE: usize> {
22    fft_size: usize,
23    half_plus_one: usize,
24    fft: RealFft<T>,
25    fft_in: Vec<T>,
26    fft_out: Vec<Complex<T>>,
27    ifft_out: Vec<T>,
28    overlap: Vec<T>,
29    threshold: T,
30    ratio: f32,
31}
32
33impl<T: Transcendental, const BUF_SIZE: usize> SpectralGate<T, BUF_SIZE> {
34    /// Create a new spectral gate.
35    pub fn new() -> Self {
36        let fft_size = rill_core::utils::next_power_of_two(2 * BUF_SIZE).max(4);
37        let half_plus_one = fft_size / 2 + 1;
38        let fft = RealFft::new(fft_size);
39        let overlap_len = fft_size - BUF_SIZE;
40
41        Self {
42            fft_size,
43            half_plus_one,
44            fft,
45            fft_in: vec![T::ZERO; fft_size],
46            fft_out: vec![Complex::new(T::ZERO, T::ZERO); half_plus_one],
47            ifft_out: vec![T::ZERO; fft_size],
48            overlap: vec![T::ZERO; overlap_len],
49            threshold: T::from_f32(0.01),
50            ratio: 0.0,
51        }
52    }
53
54    /// Set the gate threshold. Bins with magnitude below this are attenuated.
55    pub fn set_threshold(&mut self, threshold: T) {
56        self.threshold = threshold;
57    }
58
59    /// Set the gate ratio. 0.0 = hard gate (silence), 1.0 = no gate (passthrough).
60    pub fn set_ratio(&mut self, ratio: f32) {
61        self.ratio = ratio.clamp(0.0, 1.0);
62    }
63
64    /// Returns the FFT size.
65    pub fn fft_size(&self) -> usize {
66        self.fft_size
67    }
68
69    /// Process one block of samples.
70    pub fn process(&mut self, input: &[T], output: &mut [T]) {
71        assert_eq!(input.len(), BUF_SIZE, "input must have BUF_SIZE elements");
72        assert_eq!(output.len(), BUF_SIZE, "output must have BUF_SIZE elements");
73
74        self.fft_in.fill(T::ZERO);
75        self.fft_in[..BUF_SIZE].copy_from_slice(input);
76        self.fft.forward(&self.fft_in, &mut self.fft_out);
77
78        let one_minus_ratio = T::from_f32(1.0 - self.ratio);
79        for i in 0..self.half_plus_one {
80            let c = self.fft_out[i];
81            let mag_sq = c.re * c.re + c.im * c.im;
82            let mag = mag_sq.to_f64().sqrt() as f32;
83            if mag < self.threshold.to_f32() {
84                let scale = if self.ratio < 0.001 {
85                    T::ZERO
86                } else {
87                    T::from_f32((mag / self.threshold.to_f32()) * (1.0 - self.ratio))
88                        / self.threshold
89                };
90                self.fft_out[i] = Complex::new(c.re * scale, c.im * scale);
91            } else {
92                // Expand above threshold: apply soft knee
93                let above = mag - self.threshold.to_f32();
94                let gain = T::from_f32(1.0) + one_minus_ratio * T::from_f32(above / (1.0 + above));
95                self.fft_out[i] = Complex::new(c.re * gain, c.im * gain);
96            }
97        }
98
99        self.fft.inverse(&self.fft_out, &mut self.ifft_out);
100
101        for (out, (ifft_val, overlap_val)) in output
102            .iter_mut()
103            .zip(self.ifft_out.iter().zip(self.overlap.iter()))
104        {
105            *out = *ifft_val + *overlap_val;
106        }
107
108        let overlap_len = self.fft_size - BUF_SIZE;
109        for i in 0..overlap_len {
110            self.overlap[i] = self.ifft_out[BUF_SIZE + i];
111        }
112    }
113}
114
115impl<T: Transcendental, const BUF_SIZE: usize> Default for SpectralGate<T, BUF_SIZE> {
116    fn default() -> Self {
117        Self::new()
118    }
119}
120
121impl<T: Transcendental, const BUF_SIZE: usize> Algorithm<T> for SpectralGate<T, BUF_SIZE> {
122    fn process(&mut self, input: Option<&[T]>, output: &mut [T]) -> ProcessResult<()> {
123        match input {
124            Some(samples) => {
125                assert_eq!(
126                    samples.len(),
127                    BUF_SIZE,
128                    "SpectralGate expects BUF_SIZE={} input",
129                    BUF_SIZE
130                );
131                assert_eq!(
132                    output.len(),
133                    BUF_SIZE,
134                    "SpectralGate expects BUF_SIZE={} output",
135                    BUF_SIZE
136                );
137                self.process(samples, output);
138                Ok(())
139            }
140            None => {
141                output.fill(T::ZERO);
142                Ok(())
143            }
144        }
145    }
146
147    fn reset(&mut self) {
148        self.fft_in.fill(T::ZERO);
149        self.fft_out
150            .fill(num_complex::Complex::new(T::ZERO, T::ZERO));
151        self.ifft_out.fill(T::ZERO);
152        self.overlap.fill(T::ZERO);
153    }
154
155    fn metadata(&self) -> AlgorithmMetadata {
156        AlgorithmMetadata {
157            name: "SpectralGate",
158            category: AlgorithmCategory::Effect,
159            description: "Frequency-domain noise gate via FFT",
160            author: "Rill",
161            version: env!("CARGO_PKG_VERSION"),
162        }
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn test_passthrough_with_high_ratio() {
172        let mut gate = SpectralGate::<f32, 64>::new();
173        gate.set_threshold(0.0);
174        gate.set_ratio(1.0);
175
176        let input: Vec<f32> = (0..64).map(|i| (i as f32 * 0.1).sin()).collect();
177        let mut output = vec![0.0f32; 64];
178        gate.process(&input, &mut output);
179
180        for (i, o) in input.iter().zip(output.iter()) {
181            assert!((i - o).abs() < 0.01, "expected {i}, got {o}");
182        }
183    }
184
185    #[test]
186    fn test_silence_with_zero_threshold_ratio() {
187        let mut gate = SpectralGate::<f32, 64>::new();
188        gate.set_threshold(100.0);
189        gate.set_ratio(0.0);
190
191        let input: Vec<f32> = (0..64).map(|i| (i as f32 * 0.1).sin()).collect();
192        let mut output = vec![0.0f32; 64];
193        gate.process(&input, &mut output);
194
195        for o in output.iter() {
196            assert!(o.abs() < 0.01);
197        }
198    }
199
200    #[test]
201    fn test_roundtrip_multiple_blocks() {
202        let mut gate = SpectralGate::<f32, 64>::new();
203        gate.set_threshold(0.0);
204        gate.set_ratio(1.0);
205
206        let block1: Vec<f32> = (0..64).map(|i| (i as f32 * 0.1).sin()).collect();
207        let block2: Vec<f32> = (64..128).map(|i| (i as f32 * 0.1).sin()).collect();
208
209        let mut out1 = vec![0.0f32; 64];
210        let mut out2 = vec![0.0f32; 64];
211
212        gate.process(&block1, &mut out1);
213        gate.process(&block2, &mut out2);
214
215        for (i, o) in block1.iter().zip(out1.iter()) {
216            assert!((i - o).abs() < 0.05, "block1 idx: expected {i}, got {o}");
217        }
218        for (i, o) in block2.iter().zip(out2.iter()) {
219            assert!((i - o).abs() < 0.05, "block2 idx: expected {i}, got {o}");
220        }
221    }
222}