Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions presets/Clean.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"Preamp": {
"gain": 1.1,
"bias": 1.4901161e-8,
"clipper_type": "ClassA"
"clipper_type": "Triode"
}
},
{
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
169 changes: 169 additions & 0 deletions src/amp/stages/clipper.rs
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 {
Expand All @@ -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;

Expand Down Expand Up @@ -67,6 +71,171 @@ impl ClipperType {

(1.0 - fold_amount).mul_add(soft_clip, fold_amount * folded)
}

Self::Triode => TRIODE_TABLE.process(driven),
}
Comment on lines +75 to +76
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TRIODE_TABLE is lazily initialized, and the first ClipperType::Triode.process() call will trigger allocation/table construction and one-time synchronization. If that first call happens on the real-time audio thread (e.g. when loading a preset using Triode), it can cause an audible glitch.

Consider forcing initialization off the audio thread (e.g. during app/engine startup, or when building the amp chain if any stage uses ClipperType::Triode) so audio processing never pays the init/lock cost.

Copilot uses AI. Check for mistakes.
}
}

/// 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<TubeTable> = 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}"
);
}
}
23 changes: 23 additions & 0 deletions src/amp/stages/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
Comment on lines +60 to +63
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OnePoleLP::new() computes coeff as (2π * cutoff / sample_rate).min(1.0), which is a forward-Euler approximation and becomes 1.0 for common settings (e.g. 10 kHz at 44.1 kHz => coeff>1 => clamped to 1). That effectively disables the intended lowpass (output becomes the input after one-sample lag) and makes the tone depend on oversampling/sample-rate.

Consider using a standard one-pole coefficient (e.g. a = exp(-2π * fc / fs) and then y = a*y_prev + (1-a)*x, or equivalently coeff = 1-a in the current form) so the cutoff is correct and stable across sample rates.

Copilot uses AI. Check for mistakes.

#[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 {
Expand Down
11 changes: 9 additions & 2 deletions src/amp/stages/preamp.rs
Original file line number Diff line number Diff line change
@@ -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,
}

Expand All @@ -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),
}
}
Expand All @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions src/audio/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -29,6 +30,8 @@ pub struct Manager {

impl Manager {
pub fn new(settings: Settings) -> Result<Self> {
clipper::init();

let (client, _) = Client::new("rustortion", ClientOptions::NO_START_SERVER)
.context("failed to create JACK client")?;

Expand Down
3 changes: 2 additions & 1 deletion src/gui/stages/preamp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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类",
Expand Down