Skip to main content

wavecraft_processors/
saturator.rs

1//! Soft-clip saturator processor.
2
3use wavecraft_dsp::{ParamRange, ParamSpec, Processor, ProcessorParams, Transport};
4use wavecraft_protocol::db_to_linear;
5
6const MIN_DRIVE_DB: f32 = 0.0;
7const MAX_DRIVE_DB: f32 = 30.0;
8const MIN_OUTPUT_DB: f32 = -24.0;
9const MAX_OUTPUT_DB: f32 = 24.0;
10const MIN_UNIT: f32 = 0.0;
11const MAX_UNIT: f32 = 1.0;
12
13/// Parameters for the soft-clip saturator.
14#[derive(Debug, Clone)]
15pub struct SaturatorParams {
16    /// Input drive in dB before saturation.
17    pub drive_db: f32,
18    /// Output level in dB after saturation.
19    pub output_db: f32,
20    /// Dry/wet blend where 0 = dry and 1 = fully saturated.
21    pub mix: f32,
22    /// Tonal warmth control where 0 = warm and 1 = bright.
23    pub tone: f32,
24}
25
26impl Default for SaturatorParams {
27    fn default() -> Self {
28        Self::from_param_defaults()
29    }
30}
31
32impl ProcessorParams for SaturatorParams {
33    fn param_specs() -> &'static [ParamSpec] {
34        static SPECS: [ParamSpec; 4] = [
35            ParamSpec {
36                name: "Drive",
37                id_suffix: "drive_db",
38                range: ParamRange::Linear {
39                    min: MIN_DRIVE_DB as f64,
40                    max: MAX_DRIVE_DB as f64,
41                },
42                default: 12.0,
43                unit: "dB",
44                group: Some("Saturator"),
45            },
46            ParamSpec {
47                name: "Output",
48                id_suffix: "output_db",
49                range: ParamRange::Linear {
50                    min: MIN_OUTPUT_DB as f64,
51                    max: MAX_OUTPUT_DB as f64,
52                },
53                default: 0.0,
54                unit: "dB",
55                group: Some("Saturator"),
56            },
57            ParamSpec {
58                name: "Mix",
59                id_suffix: "mix",
60                range: ParamRange::Linear {
61                    min: MIN_UNIT as f64,
62                    max: MAX_UNIT as f64,
63                },
64                default: 1.0,
65                unit: "%",
66                group: Some("Saturator"),
67            },
68            ParamSpec {
69                name: "Tone",
70                id_suffix: "tone",
71                range: ParamRange::Linear {
72                    min: MIN_UNIT as f64,
73                    max: MAX_UNIT as f64,
74                },
75                default: 0.55,
76                unit: "%",
77                group: Some("Saturator"),
78            },
79        ];
80
81        &SPECS
82    }
83
84    fn from_param_defaults() -> Self {
85        Self {
86            drive_db: 12.0,
87            output_db: 0.0,
88            mix: 1.0,
89            tone: 0.55,
90        }
91    }
92
93    fn apply_plain_values(&mut self, values: &[f32]) {
94        if let Some(drive_db) = values.first() {
95            self.drive_db = *drive_db;
96        }
97        if let Some(output_db) = values.get(1) {
98            self.output_db = *output_db;
99        }
100        if let Some(mix) = values.get(2) {
101            self.mix = *mix;
102        }
103        if let Some(tone) = values.get(3) {
104            self.tone = *tone;
105        }
106    }
107}
108
109/// Soft-clip saturator DSP processor.
110#[derive(Debug, Default)]
111pub struct SaturatorDsp;
112
113impl Processor for SaturatorDsp {
114    type Params = SaturatorParams;
115
116    fn process(
117        &mut self,
118        buffer: &mut [&mut [f32]],
119        _transport: &Transport,
120        params: &Self::Params,
121    ) {
122        let drive = db_to_linear(params.drive_db);
123        let output = db_to_linear(params.output_db);
124        let mix = params.mix.clamp(MIN_UNIT, MAX_UNIT);
125        let tone = params.tone.clamp(MIN_UNIT, MAX_UNIT);
126
127        for channel in buffer.iter_mut() {
128            for sample in channel.iter_mut() {
129                let dry = *sample;
130                let driven = dry * drive;
131                let wet = warm_soft_clip(driven, tone) * output;
132                *sample = dry + (wet - dry) * mix;
133            }
134        }
135    }
136}
137
138#[inline]
139fn warm_soft_clip(input: f32, tone: f32) -> f32 {
140    let tone = tone.clamp(MIN_UNIT, MAX_UNIT);
141
142    // Lower tone values increase damping in the nonlinear stage for a warmer response.
143    let pre_emphasis = lerp(0.85, 1.2, tone);
144    let emphasized = input * pre_emphasis;
145    let clipped = emphasized / (1.0 + emphasized.abs());
146
147    // Cubic damping adds warmth without introducing any state or allocations.
148    let warmth_amount = 0.35 * (1.0 - tone);
149    let warmed = clipped - warmth_amount * clipped * clipped * clipped;
150
151    warmed / pre_emphasis
152}
153
154#[inline]
155fn lerp(start: f32, end: f32, t: f32) -> f32 {
156    start + (end - start) * t
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    fn process_mono(drive_db: f32, output_db: f32, mix: f32, tone: f32, input: &[f32]) -> Vec<f32> {
164        let mut processor = SaturatorDsp;
165        let params = SaturatorParams {
166            drive_db,
167            output_db,
168            mix,
169            tone,
170        };
171
172        let mut mono = input.to_vec();
173        let mut buffer = [&mut mono[..]];
174        processor.process(&mut buffer, &Transport::default(), &params);
175        mono
176    }
177
178    #[test]
179    fn param_specs_use_db_suffixes_and_group() {
180        let specs = SaturatorParams::param_specs();
181        assert_eq!(specs.len(), 4);
182        assert_eq!(specs[0].id_suffix, "drive_db");
183        assert_eq!(specs[1].id_suffix, "output_db");
184        assert_eq!(specs[2].id_suffix, "mix");
185        assert_eq!(specs[3].id_suffix, "tone");
186        assert_eq!(specs[0].group, Some("Saturator"));
187        assert_eq!(specs[1].unit, "dB");
188    }
189
190    #[test]
191    fn soft_clip_is_bounded() {
192        let output = process_mono(30.0, 0.0, 1.0, 0.5, &[10.0, -10.0, 100.0, -100.0]);
193
194        for sample in output {
195            assert!(sample.abs() <= 1.0);
196        }
197    }
198
199    #[test]
200    fn higher_drive_pushes_toward_saturation() {
201        let input = [0.5_f32, -0.5_f32];
202        let low_drive = process_mono(0.0, 0.0, 1.0, 0.5, &input);
203        let high_drive = process_mono(18.0, 0.0, 1.0, 0.5, &input);
204
205        assert!(high_drive[0].abs() > low_drive[0].abs());
206        assert!(high_drive[1].abs() > low_drive[1].abs());
207    }
208
209    #[test]
210    fn output_level_reduces_level() {
211        let input = [0.8_f32, -0.8_f32];
212        let unity = process_mono(12.0, 0.0, 1.0, 0.5, &input);
213        let reduced = process_mono(12.0, -12.0, 1.0, 0.5, &input);
214
215        assert!(reduced[0].abs() < unity[0].abs());
216        assert!(reduced[1].abs() < unity[1].abs());
217    }
218
219    #[test]
220    fn mix_blends_dry_and_wet() {
221        let input = [0.35_f32, -0.35_f32];
222        let dry = process_mono(18.0, 0.0, 0.0, 0.5, &input);
223        let wet = process_mono(18.0, 0.0, 1.0, 0.5, &input);
224        let blended = process_mono(18.0, 0.0, 0.5, 0.5, &input);
225
226        assert_eq!(dry, input);
227        assert!(wet[0] != input[0]);
228        assert!(blended[0] != dry[0]);
229        assert!(blended[0] != wet[0]);
230    }
231
232    #[test]
233    fn tone_changes_warmth_curve() {
234        let input = [0.75_f32, -0.75_f32];
235        let warm = process_mono(14.0, 0.0, 1.0, 0.0, &input);
236        let bright = process_mono(14.0, 0.0, 1.0, 1.0, &input);
237
238        assert_ne!(warm[0], bright[0]);
239        assert_ne!(warm[1], bright[1]);
240    }
241}