#[derive(Debug, Clone, Copy, PartialEq, Default, serde::Serialize, serde::Deserialize)]
pub enum SaturationType {
#[default]
Tape, Tube, Transistor, }
pub struct Saturation {
sat_type: SaturationType,
drive: f64,
threshold: f64,
mix: f64,
input_gain_db: f64,
output_gain_db: f64,
input_gain_linear: f64,
output_gain_linear: f64,
enabled: bool,
highpass_mode: bool,
highpass_cutoff: f64,
sample_rate: f64,
hpf_coef: f64,
hpf_states: Vec<f64>,
prev_inputs: Vec<f64>,
}
impl Saturation {
pub fn new() -> Self {
let mut instance = Self {
sat_type: SaturationType::Tube,
drive: 0.25,
threshold: 0.88,
mix: 0.2,
input_gain_db: 0.0,
output_gain_db: 0.0,
input_gain_linear: 1.0,
output_gain_linear: 1.0,
enabled: true,
highpass_mode: false,
highpass_cutoff: 4000.0,
sample_rate: 44100.0,
hpf_coef: 0.0, hpf_states: vec![0.0; 2],
prev_inputs: vec![0.0; 2],
};
instance.update_hpf_coef();
instance
}
pub fn with_type(sat_type: SaturationType) -> Self {
Self {
sat_type,
..Self::new()
}
}
pub fn set_drive(&mut self, drive: f64) {
self.drive = drive.clamp(0.0, 2.0);
}
pub fn set_threshold(&mut self, threshold: f64) {
self.threshold = threshold.clamp(0.0, 1.0);
}
pub fn set_mix(&mut self, mix: f64) {
self.mix = mix.clamp(0.0, 1.0);
}
pub fn set_input_gain(&mut self, gain_db: f64) {
self.input_gain_db = gain_db;
self.input_gain_linear = db_to_linear(gain_db);
}
pub fn set_output_gain(&mut self, gain_db: f64) {
self.output_gain_db = gain_db;
self.output_gain_linear = db_to_linear(gain_db);
}
pub fn set_enabled(&mut self, enabled: bool) {
self.enabled = enabled;
}
pub fn set_type(&mut self, sat_type: SaturationType) {
self.sat_type = sat_type;
}
pub fn set_highpass_mode(&mut self, enabled: bool) {
self.highpass_mode = enabled;
}
pub fn set_highpass_cutoff(&mut self, hz: f64) {
self.highpass_cutoff = hz.clamp(1000.0, 12000.0);
self.update_hpf_coef();
}
pub fn set_sample_rate(&mut self, sr: f64) {
self.sample_rate = sr;
self.update_hpf_coef();
}
pub fn set_channel_count(&mut self, channels: usize) {
let channels = channels.max(1);
if self.hpf_states.len() != channels {
self.hpf_states.resize(channels, 0.0);
self.prev_inputs.resize(channels, 0.0);
}
}
fn update_hpf_coef(&mut self) {
self.hpf_coef =
self.sample_rate / (self.sample_rate + std::f64::consts::TAU * self.highpass_cutoff);
}
pub fn process(&mut self, samples: &mut [f64]) {
self.process_with_channels(samples, 2) }
pub fn process_with_channels(&mut self, samples: &mut [f64], channels: usize) {
if !self.enabled {
return;
}
if self.highpass_mode {
self.process_highpass(samples, channels);
} else {
self.process_fullband(samples);
}
}
pub fn process_with_sr(&mut self, samples: &mut [f64], channels: usize, sample_rate: f64) {
if (self.sample_rate - sample_rate).abs() > 1.0 {
self.set_sample_rate(sample_rate);
}
self.process_with_channels(samples, channels);
}
fn process_fullband(&mut self, samples: &mut [f64]) {
let input_gain = self.input_gain_linear;
let output_gain = self.output_gain_linear;
let threshold = self.threshold;
let drive_plus1 = 1.0 + self.drive;
let mix = self.mix;
let one_minus_mix = 1.0 - mix;
let sat_type = self.sat_type;
for sample in samples.iter_mut() {
let dry = *sample * input_gain;
if dry.abs() > threshold {
let driven = dry * drive_plus1;
let saturated = Self::apply_saturation_type(sat_type, driven);
*sample = (dry * one_minus_mix + saturated * mix) * output_gain;
} else {
*sample = dry;
}
}
}
fn process_highpass(&mut self, samples: &mut [f64], channels: usize) {
let input_gain = self.input_gain_linear;
let output_gain = self.output_gain_linear;
let alpha = self.hpf_coef;
let threshold = self.threshold;
let drive_plus1 = 1.0 + self.drive;
let mix = self.mix;
let sat_type = self.sat_type;
debug_assert!(
self.hpf_states.len() >= channels,
"Saturation HPF state undersized for {} channels (have {}); call set_channel_count during setup",
channels,
self.hpf_states.len()
);
let frames = samples.len() / channels;
for frame in 0..frames {
for ch in 0..channels {
let idx = frame * channels + ch;
if idx >= samples.len() {
break;
}
let input = samples[idx] * input_gain;
let high = alpha * self.hpf_states[ch] + alpha * (input - self.prev_inputs[ch]);
self.hpf_states[ch] = high;
self.prev_inputs[ch] = input;
#[cfg(not(any(
target_arch = "x86",
target_arch = "x86_64",
target_arch = "aarch64"
)))]
{
self.hpf_states[ch] =
crate::runtime::flush_subnormal_sample(self.hpf_states[ch]);
self.prev_inputs[ch] =
crate::runtime::flush_subnormal_sample(self.prev_inputs[ch]);
}
let saturated_high = if high.abs() > threshold {
let driven = high * drive_plus1;
Self::apply_saturation_type(sat_type, driven)
} else {
high
};
samples[idx] = (input + (saturated_high - high) * mix) * output_gain;
}
}
}
#[inline(always)]
fn apply_saturation_type(sat_type: SaturationType, x: f64) -> f64 {
match sat_type {
SaturationType::Tape => x.signum() * (1.0 - (-x.abs()).exp()),
SaturationType::Tube => x.tanh(),
SaturationType::Transistor => {
if x.abs() <= 1.5 {
x - (x * x * x) / 3.0
} else {
x.signum() * 0.375
}
}
}
}
pub fn reset(&mut self) {
self.hpf_states.fill(0.0);
self.prev_inputs.fill(0.0);
}
pub fn get_settings(&self) -> SaturationSettings {
SaturationSettings {
sat_type: self.sat_type,
drive: self.drive,
threshold: self.threshold,
mix: self.mix,
input_gain_db: self.input_gain_db,
output_gain_db: self.output_gain_db,
enabled: self.enabled,
highpass_mode: self.highpass_mode,
highpass_cutoff: self.highpass_cutoff,
}
}
}
impl Default for Saturation {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct SaturationSettings {
pub sat_type: SaturationType,
pub drive: f64,
pub threshold: f64,
pub mix: f64,
pub input_gain_db: f64,
pub output_gain_db: f64,
pub enabled: bool,
pub highpass_mode: bool,
pub highpass_cutoff: f64,
}
use super::dsp::db_to_linear;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tube_saturation() {
let mut sat = Saturation::with_type(SaturationType::Tube);
sat.set_enabled(true);
sat.set_mix(1.0);
let mut samples = vec![0.9, -0.9, 0.5, -0.5];
sat.process(&mut samples);
assert!(samples[0].abs() < 0.9);
assert!(samples[1].abs() < 0.9);
assert!((samples[2].abs() - 0.5).abs() < 0.1);
}
#[test]
fn test_disabled() {
let mut sat = Saturation::new();
sat.set_enabled(false);
let mut samples = vec![0.9, -0.9, 0.5, -0.5];
sat.process(&mut samples);
assert!((samples[0] - 0.9).abs() < 1e-10);
assert!((samples[1] - (-0.9)).abs() < 1e-10);
}
#[test]
fn test_cached_linear_gains_update_with_db_setters() {
let mut sat = Saturation::new();
sat.set_input_gain(6.0);
sat.set_output_gain(-3.0);
assert!((sat.input_gain_linear - db_to_linear(6.0)).abs() < 1e-12);
assert!((sat.output_gain_linear - db_to_linear(-3.0)).abs() < 1e-12);
assert_eq!(sat.input_gain_db, 6.0);
assert_eq!(sat.output_gain_db, -3.0);
}
#[test]
fn test_threshold() {
let mut sat = Saturation::with_type(SaturationType::Tube);
sat.set_enabled(true);
sat.set_threshold(0.8);
sat.set_mix(1.0);
let mut samples = vec![0.5];
sat.process(&mut samples);
assert!((samples[0] - 0.5).abs() < 1e-10);
let mut samples = vec![0.9];
sat.process(&mut samples);
assert!(samples[0].abs() < 0.9);
}
#[test]
fn test_mix() {
let mut sat = Saturation::with_type(SaturationType::Tube);
sat.set_enabled(true);
sat.set_drive(0.0); sat.set_mix(0.5);
let mut samples = vec![1.0];
sat.process(&mut samples);
let expected = (1.0 + 1.0_f64.tanh()) * 0.5;
assert!((samples[0] - expected).abs() < 0.01);
}
#[test]
fn test_hpf_coefficient() {
let mut sat = Saturation::new();
sat.set_sample_rate(44100.0);
sat.set_highpass_cutoff(4000.0);
let expected = 44100.0 / (44100.0 + std::f64::consts::TAU * 4000.0);
assert!((sat.hpf_coef - expected).abs() < 0.001);
}
#[test]
fn test_hpf_dc_rejection() {
let mut sat = Saturation::new();
sat.set_highpass_mode(true);
sat.set_highpass_cutoff(4000.0);
sat.set_sample_rate(44100.0);
sat.set_mix(0.5); sat.set_threshold(2.0);
let mut samples: Vec<f64> = vec![0.0; 200]; for i in 0..100 {
samples[i * 2] = 1.0; samples[i * 2 + 1] = 1.0; }
sat.process_with_channels(&mut samples, 2);
let last_l: f64 = samples.iter().skip(180).step_by(2).take(10).sum::<f64>() / 10.0;
let last_r: f64 = samples.iter().skip(181).step_by(2).take(10).sum::<f64>() / 10.0;
assert!(
(last_l - 1.0).abs() < 0.1,
"L output should be close to 1.0, got {}",
last_l
);
assert!(
(last_r - 1.0).abs() < 0.1,
"R output should be close to 1.0, got {}",
last_r
);
}
#[test]
fn test_highpass_flushes_denormals_with_audio_thread_init() {
crate::runtime::audio_thread_init();
if !crate::runtime::audio_thread_float_mode_is_enabled() {
return;
}
let mut sat = Saturation::new();
sat.set_highpass_mode(true);
let subnormal = f64::from_bits(1);
sat.hpf_states[0] = subnormal;
sat.prev_inputs[0] = -subnormal;
let mut samples = vec![0.0, 0.0];
sat.process_with_channels(&mut samples, 2);
assert_eq!(sat.hpf_states[0], 0.0);
assert_eq!(sat.prev_inputs[0], 0.0);
}
#[test]
fn test_highpass_multichannel_after_set_channel_count_does_not_panic() {
let mut sat = Saturation::new();
sat.set_highpass_mode(true);
sat.set_channel_count(6);
let mut samples = vec![0.5; 6 * 8];
sat.process_with_channels(&mut samples, 6);
assert_eq!(sat.hpf_states.len(), 6);
assert_eq!(sat.prev_inputs.len(), 6);
}
#[test]
fn test_set_channel_count_resizes_state_off_rt() {
let mut sat = Saturation::new();
assert_eq!(sat.hpf_states.len(), 2);
sat.set_channel_count(8);
assert_eq!(sat.hpf_states.len(), 8);
assert_eq!(sat.prev_inputs.len(), 8);
sat.set_channel_count(0);
assert_eq!(sat.hpf_states.len(), 1);
}
}