use aether_core::{node::DspNode, param::ParamBlock, BUFFER_SIZE, MAX_INPUTS};
pub struct Waveshaper {
tone_state: f32,
crush_phase: f32,
crush_held: f32,
}
impl Waveshaper {
pub fn new() -> Self {
Self {
tone_state: 0.0,
crush_phase: 0.0,
crush_held: 0.0,
}
}
#[inline(always)]
fn tanh_approx(x: f32) -> f32 {
let x2 = x * x;
x * (27.0 + x2) / (27.0 + 9.0 * x2)
}
#[inline(always)]
fn tube_sat(x: f32) -> f32 {
if x >= 0.0 {
1.0 - (-x).exp()
} else {
-1.0 + (x).exp()
}
}
}
impl Default for Waveshaper {
fn default() -> Self { Self::new() }
}
impl DspNode for Waveshaper {
fn process(
&mut self,
inputs: &[Option<&[f32; BUFFER_SIZE]>; MAX_INPUTS],
output: &mut [f32; BUFFER_SIZE],
params: &mut ParamBlock,
sample_rate: f32,
) {
let silence = [0.0f32; BUFFER_SIZE];
let input = inputs[0].unwrap_or(&silence);
let drive = params.get(0).current.clamp(0.0, 1.0);
let mode = params.get(1).current as u32;
let tone = params.get(2).current.clamp(0.0, 1.0);
let wet = params.get(3).current.clamp(0.0, 1.0);
let gain = 1.0 + drive * 19.0;
let tone_coeff = tone * 0.95;
for (i, out) in output.iter_mut().enumerate() {
let dry = input[i];
let driven = dry * gain;
let shaped = match mode {
0 => Self::tanh_approx(driven),
1 => driven.clamp(-1.0, 1.0),
2 => {
let mut v = driven;
while v.abs() > 1.0 {
if v > 1.0 { v = 2.0 - v; }
else if v < -1.0 { v = -2.0 - v; }
}
v
}
3 => {
let crush_rate = 1.0 - drive * 0.95; self.crush_phase += crush_rate;
if self.crush_phase >= 1.0 {
self.crush_phase -= 1.0;
let bits = 4.0 + (1.0 - drive) * 12.0;
let levels = 2.0f32.powf(bits);
self.crush_held = (driven * levels).round() / levels;
}
self.crush_held
}
_ => Self::tube_sat(driven),
};
let normalized = shaped / gain.sqrt();
self.tone_state = self.tone_state + tone_coeff * (normalized - self.tone_state);
let toned = normalized + tone * (normalized - self.tone_state);
*out = dry + wet * (toned - dry);
params.tick_all();
}
let _ = sample_rate;
}
fn type_name(&self) -> &'static str { "Waveshaper" }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_waveshaper_zero_drive_passthrough() {
let mut ws = Waveshaper::new();
let mut params = ParamBlock::new();
for &v in &[0.0f32, 0.0, 0.5, 1.0] { params.add(v); }
let input: [f32; BUFFER_SIZE] = std::array::from_fn(|i| (i as f32 / BUFFER_SIZE as f32) * 0.5);
let inputs = [Some(&input); MAX_INPUTS];
let mut output = [0.0f32; BUFFER_SIZE];
ws.process(&inputs, &mut output, &mut params, 48000.0);
for i in 0..BUFFER_SIZE {
assert!((output[i] - input[i]).abs() < 0.05, "zero drive should be near-passthrough");
}
}
#[test]
fn test_waveshaper_hard_clip_bounds() {
let mut ws = Waveshaper::new();
let mut params = ParamBlock::new();
for &v in &[1.0f32, 1.0, 0.0, 1.0] { params.add(v); } let input = [2.0f32; BUFFER_SIZE]; let inputs = [Some(&input); MAX_INPUTS];
let mut output = [0.0f32; BUFFER_SIZE];
ws.process(&inputs, &mut output, &mut params, 48000.0);
for s in &output {
assert!(s.abs() <= 1.5, "hard clip output should be bounded, got {s}");
}
}
}