use crate::signal::{FreqSignal, math::gain_to_db};
use eframe::egui;
use egui_plot::{GridInput, GridMark, Legend, Line, Plot, PlotPoint, PlotPoints};
use crate::plot::native_options_any_thread;
use crate::plot::save::SavePlotState;
const DB_FLOOR: f64 = -120.0;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FreqValue {
Magnitude,
MagnitudeDb,
PhaseRadians,
PhaseDegrees,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FreqPlotOptions {
pub log_freq: bool,
pub value: FreqValue,
}
impl Default for FreqPlotOptions {
fn default() -> Self {
Self {
log_freq: true,
value: FreqValue::MagnitudeDb,
}
}
}
impl FreqValue {
fn value(&self, magnitude: f64, phase_radians: f64) -> f64 {
match self {
FreqValue::MagnitudeDb => gain_to_db(magnitude).max(DB_FLOOR),
FreqValue::Magnitude => magnitude,
FreqValue::PhaseRadians => phase_radians,
FreqValue::PhaseDegrees => phase_radians.to_degrees(),
}
}
fn y_label(&self) -> &'static str {
match self {
FreqValue::MagnitudeDb => "Magnitude (dB)",
FreqValue::Magnitude => "Magnitude",
FreqValue::PhaseRadians => "Phase (rad)",
FreqValue::PhaseDegrees => "Phase (deg)",
}
}
fn format_y(&self, y: f64) -> String {
match self {
FreqValue::MagnitudeDb => format!("{y:.1} dB"),
FreqValue::Magnitude => format!("{y:.4}"),
FreqValue::PhaseRadians => format!("{y:.4} rad"),
FreqValue::PhaseDegrees => format!("{y:.2} deg"),
}
}
}
fn spectrum_grid_spacer(input: GridInput) -> Vec<GridMark> {
let (lo, hi) = (input.bounds.0, input.bounds.1);
let mut marks = Vec::new();
let start_exp = lo.floor() as i32 - 1;
let end_exp = hi.ceil() as i32 + 1;
for exp in start_exp..=end_exp {
let decade = 10f64.powi(exp);
for mult in 1..10 {
let hz = decade * mult as f64;
let log_pos = hz.log10();
if log_pos < lo || log_pos > hi {
continue;
}
let step_size = match mult {
1 => 1.0,
2 | 5 => 0.3,
_ => 0.1,
};
marks.push(GridMark {
value: log_pos,
step_size,
});
}
}
marks
}
pub(crate) struct FreqSignalPlot {
series: Vec<(String, Vec<PlotPoint>)>,
options: FreqPlotOptions,
save: SavePlotState,
}
impl FreqSignalPlot {
pub(crate) fn new(signals: &[&FreqSignal], title: &str, options: FreqPlotOptions) -> Self {
let mut series = Vec::new();
for (signal_index, signal) in signals.iter().enumerate() {
let freq_bins = signal.freq_bins();
let derived = match options.value {
FreqValue::Magnitude => Some(signal.to_magnitude()),
FreqValue::MagnitudeDb => Some(signal.to_magnitude_db()),
FreqValue::PhaseRadians | FreqValue::PhaseDegrees => Some(signal.to_phase()),
};
let num_channels = signal.num_channels();
for (channel_index, channel) in signal.channel_iter().enumerate() {
let derived_channel = derived.as_ref().map(|data| data.channel(channel_index));
let label = crate::plot::legend::build_channel_label(
signal.channel_label(channel_index),
signal.comment(),
signal_index,
channel_index,
num_channels,
);
let points = freq_bins
.iter()
.zip(channel.iter().enumerate())
.filter(|&(&f, _)| !options.log_freq || f > 0.0)
.map(|(&f, (bin_index, c))| {
let x = if options.log_freq { f.log10() } else { f };
let y = if let Some(derived_channel) = &derived_channel {
let value = derived_channel[bin_index];
if matches!(options.value, FreqValue::PhaseDegrees) {
value.to_degrees()
} else {
value
}
} else {
options.value.value(c.norm(), c.arg())
};
PlotPoint::new(x, y)
})
.collect();
series.push((label, points));
}
}
Self {
series,
options,
save: SavePlotState::new(title),
}
}
}
impl eframe::App for FreqSignalPlot {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
egui::Panel::top("controls").show(ui, |ui| {
self.save.show_panel(ui);
});
egui::CentralPanel::default().show(ui, |ui| {
let mut plot = Plot::new("freq_signal")
.x_axis_label("Frequency (Hz)")
.y_axis_label(self.options.value.y_label())
.legend(Legend::default());
if self.options.log_freq {
let value = self.options.value;
plot = plot
.x_grid_spacer(spectrum_grid_spacer)
.x_axis_formatter(|mark, _range| {
let hz = 10f64.powf(mark.value);
if hz >= 1000.0 {
format!("{:.0} kHz", hz / 1000.0)
} else {
format!("{hz:.0} Hz")
}
})
.label_formatter(move |pos| {
let (name, point) = match pos {
egui_plot::HoverPosition::NearDataPoint {
plot_name,
position,
..
} => (*plot_name, position),
egui_plot::HoverPosition::Elsewhere { position } => ("", position),
};
let hz = 10f64.powf(point.x);
let freq_str = if hz >= 1000.0 {
format!("{:.1} kHz", hz / 1000.0)
} else {
format!("{hz:.1} Hz")
};
let y_str = value.format_y(point.y);
Some(if name.is_empty() {
format!("{freq_str}\n{y_str}")
} else {
format!("{name}\n{freq_str}\n{y_str}")
})
});
}
plot.show(ui, |plot_ui| {
for (label, points) in &self.series {
plot_ui.line(Line::new(label.clone(), PlotPoints::Borrowed(points)));
}
});
});
self.save.handle_screenshot(ui.ctx());
}
}
pub fn show_freq_with_options(
title: &str,
signals: &[&FreqSignal],
options: FreqPlotOptions,
) -> Result<(), crate::plot::Error> {
Ok(eframe::run_native(
title,
native_options_any_thread(),
Box::new(|_cc| Ok(Box::new(FreqSignalPlot::new(signals, title, options)))),
)?)
}
pub fn show_freq(title: &str, signals: &[&FreqSignal]) -> Result<(), crate::plot::Error> {
show_freq_with_options(title, signals, Default::default())
}
#[cfg(test)]
mod tests {
use crate::signal::{TimeSignal, ndarray::arr2};
use super::*;
fn make_signal() -> FreqSignal {
let sample_rate = 44100.0;
TimeSignal::new(
arr2(&[
std::array::from_fn::<f64, 4096, _>(|i| {
(i as f64 / sample_rate * 440.0 * std::f64::consts::TAU).sin()
}),
std::array::from_fn::<f64, 4096, _>(|i| {
(i as f64 / sample_rate * 880.0 * std::f64::consts::TAU).sin()
}),
]),
sample_rate,
)
.unwrap()
.into_freq()
}
#[test]
#[ignore]
fn test_show_freq_signal_log_db() {
show_freq("Freq Signal (Log, dB)", &[&make_signal()]).unwrap();
}
#[test]
#[ignore]
fn test_show_freq_signal_phase() {
show_freq_with_options(
"Freq Signal (Phase)",
&[&make_signal()],
FreqPlotOptions {
log_freq: true,
value: FreqValue::PhaseDegrees,
},
)
.unwrap();
}
#[test]
#[ignore]
fn test_show_freq_signal_phase_degrees() {
show_freq_with_options(
"Freq Signal (Phase, Degrees)",
&[&make_signal()],
FreqPlotOptions {
log_freq: true,
value: FreqValue::PhaseDegrees,
},
)
.unwrap();
}
}