use std::f32::consts::PI;
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum WindowFn {
Rectangular,
#[default]
Hann,
Hamming,
Blackman,
}
#[non_exhaustive]
#[derive(Debug, Clone)]
pub struct StreamingFftConfig {
pub window_size: usize,
pub hop_size: usize,
pub window_fn: WindowFn,
pub n_channels: usize,
}
impl Default for StreamingFftConfig {
fn default() -> Self {
Self {
window_size: 512,
hop_size: 256,
window_fn: WindowFn::Hann,
n_channels: 1,
}
}
}
#[derive(Debug, Clone)]
pub struct SpectralFrame {
pub magnitudes: Vec<f32>,
pub phases: Vec<f32>,
pub timestamp: usize,
}
pub struct StreamingFftProcessor {
config: StreamingFftConfig,
ring_buffer: Vec<f32>,
buffer_pos: usize,
sample_count: usize,
samples_since_last_frame: usize,
}
impl StreamingFftProcessor {
pub fn new(config: StreamingFftConfig) -> Self {
debug_assert!(
config.window_size.is_power_of_two(),
"window_size must be a power of two, got {}",
config.window_size
);
let ring_buffer = vec![0.0_f32; config.window_size];
Self {
ring_buffer,
buffer_pos: 0,
sample_count: 0,
samples_since_last_frame: 0,
config,
}
}
pub fn push_samples(&mut self, samples: &[f32]) -> Vec<SpectralFrame> {
let mut frames = Vec::new();
let hop = self.config.hop_size;
for &sample in samples {
self.ring_buffer[self.buffer_pos % self.config.window_size] = sample;
self.buffer_pos += 1;
self.sample_count += 1;
self.samples_since_last_frame += 1;
if self.samples_since_last_frame >= hop {
if self.sample_count >= self.config.window_size {
let window_data = self.extract_window();
let frame = self.process_window(&window_data);
frames.push(frame);
}
self.samples_since_last_frame = 0;
}
}
frames
}
pub fn pending_samples(&self) -> usize {
self.config
.hop_size
.saturating_sub(self.samples_since_last_frame)
}
pub fn reset(&mut self) {
self.ring_buffer.fill(0.0);
self.buffer_pos = 0;
self.sample_count = 0;
self.samples_since_last_frame = 0;
}
fn extract_window(&self) -> Vec<f32> {
let ws = self.config.window_size;
let mut out = Vec::with_capacity(ws);
let start = self.buffer_pos % ws;
for i in 0..ws {
out.push(self.ring_buffer[(start + i) % ws]);
}
out
}
fn process_window(&self, window_data: &[f32]) -> SpectralFrame {
let timestamp = self.sample_count.saturating_sub(self.config.window_size);
let windowed = self.apply_window(window_data);
let spectrum = self.fft_radix2(&windowed);
let n_bins = self.config.window_size / 2 + 1;
let mut magnitudes = Vec::with_capacity(n_bins);
let mut phases = Vec::with_capacity(n_bins);
for (re, im) in spectrum.iter().take(n_bins) {
magnitudes.push((re * re + im * im).sqrt());
phases.push(im.atan2(*re));
}
SpectralFrame {
magnitudes,
phases,
timestamp,
}
}
fn apply_window(&self, data: &[f32]) -> Vec<f32> {
let n = data.len();
match self.config.window_fn {
WindowFn::Rectangular => data.to_vec(),
WindowFn::Hann => data
.iter()
.enumerate()
.map(|(i, &x)| {
let w = 0.5 * (1.0 - (2.0 * PI * i as f32 / (n - 1) as f32).cos());
x * w
})
.collect(),
WindowFn::Hamming => data
.iter()
.enumerate()
.map(|(i, &x)| {
let w = 0.54 - 0.46 * (2.0 * PI * i as f32 / (n - 1) as f32).cos();
x * w
})
.collect(),
WindowFn::Blackman => data
.iter()
.enumerate()
.map(|(i, &x)| {
let phase = 2.0 * PI * i as f32 / (n - 1) as f32;
let w = 0.42 - 0.5 * phase.cos() + 0.08 * (2.0 * phase).cos();
x * w
})
.collect(),
}
}
pub(crate) fn fft_radix2(&self, data: &[f32]) -> Vec<(f32, f32)> {
let n_orig = data.len();
let n = if n_orig.is_power_of_two() {
n_orig
} else {
n_orig.next_power_of_two()
};
let mut a: Vec<(f32, f32)> = data.iter().map(|&x| (x, 0.0)).collect();
a.resize(n, (0.0, 0.0));
let log2_n = n.trailing_zeros() as usize;
for i in 0..n {
let j = bit_reverse(i, log2_n);
if j > i {
a.swap(i, j);
}
}
let mut len = 2_usize;
while len <= n {
let half = len / 2;
let angle = -2.0 * PI / len as f32;
let w_base = (angle.cos(), angle.sin());
for start in (0..n).step_by(len) {
let mut wr = 1.0_f32;
let mut wi = 0.0_f32;
for k in 0..half {
let u = a[start + k];
let v = a[start + k + half];
let vw = (v.0 * wr - v.1 * wi, v.0 * wi + v.1 * wr);
a[start + k] = (u.0 + vw.0, u.1 + vw.1);
a[start + k + half] = (u.0 - vw.0, u.1 - vw.1);
let new_wr = wr * w_base.0 - wi * w_base.1;
let new_wi = wr * w_base.1 + wi * w_base.0;
wr = new_wr;
wi = new_wi;
}
}
len <<= 1;
}
a
}
}
#[inline]
fn bit_reverse(x: usize, bits: usize) -> usize {
let mut result = 0_usize;
let mut v = x;
for _ in 0..bits {
result = (result << 1) | (v & 1);
v >>= 1;
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_streaming_fft_config_default() {
let cfg = StreamingFftConfig::default();
assert_eq!(cfg.window_size, 512);
assert_eq!(cfg.hop_size, 256);
assert_eq!(cfg.window_fn, WindowFn::Hann);
assert_eq!(cfg.n_channels, 1);
}
#[test]
fn test_push_samples_returns_frames_when_enough_data() {
let cfg = StreamingFftConfig {
window_size: 64,
hop_size: 32,
..Default::default()
};
let mut proc = StreamingFftProcessor::new(cfg);
let samples: Vec<f32> = (0..96).map(|i| (i as f32 * 0.1).sin()).collect();
let frames = proc.push_samples(&samples);
assert!(!frames.is_empty(), "expected at least one frame");
}
#[test]
fn test_spectral_frame_magnitude_length() {
let cfg = StreamingFftConfig {
window_size: 64,
hop_size: 32,
..Default::default()
};
let mut proc = StreamingFftProcessor::new(cfg);
let samples: Vec<f32> = (0..128).map(|i| (i as f32 * 0.05).sin()).collect();
let frames = proc.push_samples(&samples);
assert!(!frames.is_empty());
for frame in &frames {
assert_eq!(
frame.magnitudes.len(),
33, "magnitudes length wrong"
);
}
}
#[test]
fn test_fft_pure_tone_peak_at_correct_bin() {
let n = 64_usize;
let k_tone = 4_usize;
let data: Vec<f32> = (0..n)
.map(|i| (2.0 * PI * k_tone as f32 * i as f32 / n as f32).cos())
.collect();
let cfg = StreamingFftConfig {
window_size: n,
..Default::default()
};
let proc = StreamingFftProcessor::new(cfg);
let spectrum = proc.fft_radix2(&data);
let magnitudes: Vec<f32> = spectrum
.iter()
.take(n / 2 + 1)
.map(|(re, im)| (re * re + im * im).sqrt())
.collect();
let peak_bin = magnitudes
.iter()
.enumerate()
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
.map(|(i, _)| i)
.unwrap_or(0);
assert_eq!(peak_bin, k_tone, "peak at wrong bin: {peak_bin}");
}
#[test]
fn test_reset_clears_buffer() {
let cfg = StreamingFftConfig {
window_size: 64,
hop_size: 32,
..Default::default()
};
let mut proc = StreamingFftProcessor::new(cfg);
let samples: Vec<f32> = vec![1.0; 96];
let _ = proc.push_samples(&samples);
assert!(proc.sample_count > 0);
proc.reset();
assert_eq!(proc.sample_count, 0);
assert!(proc.ring_buffer.iter().all(|&v| v == 0.0));
}
#[test]
fn test_hann_window_applied_correctly() {
let cfg = StreamingFftConfig {
window_size: 8,
window_fn: WindowFn::Hann,
..Default::default()
};
let proc = StreamingFftProcessor::new(cfg);
let data = vec![1.0_f32; 8];
let windowed = proc.apply_window(&data);
assert!(windowed[0].abs() < 1e-6, "Hann w[0] should be ~0");
assert!(windowed[7].abs() < 1e-6, "Hann w[N-1] should be ~0");
assert!(
windowed[4] > 0.9,
"Hann w[4] should be near 1.0: {}",
windowed[4]
);
}
#[test]
fn test_fft_radix2_handles_non_power_of_two_via_padding() {
let cfg = StreamingFftConfig::default();
let proc = StreamingFftProcessor::new(cfg);
let data = vec![1.0_f32; 10];
let result = proc.fft_radix2(&data);
assert_eq!(result.len(), 16, "should be padded to next power of two");
}
#[test]
fn test_pending_samples_decreases_as_samples_arrive() {
let cfg = StreamingFftConfig {
window_size: 64,
hop_size: 32,
..Default::default()
};
let mut proc = StreamingFftProcessor::new(cfg);
let initial_pending = proc.pending_samples();
let samples = vec![0.0_f32; 10];
let _ = proc.push_samples(&samples);
assert!(
proc.pending_samples() < initial_pending || proc.pending_samples() == 0,
"pending_samples did not decrease"
);
}
}