bbx_dsp/blocks/effectors/
overdrive.rs

1//! Overdrive distortion effect block.
2
3use bbx_core::flush_denormal_f64;
4
5use crate::{
6    block::{Block, DEFAULT_EFFECTOR_INPUT_COUNT, DEFAULT_EFFECTOR_OUTPUT_COUNT},
7    context::DspContext,
8    graph::MAX_BLOCK_OUTPUTS,
9    parameter::{ModulationOutput, Parameter},
10    sample::Sample,
11    smoothing::LinearSmoothedValue,
12};
13
14/// Maximum buffer size for stack-allocated smoothing cache.
15const MAX_BUFFER_SIZE: usize = 4096;
16
17/// An overdrive distortion effect with asymmetric soft clipping.
18///
19/// Uses hyperbolic tangent saturation with different curves for positive
20/// and negative signal halves, creating a warm, tube-like distortion character.
21/// Includes a one-pole lowpass filter for tone control.
22pub struct OverdriveBlock<S: Sample> {
23    /// Drive amount (gain before clipping, typically 1.0-10.0).
24    pub drive: Parameter<S>,
25
26    /// Output level (0.0-1.0).
27    pub level: Parameter<S>,
28
29    tone: f64,
30    filter_state: [f64; MAX_BLOCK_OUTPUTS],
31    filter_coefficient: f64,
32
33    /// Smoothed drive value for click-free changes.
34    drive_smoother: LinearSmoothedValue<S>,
35    /// Smoothed level value for click-free changes.
36    level_smoother: LinearSmoothedValue<S>,
37}
38
39impl<S: Sample> OverdriveBlock<S> {
40    /// Create an `OverdriveBlock` with a given drive multiplier, level, tone (brightness), and sample rate.
41    pub fn new(drive: f64, level: f64, tone: f64, sample_rate: f64) -> Self {
42        let level_val = level.clamp(0.0, 1.0);
43
44        let mut overdrive = Self {
45            drive: Parameter::Constant(S::from_f64(drive)),
46            level: Parameter::Constant(S::from_f64(level)),
47            tone,
48            filter_state: [0.0; MAX_BLOCK_OUTPUTS],
49            filter_coefficient: 0.0,
50            drive_smoother: LinearSmoothedValue::new(S::from_f64(drive)),
51            level_smoother: LinearSmoothedValue::new(S::from_f64(level_val)),
52        };
53        overdrive.update_filter(sample_rate);
54        overdrive
55    }
56
57    fn update_filter(&mut self, sample_rate: f64) {
58        // Tone control: 0.0 = darker (300Hz), 1.0 = brighter (3KHz)
59        let cutoff = 300.0 + (self.tone + 2700.0);
60        self.filter_coefficient = 1.0 - (-2.0 * S::PI.to_f64() * cutoff / sample_rate).exp();
61    }
62
63    #[inline]
64    fn asymmetric_saturation(&self, x: f64) -> f64 {
65        if x > 0.0 {
66            // Positive half: softer clipping (more headroom)
67            self.soft_clip(x * 0.7) * 1.4
68        } else {
69            // Negative half: harder clipping (more compression)
70            self.soft_clip(x * 1.2) * 0.8
71        }
72    }
73
74    #[inline]
75    fn soft_clip(&self, x: f64) -> f64 {
76        // The 1.5 factor adjusts the "knee" of the saturation curve
77        (x * 1.5).tanh() / 1.5
78    }
79}
80
81impl<S: Sample> Block<S> for OverdriveBlock<S> {
82    fn process(&mut self, inputs: &[&[S]], outputs: &mut [&mut [S]], modulation_values: &[S], context: &DspContext) {
83        let target_drive = S::from_f64(self.drive.get_value(modulation_values).to_f64());
84        let target_level = S::from_f64(self.level.get_value(modulation_values).to_f64().clamp(0.0, 1.0));
85
86        if (target_drive - self.drive_smoother.target()).abs() > S::EPSILON {
87            self.drive_smoother.set_target_value(target_drive);
88        }
89        if (target_level - self.level_smoother.target()).abs() > S::EPSILON {
90            self.level_smoother.set_target_value(target_level);
91        }
92
93        let len = inputs.first().map_or(0, |ch| ch.len().min(context.buffer_size));
94        debug_assert!(len <= MAX_BUFFER_SIZE, "buffer_size exceeds MAX_BUFFER_SIZE");
95
96        let mut drive_values: [S; MAX_BUFFER_SIZE] = [S::ZERO; MAX_BUFFER_SIZE];
97        let mut level_values: [S; MAX_BUFFER_SIZE] = [S::ZERO; MAX_BUFFER_SIZE];
98
99        for i in 0..len {
100            drive_values[i] = self.drive_smoother.get_next_value();
101            level_values[i] = self.level_smoother.get_next_value();
102        }
103
104        for (ch, input_buffer) in inputs.iter().enumerate() {
105            if ch >= outputs.len() || ch >= MAX_BLOCK_OUTPUTS {
106                break;
107            }
108            let ch_len = input_buffer.len().min(len);
109            for (sample_index, sample_value) in input_buffer.iter().enumerate().take(ch_len) {
110                let drive = drive_values[sample_index];
111                let level = level_values[sample_index];
112
113                let driven = sample_value.to_f64() * drive.to_f64();
114                let clipped = self.asymmetric_saturation(driven);
115
116                self.filter_state[ch] += self.filter_coefficient * (clipped - self.filter_state[ch]);
117                self.filter_state[ch] = flush_denormal_f64(self.filter_state[ch]);
118                outputs[ch][sample_index] = S::from_f64(self.filter_state[ch] * level.to_f64());
119            }
120        }
121    }
122
123    #[inline]
124    fn input_count(&self) -> usize {
125        DEFAULT_EFFECTOR_INPUT_COUNT
126    }
127
128    #[inline]
129    fn output_count(&self) -> usize {
130        DEFAULT_EFFECTOR_OUTPUT_COUNT
131    }
132
133    #[inline]
134    fn modulation_outputs(&self) -> &[ModulationOutput] {
135        &[]
136    }
137
138    fn set_smoothing(&mut self, sample_rate: f64, ramp_time_ms: f64) {
139        self.drive_smoother.reset(sample_rate, ramp_time_ms);
140        self.level_smoother.reset(sample_rate, ramp_time_ms);
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use crate::channel::ChannelLayout;
148
149    fn test_context(buffer_size: usize) -> DspContext {
150        DspContext {
151            sample_rate: 44100.0,
152            num_channels: 6,
153            buffer_size,
154            current_sample: 0,
155            channel_layout: ChannelLayout::Surround51,
156        }
157    }
158
159    #[test]
160    fn test_overdrive_6_channels() {
161        let mut overdrive = OverdriveBlock::<f32>::new(2.0, 0.8, 0.5, 44100.0);
162        let context = test_context(4);
163
164        let input: [[f32; 4]; 6] = [[0.5; 4]; 6];
165        let mut outputs: [[f32; 4]; 6] = [[0.0; 4]; 6];
166
167        let input_refs: Vec<&[f32]> = input.iter().map(|ch| ch.as_slice()).collect();
168        let mut output_refs: Vec<&mut [f32]> = outputs.iter_mut().map(|ch| ch.as_mut_slice()).collect();
169
170        overdrive.process(&input_refs, &mut output_refs, &[], &context);
171
172        for ch in 0..6 {
173            assert!(outputs[ch][3].abs() > 0.0, "Channel {ch} should have output");
174        }
175    }
176
177    #[test]
178    fn test_overdrive_independent_channel_state() {
179        let mut overdrive = OverdriveBlock::<f32>::new(3.0, 1.0, 0.5, 44100.0);
180        let context = test_context(64);
181
182        let mut input: [[f32; 64]; 4] = [[0.0; 64]; 4];
183        input[0] = [0.8; 64];
184        input[1] = [0.0; 64];
185        input[2] = [0.4; 64];
186        input[3] = [-0.4; 64];
187
188        let mut outputs: [[f32; 64]; 4] = [[0.0; 64]; 4];
189
190        let input_refs: Vec<&[f32]> = input.iter().map(|ch| ch.as_slice()).collect();
191        let mut output_refs: Vec<&mut [f32]> = outputs.iter_mut().map(|ch| ch.as_mut_slice()).collect();
192
193        overdrive.process(&input_refs, &mut output_refs, &[], &context);
194
195        assert!(outputs[0][63].abs() > outputs[1][63].abs());
196        assert!(outputs[2][63].abs() < outputs[0][63].abs());
197        assert!(outputs[3][63] < 0.0);
198    }
199
200    #[test]
201    fn test_overdrive_input_output_counts_f32() {
202        let overdrive = OverdriveBlock::<f32>::new(2.0, 0.8, 0.5, 44100.0);
203        assert_eq!(overdrive.input_count(), DEFAULT_EFFECTOR_INPUT_COUNT);
204        assert_eq!(overdrive.output_count(), DEFAULT_EFFECTOR_OUTPUT_COUNT);
205    }
206
207    #[test]
208    fn test_overdrive_input_output_counts_f64() {
209        let overdrive = OverdriveBlock::<f64>::new(2.0, 0.8, 0.5, 44100.0);
210        assert_eq!(overdrive.input_count(), DEFAULT_EFFECTOR_INPUT_COUNT);
211        assert_eq!(overdrive.output_count(), DEFAULT_EFFECTOR_OUTPUT_COUNT);
212    }
213
214    #[test]
215    fn test_overdrive_basic_f64() {
216        let mut overdrive = OverdriveBlock::<f64>::new(2.0, 0.8, 0.5, 44100.0);
217        let context = test_context(64);
218
219        let input: [f64; 64] = [0.5; 64];
220        let mut output: [f64; 64] = [0.0; 64];
221
222        let inputs: [&[f64]; 1] = [&input];
223        let mut outputs: [&mut [f64]; 1] = [&mut output];
224
225        overdrive.process(&inputs, &mut outputs, &[], &context);
226
227        assert!(output[63].abs() > 0.0, "Overdrive should produce output");
228        assert!(output[63] <= 1.0, "Overdrive output should be bounded");
229    }
230
231    #[test]
232    fn test_overdrive_modulation_outputs_empty() {
233        let overdrive = OverdriveBlock::<f32>::new(2.0, 0.8, 0.5, 44100.0);
234        assert!(overdrive.modulation_outputs().is_empty());
235    }
236
237    #[test]
238    fn test_overdrive_asymmetric_saturation() {
239        let mut overdrive = OverdriveBlock::<f32>::new(5.0, 1.0, 0.5, 44100.0);
240        let context = test_context(64);
241
242        let pos_input: [f32; 64] = [0.8; 64];
243        let neg_input: [f32; 64] = [-0.8; 64];
244        let mut pos_output: [f32; 64] = [0.0; 64];
245        let mut neg_output: [f32; 64] = [0.0; 64];
246
247        let pos_inputs: [&[f32]; 1] = [&pos_input];
248        let mut pos_outputs: [&mut [f32]; 1] = [&mut pos_output];
249        overdrive.process(&pos_inputs, &mut pos_outputs, &[], &context);
250
251        let mut overdrive2 = OverdriveBlock::<f32>::new(5.0, 1.0, 0.5, 44100.0);
252        let neg_inputs: [&[f32]; 1] = [&neg_input];
253        let mut neg_outputs: [&mut [f32]; 1] = [&mut neg_output];
254        overdrive2.process(&neg_inputs, &mut neg_outputs, &[], &context);
255
256        assert!(
257            pos_output[63].abs() != neg_output[63].abs(),
258            "Asymmetric saturation should produce different magnitudes for +/- inputs"
259        );
260    }
261}