use ratatui::{
Frame,
layout::Rect,
style::{Color, Style, Stylize},
symbols,
widgets::{Axis, Block, Borders, Chart, Dataset, GraphType},
};
use crate::app::App;
use crate::effects::equalizer::{BiquadCoeffs, biquad_coefficients, biquad_magnitude_db};
use crate::pipeline::SAMPLE_RATE;
const RESPONSE_POINTS: usize = 200;
#[allow(
clippy::cast_precision_loss,
reason = "n is small (200), no precision issue"
)]
fn log_frequencies(start: f32, end: f32, n: usize) -> Vec<f32> {
let ratio = (end / start).powf(1.0 / (n.saturating_sub(1)) as f32);
let mut freqs = Vec::with_capacity(n);
let mut f = start;
for _ in 0..n {
freqs.push(f);
f *= ratio;
}
freqs
}
fn combined_response_db(coeffs: &[BiquadCoeffs], freq_hz: f32, sample_rate: f32) -> f64 {
coeffs
.iter()
.map(|c| f64::from(biquad_magnitude_db(c, freq_hz, sample_rate)))
.sum()
}
pub fn render(app: &App, frame: &mut Frame, area: Rect) {
let bands = &app.eq.bands;
let bypass = app.eq.bypass;
let preamp = app.preamp;
let curve: Vec<(f64, f64)> = if bands.is_empty() || bypass {
vec![(20.0f64.log10(), 0.0), (20000.0f64.log10(), 0.0)]
} else {
let coeffs: Vec<BiquadCoeffs> = bands
.iter()
.map(|b| biquad_coefficients(b, SAMPLE_RATE))
.collect();
let freqs = log_frequencies(20.0, 20000.0, RESPONSE_POINTS);
freqs
.iter()
.map(|&f| {
let x = f64::from(f).log10();
let y = combined_response_db(&coeffs, f, SAMPLE_RATE);
(x, y)
})
.collect()
};
let ref_line: [(f64, f64); 2] = [(20.0f64.log10(), 0.0), (20000.0f64.log10(), 0.0)];
let curve_dark = bypass || bands.is_empty();
let curve_style = if curve_dark {
Style::default().dark_gray()
} else {
Style::default().cyan().bold()
};
let mut datasets = vec![
Dataset::default()
.name("0 dB Ref")
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.style(Style::default().dark_gray().not_bold())
.data(&ref_line),
];
datasets.push(
Dataset::default()
.name(if bypass { "Bypassed" } else { "Response" })
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.style(curve_style)
.data(&curve),
);
let mut title = String::from(" Frequency Response ");
if !bands.is_empty() {
let _ = std::fmt::write(&mut title, format_args!("| Preamp: {preamp:.1} dB "));
}
if bypass {
title.push_str("[BYPASSED] ");
}
let x_axis = Axis::default()
.title("Frequency (Hz)".white())
.style(Style::default().gray())
.bounds([20.0f64.log10(), 20000.0f64.log10()])
.labels(["20", "100", "1k", "10k", "20k"]);
let y_axis = Axis::default()
.title("Gain (dB)".white())
.style(Style::default().gray())
.bounds([-24.0, 24.0])
.labels(["-24", "-12", "0", "+12", "+24"]);
let border_style = if bypass {
Style::default().fg(Color::DarkGray)
} else {
Style::default().fg(Color::Cyan)
};
let chart = Chart::new(datasets)
.block(
Block::default()
.title(title.as_str())
.borders(Borders::ALL)
.border_style(border_style),
)
.x_axis(x_axis)
.y_axis(y_axis);
frame.render_widget(chart, area);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn log_frequencies_range() {
let freqs = log_frequencies(20.0, 20000.0, 200);
assert_eq!(freqs.len(), 200);
assert!((freqs[0] - 20.0).abs() < 0.01);
assert!((freqs[199] - 20000.0).abs() < 1.0);
let ratio = freqs[1] / freqs[0];
for i in 2..freqs.len() {
let r = freqs[i] / freqs[i - 1];
assert!((r - ratio).abs() < 0.001, "non-uniform log spacing at {i}");
}
}
#[test]
fn log_frequencies_single_point() {
let freqs = log_frequencies(100.0, 100.0, 1);
assert_eq!(freqs.len(), 1);
assert!((freqs[0] - 100.0).abs() < 0.01);
}
#[test]
fn combined_response_empty_coeffs() {
assert!((combined_response_db(&[], 1000.0, 48000.0) - 0.0).abs() < f64::EPSILON);
}
#[test]
fn combined_response_peak_at_center() {
let band = crate::state::EqBand {
frequency: 1000.0,
gain: 6.0,
q: 1.0,
filter_type: crate::state::FilterType::Peak,
};
let coeffs = vec![biquad_coefficients(&band, 48000.0)];
let db = combined_response_db(&coeffs, 1000.0, 48000.0);
assert!(
(db - 6.0).abs() < 0.5,
"expected ~6 dB at center, got {db:.3}"
);
}
#[test]
fn combined_response_far_from_center() {
let band = crate::state::EqBand {
frequency: 1000.0,
gain: 12.0,
q: 5.0,
filter_type: crate::state::FilterType::Peak,
};
let coeffs = vec![biquad_coefficients(&band, 48000.0)];
let db = combined_response_db(&coeffs, 100.0, 48000.0);
assert!(
db.abs() < 1.0,
"expected near 0 dB far from center, got {db:.3}"
);
}
#[test]
fn response_far_from_center_very_low_freq() {
let band = crate::state::EqBand {
frequency: 1000.0,
gain: 6.0,
q: 1.0,
filter_type: crate::state::FilterType::Peak,
};
let coeffs = vec![biquad_coefficients(&band, 48000.0)];
let db = combined_response_db(&coeffs, 20.0, 48000.0);
assert!(
db.abs() < 0.1,
"expected ~0 dB at 20 Hz for 1 kHz PK filter, got {db:.6}"
);
}
#[test]
fn multiple_bands_response_far_from_centers() {
let bands = [
crate::state::EqBand {
frequency: 2000.0,
gain: 8.0,
q: 2.0,
filter_type: crate::state::FilterType::Peak,
},
crate::state::EqBand {
frequency: 5000.0,
gain: -3.0,
q: 1.5,
filter_type: crate::state::FilterType::Peak,
},
crate::state::EqBand {
frequency: 10000.0,
gain: 2.0,
q: 0.7,
filter_type: crate::state::FilterType::HighShelf,
},
];
let coeffs: Vec<_> = bands
.iter()
.map(|b| biquad_coefficients(b, 48000.0))
.collect();
let db_20 = combined_response_db(&coeffs, 20.0, 48000.0);
let db_50 = combined_response_db(&coeffs, 50.0, 48000.0);
let db_100 = combined_response_db(&coeffs, 100.0, 48000.0);
assert!(db_20.abs() < 0.2, "20 Hz: expected ~0, got {db_20:.6}");
assert!(db_50.abs() < 0.2, "50 Hz: expected ~0, got {db_50:.6}");
assert!(db_100.abs() < 0.2, "100 Hz: expected ~0, got {db_100:.6}");
}
}