bbx_draw/visualizers/
spectrum.rs

1//! FFT-based spectrum analyzer.
2
3use nannou::{
4    Draw,
5    geom::{Point2, Rect},
6};
7use rustfft::{FftPlanner, num_complex::Complex};
8
9use crate::{
10    Visualizer,
11    bridge::AudioBridgeConsumer,
12    config::{SpectrumConfig, SpectrumDisplayMode},
13};
14
15/// FFT-based spectrum analyzer visualizer.
16///
17/// Displays frequency content of audio using bars, line, or filled display modes.
18/// Features temporal smoothing and optional peak hold.
19pub struct SpectrumAnalyzer {
20    consumer: AudioBridgeConsumer,
21    config: SpectrumConfig,
22    fft_planner: FftPlanner<f32>,
23    window: Vec<f32>,
24    sample_buffer: Vec<f32>,
25    fft_buffer: Vec<Complex<f32>>,
26    magnitudes: Vec<f32>,
27    smoothed_magnitudes: Vec<f32>,
28    peaks: Vec<f32>,
29    write_position: usize,
30}
31
32impl SpectrumAnalyzer {
33    /// Create a new spectrum analyzer with default configuration.
34    pub fn new(consumer: AudioBridgeConsumer) -> Self {
35        let config = SpectrumConfig::default();
36        Self::with_config(consumer, config)
37    }
38
39    /// Create a new spectrum analyzer with custom configuration.
40    pub fn with_config(consumer: AudioBridgeConsumer, config: SpectrumConfig) -> Self {
41        let fft_size = config.fft_size;
42        let window = hann_window(fft_size);
43        let num_bins = fft_size / 2;
44
45        Self {
46            consumer,
47            fft_planner: FftPlanner::new(),
48            window,
49            sample_buffer: vec![0.0; fft_size],
50            fft_buffer: vec![Complex::new(0.0, 0.0); fft_size],
51            magnitudes: vec![0.0; num_bins],
52            smoothed_magnitudes: vec![config.min_db; num_bins],
53            peaks: vec![config.min_db; num_bins],
54            write_position: 0,
55            config,
56        }
57    }
58
59    /// Get the current configuration.
60    pub fn config(&self) -> &SpectrumConfig {
61        &self.config
62    }
63
64    /// Update the configuration (may reset internal state).
65    pub fn set_config(&mut self, config: SpectrumConfig) {
66        if config.fft_size != self.config.fft_size {
67            let fft_size = config.fft_size;
68            let num_bins = fft_size / 2;
69            self.window = hann_window(fft_size);
70            self.sample_buffer = vec![0.0; fft_size];
71            self.fft_buffer = vec![Complex::new(0.0, 0.0); fft_size];
72            self.magnitudes = vec![0.0; num_bins];
73            self.smoothed_magnitudes = vec![config.min_db; num_bins];
74            self.peaks = vec![config.min_db; num_bins];
75            self.write_position = 0;
76        }
77        self.config = config;
78    }
79
80    fn compute_fft(&mut self) {
81        let fft_size = self.config.fft_size;
82        let fft = self.fft_planner.plan_fft_forward(fft_size);
83
84        for i in 0..fft_size {
85            let idx = (self.write_position + i) % fft_size;
86            self.fft_buffer[i] = Complex::new(self.sample_buffer[idx] * self.window[i], 0.0);
87        }
88
89        fft.process(&mut self.fft_buffer);
90
91        let num_bins = fft_size / 2;
92        let scale = 2.0 / fft_size as f32;
93
94        for i in 0..num_bins {
95            let magnitude = self.fft_buffer[i].norm() * scale;
96            self.magnitudes[i] = amplitude_to_db(magnitude);
97        }
98    }
99
100    fn apply_smoothing(&mut self) {
101        let smoothing = self.config.smoothing;
102        for i in 0..self.magnitudes.len() {
103            self.smoothed_magnitudes[i] =
104                smoothing * self.smoothed_magnitudes[i] + (1.0 - smoothing) * self.magnitudes[i];
105        }
106    }
107
108    fn update_peaks(&mut self) {
109        let decay = self.config.peak_decay;
110        for i in 0..self.magnitudes.len() {
111            if self.smoothed_magnitudes[i] > self.peaks[i] {
112                self.peaks[i] = self.smoothed_magnitudes[i];
113            } else {
114                self.peaks[i] -= decay;
115                self.peaks[i] = self.peaks[i].max(self.config.min_db);
116            }
117        }
118    }
119
120    fn db_to_normalized(&self, db: f32) -> f32 {
121        let range = self.config.max_db - self.config.min_db;
122        ((db - self.config.min_db) / range).clamp(0.0, 1.0)
123    }
124}
125
126impl Visualizer for SpectrumAnalyzer {
127    fn update(&mut self) {
128        let mut has_data = false;
129
130        while let Some(frame) = self.consumer.try_pop() {
131            if let Some(channel_iter) = frame.channel_samples(0) {
132                for sample in channel_iter {
133                    self.sample_buffer[self.write_position] = sample;
134                    self.write_position = (self.write_position + 1) % self.config.fft_size;
135                    has_data = true;
136                }
137            }
138        }
139
140        if has_data {
141            self.compute_fft();
142            self.apply_smoothing();
143            if self.config.show_peaks {
144                self.update_peaks();
145            }
146        }
147    }
148
149    fn draw(&self, draw: &Draw, bounds: Rect) {
150        let num_bins = self.smoothed_magnitudes.len();
151        if num_bins == 0 {
152            return;
153        }
154
155        match self.config.display_mode {
156            SpectrumDisplayMode::Bars => {
157                let bar_width = bounds.w() / num_bins as f32;
158
159                for (i, &db) in self.smoothed_magnitudes.iter().enumerate() {
160                    let height = self.db_to_normalized(db) * bounds.h();
161                    let x = bounds.left() + (i as f32 + 0.5) * bar_width;
162                    let y = bounds.bottom() + height / 2.0;
163
164                    draw.rect()
165                        .x_y(x, y)
166                        .w_h(bar_width * 0.8, height)
167                        .color(self.config.bar_color);
168
169                    if self.config.show_peaks {
170                        let peak_height = self.db_to_normalized(self.peaks[i]) * bounds.h();
171                        let peak_y = bounds.bottom() + peak_height;
172                        draw.line()
173                            .start(Point2::new(x - bar_width * 0.4, peak_y))
174                            .end(Point2::new(x + bar_width * 0.4, peak_y))
175                            .weight(2.0)
176                            .color(self.config.peak_color);
177                    }
178                }
179            }
180            SpectrumDisplayMode::Line => {
181                let points: Vec<Point2> = self
182                    .smoothed_magnitudes
183                    .iter()
184                    .enumerate()
185                    .map(|(i, &db)| {
186                        let x = bounds.left() + (i as f32 / num_bins as f32) * bounds.w();
187                        let y = bounds.bottom() + self.db_to_normalized(db) * bounds.h();
188                        Point2::new(x, y)
189                    })
190                    .collect();
191
192                if points.len() >= 2 {
193                    draw.polyline().weight(2.0).color(self.config.bar_color).points(points);
194                }
195            }
196            SpectrumDisplayMode::Filled => {
197                let mut points: Vec<Point2> = Vec::with_capacity(num_bins + 2);
198
199                points.push(Point2::new(bounds.left(), bounds.bottom()));
200
201                for (i, &db) in self.smoothed_magnitudes.iter().enumerate() {
202                    let x = bounds.left() + (i as f32 / num_bins as f32) * bounds.w();
203                    let y = bounds.bottom() + self.db_to_normalized(db) * bounds.h();
204                    points.push(Point2::new(x, y));
205                }
206
207                points.push(Point2::new(bounds.right(), bounds.bottom()));
208
209                if points.len() >= 3 {
210                    draw.polygon().color(self.config.bar_color).points(points);
211                }
212            }
213        }
214    }
215}
216
217fn hann_window(size: usize) -> Vec<f32> {
218    (0..size)
219        .map(|i| 0.5 * (1.0 - (std::f32::consts::TAU * i as f32 / size as f32).cos()))
220        .collect()
221}
222
223fn amplitude_to_db(amplitude: f32) -> f32 {
224    if amplitude <= 0.0 {
225        -120.0
226    } else {
227        20.0 * amplitude.log10()
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use crate::bridge::audio_bridge;
235
236    #[test]
237    fn test_spectrum_creation() {
238        let (_producer, consumer) = audio_bridge(4);
239        let analyzer = SpectrumAnalyzer::new(consumer);
240        assert_eq!(analyzer.sample_buffer.len(), 2048);
241        assert_eq!(analyzer.magnitudes.len(), 1024);
242    }
243
244    #[test]
245    fn test_hann_window() {
246        let window = hann_window(4);
247        assert!((window[0] - 0.0).abs() < 0.001);
248        assert!((window[1] - 0.5).abs() < 0.001);
249        assert!((window[2] - 1.0).abs() < 0.001);
250        assert!((window[3] - 0.5).abs() < 0.001);
251    }
252
253    #[test]
254    fn test_amplitude_to_db() {
255        assert!((amplitude_to_db(1.0) - 0.0).abs() < 0.001);
256        assert!((amplitude_to_db(0.1) - (-20.0)).abs() < 0.001);
257    }
258}