1use 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#[derive(Debug, Clone)]
15pub struct SaturatorParams {
16 pub drive_db: f32,
18 pub output_db: f32,
20 pub mix: f32,
22 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#[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 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 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(), ¶ms);
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}