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, diff --git a/src/amp/stages/clipper.rs b/src/amp/stages/clipper.rs index 65fa29b..629e8a3 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,11 +21,13 @@ 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)), } } } impl ClipperType { + #[inline] pub fn process(&self, input: f32, drive: f32) -> f32 { let driven = input * drive; @@ -67,6 +71,171 @@ impl ClipperType { (1.0 - fold_amount).mul_add(soft_clip, fold_amount * folded) } + + Self::Triode => TRIODE_TABLE.process(driven), + } + } +} + +/// 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: [f32; Self::SIZE], + input_min: f32, + input_max: f32, +} + +impl TubeTable { + const SIZE: usize = 256; +} + +impl TubeTable { + fn new() -> Self { + // 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 = [0.0f64; Self::SIZE]; + for (idx, sample) in raw.iter_mut().enumerate() { + 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; + } + + 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 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, + input_min: input_min as f32, + input_max: input_max as f32, + } + } + + #[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); + 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); + +/// 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::*; + + #[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..cbcebc3 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 = 1.0 - (-2.0 * PI * cutoff_hz / sample_rate).exp(); + 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/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")?; 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类",