feat: add 12AX7 triode clipper and inter-stage filtering#211
Conversation
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.
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.
There was a problem hiding this comment.
Pull request overview
Adds a new physically-modeled “Triode” preamp clipper option and inserts an inter-stage lowpass filter between the preamp’s cascaded nonlinearities, then updates the Clean preset to use the new sound.
Changes:
- Add
ClipperType::Triodebacked by a lookup table and interpolation, plus i18n/UI integration. - Add an inter-stage one-pole lowpass filter in the preamp stage to reduce high-frequency “fizz” in cascaded waveshapers.
- Update
presets/Clean.jsonto use the Triode clipper (and adjust preset values).
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
src/i18n/mod.rs |
Adds translation key/strings for the new Triode clipper label. |
src/gui/stages/preamp.rs |
Exposes the Triode clipper in the GUI clipper-type picker. |
src/amp/stages/preamp.rs |
Inserts an inter-stage LP filter between the two nonlinear stages. |
src/amp/stages/common.rs |
Introduces a reusable one-pole lowpass filter implementation. |
src/amp/stages/clipper.rs |
Adds ClipperType::Triode using a lazily-initialized lookup table and tests. |
presets/Clean.json |
Switches Clean preset to Triode and adjusts related preset parameters. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 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 } | ||
| } |
There was a problem hiding this comment.
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.
| Self::Triode => TRIODE_TABLE.process(driven), | ||
| } |
There was a problem hiding this comment.
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.
- Replace Vec<f32> 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
Summary
Details
The Koren triode model computes plate current from the standard 12AX7 parameters (mu=100, Kp=600, Kvb=300, Kg1=1060, Ex=1.4). The table is built at init time in f64 precision, normalized to [-1, 1], and interpolated at runtime with cubic Hermite — same CPU cost as tanh.
The inter-stage LP filter models plate load capacitance that rolls off upper harmonics before they reach the second nonlinearity. Without this, cascaded waveshapers re-distort the full harmonic spectrum, which is the primary cause of "digital fizz" in amp sims.
Test plan
make lintpassesmake testpasses (5 new clipper tests + all existing)