Skip to main content

audio_lab/dsp/
stft.rs

1use crate::signal::{Spectrogram, TimeSignal};
2use ndarray::prelude::*;
3use num::complex::Complex64;
4use realfft::RealFftPlanner;
5
6pub use crate::dsp::window::WindowFn;
7use crate::dsp::window::generate_periodic_window;
8
9#[derive(Debug, thiserror::Error)]
10pub enum StftError {
11    #[error("window_size must be > 0")]
12    WindowSizeZero,
13    #[error("hop_size must be > 0")]
14    HopSizeZero,
15    #[error("window_size ({window_size}) must be <= signal length ({signal_length})")]
16    WindowLargerThanSignal {
17        window_size: usize,
18        signal_length: usize,
19    },
20}
21
22#[derive(Debug, Clone)]
23pub struct StftConfig {
24    /// FFT window length in samples.
25    pub window_size: usize,
26    /// Step between successive frames in samples (default: `window_size / 4`, i.e. 75% overlap).
27    pub hop_size: usize,
28    /// Tapering window applied to each frame before the FFT.
29    pub window_fn: WindowFn,
30}
31
32impl StftConfig {
33    pub fn new(window_size: usize) -> Self {
34        Self {
35            window_size,
36            hop_size: window_size / 4,
37            window_fn: WindowFn::Hann,
38        }
39    }
40}
41
42impl Default for StftConfig {
43    fn default() -> Self {
44        Self::new(1024)
45    }
46}
47
48/// Compute the short-time Fourier transform of a [`TimeSignal`].
49///
50/// Returns a [`Spectrogram`] with shape `(num_channels, num_frames, num_freq_bins)`
51/// where `num_freq_bins = window_size / 2 + 1`.
52///
53/// The number of frames is `(num_samples - window_size) / hop_size + 1` (no padding).
54pub fn stft(signal: &TimeSignal, config: &StftConfig) -> Result<Spectrogram, StftError> {
55    if config.window_size == 0 {
56        return Err(StftError::WindowSizeZero);
57    }
58    if config.hop_size == 0 {
59        return Err(StftError::HopSizeZero);
60    }
61    let num_samples = signal.num_time_steps();
62    if config.window_size > num_samples {
63        return Err(StftError::WindowLargerThanSignal {
64            window_size: config.window_size,
65            signal_length: num_samples,
66        });
67    }
68
69    let num_channels = signal.num_channels();
70    let window_size = config.window_size;
71    let hop_size = config.hop_size;
72    let num_frames = (num_samples - window_size) / hop_size + 1;
73    let num_freq_bins = window_size / 2 + 1;
74    let sample_rate = signal.sample_rate();
75
76    let window = generate_periodic_window(config.window_fn, window_size);
77
78    let fft = RealFftPlanner::<f64>::new().plan_fft_forward(window_size);
79    let mut data: Array3<Complex64> = Array3::zeros((num_channels, num_frames, num_freq_bins));
80
81    // Buffers reused for every frame. The FFT writes straight into `data`.
82    let mut windowed_frame = fft.make_input_vec();
83    let mut scratch = fft.make_scratch_vec();
84
85    for (ch_data, mut ch_out) in signal.channel_iter().zip(data.outer_iter_mut()) {
86        for (frame_idx, mut frame_out) in ch_out.outer_iter_mut().enumerate() {
87            let start = frame_idx * hop_size;
88            azip!((wf in &mut windowed_frame, &s in ch_data.slice(s![start..start + window_size]), &w in &window) {
89                *wf = s * w;
90            });
91
92            fft.process_with_scratch(
93                &mut windowed_frame,
94                frame_out
95                    .as_slice_mut()
96                    .expect("spectrogram rows are contiguous"),
97                &mut scratch,
98            )
99            .expect("buffer lengths come from the FFT plan");
100        }
101    }
102
103    let mut spectrogram =
104        Spectrogram::new(data, sample_rate, window_size, hop_size, config.window_fn)
105            .expect("inputs were validated above and the data has window_size / 2 + 1 bins");
106    for ch in 0..signal.num_channels() {
107        spectrogram.set_channel_label(ch, signal.channel_label(ch));
108    }
109    Ok(spectrogram)
110}
111
112#[cfg(test)]
113mod tests {
114    use crate::signal::{
115        NoiseConfig, SineConfig, Spectrum, SweepConfig, SweepType, TimeSignal, generate_noise,
116        generate_sine, generate_sweep,
117    };
118    use approx::assert_abs_diff_eq;
119    use ndarray::arr2;
120
121    use super::*;
122
123    #[test]
124    fn stft_errors() {
125        let signal = TimeSignal::new(arr2(&[[0.0; 16]]), 16.0).unwrap();
126
127        assert!(matches!(
128            stft(
129                &signal,
130                &StftConfig {
131                    window_size: 0,
132                    hop_size: 4,
133                    window_fn: WindowFn::Hann
134                }
135            ),
136            Err(StftError::WindowSizeZero)
137        ));
138        assert!(matches!(
139            stft(
140                &signal,
141                &StftConfig {
142                    window_size: 8,
143                    hop_size: 0,
144                    window_fn: WindowFn::Hann
145                }
146            ),
147            Err(StftError::HopSizeZero)
148        ));
149        assert!(matches!(
150            stft(
151                &signal,
152                &StftConfig {
153                    window_size: 32,
154                    hop_size: 8,
155                    window_fn: WindowFn::Hann
156                }
157            ),
158            Err(StftError::WindowLargerThanSignal { .. })
159        ));
160        // A 2-sample Hann window works: its periodic form is [0, 1], not all zeros.
161        let spec = stft(
162            &signal,
163            &StftConfig {
164                window_size: 2,
165                hop_size: 1,
166                window_fn: WindowFn::Hann,
167            },
168        )
169        .unwrap();
170        assert!(spec.normalization().coherent_gain() > 0.0);
171    }
172
173    #[test]
174    fn stft_dimensions() {
175        // 32 samples, window=8, hop=4 → (32-8)/4+1 = 7 frames, 5 freq bins
176        let signal = TimeSignal::new(arr2(&[[1.0; 32]]), 16.0).unwrap();
177        let config = StftConfig {
178            window_size: 8,
179            hop_size: 4,
180            window_fn: WindowFn::Rectangular,
181        };
182        let spec = stft(&signal, &config).unwrap();
183
184        assert_eq!(spec.num_channels(), 1);
185        assert_eq!(spec.num_frames(), 7);
186        assert_eq!(spec.num_freq_bins(), 5);
187        assert_eq!(spec.sample_rate(), 16.0);
188        assert_eq!(spec.window_size(), 8);
189        assert_eq!(spec.hop_size(), 4);
190    }
191
192    #[test]
193    fn stft_frame_times_and_freq_bins() {
194        let signal = TimeSignal::new(arr2(&[[0.0; 16]]), 8.0).unwrap();
195        // window=8, hop=4 → (16-8)/4+1 = 3 frames
196        let config = StftConfig {
197            window_size: 8,
198            hop_size: 4,
199            window_fn: WindowFn::Rectangular,
200        };
201        let spec = stft(&signal, &config).unwrap();
202
203        // frame centers: (0*4+4)/8, (1*4+4)/8, (2*4+4)/8 = 0.5, 1.0, 1.5 s
204        assert_abs_diff_eq!(spec.frame_times()[0], 0.5, epsilon = 1e-10);
205        assert_abs_diff_eq!(spec.frame_times()[1], 1.0, epsilon = 1e-10);
206        assert_abs_diff_eq!(spec.frame_times()[2], 1.5, epsilon = 1e-10);
207
208        // freq bins: 0, 1, 2, 3, 4 Hz  (sr=8, N=8 → bin spacing = 8/8 = 1 Hz)
209        assert_abs_diff_eq!(spec.freq_bins()[0], 0.0, epsilon = 1e-10);
210        assert_abs_diff_eq!(spec.freq_bins()[4], 4.0, epsilon = 1e-10);
211    }
212
213    #[test]
214    fn stft_dc_signal() {
215        // A constant signal of amplitude 1 with a rectangular window should give
216        // DC bin = window_size and all other bins ≈ 0.
217        let window_size = 8;
218        let signal = TimeSignal::new(arr2(&[[1.0; 32]]), 16.0).unwrap();
219        let config = StftConfig {
220            window_size,
221            hop_size: 4,
222            window_fn: WindowFn::Rectangular,
223        };
224        let spec = stft(&signal, &config).unwrap();
225
226        for frame in 0..spec.num_frames() {
227            let ch_data = spec.channel(0);
228            let dc = ch_data[[frame, 0]];
229            assert_abs_diff_eq!(dc.re, window_size as f64, epsilon = 1e-10);
230            assert_abs_diff_eq!(dc.im, 0.0, epsilon = 1e-10);
231            for bin in 1..spec.num_freq_bins() {
232                assert_abs_diff_eq!(ch_data[[frame, bin]].norm(), 0.0, epsilon = 1e-8);
233            }
234        }
235    }
236
237    #[test]
238    fn stft_multi_channel() {
239        let data = arr2(&[[1.0; 32], [2.0; 32]]);
240        let signal = TimeSignal::new(data, 16.0).unwrap();
241        let config = StftConfig {
242            window_size: 8,
243            hop_size: 4,
244            window_fn: WindowFn::Rectangular,
245        };
246        let spec = stft(&signal, &config).unwrap();
247
248        assert_eq!(spec.num_channels(), 2);
249        // Channel 1 DC should be 2x channel 0 DC
250        for frame in 0..spec.num_frames() {
251            let dc_ch0 = spec.channel(0)[[frame, 0]].re;
252            let dc_ch1 = spec.channel(1)[[frame, 0]].re;
253            assert_abs_diff_eq!(dc_ch1, 2.0 * dc_ch0, epsilon = 1e-10);
254        }
255    }
256
257    #[test]
258    fn calibrated_spectra_for_bin_centered_sine() {
259        let sample_rate = 8.0;
260        let window_size = 8;
261        let signal = TimeSignal::new(
262            arr2(&[std::array::from_fn::<f64, 8, _>(|n| {
263                (std::f64::consts::TAU * 2.0 * n as f64 / window_size as f64).sin()
264            })]),
265            sample_rate,
266        )
267        .unwrap();
268        let spec = stft(
269            &signal,
270            &StftConfig {
271                window_size,
272                hop_size: window_size,
273                window_fn: WindowFn::Rectangular,
274            },
275        )
276        .unwrap();
277
278        assert_abs_diff_eq!(spec.raw_magnitude()[[0, 0, 2]], 4.0, epsilon = 1e-10);
279        assert_abs_diff_eq!(spec.amplitude_spectrum()[[0, 0, 2]], 1.0, epsilon = 1e-10);
280        assert_abs_diff_eq!(spec.power_spectrum()[[0, 0, 2]], 0.5, epsilon = 1e-10);
281        assert_abs_diff_eq!(
282            spec.power_spectral_density()[[0, 0, 2]],
283            0.5,
284            epsilon = 1e-10
285        );
286    }
287
288    #[test]
289    fn calibrated_dc_amplitude_is_window_corrected() {
290        let signal = TimeSignal::new(arr2(&[[1.0; 8]]), 8.0).unwrap();
291        let spec = stft(
292            &signal,
293            &StftConfig {
294                window_size: 8,
295                hop_size: 8,
296                window_fn: WindowFn::Hann,
297            },
298        )
299        .unwrap();
300
301        assert_abs_diff_eq!(spec.amplitude_spectrum()[[0, 0, 0]], 1.0, epsilon = 1e-10);
302    }
303
304    #[test]
305    fn stft_generated_sine_has_peak_at_expected_frequency() {
306        let signal = generate_sine(
307            800,
308            440.0,
309            SineConfig {
310                amplitude: 0.75,
311                sample_rate: 8_000.0,
312                num_channels: 1,
313            },
314        )
315        .unwrap();
316        let spec = stft(
317            &signal,
318            &StftConfig {
319                window_size: 800,
320                hop_size: 800,
321                window_fn: WindowFn::Rectangular,
322            },
323        )
324        .unwrap();
325
326        let magnitudes = spec.amplitude_spectrum();
327        let peak_bin = (0..spec.num_freq_bins())
328            .max_by(|&left, &right| {
329                magnitudes[[0, 0, left]]
330                    .partial_cmp(&magnitudes[[0, 0, right]])
331                    .unwrap()
332            })
333            .unwrap();
334
335        assert_abs_diff_eq!(spec.freq_bins()[peak_bin], 440.0, epsilon = 1e-12);
336        assert_abs_diff_eq!(magnitudes[[0, 0, peak_bin]], 0.75, epsilon = 1e-10);
337    }
338
339    #[test]
340    fn stft_generated_sweep_shows_increasing_dominant_frequency() {
341        let signal = generate_sweep(
342            4_096,
343            200.0..3_200.0,
344            SweepConfig {
345                sample_rate: 16_000.0,
346                fade_out: 0,
347                sweep_type: SweepType::Linear,
348                ..Default::default()
349            },
350        )
351        .unwrap();
352        let spec = stft(
353            &signal,
354            &StftConfig {
355                window_size: 512,
356                hop_size: 256,
357                window_fn: WindowFn::Hann,
358            },
359        )
360        .unwrap();
361        let magnitudes = spec.amplitude_spectrum();
362
363        let dominant_frequency = |frame: usize| {
364            let peak_bin = (0..spec.num_freq_bins())
365                .max_by(|&left, &right| {
366                    magnitudes[[0, frame, left]]
367                        .partial_cmp(&magnitudes[[0, frame, right]])
368                        .unwrap()
369                })
370                .unwrap();
371            spec.freq_bins()[peak_bin]
372        };
373
374        let first_peak = dominant_frequency(0);
375        let last_peak = dominant_frequency(spec.num_frames() - 1);
376
377        assert!(first_peak < last_peak);
378        assert!(first_peak >= 0.0);
379        assert!(last_peak <= signal.sample_rate() / 2.0);
380    }
381
382    #[test]
383    fn stft_generated_noise_has_broadband_energy() {
384        let signal = generate_noise(
385            2_048,
386            NoiseConfig {
387                sample_rate: 16_000.0,
388                spectrum: Spectrum::White,
389                seed: Some(123),
390                ..Default::default()
391            },
392        )
393        .unwrap();
394        let spec = stft(
395            &signal,
396            &StftConfig {
397                window_size: 256,
398                hop_size: 128,
399                window_fn: WindowFn::Hann,
400            },
401        )
402        .unwrap();
403        let magnitudes = spec.amplitude_spectrum();
404
405        for frame in 0..spec.num_frames() {
406            let frame_energy: f64 = (0..spec.num_freq_bins())
407                .map(|bin| magnitudes[[0, frame, bin]])
408                .sum();
409            assert!(frame_energy > 0.0);
410        }
411
412        let active_bins = (0..spec.num_freq_bins())
413            .filter(|&bin| magnitudes[[0, 0, bin]] > 1e-3)
414            .count();
415        assert!(active_bins > spec.num_freq_bins() / 2);
416    }
417}