use crate::dsp::parameter::Parameter;
use crate::sim::components::CircuitComponent;
use crate::sim::core::solver::NewtonSolver;
use crate::simd::generic::{ScalarFloat, simd_log1pexp, simd_sigmoid};
#[derive(Clone, Debug)]
pub struct TriodeParams {
pub mu: f32,
pub k: f32,
pub vk: f32,
pub plate_resistance: f32,
pub supply_voltage: f32,
pub bias: f32,
}
pub const PARAMS_12AX7: TriodeParams = TriodeParams {
mu: 100.0,
k: 2.1e-6,
vk: 1.3,
plate_resistance: 100_000.0,
supply_voltage: 250.0,
bias: -1.5,
};
pub const PARAMS_12AT7: TriodeParams = TriodeParams {
mu: 60.0,
k: 3.1e-6,
vk: 1.5,
plate_resistance: 80_000.0,
supply_voltage: 250.0,
bias: -2.0,
};
pub struct TriodeStage {
params: TriodeParams,
solver: NewtonSolver,
prev_vp: f32,
input_gain: Parameter,
output_gain: Parameter,
sample_rate: f32,
}
impl TriodeStage {
pub fn new(params: TriodeParams) -> Self {
let initial_vp = params.supply_voltage * 0.6; Self {
params,
solver: NewtonSolver {
max_iterations: 4,
tolerance: 1e-4,
voltage_clamp: 500.0,
},
prev_vp: initial_vp,
input_gain: Parameter::new(1.0),
output_gain: Parameter::new(1.0),
sample_rate: 44100.0,
}
}
#[inline]
fn plate_current(&self, vg: f32, vp: f32) -> f32 {
let e = (vg + vp / self.params.mu) / self.params.vk;
let l = simd_log1pexp(ScalarFloat(e)).0;
self.params.k * l * l
}
#[inline]
fn plate_current_derivative(&self, vg: f32, vp: f32) -> f32 {
let e = (vg + vp / self.params.mu) / self.params.vk;
let l = simd_log1pexp(ScalarFloat(e)).0;
let sig = simd_sigmoid(ScalarFloat(e)).0;
(2.0 * self.params.k * l) / (self.params.mu * self.params.vk) * sig
}
#[inline]
fn solve_plate_voltage(&mut self, vg: f32) -> f32 {
let b_plus = self.params.supply_voltage;
let rp = self.params.plate_resistance;
let initial_guess = self.prev_vp;
let vp = self.solver.solve(initial_guess, |vp| {
let ip = self.plate_current(vg, vp);
let dip_dvp = self.plate_current_derivative(vg, vp);
let f = b_plus - rp * ip - vp;
let f_prime = -rp * dip_dvp - 1.0;
(f, f_prime)
});
self.prev_vp = vp;
vp
}
}
impl CircuitComponent for TriodeStage {
fn prepare(&mut self, sample_rate: f32) {
self.sample_rate = sample_rate;
self.prev_vp = self.params.supply_voltage * 0.6;
self.input_gain.reset(1.0);
self.output_gain.reset(1.0);
}
fn process_block(&mut self, input: &[f32], output: &mut [f32]) {
let bias = self.params.bias;
let b_plus = self.params.supply_voltage;
for i in 0..input.len().min(output.len()) {
self.input_gain.step();
self.output_gain.step();
let vg = bias + input[i] * self.input_gain.value;
let vp = self.solve_plate_voltage(vg);
let vp_quiescent = b_plus * 0.6;
let out = (vp - vp_quiescent) / (b_plus * 0.4) * self.output_gain.value;
output[i] = out.clamp(-1.0, 1.0);
}
}
fn update_parameters(&mut self) {
}
}
#[derive(Clone, Debug)]
pub struct PentodeParams {
pub k: f32,
pub vk: f32,
pub va: f32,
pub plate_resistance: f32,
pub supply_voltage: f32,
pub bias: f32,
}
pub const PARAMS_EL34: PentodeParams = PentodeParams {
k: 1.8e-6,
vk: 1.5,
va: 300.0,
plate_resistance: 2_000.0,
supply_voltage: 450.0,
bias: -30.0,
};
pub struct PentodeStage {
params: PentodeParams,
solver: NewtonSolver,
prev_vp: f32,
input_gain: Parameter,
output_gain: Parameter,
sample_rate: f32,
}
impl PentodeStage {
pub fn new(params: PentodeParams) -> Self {
Self {
params,
solver: NewtonSolver {
max_iterations: 4,
tolerance: 1e-4,
voltage_clamp: 500.0,
},
prev_vp: 300.0,
input_gain: Parameter::new(1.0),
output_gain: Parameter::new(1.0),
sample_rate: 44100.0,
}
}
#[inline]
fn plate_current(&self, vg: f32, vp: f32) -> f32 {
let e = vg / self.params.vk;
let l = simd_log1pexp(ScalarFloat(e)).0;
self.params.k * l * l * (1.0 + vp / self.params.va)
}
#[inline]
fn plate_current_derivative_vp(&self, vg: f32, _vp: f32) -> f32 {
let e = vg / self.params.vk;
let l = simd_log1pexp(ScalarFloat(e)).0;
self.params.k * l * l / self.params.va
}
#[inline]
fn solve_plate_voltage(&mut self, vg: f32) -> f32 {
let b_plus = self.params.supply_voltage;
let rp = self.params.plate_resistance;
let initial_guess = self.prev_vp;
let vp = self.solver.solve(initial_guess, |vp| {
let ip = self.plate_current(vg, vp);
let dip_dvp = self.plate_current_derivative_vp(vg, vp);
let f = b_plus - rp * ip - vp;
let f_prime = -rp * dip_dvp - 1.0;
(f, f_prime)
});
self.prev_vp = vp;
vp
}
}
impl CircuitComponent for PentodeStage {
fn prepare(&mut self, sample_rate: f32) {
self.sample_rate = sample_rate;
self.prev_vp = self.params.supply_voltage * 0.65;
self.input_gain.reset(1.0);
self.output_gain.reset(1.0);
}
fn process_block(&mut self, input: &[f32], output: &mut [f32]) {
let bias = self.params.bias;
let b_plus = self.params.supply_voltage;
for i in 0..input.len().min(output.len()) {
self.input_gain.step();
self.output_gain.step();
let vg = bias + input[i] * self.input_gain.value;
let vp = self.solve_plate_voltage(vg);
let vp_quiescent = b_plus * 0.65;
let out = (vp - vp_quiescent) / (b_plus * 0.35) * self.output_gain.value;
output[i] = out.clamp(-1.0, 1.0);
}
}
fn update_parameters(&mut self) {}
}
#[cfg(test)]
mod tests {
#![allow(clippy::needless_range_loop)]
use super::*;
#[test]
fn test_triode_transfer_curve() {
let params = PARAMS_12AX7;
let mut stage = TriodeStage::new(params.clone());
stage.prepare(44100.0);
for i in 0..50 {
let vg = -5.0 + 5.0 * (i as f32) / 50.0;
let ip = stage.plate_current(vg, 150.0);
assert!(ip >= 0.0, "Ip({vg}) = {ip} < 0");
assert!(ip < 0.01, "Ip({vg}) = {ip} too large");
}
let ip_cutoff = stage.plate_current(-5.0, 150.0);
assert!(ip_cutoff < 1e-6, "Ip at cutoff = {ip_cutoff}");
let ip_zero = stage.plate_current(0.0, 150.0);
assert!(ip_zero > 1e-7, "Ip at zero bias = {ip_zero}");
}
#[test]
fn test_triode_newton_convergence() {
let params = PARAMS_12AX7;
let mut stage = TriodeStage::new(params);
stage.prepare(44100.0);
for i in 0..20 {
let vg = -3.0 + 3.0 * (i as f32) / 20.0;
let b_plus = stage.params.supply_voltage;
let rp = stage.params.plate_resistance;
let (vp, iters) = stage.solver.solve_counted(b_plus * 0.6, |vp| {
let ip = stage.plate_current(vg, vp);
let dip = stage.plate_current_derivative(vg, vp);
(b_plus - rp * ip - vp, -rp * dip - 1.0)
});
assert!(iters <= 4, "Newton took {iters} iterations for Vg={vg}");
assert!(vp.is_finite(), "Vp is not finite for Vg={vg}");
}
}
#[test]
fn test_triode_audio_sine() {
let params = PARAMS_12AX7;
let mut stage = TriodeStage::new(params);
stage.prepare(44100.0);
let block_size = 128;
let mut input = vec![0.0f32; block_size];
let mut output = vec![0.0f32; block_size];
for i in 0..block_size {
input[i] = 0.5 * (2.0 * std::f32::consts::PI * 1000.0 * i as f32 / 44100.0).sin();
}
stage.process_block(&input, &mut output);
let mut has_nonzero = false;
for &s in &output {
assert!(!s.is_nan(), "output contains NaN");
assert!(!s.is_infinite(), "output contains Inf");
assert!(s.abs() <= 1.0, "output {s} exceeds [-1, 1]");
if s.abs() > 0.001 {
has_nonzero = true;
}
}
assert!(has_nonzero, "output is all zeros");
let max_pos = output.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
let max_neg = output.iter().cloned().fold(f32::INFINITY, f32::min);
assert!(
(max_pos.abs() - max_neg.abs()).abs() > 0.001,
"clipping is too symmetric: pos={max_pos}, neg={max_neg}"
);
}
#[test]
fn test_pentode_basic() {
let params = PARAMS_EL34;
let mut stage = PentodeStage::new(params);
stage.prepare(44100.0);
let block_size = 64;
let mut input = vec![0.0f32; block_size];
let mut output = vec![0.0f32; block_size];
for i in 0..block_size {
input[i] = 0.3 * (2.0 * std::f32::consts::PI * 440.0 * i as f32 / 44100.0).sin();
}
stage.process_block(&input, &mut output);
for &s in &output {
assert!(!s.is_nan(), "pentode output NaN");
assert!(!s.is_infinite(), "pentode output Inf");
}
}
}