Skip to main content

oxifft/streaming/
sdft.rs

1//! Sliding DFT (SDFT) — O(N) per-sample full-spectrum update, O(1) per-bin.
2//!
3//! The Sliding DFT recursively updates the DFT as each new sample arrives:
4//!
5//! ```text
6//! X[k] = (X[k] - x_old + x_new) * W_N^k
7//! ```
8//!
9//! where `W_N^k = exp(2πik/N)` is precomputed.
10//!
11//! This module provides three main structures:
12//!
13//! - [`SlidingDft`] — maintains all N frequency bins, O(N) per sample
14//! - [`ModulatedSdft`] — numerically stable variant that avoids drift
15//! - [`SingleBinTracker`] — tracks a single frequency bin in O(1)
16//!
17//! # Example
18//!
19//! ```
20//! use oxifft::kernel::{Complex, Float};
21//! use oxifft::streaming::sdft::{SlidingDft, SingleBinTracker, sliding_dft, single_bin_tracker};
22//!
23//! // Create a sliding DFT with window size 8
24//! let mut sdft = sliding_dft(8);
25//!
26//! // Push samples one at a time
27//! for i in 0..16 {
28//!     sdft.push_real(i as f64);
29//! }
30//!
31//! // Read the current spectrum
32//! let spec = sdft.spectrum();
33//! assert_eq!(spec.len(), 8);
34//!
35//! // Track a single frequency bin efficiently
36//! let mut tracker = single_bin_tracker(3, 8);
37//! for i in 0..16 {
38//!     tracker.push(Complex::new(i as f64, 0.0));
39//! }
40//! let mag = tracker.magnitude();
41//! ```
42
43use crate::kernel::{Complex, Float};
44use crate::prelude::*;
45
46// ---------------------------------------------------------------------------
47// SlidingDft
48// ---------------------------------------------------------------------------
49
50/// Standard Sliding DFT that maintains all N frequency bins.
51///
52/// After the first N samples have been pushed the spectrum is valid.
53/// Each subsequent `push` updates *all* N bins in O(N).
54///
55/// **Note:** This variant can accumulate numerical drift over very long
56/// runs.  If that is a concern, use [`ModulatedSdft`] instead.
57#[derive(Clone, Debug)]
58pub struct SlidingDft<T: Float> {
59    /// Window size.
60    n: usize,
61    /// Circular buffer storing the last N time-domain samples.
62    buffer: Vec<Complex<T>>,
63    /// Current frequency-domain spectrum (N bins).
64    spectrum: Vec<Complex<T>>,
65    /// Precomputed twiddle factors W_N^k = exp(2πik/N) for k = 0..N-1.
66    twiddles: Vec<Complex<T>>,
67    /// Current write position in the circular buffer.
68    pos: usize,
69    /// Number of samples pushed so far (clamped at N once full).
70    samples_pushed: usize,
71}
72
73impl<T: Float> SlidingDft<T> {
74    /// Create a new Sliding DFT with window size `n`.
75    ///
76    /// # Panics
77    ///
78    /// Panics if `n == 0`.
79    pub fn new(n: usize) -> Self {
80        assert!(n > 0, "SlidingDft window size must be > 0");
81
82        let twiddles = Self::compute_twiddles(n);
83
84        Self {
85            n,
86            buffer: vec![Complex::zero(); n],
87            spectrum: vec![Complex::zero(); n],
88            twiddles,
89            pos: 0,
90            samples_pushed: 0,
91        }
92    }
93
94    /// Push one complex sample and update all frequency bins.
95    pub fn push(&mut self, sample: Complex<T>) {
96        let x_old = self.buffer[self.pos];
97        self.buffer[self.pos] = sample;
98        self.pos = (self.pos + 1) % self.n;
99
100        if self.samples_pushed < self.n {
101            self.samples_pushed += 1;
102            if self.samples_pushed == self.n {
103                // First full window — compute the DFT from scratch.
104                self.compute_initial_dft();
105            }
106            return;
107        }
108
109        // Sliding update: X[k] = (X[k] - x_old + x_new) * W_N^k
110        let diff = sample - x_old;
111        for k in 0..self.n {
112            self.spectrum[k] = (self.spectrum[k] + diff) * self.twiddles[k];
113        }
114    }
115
116    /// Convenience: push a real-valued sample (imaginary part = 0).
117    #[inline]
118    pub fn push_real(&mut self, sample: T) {
119        self.push(Complex::new(sample, T::ZERO));
120    }
121
122    /// Current frequency-domain spectrum.
123    ///
124    /// Returns a zero-filled slice until the first N samples have been pushed.
125    #[inline]
126    pub fn spectrum(&self) -> &[Complex<T>] {
127        &self.spectrum
128    }
129
130    /// Read a single frequency bin.
131    ///
132    /// Returns `Complex::zero()` if the index is out of range or not yet initialised.
133    #[inline]
134    pub fn bin(&self, k: usize) -> Complex<T> {
135        if k < self.n {
136            self.spectrum[k]
137        } else {
138            Complex::zero()
139        }
140    }
141
142    /// Magnitude spectrum: |X\[k\]| for each bin.
143    pub fn magnitude_spectrum(&self) -> Vec<T> {
144        self.spectrum.iter().map(|c| c.norm()).collect()
145    }
146
147    /// Power spectrum: |X\[k\]|² for each bin.
148    pub fn power_spectrum(&self) -> Vec<T> {
149        self.spectrum.iter().map(|c| c.norm_sqr()).collect()
150    }
151
152    /// Window size.
153    #[inline]
154    pub fn window_size(&self) -> usize {
155        self.n
156    }
157
158    /// Whether the buffer has been filled at least once (spectrum is valid).
159    #[inline]
160    pub fn is_initialized(&self) -> bool {
161        self.samples_pushed >= self.n
162    }
163
164    /// Reset to initial (empty) state, keeping the same window size.
165    pub fn reset(&mut self) {
166        for v in &mut self.buffer {
167            *v = Complex::zero();
168        }
169        for v in &mut self.spectrum {
170            *v = Complex::zero();
171        }
172        self.pos = 0;
173        self.samples_pushed = 0;
174    }
175
176    // -- private helpers -----------------------------------------------------
177
178    /// Compute W_N^k = exp(2πik/N) for k = 0..N-1.
179    fn compute_twiddles(n: usize) -> Vec<Complex<T>> {
180        let n_f = T::from_usize(n);
181        (0..n)
182            .map(|k| {
183                let angle = T::TWO_PI * T::from_usize(k) / n_f;
184                Complex::cis(angle)
185            })
186            .collect()
187    }
188
189    /// Brute-force DFT of the current circular buffer (called once).
190    fn compute_initial_dft(&mut self) {
191        let n_f = T::from_usize(self.n);
192        for k in 0..self.n {
193            let mut sum = Complex::zero();
194            for m in 0..self.n {
195                // The buffer is circular; the oldest sample is at `self.pos`.
196                let idx = (self.pos + m) % self.n;
197                let angle = -T::TWO_PI * T::from_usize(k) * T::from_usize(m) / n_f;
198                sum = sum + self.buffer[idx] * Complex::cis(angle);
199            }
200            self.spectrum[k] = sum;
201        }
202    }
203}
204
205// ---------------------------------------------------------------------------
206// ModulatedSdft — numerically stable variant
207// ---------------------------------------------------------------------------
208
209/// Numerically stable Sliding DFT using modulation.
210///
211/// This variant avoids the cumulative drift of [`SlidingDft`] by recomputing
212/// a correction factor at every step.  After each `push` the bins are exact
213/// (within floating-point precision of a single DFT).
214///
215/// The modulated SDFT stores an *intermediate* spectrum Y\[k\] and applies the
216/// modulation phase `W_N^{-k·n}` on readout so accumulated twiddle
217/// multiplications are avoided.
218///
219/// Algorithm for each new sample `x_new` replacing `x_old`:
220///
221/// ```text
222/// Y[k] += x_new - x_old          // O(N) total
223/// X[k]  = Y[k] * W_N^{-k·pos}    // phase correction on readout
224/// ```
225///
226/// Because the twiddle multiplication only happens on *readout*, there is no
227/// multiplicative drift over time.
228#[derive(Clone, Debug)]
229pub struct ModulatedSdft<T: Float> {
230    /// Window size.
231    n: usize,
232    /// Circular buffer of time-domain samples.
233    buffer: Vec<Complex<T>>,
234    /// Intermediate (un-modulated) spectrum.
235    y_spectrum: Vec<Complex<T>>,
236    /// Precomputed twiddle table W_N^k, k = 0..N-1.
237    twiddles: Vec<Complex<T>>,
238    /// Precomputed inverse twiddle table W_N^{-k}, k = 0..N-1.
239    inv_twiddles: Vec<Complex<T>>,
240    /// Current write position.
241    pos: usize,
242    /// Number of accumulated samples (clamped at N).
243    samples_pushed: usize,
244}
245
246impl<T: Float> ModulatedSdft<T> {
247    /// Create a new modulated Sliding DFT with window size `n`.
248    ///
249    /// # Panics
250    ///
251    /// Panics if `n == 0`.
252    pub fn new(n: usize) -> Self {
253        assert!(n > 0, "ModulatedSdft window size must be > 0");
254
255        let n_f = T::from_usize(n);
256        let twiddles: Vec<Complex<T>> = (0..n)
257            .map(|k| {
258                let angle = T::TWO_PI * T::from_usize(k) / n_f;
259                Complex::cis(angle)
260            })
261            .collect();
262        let inv_twiddles: Vec<Complex<T>> = (0..n)
263            .map(|k| {
264                let angle = -T::TWO_PI * T::from_usize(k) / n_f;
265                Complex::cis(angle)
266            })
267            .collect();
268
269        Self {
270            n,
271            buffer: vec![Complex::zero(); n],
272            y_spectrum: vec![Complex::zero(); n],
273            twiddles,
274            inv_twiddles,
275            pos: 0,
276            samples_pushed: 0,
277        }
278    }
279
280    /// Push one complex sample and update the internal spectrum.
281    pub fn push(&mut self, sample: Complex<T>) {
282        let x_old = self.buffer[self.pos];
283        self.buffer[self.pos] = sample;
284        self.pos = (self.pos + 1) % self.n;
285
286        if self.samples_pushed < self.n {
287            self.samples_pushed += 1;
288            if self.samples_pushed == self.n {
289                self.compute_initial_dft();
290            }
291            return;
292        }
293
294        // Modulated update: rotate out old, rotate in new
295        let diff = sample - x_old;
296        for k in 0..self.n {
297            // Shift by one sample: Y[k] *= W_N^k
298            self.y_spectrum[k] = (self.y_spectrum[k] + diff) * self.twiddles[k];
299        }
300    }
301
302    /// Convenience: push a real-valued sample.
303    #[inline]
304    pub fn push_real(&mut self, sample: T) {
305        self.push(Complex::new(sample, T::ZERO));
306    }
307
308    /// Compute and return the current corrected spectrum.
309    ///
310    /// This applies the modulation phase on read, guaranteeing no drift.
311    pub fn spectrum(&self) -> Vec<Complex<T>> {
312        if self.samples_pushed < self.n {
313            return vec![Complex::zero(); self.n];
314        }
315        // The oldest sample in the buffer is at position `self.pos`.
316        // Apply W_N^{-k*pos} correction.
317        let mut out = Vec::with_capacity(self.n);
318        for k in 0..self.n {
319            // correction = W_N^{-k * pos}
320            let phase_idx = (k * self.pos) % self.n;
321            let correction = self.inv_twiddles[phase_idx];
322            out.push(self.y_spectrum[k] * correction);
323        }
324        out
325    }
326
327    /// Read a single corrected frequency bin.
328    pub fn bin(&self, k: usize) -> Complex<T> {
329        if k >= self.n || self.samples_pushed < self.n {
330            return Complex::zero();
331        }
332        let phase_idx = (k * self.pos) % self.n;
333        let correction = self.inv_twiddles[phase_idx];
334        self.y_spectrum[k] * correction
335    }
336
337    /// Magnitude spectrum: |X\[k\]| for each bin.
338    pub fn magnitude_spectrum(&self) -> Vec<T> {
339        self.spectrum().iter().map(|c| c.norm()).collect()
340    }
341
342    /// Power spectrum: |X\[k\]|² for each bin.
343    pub fn power_spectrum(&self) -> Vec<T> {
344        self.spectrum().iter().map(|c| c.norm_sqr()).collect()
345    }
346
347    /// Window size.
348    #[inline]
349    pub fn window_size(&self) -> usize {
350        self.n
351    }
352
353    /// Whether the buffer has been filled at least once.
354    #[inline]
355    pub fn is_initialized(&self) -> bool {
356        self.samples_pushed >= self.n
357    }
358
359    /// Reset state, keep window size.
360    pub fn reset(&mut self) {
361        for v in &mut self.buffer {
362            *v = Complex::zero();
363        }
364        for v in &mut self.y_spectrum {
365            *v = Complex::zero();
366        }
367        self.pos = 0;
368        self.samples_pushed = 0;
369    }
370
371    // -- private helpers -----------------------------------------------------
372
373    /// Brute-force DFT of the current circular buffer (called once).
374    fn compute_initial_dft(&mut self) {
375        let n_f = T::from_usize(self.n);
376        for k in 0..self.n {
377            let mut sum = Complex::zero();
378            for m in 0..self.n {
379                let idx = (self.pos + m) % self.n;
380                let angle = -T::TWO_PI * T::from_usize(k) * T::from_usize(m) / n_f;
381                sum = sum + self.buffer[idx] * Complex::cis(angle);
382            }
383            self.y_spectrum[k] = sum;
384        }
385    }
386}
387
388// ---------------------------------------------------------------------------
389// SingleBinTracker — O(1) per sample for a single frequency bin
390// ---------------------------------------------------------------------------
391
392/// Tracks a single frequency bin of a sliding window in O(1) per sample.
393///
394/// Useful for tone detection, frequency monitoring, or DTMF decoding where
395/// only one (or a few) bins are of interest.
396#[derive(Clone, Debug)]
397pub struct SingleBinTracker<T: Float> {
398    /// Which bin to track (0 ≤ k < N).
399    k: usize,
400    /// Window size.
401    n: usize,
402    /// Circular buffer of samples.
403    buffer: Vec<Complex<T>>,
404    /// Current bin value.
405    value: Complex<T>,
406    /// Precomputed twiddle W_N^k.
407    twiddle: Complex<T>,
408    /// Write position.
409    pos: usize,
410    /// Samples pushed so far (clamped at N).
411    samples_pushed: usize,
412}
413
414impl<T: Float> SingleBinTracker<T> {
415    /// Create a new single-bin tracker.
416    ///
417    /// # Arguments
418    ///
419    /// * `frequency_bin` — the DFT bin index k (0 ≤ k < window_size).
420    /// * `window_size` — the sliding window length N.
421    ///
422    /// # Panics
423    ///
424    /// Panics if `window_size == 0` or `frequency_bin >= window_size`.
425    pub fn new(frequency_bin: usize, window_size: usize) -> Self {
426        assert!(window_size > 0, "SingleBinTracker window size must be > 0");
427        assert!(
428            frequency_bin < window_size,
429            "frequency_bin ({frequency_bin}) must be < window_size ({window_size})"
430        );
431
432        let n_f = T::from_usize(window_size);
433        let angle = T::TWO_PI * T::from_usize(frequency_bin) / n_f;
434        let twiddle = Complex::cis(angle);
435
436        Self {
437            k: frequency_bin,
438            n: window_size,
439            buffer: vec![Complex::zero(); window_size],
440            value: Complex::zero(),
441            twiddle,
442            pos: 0,
443            samples_pushed: 0,
444        }
445    }
446
447    /// Push one complex sample — O(1).
448    pub fn push(&mut self, sample: Complex<T>) {
449        let x_old = self.buffer[self.pos];
450        self.buffer[self.pos] = sample;
451        self.pos = (self.pos + 1) % self.n;
452
453        if self.samples_pushed < self.n {
454            self.samples_pushed += 1;
455            if self.samples_pushed == self.n {
456                self.compute_initial_bin();
457            }
458            return;
459        }
460
461        // X[k] = (X[k] - x_old + x_new) * W_N^k
462        self.value = (self.value + sample - x_old) * self.twiddle;
463    }
464
465    /// Convenience: push a real-valued sample.
466    #[inline]
467    pub fn push_real(&mut self, sample: T) {
468        self.push(Complex::new(sample, T::ZERO));
469    }
470
471    /// Current (complex) bin value.
472    #[inline]
473    pub fn value(&self) -> Complex<T> {
474        self.value
475    }
476
477    /// Magnitude of the tracked bin: |X\[k\]|.
478    #[inline]
479    pub fn magnitude(&self) -> T {
480        self.value.norm()
481    }
482
483    /// Phase (argument) of the tracked bin.
484    #[inline]
485    pub fn phase(&self) -> T {
486        self.value.arg()
487    }
488
489    /// The bin index being tracked.
490    #[inline]
491    pub fn bin_index(&self) -> usize {
492        self.k
493    }
494
495    /// Window size.
496    #[inline]
497    pub fn window_size(&self) -> usize {
498        self.n
499    }
500
501    /// Whether the buffer has been filled at least once.
502    #[inline]
503    pub fn is_initialized(&self) -> bool {
504        self.samples_pushed >= self.n
505    }
506
507    /// Reset state, keep parameters.
508    pub fn reset(&mut self) {
509        for v in &mut self.buffer {
510            *v = Complex::zero();
511        }
512        self.value = Complex::zero();
513        self.pos = 0;
514        self.samples_pushed = 0;
515    }
516
517    // -- private helpers -----------------------------------------------------
518
519    /// DFT of bin k from the current circular buffer.
520    fn compute_initial_bin(&mut self) {
521        let n_f = T::from_usize(self.n);
522        let mut sum = Complex::zero();
523        for m in 0..self.n {
524            let idx = (self.pos + m) % self.n;
525            let angle = -T::TWO_PI * T::from_usize(self.k) * T::from_usize(m) / n_f;
526            sum = sum + self.buffer[idx] * Complex::cis(angle);
527        }
528        self.value = sum;
529    }
530}
531
532// ---------------------------------------------------------------------------
533// Convenience constructors
534// ---------------------------------------------------------------------------
535
536/// Create a [`SlidingDft<f64>`] with window size `n`.
537#[inline]
538pub fn sliding_dft(n: usize) -> SlidingDft<f64> {
539    SlidingDft::new(n)
540}
541
542/// Create a [`SingleBinTracker<f64>`] for the given bin and window size.
543#[inline]
544pub fn single_bin_tracker(bin: usize, window: usize) -> SingleBinTracker<f64> {
545    SingleBinTracker::new(bin, window)
546}
547
548// ---------------------------------------------------------------------------
549// Tests
550// ---------------------------------------------------------------------------
551
552#[cfg(test)]
553mod tests {
554    use super::*;
555
556    /// Helper: brute-force DFT of a complex slice (reference implementation).
557    fn reference_dft(x: &[Complex<f64>]) -> Vec<Complex<f64>> {
558        let n = x.len();
559        let n_f = n as f64;
560        (0..n)
561            .map(|k| {
562                let mut sum = Complex::<f64>::zero();
563                for (m, xm) in x.iter().enumerate() {
564                    let angle = -core::f64::consts::TAU * (k as f64) * (m as f64) / n_f;
565                    sum = sum + *xm * Complex::cis(angle);
566                }
567                sum
568            })
569            .collect()
570    }
571
572    /// Assert two complex slices are approximately equal.
573    fn assert_spectrum_close(a: &[Complex<f64>], b: &[Complex<f64>], tol: f64) {
574        assert_eq!(a.len(), b.len(), "spectrum length mismatch");
575        for (i, (ai, bi)) in a.iter().zip(b.iter()).enumerate() {
576            let diff = (*ai - *bi).norm();
577            assert!(diff < tol, "bin {i}: |{ai:?} - {bi:?}| = {diff} >= {tol}");
578        }
579    }
580
581    // -- SlidingDft tests ---------------------------------------------------
582
583    #[test]
584    fn sdft_matches_reference_dft() {
585        let n = 16;
586        let mut sdft = SlidingDft::<f64>::new(n);
587
588        // Push 2*N samples so the window has slid.
589        let samples: Vec<Complex<f64>> = (0..2 * n)
590            .map(|i| Complex::new(i as f64, -(i as f64) * 0.5))
591            .collect();
592
593        for s in &samples {
594            sdft.push(*s);
595        }
596
597        // The current window is the last N samples.
598        let window = &samples[n..];
599        let ref_spectrum = reference_dft(window);
600
601        assert_spectrum_close(sdft.spectrum(), &ref_spectrum, 1e-9);
602    }
603
604    #[test]
605    fn sdft_real_input_convenience() {
606        let n = 8;
607        let mut sdft = SlidingDft::<f64>::new(n);
608
609        let reals: Vec<f64> = (0..n).map(|i| (i as f64) * 1.5).collect();
610        for &r in &reals {
611            sdft.push_real(r);
612        }
613
614        let complex_in: Vec<Complex<f64>> = reals.iter().map(|&r| Complex::new(r, 0.0)).collect();
615
616        let ref_spectrum = reference_dft(&complex_in);
617        assert_spectrum_close(sdft.spectrum(), &ref_spectrum, 1e-12);
618    }
619
620    #[test]
621    fn sdft_magnitude_and_power() {
622        let n = 8;
623        let mut sdft = SlidingDft::<f64>::new(n);
624        for i in 0..n {
625            sdft.push_real(i as f64);
626        }
627
628        let mag = sdft.magnitude_spectrum();
629        let pow = sdft.power_spectrum();
630        assert_eq!(mag.len(), n);
631        assert_eq!(pow.len(), n);
632
633        for i in 0..n {
634            let expected_mag = sdft.spectrum()[i].norm();
635            let expected_pow = sdft.spectrum()[i].norm_sqr();
636            assert!((mag[i] - expected_mag).abs() < 1e-14);
637            assert!((pow[i] - expected_pow).abs() < 1e-14);
638        }
639    }
640
641    #[test]
642    fn sdft_reset() {
643        let n = 4;
644        let mut sdft = SlidingDft::<f64>::new(n);
645        for i in 0..n {
646            sdft.push_real(i as f64);
647        }
648        assert!(sdft.is_initialized());
649
650        sdft.reset();
651        assert!(!sdft.is_initialized());
652
653        // Spectrum should be zero after reset.
654        for &bin in sdft.spectrum() {
655            assert!((bin.norm()) < 1e-15);
656        }
657    }
658
659    #[test]
660    fn sdft_edge_n1() {
661        let mut sdft = SlidingDft::<f64>::new(1);
662        sdft.push_real(42.0);
663        assert!(sdft.is_initialized());
664        assert!((sdft.bin(0).re - 42.0).abs() < 1e-14);
665
666        sdft.push_real(7.0);
667        assert!((sdft.bin(0).re - 7.0).abs() < 1e-14);
668    }
669
670    #[test]
671    fn sdft_edge_n2() {
672        let mut sdft = SlidingDft::<f64>::new(2);
673        sdft.push_real(1.0);
674        sdft.push_real(2.0);
675        assert!(sdft.is_initialized());
676
677        let window = [Complex::new(1.0, 0.0), Complex::new(2.0, 0.0)];
678        let ref_spec = reference_dft(&window);
679        assert_spectrum_close(sdft.spectrum(), &ref_spec, 1e-14);
680    }
681
682    #[test]
683    fn sdft_pure_sinusoid() {
684        let n = 64;
685        let mut sdft = SlidingDft::<f64>::new(n);
686
687        // Generate a pure sinusoid at bin 5.
688        let bin_freq = 5;
689        for i in 0..2 * n {
690            let t = core::f64::consts::TAU * (bin_freq as f64) * (i as f64) / (n as f64);
691            sdft.push_real(t.cos());
692        }
693
694        // Bin 5 (and mirror N-5) should dominate.
695        let mag = sdft.magnitude_spectrum();
696        let peak = mag[bin_freq];
697        for (k, &m) in mag.iter().enumerate() {
698            if k != bin_freq && k != n - bin_freq {
699                assert!(
700                    m < peak * 0.01,
701                    "bin {k} magnitude {m} is too large compared to peak {peak}"
702                );
703            }
704        }
705    }
706
707    #[test]
708    fn sdft_bin_out_of_range() {
709        let sdft = SlidingDft::<f64>::new(4);
710        let v = sdft.bin(100);
711        assert!(v.norm() < 1e-15);
712    }
713
714    // -- ModulatedSdft tests -----------------------------------------------
715
716    #[test]
717    fn modulated_sdft_matches_reference() {
718        let n = 16;
719        let mut msdft = ModulatedSdft::<f64>::new(n);
720
721        let samples: Vec<Complex<f64>> = (0..2 * n)
722            .map(|i| Complex::new((i as f64).sin(), (i as f64).cos()))
723            .collect();
724
725        for s in &samples {
726            msdft.push(*s);
727        }
728
729        let window = &samples[n..];
730        let ref_spectrum = reference_dft(window);
731        let got = msdft.spectrum();
732
733        assert_spectrum_close(&got, &ref_spectrum, 1e-9);
734    }
735
736    #[test]
737    fn modulated_sdft_stable_over_long_run() {
738        // Push >10000 samples and verify the result is still accurate.
739        let n = 32;
740        let mut msdft = ModulatedSdft::<f64>::new(n);
741        let mut plain_sdft = SlidingDft::<f64>::new(n);
742
743        let total = 12_000;
744        let mut recent = Vec::with_capacity(n);
745
746        for i in 0..total {
747            let val = Complex::new(((i as f64) * 0.1).sin(), ((i as f64) * 0.07).cos());
748            msdft.push(val);
749            plain_sdft.push(val);
750
751            // Keep track of the last N samples for reference DFT.
752            recent.push(val);
753            if recent.len() > n {
754                recent.remove(0);
755            }
756        }
757
758        let ref_spectrum = reference_dft(&recent);
759        let mod_spec = msdft.spectrum();
760
761        // The modulated variant should be very close to reference.
762        assert_spectrum_close(&mod_spec, &ref_spectrum, 1e-6);
763
764        // The plain variant may have drifted more (we just check it compiles).
765        let _plain_spec = plain_sdft.spectrum();
766    }
767
768    #[test]
769    fn modulated_sdft_reset() {
770        let n = 8;
771        let mut msdft = ModulatedSdft::<f64>::new(n);
772        for i in 0..n {
773            msdft.push_real(i as f64);
774        }
775        assert!(msdft.is_initialized());
776
777        msdft.reset();
778        assert!(!msdft.is_initialized());
779
780        let spec = msdft.spectrum();
781        for bin in &spec {
782            assert!(bin.norm() < 1e-15);
783        }
784    }
785
786    #[test]
787    fn modulated_sdft_magnitude_power() {
788        let n = 8;
789        let mut msdft = ModulatedSdft::<f64>::new(n);
790        for i in 0..n {
791            msdft.push_real(i as f64);
792        }
793
794        let spec = msdft.spectrum();
795        let mag = msdft.magnitude_spectrum();
796        let pow = msdft.power_spectrum();
797
798        for i in 0..n {
799            assert!((mag[i] - spec[i].norm()).abs() < 1e-14);
800            assert!((pow[i] - spec[i].norm_sqr()).abs() < 1e-14);
801        }
802    }
803
804    #[test]
805    fn modulated_sdft_single_bin() {
806        let n = 16;
807        let mut msdft = ModulatedSdft::<f64>::new(n);
808
809        for i in 0..2 * n {
810            msdft.push_real((i as f64) * 0.3);
811        }
812
813        let full = msdft.spectrum();
814        for k in 0..n {
815            let single = msdft.bin(k);
816            let diff = (single - full[k]).norm();
817            assert!(diff < 1e-12, "bin {k} mismatch: {diff}");
818        }
819    }
820
821    // -- SingleBinTracker tests ---------------------------------------------
822
823    #[test]
824    fn single_bin_matches_full_sdft() {
825        let n = 16;
826        let k = 5;
827        let mut sdft = SlidingDft::<f64>::new(n);
828        let mut tracker = SingleBinTracker::<f64>::new(k, n);
829
830        let samples: Vec<Complex<f64>> = (0..3 * n)
831            .map(|i| Complex::new((i as f64).sin(), (i as f64 * 0.3).cos()))
832            .collect();
833
834        for s in &samples {
835            sdft.push(*s);
836            tracker.push(*s);
837        }
838
839        let diff = (sdft.bin(k) - tracker.value()).norm();
840        assert!(diff < 1e-9, "tracker vs sdft bin {k}: diff = {diff}");
841    }
842
843    #[test]
844    fn single_bin_magnitude_phase() {
845        let n = 8;
846        let k = 3;
847        let mut tracker = SingleBinTracker::<f64>::new(k, n);
848
849        for i in 0..n {
850            tracker.push_real(i as f64);
851        }
852
853        let v = tracker.value();
854        assert!((tracker.magnitude() - v.norm()).abs() < 1e-14);
855        assert!((tracker.phase() - v.arg()).abs() < 1e-14);
856    }
857
858    #[test]
859    fn single_bin_reset() {
860        let n = 8;
861        let mut tracker = SingleBinTracker::<f64>::new(2, n);
862        for i in 0..n {
863            tracker.push_real(i as f64);
864        }
865        assert!(tracker.is_initialized());
866
867        tracker.reset();
868        assert!(!tracker.is_initialized());
869        assert!(tracker.value().norm() < 1e-15);
870    }
871
872    #[test]
873    fn single_bin_accessors() {
874        let tracker = SingleBinTracker::<f64>::new(3, 16);
875        assert_eq!(tracker.bin_index(), 3);
876        assert_eq!(tracker.window_size(), 16);
877        assert!(!tracker.is_initialized());
878    }
879
880    #[test]
881    fn single_bin_tone_detection() {
882        // Generate a pure tone at bin=7 and verify the tracker sees a big peak.
883        let n = 64;
884        let target_bin = 7;
885        let mut tracker = SingleBinTracker::<f64>::new(target_bin, n);
886        let mut other_tracker = SingleBinTracker::<f64>::new(13, n);
887
888        for i in 0..2 * n {
889            let t = core::f64::consts::TAU * (target_bin as f64) * (i as f64) / (n as f64);
890            let sample = Complex::new(t.cos(), 0.0);
891            tracker.push(sample);
892            other_tracker.push(sample);
893        }
894
895        // The matching tracker should have a large magnitude.
896        // The non-matching tracker should be near zero.
897        assert!(tracker.magnitude() > 20.0);
898        assert!(other_tracker.magnitude() < 1.0);
899    }
900
901    // -- f32 tests ----------------------------------------------------------
902
903    #[test]
904    fn sdft_f32_works() {
905        let n = 8;
906        let mut sdft = SlidingDft::<f32>::new(n);
907        for i in 0..n {
908            sdft.push_real(i as f32);
909        }
910        assert!(sdft.is_initialized());
911        assert_eq!(sdft.spectrum().len(), n);
912    }
913
914    #[test]
915    fn modulated_sdft_f32_works() {
916        let n = 8;
917        let mut msdft = ModulatedSdft::<f32>::new(n);
918        for i in 0..n {
919            msdft.push_real(i as f32);
920        }
921        assert!(msdft.is_initialized());
922        assert_eq!(msdft.spectrum().len(), n);
923    }
924
925    #[test]
926    fn single_bin_f32_works() {
927        let mut tracker = SingleBinTracker::<f32>::new(2, 8);
928        for i in 0..8 {
929            tracker.push_real(i as f32);
930        }
931        assert!(tracker.is_initialized());
932        assert!(tracker.magnitude() > 0.0);
933    }
934
935    // -- convenience function tests -----------------------------------------
936
937    #[test]
938    fn convenience_sliding_dft() {
939        let mut s = sliding_dft(4);
940        for i in 0..4 {
941            s.push_real(i as f64);
942        }
943        assert!(s.is_initialized());
944    }
945
946    #[test]
947    fn convenience_single_bin_tracker() {
948        let mut t = single_bin_tracker(1, 4);
949        for i in 0..4 {
950            t.push_real(i as f64);
951        }
952        assert!(t.is_initialized());
953    }
954}