Skip to main content

egui_cha_ds/atoms/audio/
spectrum.rs

1//! Spectrum atom - Frequency spectrum visualization for EDM/VJ applications
2//!
3//! Displays FFT frequency bin data as vertical bars. Commonly used for
4//! audio spectrum analyzers in DAWs and VJ software.
5//!
6//! # Features
7//! - Vertical bar display with configurable band count
8//! - Peak hold indicators
9//! - Multiple color modes (solid, gradient, rainbow)
10//! - Mirrored mode for symmetric display
11//!
12//! # Example
13//! ```ignore
14//! // Basic spectrum
15//! Spectrum::new(&fft_bins)
16//!     .show(ctx.ui);
17//!
18//! // With peak hold and gradient
19//! Spectrum::new(&fft_bins)
20//!     .bands(32)
21//!     .peak_hold(true)
22//!     .gradient(true)
23//!     .show(ctx.ui);
24//!
25//! // Mirrored (symmetric) display
26//! Spectrum::new(&fft_bins)
27//!     .mirrored(true)
28//!     .show(ctx.ui);
29//! ```
30
31use crate::Theme;
32use egui::{Color32, Pos2, Rect, Response, Sense, Stroke, Ui, Vec2, Widget};
33
34/// Color mode for spectrum bars
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
36pub enum SpectrumColorMode {
37    /// Single color from theme
38    #[default]
39    Solid,
40    /// Gradient from bottom to top
41    Gradient,
42    /// Rainbow across frequency bands
43    Rainbow,
44}
45
46/// A frequency spectrum visualization component
47pub struct Spectrum<'a> {
48    bins: &'a [f32],
49    bands: usize,
50    height: Option<f32>,
51    color_mode: SpectrumColorMode,
52    peak_hold: bool,
53    peaks: Option<&'a [f32]>,
54    mirrored: bool,
55    bar_gap: f32,
56}
57
58impl<'a> Spectrum<'a> {
59    /// Create a new spectrum from FFT bin data
60    ///
61    /// Bins should be normalized to 0.0..1.0 range
62    pub fn new(bins: &'a [f32]) -> Self {
63        Self {
64            bins,
65            bands: 32,
66            height: None,
67            color_mode: SpectrumColorMode::default(),
68            peak_hold: false,
69            peaks: None,
70            mirrored: false,
71            bar_gap: 2.0,
72        }
73    }
74
75    /// Set number of bands to display (default: 32)
76    pub fn bands(mut self, bands: usize) -> Self {
77        self.bands = bands.max(1);
78        self
79    }
80
81    /// Set display height (default: uses theme spacing)
82    pub fn height(mut self, height: f32) -> Self {
83        self.height = Some(height);
84        self
85    }
86
87    /// Set color mode
88    pub fn color_mode(mut self, mode: SpectrumColorMode) -> Self {
89        self.color_mode = mode;
90        self
91    }
92
93    /// Use gradient color mode
94    pub fn gradient(mut self) -> Self {
95        self.color_mode = SpectrumColorMode::Gradient;
96        self
97    }
98
99    /// Use rainbow color mode
100    pub fn rainbow(mut self) -> Self {
101        self.color_mode = SpectrumColorMode::Rainbow;
102        self
103    }
104
105    /// Enable peak hold indicators
106    pub fn peak_hold(mut self, enabled: bool) -> Self {
107        self.peak_hold = enabled;
108        self
109    }
110
111    /// Provide external peak values (for smooth decay)
112    pub fn peaks(mut self, peaks: &'a [f32]) -> Self {
113        self.peaks = Some(peaks);
114        self.peak_hold = true;
115        self
116    }
117
118    /// Enable mirrored (symmetric) display
119    pub fn mirrored(mut self, enabled: bool) -> Self {
120        self.mirrored = enabled;
121        self
122    }
123
124    /// Set gap between bars (default: 2.0)
125    pub fn bar_gap(mut self, gap: f32) -> Self {
126        self.bar_gap = gap;
127        self
128    }
129
130    /// Show the spectrum
131    pub fn show(self, ui: &mut Ui) -> Response {
132        let theme = Theme::current(ui.ctx());
133
134        // Calculate dimensions
135        let height = self.height.unwrap_or(theme.spacing_xl * 3.0);
136        let width = ui.available_width();
137
138        let (rect, response) = ui.allocate_exact_size(Vec2::new(width, height), Sense::hover());
139
140        if ui.is_rect_visible(rect) {
141            let painter = ui.painter();
142
143            // Background
144            painter.rect_filled(rect, theme.radius_sm, theme.bg_secondary);
145
146            // Calculate bands
147            let display_bands = if self.mirrored {
148                self.bands / 2
149            } else {
150                self.bands
151            };
152
153            let total_gap = self.bar_gap * (display_bands.saturating_sub(1)) as f32;
154            let bar_width = if self.mirrored {
155                (width - total_gap) / display_bands as f32 / 2.0 - self.bar_gap / 2.0
156            } else {
157                (width - total_gap) / display_bands as f32
158            };
159
160            let bins_per_band = self.bins.len() / display_bands.max(1);
161
162            // Draw bars
163            for i in 0..display_bands {
164                // Average bins for this band
165                let start = i * bins_per_band;
166                let end = ((i + 1) * bins_per_band).min(self.bins.len());
167                let slice = &self.bins[start..end];
168
169                let value = if slice.is_empty() {
170                    0.0
171                } else {
172                    // Use max for more responsive display
173                    slice.iter().cloned().fold(0.0_f32, f32::max)
174                };
175
176                let bar_height = value.clamp(0.0, 1.0) * (height - theme.spacing_xs * 2.0);
177
178                // Get color for this band
179                let color = self.get_bar_color(i, display_bands, value, &theme);
180
181                if self.mirrored {
182                    // Right side
183                    let x_right = rect.center().x
184                        + (i as f32 * (bar_width + self.bar_gap))
185                        + self.bar_gap / 2.0;
186                    let bar_rect = Rect::from_min_max(
187                        Pos2::new(x_right, rect.max.y - theme.spacing_xs - bar_height),
188                        Pos2::new(x_right + bar_width, rect.max.y - theme.spacing_xs),
189                    );
190                    painter.rect_filled(bar_rect, theme.radius_sm * 0.5, color);
191
192                    // Left side (mirror)
193                    let x_left = rect.center().x
194                        - (i as f32 * (bar_width + self.bar_gap))
195                        - bar_width
196                        - self.bar_gap / 2.0;
197                    let bar_rect_left = Rect::from_min_max(
198                        Pos2::new(x_left, rect.max.y - theme.spacing_xs - bar_height),
199                        Pos2::new(x_left + bar_width, rect.max.y - theme.spacing_xs),
200                    );
201                    painter.rect_filled(bar_rect_left, theme.radius_sm * 0.5, color);
202
203                    // Peak indicators
204                    if self.peak_hold {
205                        let peak_value =
206                            self.peaks.and_then(|p| p.get(i).cloned()).unwrap_or(value);
207                        let peak_y = rect.max.y
208                            - theme.spacing_xs
209                            - peak_value.clamp(0.0, 1.0) * (height - theme.spacing_xs * 2.0);
210
211                        // Right peak
212                        painter.line_segment(
213                            [
214                                Pos2::new(x_right, peak_y),
215                                Pos2::new(x_right + bar_width, peak_y),
216                            ],
217                            Stroke::new(theme.stroke_width * 2.0, theme.primary),
218                        );
219                        // Left peak
220                        painter.line_segment(
221                            [
222                                Pos2::new(x_left, peak_y),
223                                Pos2::new(x_left + bar_width, peak_y),
224                            ],
225                            Stroke::new(theme.stroke_width * 2.0, theme.primary),
226                        );
227                    }
228                } else {
229                    // Normal (non-mirrored)
230                    let x = rect.min.x + (i as f32 * (bar_width + self.bar_gap));
231                    let bar_rect = Rect::from_min_max(
232                        Pos2::new(x, rect.max.y - theme.spacing_xs - bar_height),
233                        Pos2::new(x + bar_width, rect.max.y - theme.spacing_xs),
234                    );
235                    painter.rect_filled(bar_rect, theme.radius_sm * 0.5, color);
236
237                    // Peak indicator
238                    if self.peak_hold {
239                        let peak_value =
240                            self.peaks.and_then(|p| p.get(i).cloned()).unwrap_or(value);
241                        let peak_y = rect.max.y
242                            - theme.spacing_xs
243                            - peak_value.clamp(0.0, 1.0) * (height - theme.spacing_xs * 2.0);
244
245                        painter.line_segment(
246                            [Pos2::new(x, peak_y), Pos2::new(x + bar_width, peak_y)],
247                            Stroke::new(theme.stroke_width * 2.0, theme.primary),
248                        );
249                    }
250                }
251            }
252
253            // Border
254            painter.rect_stroke(
255                rect,
256                theme.radius_sm,
257                Stroke::new(theme.border_width, theme.border),
258                egui::StrokeKind::Outside,
259            );
260        }
261
262        response
263    }
264
265    fn get_bar_color(&self, index: usize, total: usize, value: f32, theme: &Theme) -> Color32 {
266        match self.color_mode {
267            SpectrumColorMode::Solid => theme.primary,
268            SpectrumColorMode::Gradient => {
269                // Gradient from secondary (low) to primary (high)
270                let t = value.clamp(0.0, 1.0);
271                Color32::from_rgb(
272                    lerp_u8(theme.secondary.r(), theme.primary.r(), t),
273                    lerp_u8(theme.secondary.g(), theme.primary.g(), t),
274                    lerp_u8(theme.secondary.b(), theme.primary.b(), t),
275                )
276            }
277            SpectrumColorMode::Rainbow => {
278                // HSV rainbow across frequency bands
279                let hue = (index as f32 / total as f32) * 360.0;
280                hsv_to_rgb(hue, 0.8, 0.9)
281            }
282        }
283    }
284}
285
286impl Widget for Spectrum<'_> {
287    fn ui(self, ui: &mut Ui) -> Response {
288        self.show(ui)
289    }
290}
291
292// Helper functions
293fn lerp_u8(a: u8, b: u8, t: f32) -> u8 {
294    (a as f32 + (b as f32 - a as f32) * t) as u8
295}
296
297fn hsv_to_rgb(h: f32, s: f32, v: f32) -> Color32 {
298    let c = v * s;
299    let x = c * (1.0 - ((h / 60.0) % 2.0 - 1.0).abs());
300    let m = v - c;
301
302    let (r, g, b) = match (h / 60.0) as i32 {
303        0 => (c, x, 0.0),
304        1 => (x, c, 0.0),
305        2 => (0.0, c, x),
306        3 => (0.0, x, c),
307        4 => (x, 0.0, c),
308        _ => (c, 0.0, x),
309    };
310
311    Color32::from_rgb(
312        ((r + m) * 255.0) as u8,
313        ((g + m) * 255.0) as u8,
314        ((b + m) * 255.0) as u8,
315    )
316}