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}