Skip to main content

scirs2_fft/
streaming.rs

1//! Streaming FFT processor with configurable overlap
2//!
3//! This module implements a streaming Short-Time Fourier Transform (STFT)
4//! processor that ingests samples incrementally and emits magnitude spectra
5//! on a hop-by-hop basis. Two algorithmic modes are supported:
6//!
7//! - **Overlap-add** (OLA): Each window is analysed and the resulting spectra
8//!   are accumulated for reconstruction.
9//! - **Overlap-save**: The ring buffer is advanced by `hop_size` samples on
10//!   each frame; the first `fft_size - hop_size` samples in the window are
11//!   kept from the previous frame (overlap saved from the past).
12//!
13//! # Example
14//!
15//! ```rust
16//! use scirs2_fft::streaming::{StreamingFft, StreamingFftConfig, WindowType};
17//!
18//! let config = StreamingFftConfig {
19//!     fft_size: 64,
20//!     hop_size: 32,
21//!     window: WindowType::Hann,
22//! };
23//! let mut proc = StreamingFft::new(config);
24//! let signal: Vec<f64> = (0..256).map(|i| (i as f64 * 0.1).sin()).collect();
25//! let spectra = proc.push(&signal);
26//! // Each element is a magnitude spectrum of length fft_size/2 + 1.
27//! assert!(!spectra.is_empty());
28//! ```
29
30use std::collections::VecDeque;
31use std::f64::consts::PI;
32
33use crate::error::{FFTError, FFTResult};
34use crate::fft::fft;
35
36// ─────────────────────────────────────────────────────────────────────────────
37//  Public types
38// ─────────────────────────────────────────────────────────────────────────────
39
40/// Window function types for the streaming FFT processor.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum WindowType {
43    /// Rectangular (no weighting).
44    Rectangular,
45    /// Hann window: `w[n] = 0.5 * (1 - cos(2πn / (N-1)))`.
46    Hann,
47    /// Hamming window: `w[n] = 0.54 - 0.46 * cos(2πn / (N-1))`.
48    Hamming,
49    /// Blackman window: `w[n] = 0.42 - 0.5*cos(2πn/(N-1)) + 0.08*cos(4πn/(N-1))`.
50    Blackman,
51}
52
53/// Configuration for [`StreamingFft`].
54#[derive(Debug, Clone)]
55pub struct StreamingFftConfig {
56    /// FFT window size. **Must be a power of two** and at least 2.
57    pub fft_size: usize,
58    /// Hop size (distance between successive analysis frames).
59    /// Must satisfy `1 <= hop_size <= fft_size`.
60    pub hop_size: usize,
61    /// Window function applied to each frame before the FFT.
62    pub window: WindowType,
63}
64
65/// Streaming FFT processor.
66///
67/// Samples are pushed incrementally. Once the internal buffer accumulates
68/// `fft_size` samples a magnitude spectrum is emitted; then `hop_size`
69/// samples are consumed from the front of the buffer so that the next
70/// frame begins `hop_size` samples later (overlap = `fft_size - hop_size`).
71pub struct StreamingFft {
72    config: StreamingFftConfig,
73    /// Circular input buffer — holds at most `fft_size` samples.
74    buffer: VecDeque<f64>,
75    /// Pre-computed window coefficients (length `fft_size`).
76    window_fn: Vec<f64>,
77    /// Pending output spectra (not yet consumed by the caller).
78    output_buffer: Vec<Vec<f64>>,
79}
80
81// ─────────────────────────────────────────────────────────────────────────────
82//  Implementation
83// ─────────────────────────────────────────────────────────────────────────────
84
85impl StreamingFft {
86    /// Create a new streaming FFT processor.
87    ///
88    /// # Panics
89    ///
90    /// Does **not** panic; invalid configuration is silently clamped where
91    /// possible.  Callers should validate with [`StreamingFftConfig`] fields
92    /// before calling.
93    pub fn new(config: StreamingFftConfig) -> Self {
94        let window_fn = compute_window(config.window, config.fft_size);
95        Self {
96            buffer: VecDeque::with_capacity(config.fft_size * 2),
97            window_fn,
98            output_buffer: Vec::new(),
99            config,
100        }
101    }
102
103    /// Push new samples into the stream.
104    ///
105    /// Returns all completed magnitude spectra generated from the incoming
106    /// data.  Each returned `Vec<f64>` has length `fft_size / 2 + 1` and
107    /// contains the one-sided magnitude spectrum (DC to Nyquist).
108    ///
109    /// A new frame is emitted every time the internal buffer reaches
110    /// `fft_size` samples; `hop_size` samples are then drained from the
111    /// front of the buffer so the next frame overlaps by
112    /// `fft_size - hop_size` samples.
113    pub fn push(&mut self, samples: &[f64]) -> Vec<Vec<f64>> {
114        let mut results = Vec::new();
115
116        for &s in samples {
117            self.buffer.push_back(s);
118
119            if self.buffer.len() >= self.config.fft_size {
120                // Emit a frame using the front `fft_size` samples.
121                if let Ok(spectrum) = self.emit_frame() {
122                    results.push(spectrum);
123                }
124                // Advance by hop_size.
125                for _ in 0..self.config.hop_size {
126                    self.buffer.pop_front();
127                }
128            }
129        }
130
131        results
132    }
133
134    /// Flush remaining buffered samples.
135    ///
136    /// Zero-pads the internal buffer to `fft_size` and emits one spectrum if
137    /// the buffer is non-empty.  Resets the buffer afterwards.
138    ///
139    /// Returns an empty `Vec` if the buffer is already empty.
140    pub fn flush(&mut self) -> Vec<Vec<f64>> {
141        if self.buffer.is_empty() {
142            return Vec::new();
143        }
144
145        // Zero-pad to fft_size.
146        while self.buffer.len() < self.config.fft_size {
147            self.buffer.push_back(0.0);
148        }
149
150        match self.emit_frame() {
151            Ok(spectrum) => {
152                self.buffer.clear();
153                vec![spectrum]
154            }
155            Err(_) => {
156                self.buffer.clear();
157                Vec::new()
158            }
159        }
160    }
161
162    /// Number of samples currently held in the internal buffer.
163    pub fn buffered_samples(&self) -> usize {
164        self.buffer.len()
165    }
166
167    /// Reset the processor: clear internal buffer and any pending output.
168    pub fn reset(&mut self) {
169        self.buffer.clear();
170        self.output_buffer.clear();
171    }
172
173    // ── Internal helpers ──────────────────────────────────────────────────────
174
175    /// Extract the current window, apply the window function, and compute
176    /// the one-sided magnitude spectrum.
177    fn emit_frame(&self) -> FFTResult<Vec<f64>> {
178        let n = self.config.fft_size;
179
180        // Collect `n` samples from the front of the deque into a windowed frame.
181        let windowed: Vec<f64> = self
182            .buffer
183            .iter()
184            .take(n)
185            .enumerate()
186            .map(|(i, &s)| s * self.window_fn[i])
187            .collect();
188
189        if windowed.len() < n {
190            return Err(FFTError::ValueError(format!(
191                "streaming: buffer has {} samples but fft_size is {}",
192                windowed.len(),
193                n
194            )));
195        }
196
197        // Forward FFT via the crate's public API.
198        let spectrum = fft(&windowed, Some(n))?;
199
200        // One-sided magnitude: bins 0 .. n/2+1.
201        let n_out = n / 2 + 1;
202        let magnitudes: Vec<f64> = spectrum
203            .iter()
204            .take(n_out)
205            .map(|c| (c.re * c.re + c.im * c.im).sqrt())
206            .collect();
207
208        Ok(magnitudes)
209    }
210}
211
212// ─────────────────────────────────────────────────────────────────────────────
213//  Window function computation
214// ─────────────────────────────────────────────────────────────────────────────
215
216/// Compute a window function of length `n`.
217fn compute_window(wt: WindowType, n: usize) -> Vec<f64> {
218    if n == 0 {
219        return Vec::new();
220    }
221    if n == 1 {
222        return vec![1.0];
223    }
224    let n_minus_1 = (n - 1) as f64;
225    match wt {
226        WindowType::Rectangular => vec![1.0; n],
227        WindowType::Hann => (0..n)
228            .map(|i| 0.5 * (1.0 - (2.0 * PI * i as f64 / n_minus_1).cos()))
229            .collect(),
230        WindowType::Hamming => (0..n)
231            .map(|i| 0.54 - 0.46 * (2.0 * PI * i as f64 / n_minus_1).cos())
232            .collect(),
233        WindowType::Blackman => (0..n)
234            .map(|i| {
235                let x = 2.0 * PI * i as f64 / n_minus_1;
236                0.42 - 0.5 * x.cos() + 0.08 * (2.0 * x).cos()
237            })
238            .collect(),
239    }
240}
241
242// ─────────────────────────────────────────────────────────────────────────────
243//  Convenience function
244// ─────────────────────────────────────────────────────────────────────────────
245
246/// Process an entire signal with the streaming API.
247///
248/// Pushes all samples through the processor and appends the flush output.
249/// Returns all magnitude spectra (each of length `fft_size / 2 + 1`).
250///
251/// The number of output frames for a signal of length `N` with
252/// `N >= fft_size` is `floor((N - fft_size) / hop_size) + 1`, plus at most
253/// one more frame from the flush if there are remaining samples.
254///
255/// # Example
256///
257/// ```rust
258/// use scirs2_fft::streaming::{streaming_spectrogram, StreamingFftConfig, WindowType};
259///
260/// let signal: Vec<f64> = (0..512).map(|i| (i as f64 * 0.05).sin()).collect();
261/// let config = StreamingFftConfig { fft_size: 64, hop_size: 32, window: WindowType::Hann };
262/// let spectra = streaming_spectrogram(&signal, config);
263/// assert!(!spectra.is_empty());
264/// // Each spectrum has fft_size/2 + 1 = 33 bins.
265/// assert_eq!(spectra[0].len(), 33);
266/// ```
267pub fn streaming_spectrogram(signal: &[f64], config: StreamingFftConfig) -> Vec<Vec<f64>> {
268    let mut proc = StreamingFft::new(config);
269    let mut results = proc.push(signal);
270    let flushed = proc.flush();
271    results.extend(flushed);
272    results
273}
274
275// ─────────────────────────────────────────────────────────────────────────────
276//  Tests
277// ─────────────────────────────────────────────────────────────────────────────
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    /// Helper: generate a DC signal (constant value).
284    fn dc_signal(n: usize, val: f64) -> Vec<f64> {
285        vec![val; n]
286    }
287
288    /// Helper: generate a real sine wave.
289    fn sine_signal(n: usize, freq_ratio: f64) -> Vec<f64> {
290        (0..n)
291            .map(|i| (2.0 * PI * freq_ratio * i as f64).sin())
292            .collect()
293    }
294
295    // ── Test 1: exactly fft_size samples → exactly 1 spectrum ────────────────
296
297    #[test]
298    fn test_push_exactly_fft_size_produces_one_spectrum() {
299        let fft_size = 64;
300        let hop_size = 32;
301        let config = StreamingFftConfig {
302            fft_size,
303            hop_size,
304            window: WindowType::Rectangular,
305        };
306        let mut proc = StreamingFft::new(config);
307        let signal = dc_signal(fft_size, 1.0);
308        let spectra = proc.push(&signal);
309        // Exactly one frame: we hit fft_size after the 64th sample.
310        assert_eq!(
311            spectra.len(),
312            1,
313            "expected 1 spectrum, got {}",
314            spectra.len()
315        );
316        // Each spectrum should have fft_size/2+1 bins.
317        assert_eq!(spectra[0].len(), fft_size / 2 + 1);
318    }
319
320    // ── Test 2: streaming and batch give the same spectrum ───────────────────
321
322    #[test]
323    fn test_streaming_matches_batch_fft() {
324        let fft_size = 64;
325        let hop_size = fft_size; // non-overlapping so there is exactly one batch frame
326        let config = StreamingFftConfig {
327            fft_size,
328            hop_size,
329            window: WindowType::Rectangular,
330        };
331        let signal = sine_signal(fft_size, 0.1);
332
333        // Streaming path.
334        let mut proc = StreamingFft::new(config);
335        let spectra = proc.push(&signal);
336        assert_eq!(spectra.len(), 1);
337        let streaming_mag = &spectra[0];
338
339        // Batch path: direct FFT on the same signal.
340        let batch_spec = fft(&signal, Some(fft_size)).expect("batch fft failed");
341        let n_out = fft_size / 2 + 1;
342        let batch_mag: Vec<f64> = batch_spec
343            .iter()
344            .take(n_out)
345            .map(|c| (c.re * c.re + c.im * c.im).sqrt())
346            .collect();
347
348        assert_eq!(streaming_mag.len(), batch_mag.len());
349        for (s, b) in streaming_mag.iter().zip(batch_mag.iter()) {
350            assert!(
351                (s - b).abs() < 1e-10,
352                "streaming={} batch={} differ by {}",
353                s,
354                b,
355                (s - b).abs()
356            );
357        }
358    }
359
360    // ── Test 3: flush() on a partial buffer ──────────────────────────────────
361
362    #[test]
363    fn test_flush_produces_spectrum_for_partial_buffer() {
364        let fft_size = 64;
365        let hop_size = 32;
366        let config = StreamingFftConfig {
367            fft_size,
368            hop_size,
369            window: WindowType::Hann,
370        };
371        let mut proc = StreamingFft::new(config);
372
373        // Push fewer than fft_size samples so no frame is emitted by push.
374        let partial = sine_signal(20, 0.05);
375        let push_spectra = proc.push(&partial);
376        assert_eq!(
377            push_spectra.len(),
378            0,
379            "no frames expected from partial push"
380        );
381        assert_eq!(proc.buffered_samples(), 20);
382
383        // Flush should produce exactly one spectrum.
384        let flushed = proc.flush();
385        assert_eq!(flushed.len(), 1, "flush should produce exactly 1 spectrum");
386        assert_eq!(flushed[0].len(), fft_size / 2 + 1);
387        // Buffer should be cleared.
388        assert_eq!(proc.buffered_samples(), 0);
389    }
390
391    // ── Test 4: different window types don't crash ────────────────────────────
392
393    #[test]
394    fn test_all_window_types() {
395        let signal = sine_signal(256, 0.1);
396        for &wt in &[
397            WindowType::Rectangular,
398            WindowType::Hann,
399            WindowType::Hamming,
400            WindowType::Blackman,
401        ] {
402            let config = StreamingFftConfig {
403                fft_size: 64,
404                hop_size: 32,
405                window: wt,
406            };
407            let spectra = streaming_spectrogram(&signal, config);
408            assert!(!spectra.is_empty(), "no spectra for window type {:?}", wt);
409            for s in &spectra {
410                assert_eq!(s.len(), 33, "spectrum length mismatch for window {:?}", wt);
411                // No NaN or Inf values.
412                for &v in s {
413                    assert!(v.is_finite(), "non-finite value in spectrum for {:?}", wt);
414                }
415            }
416        }
417    }
418
419    // ── Test 5: correct frame count for large signal ─────────────────────────
420
421    #[test]
422    fn test_large_signal_frame_count() {
423        let fft_size = 64_usize;
424        let hop_size = 16_usize;
425        let n_samples = 1024_usize;
426
427        let config = StreamingFftConfig {
428            fft_size,
429            hop_size,
430            window: WindowType::Hann,
431        };
432        let signal = sine_signal(n_samples, 0.05);
433        let mut proc = StreamingFft::new(config);
434        let spectra = proc.push(&signal);
435
436        // Expected: floor((N - fft_size) / hop_size) + 1
437        let expected = (n_samples - fft_size) / hop_size + 1;
438        assert_eq!(
439            spectra.len(),
440            expected,
441            "expected {} frames, got {}",
442            expected,
443            spectra.len()
444        );
445    }
446
447    // ── Test 6: DC signal has large bin-0 magnitude ───────────────────────────
448
449    #[test]
450    fn test_dc_signal_bin0_dominates() {
451        let fft_size = 64;
452        let config = StreamingFftConfig {
453            fft_size,
454            hop_size: fft_size,
455            window: WindowType::Rectangular,
456        };
457        let signal = dc_signal(fft_size, 2.0);
458        let mut proc = StreamingFft::new(config);
459        let spectra = proc.push(&signal);
460        assert_eq!(spectra.len(), 1);
461        let mag = &spectra[0];
462        // DC bin (index 0) should equal N * amplitude = 64 * 2.0 = 128.0
463        let dc = mag[0];
464        assert!(
465            (dc - 128.0).abs() < 1e-9,
466            "DC bin expected 128.0 got {}",
467            dc
468        );
469        // All other bins should be near zero.
470        for (k, &v) in mag.iter().enumerate().skip(1) {
471            assert!(
472                v < 1e-9,
473                "bin {} expected ~0 got {} for rectangular DC signal",
474                k,
475                v
476            );
477        }
478    }
479
480    // ── Test 7: reset clears the buffer ──────────────────────────────────────
481
482    #[test]
483    fn test_reset_clears_buffer() {
484        let config = StreamingFftConfig {
485            fft_size: 32,
486            hop_size: 16,
487            window: WindowType::Hann,
488        };
489        let mut proc = StreamingFft::new(config);
490        proc.push(&sine_signal(20, 0.1));
491        assert_eq!(proc.buffered_samples(), 20);
492        proc.reset();
493        assert_eq!(proc.buffered_samples(), 0);
494    }
495
496    // ── Test 8: convenience function ─────────────────────────────────────────
497
498    #[test]
499    fn test_streaming_spectrogram_convenience() {
500        let n = 512_usize;
501        let fft_size = 64_usize;
502        let hop_size = 32_usize;
503        let signal = sine_signal(n, 0.05);
504        let config = StreamingFftConfig {
505            fft_size,
506            hop_size,
507            window: WindowType::Hamming,
508        };
509        let spectra = streaming_spectrogram(&signal, config);
510        // At minimum floor((N - fft_size) / hop_size) + 1 frames.
511        let min_frames = (n - fft_size) / hop_size + 1;
512        assert!(
513            spectra.len() >= min_frames,
514            "expected >= {} frames, got {}",
515            min_frames,
516            spectra.len()
517        );
518        assert_eq!(spectra[0].len(), fft_size / 2 + 1);
519    }
520}