Skip to main content

rustriff_lib/services/analyzers/
spectrum_analyzer_service.rs

1use crate::domain::dto::spectrum_snapshot_dto::SpectrumSnapshotDto;
2use crate::services::analyzers::spectrum_tap::{SpectrumTap, SPECTRUM_WINDOW_SIZE};
3use rustfft::num_complex::Complex;
4use rustfft::{Fft, FftPlanner};
5use std::cell::RefCell;
6use std::sync::{Arc, OnceLock};
7
8/// Lower bound for analyzer frequencies in Hz.
9const MIN_ANALYZER_FREQ_HZ: f32 = 20.0;
10/// Lower clamp for displayed magnitudes (dBFS).
11const MIN_DB: f32 = -90.0;
12/// Upper clamp for displayed magnitudes (dBFS).
13const MAX_DB: f32 = 6.0;
14/// Number of points emitted to the frontend per frame.
15const ANALYZER_BINS: usize = 96;
16/// Upper frequency visible in the analyzer. Normal sample rates (≥ 44 100 Hz) always
17/// reach Nyquist above this value, so the log-spaced bin frequencies are session-stable.
18const MAX_ANALYZER_FREQ_HZ: f32 = 20_000.0;
19
20/// Immutable per-session caches: FFT plan, Hann coefficients, and log-spaced frequencies.
21///
22/// All three only depend on the fixed window / bin constants and never change at runtime,
23/// so a single `OnceLock` initialization is sufficient.
24struct AnalyzerCaches {
25    fft: Arc<dyn Fft<f32>>,
26    hann: Vec<f32>,
27    /// Log-spaced center frequencies shared across every frame in the DTO.
28    frequencies_hz: Arc<[f32]>,
29}
30
31static CACHES: OnceLock<AnalyzerCaches> = OnceLock::new();
32
33fn caches() -> &'static AnalyzerCaches {
34    CACHES.get_or_init(|| {
35        let mut planner = FftPlanner::new();
36        let fft = planner.plan_fft_forward(SPECTRUM_WINDOW_SIZE);
37        let hann = (0..SPECTRUM_WINDOW_SIZE)
38            .map(|i| hann_window(i, SPECTRUM_WINDOW_SIZE))
39            .collect();
40        let frequencies_hz: Arc<[f32]> = (0..ANALYZER_BINS)
41            .map(|i| {
42                frequency_for_bin(i, ANALYZER_BINS, MIN_ANALYZER_FREQ_HZ, MAX_ANALYZER_FREQ_HZ)
43            })
44            .collect::<Vec<_>>()
45            .into();
46
47        AnalyzerCaches {
48            fft,
49            hann,
50            frequencies_hz,
51        }
52    })
53}
54
55// Per-thread scratch buffer so the hot path never allocates a Vec<Complex<f32>> per frame.
56thread_local! {
57    static FFT_BUF: RefCell<Vec<Complex<f32>>> =
58        RefCell::new(vec![Complex::new(0.0, 0.0); SPECTRUM_WINDOW_SIZE]);
59}
60
61/// Stateless service that converts time-domain tap samples into log-spaced dB spectrum data.
62pub struct SpectrumAnalyzerService;
63
64impl SpectrumAnalyzerService {
65    /// Builds a spectrum snapshot from the most recent samples in the tap.
66    pub fn analyze_tap(tap: &SpectrumTap) -> SpectrumSnapshotDto {
67        let sample_rate_hz = tap.sample_rate_hz();
68        let samples = tap.snapshot_window();
69        Self::analyze_samples(&samples, sample_rate_hz)
70    }
71
72    /// Computes FFT magnitudes at log-spaced frequencies and returns a frontend DTO.
73    fn analyze_samples(samples: &[f32], sample_rate_hz: u32) -> SpectrumSnapshotDto {
74        if samples.is_empty() {
75            return SpectrumSnapshotDto {
76                sample_rate_hz: sample_rate_hz.max(1),
77                frequencies_hz: caches().frequencies_hz.to_vec(),
78                magnitudes: vec![MIN_DB; ANALYZER_BINS],
79                level_db: MIN_DB,
80            };
81        }
82
83        let sample_rate = sample_rate_hz.max(1) as f32;
84
85        if samples.len() == SPECTRUM_WINDOW_SIZE {
86            // Hot path: reuse cached plan, Hann coefficients, and per-thread scratch buffer —
87            // no heap allocation occurs here beyond the output magnitudes Vec.
88            let c = caches();
89            FFT_BUF.with(|cell| {
90                let mut buf = cell.borrow_mut();
91                for (i, (dst, &sample)) in buf.iter_mut().zip(samples.iter()).enumerate() {
92                    *dst = Complex::new(sample * c.hann[i], 0.0);
93                }
94                c.fft.process(&mut buf);
95
96                let magnitudes = c
97                    .frequencies_hz
98                    .iter()
99                    .map(|&f| magnitude_db_at_frequency(&buf, sample_rate, f))
100                    .collect();
101
102                SpectrumSnapshotDto {
103                    sample_rate_hz: sample_rate_hz.max(1),
104                    frequencies_hz: c.frequencies_hz.to_vec(),
105                    magnitudes,
106                    level_db: rms_db(samples),
107                }
108            })
109        } else {
110            // Fallback path for non-standard window sizes (used in unit tests).
111            let max_frequency_hz =
112                (sample_rate * 0.5).clamp(MIN_ANALYZER_FREQ_HZ + 1.0, MAX_ANALYZER_FREQ_HZ);
113
114            let mut fft_input: Vec<Complex<f32>> = samples
115                .iter()
116                .enumerate()
117                .map(|(i, sample)| Complex::new(*sample * hann_window(i, samples.len()), 0.0))
118                .collect();
119
120            let mut planner = FftPlanner::<f32>::new();
121            planner
122                .plan_fft_forward(samples.len())
123                .process(&mut fft_input);
124
125            let frequencies_hz: Vec<f32> = (0..ANALYZER_BINS)
126                .map(|i| {
127                    frequency_for_bin(i, ANALYZER_BINS, MIN_ANALYZER_FREQ_HZ, max_frequency_hz)
128                })
129                .collect();
130
131            let magnitudes: Vec<f32> = frequencies_hz
132                .iter()
133                .map(|&f| magnitude_db_at_frequency(&fft_input, sample_rate, f))
134                .collect();
135
136            SpectrumSnapshotDto {
137                sample_rate_hz: sample_rate_hz.max(1),
138                frequencies_hz,
139                magnitudes,
140                level_db: rms_db(samples),
141            }
142        }
143    }
144}
145
146/// Reads one FFT bin nearest to the target frequency and returns clamped dBFS.
147fn magnitude_db_at_frequency(
148    spectrum: &[Complex<f32>],
149    sample_rate: f32,
150    frequency_hz: f32,
151) -> f32 {
152    let n = spectrum.len().max(2);
153    let half = n / 2;
154    if half <= 1 {
155        return MIN_DB;
156    }
157
158    let bin_index = ((frequency_hz / sample_rate) * n as f32)
159        .round()
160        .clamp(1.0, (half - 1) as f32) as usize;
161
162    let normalized = (2.0 * spectrum[bin_index].norm()) / n as f32;
163    (20.0 * normalized.max(1e-7).log10()).clamp(MIN_DB, MAX_DB)
164}
165
166/// Returns the geometric center frequency for a log-spaced analyzer bin.
167fn frequency_for_bin(index: usize, bin_count: usize, min_hz: f32, max_hz: f32) -> f32 {
168    let ratio = max_hz / min_hz;
169    let center = (index as f32 + 0.5) / bin_count as f32;
170    min_hz * ratio.powf(center)
171}
172
173/// Hann window coefficient for sample `index` in a window of length `len`.
174fn hann_window(index: usize, len: usize) -> f32 {
175    if len <= 1 {
176        return 1.0;
177    }
178
179    let phase = (2.0 * std::f32::consts::PI * index as f32) / (len as f32 - 1.0);
180    0.5 * (1.0 - phase.cos())
181}
182
183/// Computes whole-window RMS in dBFS for level metering.
184fn rms_db(samples: &[f32]) -> f32 {
185    if samples.is_empty() {
186        return MIN_DB;
187    }
188
189    let mean_square =
190        samples.iter().map(|sample| sample * sample).sum::<f32>() / samples.len() as f32;
191    let rms = mean_square.sqrt();
192    (20.0 * rms.max(1e-7).log10()).max(MIN_DB)
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    #[cfg(test)]
200    mod success_path {
201        use super::*;
202
203        #[test]
204        fn analyze_samples_returns_expected_bin_shape() {
205            let samples = vec![0.0_f32; 2048];
206            let snapshot = SpectrumAnalyzerService::analyze_samples(&samples, 48_000);
207
208            assert_eq!(snapshot.sample_rate_hz, 48_000);
209            assert_eq!(snapshot.magnitudes.len(), ANALYZER_BINS);
210            assert_eq!(snapshot.frequencies_hz.len(), ANALYZER_BINS);
211            assert!(snapshot.level_db <= 0.0);
212            assert!(snapshot.level_db >= MIN_DB);
213        }
214
215        #[test]
216        fn analyze_samples_detects_a_tone_peak() {
217            let sample_rate = 48_000.0;
218            let target_freq = 1_000.0;
219            let samples = (0..2048)
220                .map(|n| {
221                    (2.0 * std::f32::consts::PI * target_freq * (n as f32 / sample_rate)).sin()
222                        * 0.8
223                })
224                .collect::<Vec<_>>();
225
226            let snapshot = SpectrumAnalyzerService::analyze_samples(&samples, sample_rate as u32);
227            let peak_value = snapshot.magnitudes.iter().copied().fold(MIN_DB, f32::max);
228
229            assert!(peak_value > -35.0);
230        }
231    }
232
233    #[cfg(test)]
234    mod failure_path {
235        use super::*;
236
237        #[test]
238        fn analyze_samples_with_empty_input_returns_safe_defaults() {
239            let snapshot = SpectrumAnalyzerService::analyze_samples(&[], 0);
240
241            assert_eq!(snapshot.sample_rate_hz, 1);
242            assert_eq!(snapshot.magnitudes.len(), ANALYZER_BINS);
243            assert!(snapshot.magnitudes.iter().all(|value| *value == MIN_DB));
244            assert_eq!(snapshot.level_db, MIN_DB);
245        }
246
247        #[test]
248        fn magnitude_db_with_tiny_fft_input_returns_min_db_instead_of_panicking() {
249            let tiny = vec![Complex::new(0.0_f32, 0.0_f32); 2];
250            let db = magnitude_db_at_frequency(&tiny, 48_000.0, 1_000.0);
251            assert_eq!(db, MIN_DB);
252        }
253    }
254}