Skip to main content

audio_lab/plot/
freq.rs

1use crate::signal::{FreqSignal, math::gain_to_db};
2use eframe::egui;
3use egui_plot::{GridInput, GridMark, Legend, Line, Plot, PlotPoint, PlotPoints};
4
5use crate::plot::native_options_any_thread;
6use crate::plot::save::SavePlotState;
7
8const DB_FLOOR: f64 = -120.0;
9
10#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11pub enum FreqValue {
12    Magnitude,
13    MagnitudeDb,
14    PhaseRadians,
15    PhaseDegrees,
16}
17
18#[derive(Clone, Copy, Debug, PartialEq)]
19pub struct FreqPlotOptions {
20    pub log_freq: bool,
21    pub value: FreqValue,
22}
23
24impl Default for FreqPlotOptions {
25    fn default() -> Self {
26        Self {
27            log_freq: true,
28            value: FreqValue::MagnitudeDb,
29        }
30    }
31}
32
33impl FreqValue {
34    fn value(&self, magnitude: f64, phase_radians: f64) -> f64 {
35        match self {
36            FreqValue::MagnitudeDb => gain_to_db(magnitude).max(DB_FLOOR),
37            FreqValue::Magnitude => magnitude,
38            FreqValue::PhaseRadians => phase_radians,
39            FreqValue::PhaseDegrees => phase_radians.to_degrees(),
40        }
41    }
42
43    fn y_label(&self) -> &'static str {
44        match self {
45            FreqValue::MagnitudeDb => "Magnitude (dB)",
46            FreqValue::Magnitude => "Magnitude",
47            FreqValue::PhaseRadians => "Phase (rad)",
48            FreqValue::PhaseDegrees => "Phase (deg)",
49        }
50    }
51
52    fn format_y(&self, y: f64) -> String {
53        match self {
54            FreqValue::MagnitudeDb => format!("{y:.1} dB"),
55            FreqValue::Magnitude => format!("{y:.4}"),
56            FreqValue::PhaseRadians => format!("{y:.4} rad"),
57            FreqValue::PhaseDegrees => format!("{y:.2} deg"),
58        }
59    }
60}
61
62/// Returns spectrum-style grid marks in log10 space.
63/// Three tiers:
64///   - Major (step 1.0):  decade boundaries — 10, 100, 1k, 10k, 100k
65///   - Medium (step 0.3): ×2 and ×5 per decade — 20, 50, 200, 500, …
66///   - Minor (step 0.1):  remaining integers per decade — 30, 40, 60, 70, …
67fn spectrum_grid_spacer(input: GridInput) -> Vec<GridMark> {
68    let (lo, hi) = (input.bounds.0, input.bounds.1);
69    let mut marks = Vec::new();
70
71    let start_exp = lo.floor() as i32 - 1;
72    let end_exp = hi.ceil() as i32 + 1;
73
74    for exp in start_exp..=end_exp {
75        let decade = 10f64.powi(exp);
76        for mult in 1..10 {
77            let hz = decade * mult as f64;
78            let log_pos = hz.log10();
79            if log_pos < lo || log_pos > hi {
80                continue;
81            }
82            let step_size = match mult {
83                1 => 1.0,
84                2 | 5 => 0.3,
85                _ => 0.1,
86            };
87            marks.push(GridMark {
88                value: log_pos,
89                step_size,
90            });
91        }
92    }
93    marks
94}
95
96pub(crate) struct FreqSignalPlot {
97    series: Vec<(String, Vec<PlotPoint>)>,
98    options: FreqPlotOptions,
99    save: SavePlotState,
100}
101
102impl FreqSignalPlot {
103    pub(crate) fn new(signals: &[&FreqSignal], title: &str, options: FreqPlotOptions) -> Self {
104        let mut series = Vec::new();
105        for (signal_index, signal) in signals.iter().enumerate() {
106            let freq_bins = signal.freq_bins();
107            let derived = match options.value {
108                FreqValue::Magnitude => Some(signal.to_magnitude()),
109                FreqValue::MagnitudeDb => Some(signal.to_magnitude_db()),
110                FreqValue::PhaseRadians | FreqValue::PhaseDegrees => Some(signal.to_phase()),
111            };
112            let num_channels = signal.num_channels();
113            for (channel_index, channel) in signal.channel_iter().enumerate() {
114                let derived_channel = derived.as_ref().map(|data| data.channel(channel_index));
115                let label = crate::plot::legend::build_channel_label(
116                    signal.channel_label(channel_index),
117                    signal.comment(),
118                    signal_index,
119                    channel_index,
120                    num_channels,
121                );
122                let points = freq_bins
123                    .iter()
124                    .zip(channel.iter().enumerate())
125                    .filter(|&(&f, _)| !options.log_freq || f > 0.0)
126                    .map(|(&f, (bin_index, c))| {
127                        let x = if options.log_freq { f.log10() } else { f };
128                        let y = if let Some(derived_channel) = &derived_channel {
129                            let value = derived_channel[bin_index];
130                            if matches!(options.value, FreqValue::PhaseDegrees) {
131                                value.to_degrees()
132                            } else {
133                                value
134                            }
135                        } else {
136                            options.value.value(c.norm(), c.arg())
137                        };
138                        PlotPoint::new(x, y)
139                    })
140                    .collect();
141                series.push((label, points));
142            }
143        }
144        Self {
145            series,
146            options,
147            save: SavePlotState::new(title),
148        }
149    }
150}
151
152impl eframe::App for FreqSignalPlot {
153    fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
154        egui::Panel::top("controls").show(ui, |ui| {
155            self.save.show_panel(ui);
156        });
157        egui::CentralPanel::default().show(ui, |ui| {
158            let mut plot = Plot::new("freq_signal")
159                .x_axis_label("Frequency (Hz)")
160                .y_axis_label(self.options.value.y_label())
161                .legend(Legend::default());
162
163            if self.options.log_freq {
164                let value = self.options.value;
165                plot = plot
166                    .x_grid_spacer(spectrum_grid_spacer)
167                    .x_axis_formatter(|mark, _range| {
168                        let hz = 10f64.powf(mark.value);
169                        if hz >= 1000.0 {
170                            format!("{:.0} kHz", hz / 1000.0)
171                        } else {
172                            format!("{hz:.0} Hz")
173                        }
174                    })
175                    .label_formatter(move |pos| {
176                        let (name, point) = match pos {
177                            egui_plot::HoverPosition::NearDataPoint {
178                                plot_name,
179                                position,
180                                ..
181                            } => (*plot_name, position),
182                            egui_plot::HoverPosition::Elsewhere { position } => ("", position),
183                        };
184                        let hz = 10f64.powf(point.x);
185                        let freq_str = if hz >= 1000.0 {
186                            format!("{:.1} kHz", hz / 1000.0)
187                        } else {
188                            format!("{hz:.1} Hz")
189                        };
190                        let y_str = value.format_y(point.y);
191                        Some(if name.is_empty() {
192                            format!("{freq_str}\n{y_str}")
193                        } else {
194                            format!("{name}\n{freq_str}\n{y_str}")
195                        })
196                    });
197            }
198
199            plot.show(ui, |plot_ui| {
200                for (label, points) in &self.series {
201                    plot_ui.line(Line::new(label.clone(), PlotPoints::Borrowed(points)));
202                }
203            });
204        });
205        self.save.handle_screenshot(ui.ctx());
206    }
207}
208
209pub fn show_freq_with_options(
210    title: &str,
211    signals: &[&FreqSignal],
212    options: FreqPlotOptions,
213) -> Result<(), crate::plot::Error> {
214    Ok(eframe::run_native(
215        title,
216        native_options_any_thread(),
217        Box::new(|_cc| Ok(Box::new(FreqSignalPlot::new(signals, title, options)))),
218    )?)
219}
220
221pub fn show_freq(title: &str, signals: &[&FreqSignal]) -> Result<(), crate::plot::Error> {
222    show_freq_with_options(title, signals, Default::default())
223}
224
225#[cfg(test)]
226mod tests {
227    use crate::signal::{TimeSignal, ndarray::arr2};
228
229    use super::*;
230
231    fn make_signal() -> FreqSignal {
232        let sample_rate = 44100.0;
233        TimeSignal::new(
234            arr2(&[
235                std::array::from_fn::<f64, 4096, _>(|i| {
236                    (i as f64 / sample_rate * 440.0 * std::f64::consts::TAU).sin()
237                }),
238                std::array::from_fn::<f64, 4096, _>(|i| {
239                    (i as f64 / sample_rate * 880.0 * std::f64::consts::TAU).sin()
240                }),
241            ]),
242            sample_rate,
243        )
244        .unwrap()
245        .into_freq()
246    }
247
248    #[test]
249    #[ignore]
250    fn test_show_freq_signal_log_db() {
251        show_freq("Freq Signal (Log, dB)", &[&make_signal()]).unwrap();
252    }
253
254    #[test]
255    #[ignore]
256    fn test_show_freq_signal_phase() {
257        show_freq_with_options(
258            "Freq Signal (Phase)",
259            &[&make_signal()],
260            FreqPlotOptions {
261                log_freq: true,
262                value: FreqValue::PhaseDegrees,
263            },
264        )
265        .unwrap();
266    }
267
268    #[test]
269    #[ignore]
270    fn test_show_freq_signal_phase_degrees() {
271        show_freq_with_options(
272            "Freq Signal (Phase, Degrees)",
273            &[&make_signal()],
274            FreqPlotOptions {
275                log_freq: true,
276                value: FreqValue::PhaseDegrees,
277            },
278        )
279        .unwrap();
280    }
281}