From 3b541b523750605cf8f040500b6a5023fa3c74db Mon Sep 17 00:00:00 2001 From: OpenSauce Date: Sun, 1 Mar 2026 20:02:05 +0000 Subject: [PATCH 1/4] feat: add 16-band graphic EQ stage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 16-band graphic equalizer (25 Hz – 20 kHz) using cascaded biquad peaking filters with Audio EQ Cookbook coefficients. Uses f64 internal precision for numerical stability at low frequencies under high oversampling rates. DSP: - Direct Form 1 biquads with f64 coefficients and state - Fixed Q derived from bandwidth (10 octaves / 16 bands ≈ 2.28) - Nyquist guard, denormal flushing, unity passthrough optimization - 12 unit tests including stability at extreme gain + 16x oversampling GUI: - New labeled_vertical_slider reusable widget - 16 vertical faders with frequency labels and dB readout - Full-width centered layout - i18n: EN "Graphic EQ", ZH_CN "图形均衡器" - Registered as Effect in stage registry, minimap abbreviation "EQ" --- src/amp/stages/eq.rs | 446 +++++++++++++++++++++++++++ src/amp/stages/mod.rs | 1 + src/gui/components/minimap.rs | 1 + src/gui/components/widgets/common.rs | 25 +- src/gui/stages/eq.rs | 111 +++++++ src/gui/stages/mod.rs | 1 + src/i18n/mod.rs | 3 + 7 files changed, 587 insertions(+), 1 deletion(-) create mode 100644 src/amp/stages/eq.rs create mode 100644 src/gui/stages/eq.rs diff --git a/src/amp/stages/eq.rs b/src/amp/stages/eq.rs new file mode 100644 index 0000000..c805a7d --- /dev/null +++ b/src/amp/stages/eq.rs @@ -0,0 +1,446 @@ +use std::f64::consts::PI; + +use crate::amp::stages::Stage; + +pub const NUM_BANDS: usize = 16; +pub const BAND_FREQS: [f64; NUM_BANDS] = [ + 25.0, 40.0, 63.0, 100.0, 160.0, 250.0, 400.0, 630.0, 1000.0, 1600.0, 2500.0, 4000.0, 6300.0, + 10000.0, 16000.0, 20000.0, +]; +const MIN_GAIN_DB: f32 = -12.0; +const MAX_GAIN_DB: f32 = 12.0; +const DENORMAL_THRESHOLD: f64 = 1e-20; + +/// Bandwidth in octaves: 10 octaves / 16 bands +const BANDWIDTH: f64 = 10.0 / NUM_BANDS as f64; + +/// Calculate Q from bandwidth using the Audio EQ Cookbook formula: +/// Q = 1 / (2 * sinh(ln(2)/2 * BW)) +fn bandwidth_to_q(bw: f64) -> f64 { + 1.0 / (2.0 * (f64::ln(2.0) / 2.0 * bw).sinh()) +} + +/// Direct Form 1 biquad filter for peaking EQ. +/// +/// Uses f64 internally for coefficient computation and state to avoid +/// numerical instability at low frequencies (e.g. 25 Hz at high sample +/// rates), where f32 poles sit too close to the unit circle. +#[derive(Clone)] +struct Biquad { + // Normalized coefficients (f64 for precision at low freq / high SR) + b0: f64, + b1: f64, + b2: f64, + a1: f64, + a2: f64, + // State variables (f64 to match coefficient precision) + x1: f64, + x2: f64, + y1: f64, + y2: f64, +} + +impl Biquad { + /// Create a unity passthrough biquad. + const fn new() -> Self { + Self { + b0: 1.0, + b1: 0.0, + b2: 0.0, + a1: 0.0, + a2: 0.0, + x1: 0.0, + x2: 0.0, + y1: 0.0, + y2: 0.0, + } + } + + /// Set coefficients for a peaking EQ band using Audio EQ Cookbook formulas. + fn set_peaking_eq(&mut self, freq: f64, gain_db: f64, q: f64, sample_rate: f64) { + if gain_db.abs() < 1e-6 { + // Unity passthrough — skip computation + self.b0 = 1.0; + self.b1 = 0.0; + self.b2 = 0.0; + self.a1 = 0.0; + self.a2 = 0.0; + return; + } + + // Nyquist guard + let freq = freq.min(sample_rate * 0.499); + + let a = 10f64.powf(gain_db / 40.0); + let w0 = 2.0 * PI * freq / sample_rate; + let cos_w0 = w0.cos(); + let sin_w0 = w0.sin(); + let alpha = sin_w0 / (2.0 * q); + + let b0 = 1.0 + alpha * a; + let b1 = -2.0 * cos_w0; + let b2 = 1.0 - alpha * a; + let a0 = 1.0 + alpha / a; + let a1 = -2.0 * cos_w0; + let a2 = 1.0 - alpha / a; + + // Normalize by a0 + let inv_a0 = 1.0 / a0; + self.b0 = b0 * inv_a0; + self.b1 = b1 * inv_a0; + self.b2 = b2 * inv_a0; + self.a1 = a1 * inv_a0; + self.a2 = a2 * inv_a0; + } + + /// Process a single sample through the DF1 difference equation. + #[inline] + fn process(&mut self, input: f64) -> f64 { + let y = self + .b0 + .mul_add(input, self.b1.mul_add(self.x1, self.b2 * self.x2)) + - self.a1.mul_add(self.y1, self.a2 * self.y2); + + // Flush denormals + let y = if y.abs() < DENORMAL_THRESHOLD { 0.0 } else { y }; + + self.x2 = self.x1; + self.x1 = input; + self.y2 = self.y1; + self.y1 = y; + + y + } +} + +/// 16-band graphic EQ stage using cascaded biquad peaking filters. +pub struct EqStage { + biquads: [Biquad; NUM_BANDS], + gains_db: [f32; NUM_BANDS], + q: f64, + sample_rate: f64, +} + +impl EqStage { + pub fn new(gains_db: [f32; NUM_BANDS], sample_rate: f32) -> Self { + let q = bandwidth_to_q(BANDWIDTH); + let sr = f64::from(sample_rate); + let mut biquads = std::array::from_fn(|_| Biquad::new()); + + for (i, biquad) in biquads.iter_mut().enumerate() { + let gain = gains_db[i].clamp(MIN_GAIN_DB, MAX_GAIN_DB); + biquad.set_peaking_eq(BAND_FREQS[i], f64::from(gain), q, sr); + } + + Self { + biquads, + gains_db: gains_db.map(|g| g.clamp(MIN_GAIN_DB, MAX_GAIN_DB)), + q, + sample_rate: sr, + } + } + + #[cfg(test)] + fn band_param_name(index: usize) -> String { + format!("band_{index}") + } + + fn parse_band_index(name: &str) -> Option { + name.strip_prefix("band_")?.parse().ok() + } +} + +impl Stage for EqStage { + fn process(&mut self, input: f32) -> f32 { + let mut sample = f64::from(input); + for biquad in &mut self.biquads { + sample = biquad.process(sample); + } + #[allow(clippy::cast_possible_truncation)] + let out = sample as f32; + out + } + + fn set_parameter(&mut self, name: &str, value: f32) -> Result<(), &'static str> { + let idx = + Self::parse_band_index(name).ok_or("Unknown parameter (expected band_0..band_15)")?; + if idx >= NUM_BANDS { + return Err("Band index out of range (0..15)"); + } + if !(MIN_GAIN_DB..=MAX_GAIN_DB).contains(&value) { + return Err("Gain must be between -12 dB and +12 dB"); + } + self.gains_db[idx] = value; + self.biquads[idx].set_peaking_eq( + BAND_FREQS[idx], + f64::from(value), + self.q, + self.sample_rate, + ); + Ok(()) + } + + fn get_parameter(&self, name: &str) -> Result { + let idx = + Self::parse_band_index(name).ok_or("Unknown parameter (expected band_0..band_15)")?; + if idx >= NUM_BANDS { + return Err("Band index out of range (0..15)"); + } + Ok(self.gains_db[idx]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE_RATE: f32 = 44100.0; + + fn flat_gains() -> [f32; NUM_BANDS] { + [0.0; NUM_BANDS] + } + + #[test] + fn flat_passthrough() { + let mut eq = EqStage::new(flat_gains(), SAMPLE_RATE); + + // Feed a signal through the flat EQ — should pass through unchanged + for i in 0..1000 { + let input = (i as f32 * 0.01).sin(); + let output = eq.process(input); + assert!( + (output - input).abs() < 1e-6, + "Flat EQ should pass through unchanged at sample {i}: input={input}, output={output}" + ); + } + } + + #[test] + fn boost_increases_energy() { + // Boost 1 kHz band (index 8) by 12 dB + let mut gains = flat_gains(); + gains[8] = 12.0; + let mut eq = EqStage::new(gains, SAMPLE_RATE); + + // Generate a 1 kHz sine wave + let freq = 1000.0_f32; + let mut energy_in = 0.0_f64; + let mut energy_out = 0.0_f64; + let num_samples = SAMPLE_RATE as usize; + + for i in 0..num_samples { + let t = i as f32 / SAMPLE_RATE; + let input = (2.0 * std::f32::consts::PI * freq * t).sin(); + let output = eq.process(input); + energy_in += f64::from(input).powi(2); + energy_out += f64::from(output).powi(2); + } + + assert!( + energy_out > energy_in * 2.0, + "12 dB boost at 1 kHz should significantly increase energy: in={energy_in}, out={energy_out}" + ); + } + + #[test] + fn cut_decreases_energy() { + // Cut 1 kHz band (index 8) by 12 dB + let mut gains = flat_gains(); + gains[8] = -12.0; + let mut eq = EqStage::new(gains, SAMPLE_RATE); + + let freq = 1000.0_f32; + let mut energy_in = 0.0_f64; + let mut energy_out = 0.0_f64; + let num_samples = SAMPLE_RATE as usize; + + for i in 0..num_samples { + let t = i as f32 / SAMPLE_RATE; + let input = (2.0 * std::f32::consts::PI * freq * t).sin(); + let output = eq.process(input); + energy_in += f64::from(input).powi(2); + energy_out += f64::from(output).powi(2); + } + + assert!( + energy_out < energy_in * 0.5, + "12 dB cut at 1 kHz should significantly decrease energy: in={energy_in}, out={energy_out}" + ); + } + + #[test] + fn parameter_validation() { + let mut eq = EqStage::new(flat_gains(), SAMPLE_RATE); + + // Valid parameters + assert!(eq.set_parameter("band_0", 6.0).is_ok()); + assert!(eq.set_parameter("band_15", -6.0).is_ok()); + assert!((eq.get_parameter("band_0").unwrap() - 6.0).abs() < 1e-6); + assert!((eq.get_parameter("band_15").unwrap() - (-6.0)).abs() < 1e-6); + + // Out of range gain + assert!(eq.set_parameter("band_0", 13.0).is_err()); + assert!(eq.set_parameter("band_0", -13.0).is_err()); + + // Invalid band index + assert!(eq.set_parameter("band_16", 0.0).is_err()); + assert!(eq.get_parameter("band_16").is_err()); + + // Unknown parameter name + assert!(eq.set_parameter("volume", 0.0).is_err()); + assert!(eq.get_parameter("volume").is_err()); + } + + #[test] + fn denormal_flushing() { + let mut eq = EqStage::new(flat_gains(), SAMPLE_RATE); + + // Feed very small signal, then silence — state should not accumulate denormals + for _ in 0..100 { + eq.process(1e-30); + } + for _ in 0..1000 { + let out = eq.process(0.0); + assert!( + out == 0.0 || out.abs() >= f32::MIN_POSITIVE, + "Should not produce denormal values, got {out}" + ); + } + } + + #[test] + fn high_sample_rate() { + // Test at oversampled rate (e.g., 16x) + let high_rate = 44100.0 * 16.0; + let mut gains = flat_gains(); + gains[15] = 6.0; // Boost 20 kHz band + let mut eq = EqStage::new(gains, high_rate); + + // Should not produce NaN or Inf + for i in 0..10000 { + let input = (i as f32 * 0.001).sin(); + let output = eq.process(input); + assert!(output.is_finite(), "Output should be finite at sample {i}"); + } + } + + #[test] + fn all_bands_param_round_trip() { + let mut eq = EqStage::new(flat_gains(), SAMPLE_RATE); + + for i in 0..NUM_BANDS { + let name = EqStage::band_param_name(i); + let gain = i as f32 - 8.0; // -8 to +7 dB + eq.set_parameter(&name, gain).unwrap(); + let read = eq.get_parameter(&name).unwrap(); + assert!( + (read - gain).abs() < 1e-6, + "Band {i}: set {gain}, got {read}" + ); + } + } + + #[test] + fn block_processing() { + let mut gains = flat_gains(); + gains[4] = 6.0; + + let mut eq_single = EqStage::new(gains, SAMPLE_RATE); + let mut eq_block = EqStage::new(gains, SAMPLE_RATE); + + // Generate test signal + let mut signal: Vec = (0..512).map(|i| (i as f32 * 0.01).sin() * 0.5).collect(); + let reference: Vec = signal.iter().map(|&s| eq_single.process(s)).collect(); + + // Process as block + eq_block.process_block(&mut signal); + + for (i, (got, want)) in signal.iter().zip(reference.iter()).enumerate() { + assert!( + (got - want).abs() < 1e-6, + "Block/single mismatch at sample {i}: block={got}, single={want}" + ); + } + } + + #[test] + fn stability_at_all_extremes() { + // All bands at max boost + let gains = [MAX_GAIN_DB; NUM_BANDS]; + let mut eq = EqStage::new(gains, SAMPLE_RATE); + + for i in 0..10000 { + let input = if i % 100 == 0 { 1.0 } else { 0.0 }; + let output = eq.process(input); + assert!( + output.is_finite(), + "Output must be finite at sample {i}, got {output}" + ); + } + + // All bands at max cut — this is the scenario that caused rumbling with f32 + let gains = [MIN_GAIN_DB; NUM_BANDS]; + let mut eq = EqStage::new(gains, SAMPLE_RATE); + + for i in 0..10000 { + let input = (i as f32 * 0.1).sin(); + let output = eq.process(input); + assert!( + output.is_finite(), + "Output must be finite at sample {i}, got {output}" + ); + } + } + + #[test] + fn low_band_cut_high_sample_rate() { + // The exact failure case: 25 Hz band at -12 dB with 16x oversampling + let high_rate = 44100.0 * 16.0; + let mut gains = flat_gains(); + gains[0] = MIN_GAIN_DB; // 25 Hz band fully cut + let mut eq = EqStage::new(gains, high_rate); + + let mut max_out: f32 = 0.0; + for i in 0..100_000 { + let input = (i as f32 * 0.01).sin() * 0.5; + let output = eq.process(input); + assert!( + output.is_finite(), + "Output must be finite at sample {i}, got {output}" + ); + max_out = max_out.max(output.abs()); + } + // Output should stay bounded — no rumbling or blowup + assert!( + max_out < 2.0, + "25 Hz cut should not amplify signal, got max {max_out}" + ); + } + + #[test] + fn extreme_gain_high_sample_rate() { + // High oversampling + extreme gain on all bands + let high_rate = 44100.0 * 16.0; + let gains = [MAX_GAIN_DB; NUM_BANDS]; + let mut eq = EqStage::new(gains, high_rate); + + for i in 0..50000 { + let input = (i as f32 * 0.01).sin() * 0.5; + let output = eq.process(input); + assert!( + output.is_finite(), + "Output must be finite at high SR sample {i}, got {output}" + ); + } + } + + #[test] + fn bandwidth_q_value() { + let q = bandwidth_to_q(BANDWIDTH); + // Expected ~2.28 for 0.625 octave bandwidth + assert!( + (q - 2.28).abs() < 0.1, + "Q should be approximately 2.28, got {q}" + ); + } +} diff --git a/src/amp/stages/mod.rs b/src/amp/stages/mod.rs index 9b72f62..38ca1d9 100644 --- a/src/amp/stages/mod.rs +++ b/src/amp/stages/mod.rs @@ -2,6 +2,7 @@ pub mod clipper; pub mod common; pub mod compressor; pub mod delay; +pub mod eq; pub mod filter; pub mod level; pub mod multiband_saturator; diff --git a/src/gui/components/minimap.rs b/src/gui/components/minimap.rs index 7e63627..ae355b3 100644 --- a/src/gui/components/minimap.rs +++ b/src/gui/components/minimap.rs @@ -17,6 +17,7 @@ const fn stage_abbreviation(cfg: &StageConfig) -> &'static str { StageConfig::MultibandSaturator(_) => "MBS", StageConfig::Delay(_) => "Dly", StageConfig::Reverb(_) => "Rev", + StageConfig::Eq(_) => "EQ", } } diff --git a/src/gui/components/widgets/common.rs b/src/gui/components/widgets/common.rs index 6f06aaf..7e1c8f7 100644 --- a/src/gui/components/widgets/common.rs +++ b/src/gui/components/widgets/common.rs @@ -1,5 +1,7 @@ use crate::gui::messages::Message; -use iced::widget::{button, column, container, pick_list, row, rule, slider, text}; +use iced::widget::{ + button, column, container, pick_list, row, rule, slider, text, vertical_slider, +}; use iced::{Alignment, Color, Element, Length}; // ── Text sizes ────────────────────────────────────────────────────────────── @@ -56,6 +58,27 @@ pub fn labeled_slider<'a, F: 'a + Fn(f32) -> Message>( .into() } +pub fn labeled_vertical_slider<'a, F: 'a + Fn(f32) -> Message>( + label: String, + range: std::ops::RangeInclusive, + value: f32, + on_change: F, + format: impl Fn(f32) -> String + 'a, + step: f32, + height: f32, +) -> Element<'a, Message> { + column![ + text(label).size(TEXT_SIZE_SMALL), + vertical_slider(range, value, on_change) + .height(height) + .step(step), + text(format(value)).size(TEXT_SIZE_SMALL), + ] + .spacing(SPACING_TIGHT) + .align_x(Alignment::Center) + .into() +} + pub fn icon_button( icon: &str, message: Option, diff --git a/src/gui/stages/eq.rs b/src/gui/stages/eq.rs new file mode 100644 index 0000000..c0629f4 --- /dev/null +++ b/src/gui/stages/eq.rs @@ -0,0 +1,111 @@ +use iced::widget::row; +use iced::{Element, Length}; +use serde::{Deserialize, Serialize}; + +use crate::amp::stages::eq::{BAND_FREQS, EqStage, NUM_BANDS}; +use crate::gui::components::widgets::common::{ + labeled_vertical_slider, stage_card, SPACING_WIDE, +}; +use crate::gui::messages::Message; +use crate::tr; + +use super::StageMessage; + +// --- Config --- + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct EqConfig { + pub gains: [f32; NUM_BANDS], +} + +impl Default for EqConfig { + fn default() -> Self { + Self { + gains: [0.0; NUM_BANDS], + } + } +} + +impl EqConfig { + pub fn to_stage(&self, sample_rate: f32) -> EqStage { + EqStage::new(self.gains, sample_rate) + } + + pub const fn apply(&mut self, msg: EqMessage) { + match msg { + EqMessage::GainChanged(band, value) => { + if band < NUM_BANDS { + // Clamp to valid range (mirrors DSP-side validation) + let clamped = if value < -12.0 { + -12.0 + } else if value > 12.0 { + 12.0 + } else { + value + }; + self.gains[band] = clamped; + } + } + } + } +} + +// --- Message --- + +#[derive(Debug, Clone, Copy)] +pub enum EqMessage { + GainChanged(usize, f32), +} + +// --- Helpers --- + +fn format_freq(hz: f64) -> String { + if hz >= 1000.0 { + let k = hz / 1000.0; + if (k - k.round()).abs() < 0.01 { + format!("{}k", k as u32) + } else { + format!("{k:.1}k") + } + } else { + format!("{}", hz as u32) + } +} + +// --- View --- + +pub fn view( + idx: usize, + cfg: &EqConfig, + is_collapsed: bool, + can_move_up: bool, + can_move_down: bool, +) -> Element<'_, Message> { + stage_card( + tr!(stage_eq), + idx, + is_collapsed, + can_move_up, + can_move_down, + || { + let mut faders = row![].spacing(SPACING_WIDE); + for (band, &freq) in BAND_FREQS.iter().enumerate() { + faders = faders.push(labeled_vertical_slider( + format_freq(freq), + -12.0..=12.0, + cfg.gains[band], + move |v| { + Message::Stage(idx, StageMessage::Eq(EqMessage::GainChanged(band, v))) + }, + |v| format!("{v:+.1}"), + 0.1, + 150.0, + )); + } + iced::widget::container(faders) + .width(Length::Fill) + .center_x(Length::Fill) + .into() + }, + ) +} diff --git a/src/gui/stages/mod.rs b/src/gui/stages/mod.rs index b4048b0..6f55f4d 100644 --- a/src/gui/stages/mod.rs +++ b/src/gui/stages/mod.rs @@ -126,4 +126,5 @@ stage_registry! { MultibandSaturator => multiband_saturator, MultibandSaturatorConfig, MultibandSaturatorMessage, stage_multiband_saturator, Amp; Delay => delay, DelayConfig, DelayMessage, stage_delay, Effect; Reverb => reverb, ReverbConfig, ReverbMessage, stage_reverb, Effect; + Eq => eq, EqConfig, EqMessage, stage_eq, Effect; } diff --git a/src/i18n/mod.rs b/src/i18n/mod.rs index 586be38..0eea391 100644 --- a/src/i18n/mod.rs +++ b/src/i18n/mod.rs @@ -162,6 +162,7 @@ pub struct Translations { pub stage_multiband_saturator: &'static str, pub stage_delay: &'static str, pub stage_reverb: &'static str, + pub stage_eq: &'static str, // Stage parameters pub clipper: &'static str, @@ -351,6 +352,7 @@ pub static EN: Translations = Translations { stage_multiband_saturator: "Multiband Saturator", stage_delay: "Delay", stage_reverb: "Reverb", + stage_eq: "Graphic EQ", // Stage parameters clipper: "Clipper:", @@ -531,6 +533,7 @@ pub static ZH_CN: Translations = Translations { stage_multiband_saturator: "多段饱和器", stage_delay: "延迟", stage_reverb: "混响", + stage_eq: "图形均衡器", // Stage parameters clipper: "削波器:", From 7bbcc4e196fa81b57185b5875abdb4f206c0993a Mon Sep 17 00:00:00 2001 From: OpenSauce Date: Sun, 1 Mar 2026 20:22:40 +0000 Subject: [PATCH 2/4] include presets --- presets/Clean.json | 33 +++++++++++++++++++++++++++++++-- presets/Djent.json | 31 ++++++++++++++++++++++++++++++- 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/presets/Clean.json b/presets/Clean.json index a3582f5..ddc52bf 100644 --- a/presets/Clean.json +++ b/presets/Clean.json @@ -15,7 +15,7 @@ { "Preamp": { "gain": 1.1, - "bias": 1.4901161e-08, + "bias": 1.4901161e-8, "clipper_type": "ClassA" } }, @@ -45,6 +45,35 @@ "gain": 0.90000004 } }, + { + "Eq": { + "gains": [ + -8.6, + -7.0, + -3.3, + 0.0, + 0.0, + 0.0, + 0.0, + 2.4, + 3.6000001, + 1.6000001, + 2.6000001, + 3.2000003, + -0.29999983, + -4.9, + -2.9999998, + -4.2999997 + ] + } + }, + { + "Reverb": { + "room_size": 0.24, + "damping": 0.96, + "mix": 0.06 + } + }, { "Delay": { "delay_ms": 300.0, @@ -62,4 +91,4 @@ "lp_enabled": true, "lp_cutoff": 7174.0 } -} +} \ No newline at end of file diff --git a/presets/Djent.json b/presets/Djent.json index 19f734d..fa4de50 100644 --- a/presets/Djent.json +++ b/presets/Djent.json @@ -60,6 +60,35 @@ "Level": { "gain": 0.25 } + }, + { + "Eq": { + "gains": [ + -9.2, + -8.6, + -5.7, + -3.4999998, + -0.29999983, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + -0.29999983, + -0.39999983, + -1.9999999, + -6.7 + ] + } + }, + { + "Reverb": { + "room_size": 0.32999998, + "damping": 0.39, + "mix": 0.089999996 + } } ], "ir_name": "Science Amplification/4x12/G12H-150/SM57 Brighter.wav", @@ -71,4 +100,4 @@ "lp_enabled": true, "lp_cutoff": 7469.0 } -} +} \ No newline at end of file From 7d282478319e5eda3a8bf0d712a1a24275a643ee Mon Sep 17 00:00:00 2001 From: OpenSauce Date: Sun, 1 Mar 2026 20:25:14 +0000 Subject: [PATCH 3/4] fix: use per-band BW alpha formula, export shared constants, fix error strings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use the Audio EQ Cookbook BW-in-octaves alpha formula: alpha = sin(w0) * sinh(ln(2)/2 * BW * w0/sin(w0)) This maintains constant-octave bandwidth for all bands regardless of their position relative to Nyquist, fixing the 20 kHz band narrowing at 44.1/48 kHz sample rates. - Export MIN_GAIN_DB/MAX_GAIN_DB from DSP module; GUI uses them for slider range and config clamping instead of duplicated literals. - Fix ambiguous error messages: "0..15" → "0..=15" to clearly communicate inclusive bounds. --- src/amp/stages/eq.rs | 63 ++++++++++++++++++++++++++------------------ src/gui/stages/eq.rs | 14 +++++----- 2 files changed, 44 insertions(+), 33 deletions(-) diff --git a/src/amp/stages/eq.rs b/src/amp/stages/eq.rs index c805a7d..06bbbd7 100644 --- a/src/amp/stages/eq.rs +++ b/src/amp/stages/eq.rs @@ -7,19 +7,13 @@ pub const BAND_FREQS: [f64; NUM_BANDS] = [ 25.0, 40.0, 63.0, 100.0, 160.0, 250.0, 400.0, 630.0, 1000.0, 1600.0, 2500.0, 4000.0, 6300.0, 10000.0, 16000.0, 20000.0, ]; -const MIN_GAIN_DB: f32 = -12.0; -const MAX_GAIN_DB: f32 = 12.0; +pub const MIN_GAIN_DB: f32 = -12.0; +pub const MAX_GAIN_DB: f32 = 12.0; const DENORMAL_THRESHOLD: f64 = 1e-20; /// Bandwidth in octaves: 10 octaves / 16 bands const BANDWIDTH: f64 = 10.0 / NUM_BANDS as f64; -/// Calculate Q from bandwidth using the Audio EQ Cookbook formula: -/// Q = 1 / (2 * sinh(ln(2)/2 * BW)) -fn bandwidth_to_q(bw: f64) -> f64 { - 1.0 / (2.0 * (f64::ln(2.0) / 2.0 * bw).sinh()) -} - /// Direct Form 1 biquad filter for peaking EQ. /// /// Uses f64 internally for coefficient computation and state to avoid @@ -57,7 +51,12 @@ impl Biquad { } /// Set coefficients for a peaking EQ band using Audio EQ Cookbook formulas. - fn set_peaking_eq(&mut self, freq: f64, gain_db: f64, q: f64, sample_rate: f64) { + /// + /// Uses the BW-in-octaves alpha formula so that every band maintains + /// constant-octave bandwidth regardless of its position relative to + /// the sample rate: + /// `alpha = sin(w0) * sinh(ln(2)/2 * BW * w0/sin(w0))` + fn set_peaking_eq(&mut self, freq: f64, gain_db: f64, bw: f64, sample_rate: f64) { if gain_db.abs() < 1e-6 { // Unity passthrough — skip computation self.b0 = 1.0; @@ -75,7 +74,10 @@ impl Biquad { let w0 = 2.0 * PI * freq / sample_rate; let cos_w0 = w0.cos(); let sin_w0 = w0.sin(); - let alpha = sin_w0 / (2.0 * q); + + // Audio EQ Cookbook: alpha from BW in octaves + // alpha = sin(w0) * sinh(ln(2)/2 * BW * w0/sin(w0)) + let alpha = sin_w0 * (f64::ln(2.0) / 2.0 * bw * w0 / sin_w0).sinh(); let b0 = 1.0 + alpha * a; let b1 = -2.0 * cos_w0; @@ -117,25 +119,22 @@ impl Biquad { pub struct EqStage { biquads: [Biquad; NUM_BANDS], gains_db: [f32; NUM_BANDS], - q: f64, sample_rate: f64, } impl EqStage { pub fn new(gains_db: [f32; NUM_BANDS], sample_rate: f32) -> Self { - let q = bandwidth_to_q(BANDWIDTH); let sr = f64::from(sample_rate); let mut biquads = std::array::from_fn(|_| Biquad::new()); for (i, biquad) in biquads.iter_mut().enumerate() { let gain = gains_db[i].clamp(MIN_GAIN_DB, MAX_GAIN_DB); - biquad.set_peaking_eq(BAND_FREQS[i], f64::from(gain), q, sr); + biquad.set_peaking_eq(BAND_FREQS[i], f64::from(gain), BANDWIDTH, sr); } Self { biquads, gains_db: gains_db.map(|g| g.clamp(MIN_GAIN_DB, MAX_GAIN_DB)), - q, sample_rate: sr, } } @@ -163,9 +162,9 @@ impl Stage for EqStage { fn set_parameter(&mut self, name: &str, value: f32) -> Result<(), &'static str> { let idx = - Self::parse_band_index(name).ok_or("Unknown parameter (expected band_0..band_15)")?; + Self::parse_band_index(name).ok_or("Unknown parameter (expected band_0..=band_15)")?; if idx >= NUM_BANDS { - return Err("Band index out of range (0..15)"); + return Err("Band index out of range (0..=15)"); } if !(MIN_GAIN_DB..=MAX_GAIN_DB).contains(&value) { return Err("Gain must be between -12 dB and +12 dB"); @@ -174,7 +173,7 @@ impl Stage for EqStage { self.biquads[idx].set_peaking_eq( BAND_FREQS[idx], f64::from(value), - self.q, + BANDWIDTH, self.sample_rate, ); Ok(()) @@ -182,9 +181,9 @@ impl Stage for EqStage { fn get_parameter(&self, name: &str) -> Result { let idx = - Self::parse_band_index(name).ok_or("Unknown parameter (expected band_0..band_15)")?; + Self::parse_band_index(name).ok_or("Unknown parameter (expected band_0..=band_15)")?; if idx >= NUM_BANDS { - return Err("Band index out of range (0..15)"); + return Err("Band index out of range (0..=15)"); } Ok(self.gains_db[idx]) } @@ -435,12 +434,24 @@ mod tests { } #[test] - fn bandwidth_q_value() { - let q = bandwidth_to_q(BANDWIDTH); - // Expected ~2.28 for 0.625 octave bandwidth - assert!( - (q - 2.28).abs() < 0.1, - "Q should be approximately 2.28, got {q}" - ); + fn per_band_alpha_is_finite() { + // Verify that alpha computation produces valid values for all bands + // at both standard and oversampled rates + for &sr in &[44100.0_f32, 48000.0, 44100.0 * 16.0] { + let mut gains = flat_gains(); + gains[0] = 6.0; // low band + gains[15] = 6.0; // high band + let mut eq = EqStage::new(gains, sr); + + // Process a few samples — if alpha was bad, output goes NaN quickly + for i in 0..1000 { + let input = (i as f32 * 0.01).sin(); + let output = eq.process(input); + assert!( + output.is_finite(), + "Output must be finite at SR={sr}, sample {i}" + ); + } + } } } diff --git a/src/gui/stages/eq.rs b/src/gui/stages/eq.rs index c0629f4..3ca9d3e 100644 --- a/src/gui/stages/eq.rs +++ b/src/gui/stages/eq.rs @@ -2,7 +2,7 @@ use iced::widget::row; use iced::{Element, Length}; use serde::{Deserialize, Serialize}; -use crate::amp::stages::eq::{BAND_FREQS, EqStage, NUM_BANDS}; +use crate::amp::stages::eq::{BAND_FREQS, EqStage, MAX_GAIN_DB, MIN_GAIN_DB, NUM_BANDS}; use crate::gui::components::widgets::common::{ labeled_vertical_slider, stage_card, SPACING_WIDE, }; @@ -35,11 +35,11 @@ impl EqConfig { match msg { EqMessage::GainChanged(band, value) => { if band < NUM_BANDS { - // Clamp to valid range (mirrors DSP-side validation) - let clamped = if value < -12.0 { - -12.0 - } else if value > 12.0 { - 12.0 + // Clamp to valid range (uses DSP-side constants) + let clamped = if value < MIN_GAIN_DB { + MIN_GAIN_DB + } else if value > MAX_GAIN_DB { + MAX_GAIN_DB } else { value }; @@ -92,7 +92,7 @@ pub fn view( for (band, &freq) in BAND_FREQS.iter().enumerate() { faders = faders.push(labeled_vertical_slider( format_freq(freq), - -12.0..=12.0, + MIN_GAIN_DB..=MAX_GAIN_DB, cfg.gains[band], move |v| { Message::Stage(idx, StageMessage::Eq(EqMessage::GainChanged(band, v))) From 722f2a1195ee1e1d5759971b0ac12931bb605847 Mon Sep 17 00:00:00 2001 From: OpenSauce Date: Sun, 1 Mar 2026 20:28:41 +0000 Subject: [PATCH 4/4] fix: reset biquad state on unity passthrough transitions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reset x1/x2/y1/y2 when entering or leaving unity passthrough mode. State from a previous filter shape is meaningless to the passthrough (and vice versa), so retaining it would cause a brief transient click on the next coefficient change. For gain-to-gain changes (e.g. +6 dB → +12 dB), state is preserved — DF1's state variables hold actual signal values that remain meaningful across coefficient updates, which is why DF1 was chosen over DF2T. --- src/amp/stages/eq.rs | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/amp/stages/eq.rs b/src/amp/stages/eq.rs index 06bbbd7..644e22a 100644 --- a/src/amp/stages/eq.rs +++ b/src/amp/stages/eq.rs @@ -57,16 +57,37 @@ impl Biquad { /// the sample rate: /// `alpha = sin(w0) * sinh(ln(2)/2 * BW * w0/sin(w0))` fn set_peaking_eq(&mut self, freq: f64, gain_db: f64, bw: f64, sample_rate: f64) { + let was_unity = self.b1 == 0.0 && self.a1 == 0.0; + if gain_db.abs() < 1e-6 { - // Unity passthrough — skip computation + // Unity passthrough — skip computation. + // Reset state: values from the old filter shape are meaningless + // to a passthrough and would cause a transient on the next + // non-zero coefficient update. self.b0 = 1.0; self.b1 = 0.0; self.b2 = 0.0; self.a1 = 0.0; self.a2 = 0.0; + self.x1 = 0.0; + self.x2 = 0.0; + self.y1 = 0.0; + self.y2 = 0.0; return; } + // Reset state when transitioning from unity passthrough — the stored + // state (all zeros from passthrough) is trivially compatible, but when + // transitioning from a *different* active filter shape through unity + // and back, we want a clean slate. For gain-to-gain changes, DF1 + // state remains meaningful and transitions smoothly by design. + if was_unity { + self.x1 = 0.0; + self.x2 = 0.0; + self.y1 = 0.0; + self.y2 = 0.0; + } + // Nyquist guard let freq = freq.min(sample_rate * 0.499);