rustriff_lib/services/analyzers/
spectrum_analyzer_service.rs1use 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
8const MIN_ANALYZER_FREQ_HZ: f32 = 20.0;
10const MIN_DB: f32 = -90.0;
12const MAX_DB: f32 = 6.0;
14const ANALYZER_BINS: usize = 96;
16const MAX_ANALYZER_FREQ_HZ: f32 = 20_000.0;
19
20struct AnalyzerCaches {
25 fft: Arc<dyn Fft<f32>>,
26 hann: Vec<f32>,
27 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
55thread_local! {
57 static FFT_BUF: RefCell<Vec<Complex<f32>>> =
58 RefCell::new(vec![Complex::new(0.0, 0.0); SPECTRUM_WINDOW_SIZE]);
59}
60
61pub struct SpectrumAnalyzerService;
63
64impl SpectrumAnalyzerService {
65 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 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 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 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
146fn 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
166fn 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
173fn 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
183fn 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}