From 7d57edf75ff5f22258d42132b5a915cb64db4e87 Mon Sep 17 00:00:00 2001 From: OpenSauce Date: Mon, 2 Mar 2026 17:55:19 +0000 Subject: [PATCH 1/4] feat: add 12AX7 triode clipper and inter-stage filtering Add a new Triode clipper type using the Koren 12AX7 triode equation via a 256-point lookup table with cubic Hermite interpolation. This produces physically-modeled asymmetric clipping with correct even-harmonic content. Add a one-pole lowpass filter (10kHz) between the preamp's cascaded waveshapers to model plate load capacitance. This shapes upper harmonics before re-distortion, reducing digital fizz across all clipper types. --- src/amp/stages/clipper.rs | 152 ++++++++++++++++++++++++++++++++++++++ src/amp/stages/common.rs | 23 ++++++ src/amp/stages/preamp.rs | 11 ++- src/gui/stages/preamp.rs | 3 +- src/i18n/mod.rs | 3 + 5 files changed, 189 insertions(+), 3 deletions(-) diff --git a/src/amp/stages/clipper.rs b/src/amp/stages/clipper.rs index 65fa29b..4159681 100644 --- a/src/amp/stages/clipper.rs +++ b/src/amp/stages/clipper.rs @@ -1,6 +1,7 @@ use clap::ValueEnum; use serde::{Deserialize, Serialize}; use std::f32::consts::PI; +use std::sync::LazyLock; #[derive(ValueEnum, Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub enum ClipperType { @@ -9,6 +10,7 @@ pub enum ClipperType { Hard, // More aggressive clipping (similar to HardClip) Asymmetric, // Tube-like even harmonic generation ClassA, // Classic Class A tube preamp behavior + Triode, // 12AX7 triode model via lookup table } impl std::fmt::Display for ClipperType { @@ -19,6 +21,7 @@ impl std::fmt::Display for ClipperType { Self::Hard => write!(f, "{}", crate::tr!(clipper_hard)), Self::Asymmetric => write!(f, "{}", crate::tr!(clipper_asymmetric)), Self::ClassA => write!(f, "{}", crate::tr!(clipper_class_a)), + Self::Triode => write!(f, "{}", crate::tr!(clipper_triode)), } } } @@ -67,6 +70,155 @@ impl ClipperType { (1.0 - fold_amount).mul_add(soft_clip, fold_amount * folded) } + + Self::Triode => TRIODE_TABLE.process(driven), + } + } +} + +struct TubeTable { + table: Vec, + input_min: f32, + input_max: f32, +} + +impl TubeTable { + fn new() -> Self { + const TABLE_SIZE: usize = 256; + const MU: f64 = 100.0; + const KP: f64 = 600.0; + const KVB: f64 = 300.0; + const KG1: f64 = 1060.0; + const EX: f64 = 1.4; + const VP: f64 = 250.0; + const VG_SCALE: f64 = 4.0; + + let input_min: f64 = -5.0; + let input_max: f64 = 5.0; + + let mut raw = vec![0.0f64; TABLE_SIZE]; + for (idx, sample) in raw.iter_mut().enumerate() { + let t = idx as f64 / (TABLE_SIZE - 1) as f64; + let input = t.mul_add(input_max - input_min, input_min); + let vg = input * VG_SCALE; + + let inner = KP * (1.0 / MU + vg / VP.mul_add(VP, KVB).sqrt()); + let inner_clamped = inner.min(80.0); + let e1 = inner_clamped.exp().ln_1p() * VP / KP; + let e1_safe = e1.max(0.0); + *sample = e1_safe.powf(EX) / KG1; } + + let ip_min = raw.iter().copied().fold(f64::INFINITY, f64::min); + let ip_max = raw.iter().copied().fold(f64::NEG_INFINITY, f64::max); + let range = ip_max - ip_min; + + let table: Vec = raw + .iter() + .map(|&ip| { + if range > 0.0 { + (2.0 * (ip - ip_min) / range - 1.0) as f32 + } else { + 0.0 + } + }) + .collect(); + + Self { + table, + input_min: input_min as f32, + input_max: input_max as f32, + } + } + + fn process(&self, input: f32) -> f32 { + let clamped = input.clamp(self.input_min, self.input_max); + let normalized = (clamped - self.input_min) / (self.input_max - self.input_min); + let index_f = normalized * (self.table.len() - 1) as f32; + let idx = (index_f as usize).min(self.table.len() - 2); + let frac = index_f - idx as f32; + + let p0 = self.table[idx.saturating_sub(1)]; + let p1 = self.table[idx]; + let p2 = self.table[(idx + 1).min(self.table.len() - 1)]; + let p3 = self.table[(idx + 2).min(self.table.len() - 1)]; + + let coeff_a = (-0.5f32).mul_add(p0, 1.5 * p1) + (-1.5f32).mul_add(p2, 0.5 * p3); + let coeff_b = 2.5f32.mul_add(-p1, p0) + 2.0f32.mul_add(p2, -0.5 * p3); + let coeff_c = (-0.5f32).mul_add(p0, 0.5 * p2); + + coeff_a + .mul_add(frac, coeff_b) + .mul_add(frac, coeff_c) + .mul_add(frac, p1) + } +} + +static TRIODE_TABLE: LazyLock = LazyLock::new(TubeTable::new); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_triode_zero_input() { + let output = ClipperType::Triode.process(0.0, 1.0); + assert!( + output.is_finite() && (-1.1..=1.1).contains(&output), + "expected finite bounded output for zero input, got {output}" + ); + } + + #[test] + fn test_triode_bounded_output() { + for drive in [1.0, 3.0, 5.0, 10.0] { + for i in -20..=20 { + let input = i as f32 * 0.1; + let output = ClipperType::Triode.process(input, drive); + assert!( + (-1.1..=1.1).contains(&output), + "output {output} out of bounds for input={input}, drive={drive}" + ); + } + } + } + + #[test] + fn test_triode_asymmetric() { + let pos = ClipperType::Triode.process(0.5, 1.0); + let neg = ClipperType::Triode.process(-0.5, 1.0); + assert!( + (pos.abs() - neg.abs()).abs() > 1e-6, + "expected asymmetric response, got pos={pos}, neg={neg}" + ); + } + + #[test] + fn test_triode_monotonic() { + let mut prev = ClipperType::Triode.process(-2.0, 1.0); + for i in -19..=20 { + let input = i as f32 * 0.1; + let output = ClipperType::Triode.process(input, 1.0); + assert!( + output >= prev - 1e-6, + "non-monotonic at input={input}: prev={prev}, current={output}" + ); + prev = output; + } + } + + #[test] + fn test_triode_table_normalized() { + let table = &TRIODE_TABLE.table; + let min = table.iter().copied().fold(f32::INFINITY, f32::min); + let max = table.iter().copied().fold(f32::NEG_INFINITY, f32::max); + assert!( + (min - (-1.0)).abs() < 0.05, + "table min should be near -1.0, got {min}" + ); + assert!( + (max - 1.0).abs() < 0.05, + "table max should be near 1.0, got {max}" + ); } } diff --git a/src/amp/stages/common.rs b/src/amp/stages/common.rs index 4bb5eb8..4e366eb 100644 --- a/src/amp/stages/common.rs +++ b/src/amp/stages/common.rs @@ -46,6 +46,29 @@ impl DcBlocker { } } +/// One-pole low-pass filter. +/// +/// Models reactive elements like plate load capacitance. +/// `y[n] = y[n-1] + coeff * (x[n] - y[n-1])` +#[derive(Clone)] +pub struct OnePoleLP { + y_prev: f32, + coeff: f32, +} + +impl OnePoleLP { + pub fn new(cutoff_hz: f32, sample_rate: f32) -> Self { + let coeff = (2.0 * PI * cutoff_hz / sample_rate).min(1.0); + Self { y_prev: 0.0, coeff } + } + + #[inline] + pub fn process(&mut self, input: f32) -> f32 { + self.y_prev = self.coeff.mul_add(input - self.y_prev, self.y_prev); + self.y_prev + } +} + /// One-pole envelope follower with configurable attack and release coefficients. #[derive(Clone)] pub struct EnvelopeFollower { diff --git a/src/amp/stages/preamp.rs b/src/amp/stages/preamp.rs index 573cf3c..c298082 100644 --- a/src/amp/stages/preamp.rs +++ b/src/amp/stages/preamp.rs @@ -1,11 +1,12 @@ use crate::amp::stages::Stage; use crate::amp::stages::clipper::ClipperType; -use crate::amp::stages::common::DcBlocker; +use crate::amp::stages::common::{DcBlocker, OnePoleLP}; pub struct PreampStage { gain: f32, // 0..10 bias: f32, // −1..+1 clipper_type: ClipperType, + interstage_lp: OnePoleLP, dc_blocker: DcBlocker, } @@ -15,6 +16,7 @@ impl PreampStage { gain, bias: bias.clamp(-1.0, 1.0), clipper_type: clipper, + interstage_lp: OnePoleLP::new(10_000.0, sample_rate), dc_blocker: DcBlocker::new(15.0, sample_rate), } } @@ -32,10 +34,15 @@ impl Stage for PreampStage { // Instead of adding DC to the input, shift the tanh curve and recenter: let pre = drive.mul_add(input, self.bias).tanh() - self.bias.tanh(); + // Inter-stage lowpass: models plate load capacitance rolling off upper + // harmonics before they reach the next nonlinearity. Without this, + // cascaded waveshapers re-distort the full harmonic spectrum, producing fizz. + let filtered = self.interstage_lp.process(pre); + // Main clipper expects roughly zero-centered signal; keep threshold tied to gain let clipped = self .clipper_type - .process(pre, self.gain.mul_add(CLIPPER_SCALE, 1.0)); + .process(filtered, self.gain.mul_add(CLIPPER_SCALE, 1.0)); // Remove any residual DC so next stage gets a clean, centered signal self.dc_blocker.process(clipped) diff --git a/src/gui/stages/preamp.rs b/src/gui/stages/preamp.rs index 9f1fb94..2b9d363 100644 --- a/src/gui/stages/preamp.rs +++ b/src/gui/stages/preamp.rs @@ -56,12 +56,13 @@ pub enum PreampMessage { // --- View --- -const CLIPPER_TYPES: [ClipperType; 5] = [ +const CLIPPER_TYPES: [ClipperType; 6] = [ ClipperType::Soft, ClipperType::Medium, ClipperType::Hard, ClipperType::Asymmetric, ClipperType::ClassA, + ClipperType::Triode, ]; pub fn view( diff --git a/src/i18n/mod.rs b/src/i18n/mod.rs index 0eea391..f3311f5 100644 --- a/src/i18n/mod.rs +++ b/src/i18n/mod.rs @@ -205,6 +205,7 @@ pub struct Translations { pub clipper_hard: &'static str, pub clipper_asymmetric: &'static str, pub clipper_class_a: &'static str, + pub clipper_triode: &'static str, // Power amp types pub poweramp_class_a: &'static str, @@ -395,6 +396,7 @@ pub static EN: Translations = Translations { clipper_hard: "Hard Clipping", clipper_asymmetric: "Asymmetric Clipping", clipper_class_a: "Class A Tube Preamp", + clipper_triode: "12AX7 Triode", // Power amp types poweramp_class_a: "Class A", @@ -576,6 +578,7 @@ pub static ZH_CN: Translations = Translations { clipper_hard: "硬削波", clipper_asymmetric: "非对称削波", clipper_class_a: "A类电子管前级", + clipper_triode: "12AX7 三极管", // Power amp types poweramp_class_a: "A类", From cd94f5ee514543fbc62f8e909425048674da83e7 Mon Sep 17 00:00:00 2001 From: OpenSauce Date: Mon, 2 Mar 2026 17:55:36 +0000 Subject: [PATCH 2/4] chore: update Clean preset to use Triode clipper --- presets/Clean.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/presets/Clean.json b/presets/Clean.json index ddc52bf..38d8e4c 100644 --- a/presets/Clean.json +++ b/presets/Clean.json @@ -16,7 +16,7 @@ "Preamp": { "gain": 1.1, "bias": 1.4901161e-8, - "clipper_type": "ClassA" + "clipper_type": "Triode" } }, { @@ -55,10 +55,10 @@ 0.0, 0.0, 0.0, - 2.4, + 2.8000002, 3.6000001, 1.6000001, - 2.6000001, + 3.1000001, 3.2000003, -0.29999983, -4.9, @@ -83,7 +83,7 @@ } ], "ir_name": "Science Amplification/4x12/G12H-150/MD 421-U Brighter.wav", - "ir_gain": 0.1, + "ir_gain": 0.26, "pitch_shift_semitones": 0, "input_filters": { "hp_enabled": true, From 9b42c2a0608e14e228ed7c5ac61a7a4e55e2b1bf Mon Sep 17 00:00:00 2001 From: OpenSauce Date: Mon, 2 Mar 2026 17:59:37 +0000 Subject: [PATCH 3/4] fix: use correct exponential coefficient for one-pole LP filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the linear approximation (2πfc/fs) with the proper analog-derived discretization (1 - exp(-2πfc/fs)). The linear form is only accurate when fc << fs and diverges near Nyquist. --- src/amp/stages/common.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/amp/stages/common.rs b/src/amp/stages/common.rs index 4e366eb..cbcebc3 100644 --- a/src/amp/stages/common.rs +++ b/src/amp/stages/common.rs @@ -58,7 +58,7 @@ pub struct OnePoleLP { impl OnePoleLP { pub fn new(cutoff_hz: f32, sample_rate: f32) -> Self { - let coeff = (2.0 * PI * cutoff_hz / sample_rate).min(1.0); + let coeff = 1.0 - (-2.0 * PI * cutoff_hz / sample_rate).exp(); Self { y_prev: 0.0, coeff } } From db88b3af0298080ecc093c8e9b8e17fb14ae677c Mon Sep 17 00:00:00 2001 From: OpenSauce Date: Mon, 2 Mar 2026 18:05:11 +0000 Subject: [PATCH 4/4] =?UTF-8?q?refactor:=20review=20feedback=20=E2=80=94?= =?UTF-8?q?=20fixed=20array,=20inline,=20force=20init,=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace Vec with [f32; 256] for the triode lookup table to avoid heap allocation and improve cache locality - Add #[inline] to ClipperType::process() and TubeTable::process() hot paths - Force LazyLock initialization in Manager::new() before the JACK audio thread starts, avoiding lazy init on the RT callback - Add doc comments to TubeTable and Koren model constants --- src/amp/stages/clipper.rs | 59 +++++++++++++++++++++++++-------------- src/audio/manager.rs | 3 ++ 2 files changed, 41 insertions(+), 21 deletions(-) diff --git a/src/amp/stages/clipper.rs b/src/amp/stages/clipper.rs index 4159681..629e8a3 100644 --- a/src/amp/stages/clipper.rs +++ b/src/amp/stages/clipper.rs @@ -27,6 +27,7 @@ impl std::fmt::Display for ClipperType { } impl ClipperType { + #[inline] pub fn process(&self, input: f32, drive: f32) -> f32 { let driven = input * drive; @@ -76,36 +77,47 @@ impl ClipperType { } } +/// Pre-computed transfer curve for a 12AX7 triode using the Koren model. +/// +/// The table is filled at init time from the Koren plate current equation +/// (Norman Koren, "Improved Vacuum Tube Models for SPICE Simulations") +/// and looked up at runtime via cubic Hermite (Catmull-Rom) interpolation. struct TubeTable { - table: Vec, + table: [f32; Self::SIZE], input_min: f32, input_max: f32, } +impl TubeTable { + const SIZE: usize = 256; +} + impl TubeTable { fn new() -> Self { - const TABLE_SIZE: usize = 256; - const MU: f64 = 100.0; - const KP: f64 = 600.0; - const KVB: f64 = 300.0; - const KG1: f64 = 1060.0; - const EX: f64 = 1.4; - const VP: f64 = 250.0; - const VG_SCALE: f64 = 4.0; + // Koren 12AX7 triode parameters: + const MU: f64 = 100.0; // amplification factor + const KP: f64 = 600.0; // plate current coefficient + const KVB: f64 = 300.0; // knee voltage coefficient + const KG1: f64 = 1060.0; // grid current scaling + const EX: f64 = 1.4; // plate current exponent + const VP: f64 = 250.0; // plate voltage (operating point) + const VG_SCALE: f64 = 4.0; // maps normalized input to grid voltage range let input_min: f64 = -5.0; let input_max: f64 = 5.0; - let mut raw = vec![0.0f64; TABLE_SIZE]; + let mut raw = [0.0f64; Self::SIZE]; for (idx, sample) in raw.iter_mut().enumerate() { - let t = idx as f64 / (TABLE_SIZE - 1) as f64; + let t = idx as f64 / (Self::SIZE - 1) as f64; let input = t.mul_add(input_max - input_min, input_min); let vg = input * VG_SCALE; + // Koren equation: E1 = (Vp/Kp) * ln(1 + exp(Kp * (1/mu + Vg/sqrt(Kvb + Vp²)))) let inner = KP * (1.0 / MU + vg / VP.mul_add(VP, KVB).sqrt()); let inner_clamped = inner.min(80.0); let e1 = inner_clamped.exp().ln_1p() * VP / KP; let e1_safe = e1.max(0.0); + // Plate current: Ip = E1^Ex / Kg1 *sample = e1_safe.powf(EX) / KG1; } @@ -113,16 +125,14 @@ impl TubeTable { let ip_max = raw.iter().copied().fold(f64::NEG_INFINITY, f64::max); let range = ip_max - ip_min; - let table: Vec = raw - .iter() - .map(|&ip| { - if range > 0.0 { - (2.0 * (ip - ip_min) / range - 1.0) as f32 - } else { - 0.0 - } - }) - .collect(); + let mut table = [0.0f32; Self::SIZE]; + for (i, &ip) in raw.iter().enumerate() { + table[i] = if range > 0.0 { + (2.0 * (ip - ip_min) / range - 1.0) as f32 + } else { + 0.0 + }; + } Self { table, @@ -131,6 +141,7 @@ impl TubeTable { } } + #[inline] fn process(&self, input: f32) -> f32 { let clamped = input.clamp(self.input_min, self.input_max); let normalized = (clamped - self.input_min) / (self.input_max - self.input_min); @@ -156,6 +167,12 @@ impl TubeTable { static TRIODE_TABLE: LazyLock = LazyLock::new(TubeTable::new); +/// Force initialization of the triode lookup table. +/// Call during app startup to avoid lazy init on the RT audio thread. +pub fn init() { + LazyLock::force(&TRIODE_TABLE); +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/audio/manager.rs b/src/audio/manager.rs index 08a5b88..5ba8f49 100644 --- a/src/audio/manager.rs +++ b/src/audio/manager.rs @@ -7,6 +7,7 @@ use log::{error, info, warn}; use std::path::Path; +use crate::amp::stages::clipper; use crate::audio::engine::Engine; use crate::audio::engine::EngineHandle; use crate::audio::jack::{NotificationHandler, ProcessHandler}; @@ -29,6 +30,8 @@ pub struct Manager { impl Manager { pub fn new(settings: Settings) -> Result { + clipper::init(); + let (client, _) = Client::new("rustortion", ClientOptions::NO_START_SERVER) .context("failed to create JACK client")?;