Skip to main content

rustriff_lib/services/effects/distortion/
hc_distortion.rs

1use crate::domain::audio_processor::AudioProcessor;
2use crate::domain::dto::effect::effect_dto::EffectDto;
3use crate::domain::dto::effect::hcdistortion_dto::HcDistortionDto;
4use crate::domain::effect::Effect;
5use crate::services::processors::gain::gain_processor::GainProcessor;
6use atomic_float::AtomicF32;
7use std::collections::HashMap;
8use std::sync::atomic::{AtomicBool, Ordering};
9use std::sync::Arc;
10
11/// # Hard-Clipping Distortion Effect
12///
13/// `HCDistortion` implements a classic hard-clipping distortion pedal with two
14/// controllable parameters: **Drive** (clipping threshold) and **Level** (output boost).
15///
16/// ## Signal Chain
17///
18/// The processing happens in two stages:
19///
20/// 1. **Hard Clipping**
21///    - Any sample whose absolute value exceeds the `threshold` is clamped to `±threshold`
22///    - This produces the characteristic flat-top waveform of hard clipping distortion
23///    - Lower thresholds produce heavier distortion (more clipping)
24///    - Higher thresholds produce lighter distortion (less clipping)
25///
26/// 2. **Output Level Boost** (via [`GainProcessor`])
27///    - After clipping, the signal passes through a [`GainProcessor`]
28///    - The gain is controlled by a normalised `level` parameter `[0.0, 1.0]`
29///    - Maps to a linear gain range of `1.0` (unity) to `2.0` (double amplitude)
30///    - Uses smoothed transitions (one-pole filter) to avoid clicks and pops
31///
32/// ## Parameter Ranges
33///
34/// | Parameter  | Range      | UI Display | Effect |
35/// |-----------|----------|------------|--------|
36/// | `threshold` | `(0.0, 1.0]` | Drive 0–100% | Lower = heavier distortion |
37/// | `level`  | `[0.0, 1.0]`   | Level 0–100% | 0 = no boost, 1.0 = ×2 boost |
38///
39/// ## Thread-Safe Atomic Updates
40///
41/// All mutable parameters are stored as lock-free atomics:
42/// - `is_active`: [`Arc<AtomicBool>`] — enable/bypass the effect
43/// - `limit`: [`Arc<AtomicF32>`] — clipping threshold (shared with [`f32_params`](Self::f32_params))
44/// - `level`: [`Arc<AtomicF32>`] — internal gain value `[1.0, 2.0]` (shared with [`GainProcessor`])
45///
46/// This allows the audio thread to read parameter changes from command handlers
47/// without any locks or synchronisation overhead.
48pub struct HCDistortion {
49    id: u32,
50    name: String,
51    is_active: Arc<AtomicBool>,
52    /// Clip level in `(0.0, 1.0]`. Lower = heavier distortion.
53    /// Shared with command infrastructure via [`f32_params`](Self::f32_params).
54    limit: Arc<AtomicF32>,
55    /// Internal gain atomic shared with `level_gain`. Stores gain in range `[1.0, 2.0]`.
56    /// Accessed externally via normalised [`level`](Self::level) method.
57    level: Arc<AtomicF32>,
58    /// GainProcessor that applies smoothed level boost after hard clipping.
59    /// Reads gain value from `level` atomic lock-free on each sample.
60    level_gain: GainProcessor,
61    /// UI chassis colour (hex string, e.g. `"#e67e22"`).
62    color: String,
63}
64
65impl HCDistortion {
66    /// Creates a new `HCDistortion` effect.
67    ///
68    /// # Parameters
69    ///
70    /// * `id` — Unique identifier for this effect instance
71    /// * `name` — Human-readable name (e.g., "Distortion")
72    /// * `is_active` — Whether the effect is initially enabled
73    /// * `threshold` — Clip level in `(0.0, 1.0]`. Will be clamped to `[0.001, 1.0]`.
74    ///   Lower values produce heavier distortion.
75    /// * `level` — Initial output boost in `[0.0, 1.0]`. Will be clamped to `[0.0, 1.0]`.
76    ///   Maps internally to gain `[1.0, 2.0]`.
77    /// * `color` — Hex colour string for UI pedal chassis (e.g., `"#e67e22"`)
78    pub fn new(
79        id: u32,
80        name: String,
81        is_active: bool,
82        threshold: f32,
83        level: f32,
84        color: String,
85    ) -> Self {
86        let gain_value = 1.0 + level.clamp(0.0, 1.0); // map [0,1] → [1,2]
87        let level_arc = Arc::new(AtomicF32::new(gain_value));
88        let level_gain = GainProcessor::new(Arc::clone(&level_arc));
89        Self {
90            id,
91            name,
92            is_active: Arc::new(AtomicBool::new(is_active)),
93            limit: Arc::new(AtomicF32::new(threshold.clamp(0.001, 1.0))),
94            level: level_arc,
95            level_gain,
96            color,
97        }
98    }
99
100    /// Returns the current clipping threshold in range `(0.0, 1.0]`.
101    ///
102    /// # Returns
103    ///
104    /// The threshold value; lower values produce heavier clipping.
105    pub fn threshold(&self) -> f32 {
106        self.limit.load(Ordering::Relaxed)
107    }
108
109    /// Sets the clipping threshold. Value is clamped to `[0.001, 1.0]`.
110    ///
111    /// The change takes effect on the very next audio sample — no synchronisation needed.
112    ///
113    /// # Parameters
114    ///
115    /// * `threshold` — New clipping level in `(0.0, 1.0]`
116    pub fn set_threshold(&self, threshold: f32) {
117        self.limit
118            .store(threshold.clamp(0.001, 1.0), Ordering::Relaxed);
119    }
120
121    /// Returns the normalised output level in range `[0.0, 1.0]`.
122    ///
123    /// Internally the gain is stored as `[1.0, 2.0]`; this method reverses that mapping
124    /// to give the external normalised value.
125    ///
126    /// # Returns
127    ///
128    /// Normalised level: `0.0` = no boost (unity gain), `1.0` = ×2.0 boost
129    pub fn level(&self) -> f32 {
130        (self.level.load(Ordering::Relaxed) - 1.0).clamp(0.0, 1.0)
131    }
132
133    /// Sets the output level from a normalised value `[0.0, 1.0]`.
134    ///
135    /// Internally maps to gain `[1.0, 2.0]` and stores it in the atomic.
136    /// The change takes effect on the very next audio sample — no synchronisation needed.
137    ///
138    /// # Parameters
139    ///
140    /// * `level` — Normalised level in `[0.0, 1.0]`. Will be clamped.
141    pub fn set_level(&self, level: f32) {
142        self.level
143            .store(1.0 + level.clamp(0.0, 1.0), Ordering::Relaxed);
144    }
145}
146
147impl AudioProcessor for HCDistortion {
148    /// Processes a single audio sample through hard clipping and level boost.
149    ///
150    /// # Algorithm
151    ///
152    /// 1. **Load clipping threshold** atomically (lock-free)
153    /// 2. **Clamp sample** to `[-threshold, threshold]` (hard clipping)
154    /// 3. **Apply gain boost** via the [`GainProcessor`] with smoothed transitions
155    ///
156    /// # Parameters
157    ///
158    /// * `sample` — Normalised audio sample, typically `-1.0` to `1.0`
159    ///
160    /// # Returns
161    /// Processed sample: clipped and boosted by the level knob
162    fn process(&mut self, sample: f32) -> f32 {
163        let limit = self.limit.load(Ordering::Relaxed);
164        let clipped = sample.clamp(-limit, limit);
165        self.level_gain.process(clipped)
166    }
167}
168
169impl Effect for HCDistortion {
170    fn id(&self) -> u32 {
171        self.id
172    }
173    fn name(&self) -> &str {
174        &self.name
175    }
176    fn get_color(&self) -> String {
177        self.color.clone()
178    }
179    fn active_flag(&self) -> Arc<AtomicBool> {
180        Arc::clone(&self.is_active)
181    }
182
183    /// Returns a map of named f32 parameters for command infrastructure.
184    ///
185    /// This enables the generic command dispatcher to update effect parameters
186    /// without needing to know about specific effect types.
187    ///
188    /// # Returns
189    ///
190    /// HashMap with keys:
191    /// * `"threshold"` — points to `limit` atomic; external code can write new thresholds
192    /// * `"level"` — points to internal gain atomic `[1.0, 2.0]`
193    ///
194    /// # Note
195    ///
196    /// The `"level"` key stores the raw gain value. Command handlers should convert
197    /// the external normalised `[0, 1]` range to internal gain `[1, 2]` before writing.
198    fn f32_params(&self) -> HashMap<&'static str, Arc<AtomicF32>> {
199        let mut map = HashMap::new();
200        map.insert("threshold", Arc::clone(&self.limit));
201        // "level" stores the internal gain [1, 2]; the command converts [0,1] before writing.
202        map.insert("level", Arc::clone(&self.level));
203        map
204    }
205
206    /// Converts this effect into its serialisable DTO representation.
207    ///
208    /// Called when sending effect state to the frontend or external clients.
209    ///
210    /// # Returns
211    ///
212    /// [`EffectDto::HCDistortion`] with all current parameters
213    fn to_dto(&self) -> EffectDto {
214        EffectDto::HCDistortion(HcDistortionDto {
215            id: self.id,
216            name: self.name.clone(),
217            is_active: self.is_active.load(Ordering::Relaxed),
218            color: self.color.clone(),
219            threshold: self.limit.load(Ordering::Relaxed),
220            level: self.level(),
221        })
222    }
223
224    /// Processes a sample only if the effect is currently active.
225    ///
226    /// If inactive (bypassed), the sample is returned unchanged.
227    ///
228    /// # Parameters
229    ///
230    /// * `sample` — Input audio sample
231    ///
232    /// # Returns
233    ///
234    /// Processed sample if active, otherwise the input unchanged (unity bypass)
235    fn process_if_active(&mut self, sample: f32) -> f32 {
236        if self.is_active() {
237            self.process(sample)
238        } else {
239            sample
240        }
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    fn distortion(threshold: f32) -> HCDistortion {
249        HCDistortion::new(
250            0,
251            "HC".to_string(),
252            true,
253            threshold,
254            0.0,
255            "#e67e22".to_string(),
256        )
257    }
258
259    mod success_path {
260        use super::*;
261
262        #[test]
263        fn sample_within_threshold_is_unchanged() {
264            let mut fx = distortion(0.5);
265            // With level=0.0 the gain processor targets 1.0; after many samples it converges.
266            // For a quick unit check, drive it to steady-state first.
267            for _ in 0..10_000 {
268                fx.process(0.0);
269            }
270            assert!((fx.process(0.3) - 0.3).abs() < 1e-3);
271            assert!((fx.process(-0.3) - (-0.3)).abs() < 1e-3);
272        }
273
274        #[test]
275        fn sample_above_threshold_is_clipped() {
276            let mut fx = distortion(0.5);
277            for _ in 0..10_000 {
278                fx.process(0.0);
279            }
280            assert!((fx.process(0.9) - 0.5).abs() < 1e-3);
281        }
282
283        #[test]
284        fn process_if_active_clips_when_active() {
285            let mut fx = distortion(0.5);
286            for _ in 0..10_000 {
287                fx.process(0.0);
288            }
289            assert!((fx.process_if_active(0.9) - 0.5).abs() < 1e-3);
290        }
291
292        #[test]
293        fn process_if_active_passes_through_when_inactive() {
294            let mut fx = distortion(0.5);
295            fx.set_active(false);
296            assert_eq!(fx.process_if_active(0.9), 0.9);
297        }
298
299        #[test]
300        fn set_threshold_updates_clip_level() {
301            let mut fx = distortion(0.8);
302            fx.set_threshold(0.3);
303            assert!((fx.threshold() - 0.3).abs() < 1e-6);
304            for _ in 0..10_000 {
305                fx.process(0.0);
306            }
307            assert!((fx.process(0.9) - 0.3).abs() < 1e-3);
308        }
309
310        #[test]
311        fn level_boost_doubles_output_at_max() {
312            let mut fx =
313                HCDistortion::new(0, "HC".to_string(), true, 1.0, 1.0, "#e67e22".to_string());
314            // Converge gain processor to ×2.0
315            for _ in 0..20_000 {
316                fx.process(0.0);
317            }
318            let out = fx.process(0.3);
319            assert!((out - 0.6).abs() < 0.01, "expected ≈0.6, got {out}");
320        }
321
322        #[test]
323        fn level_unity_at_zero() {
324            let mut fx = distortion(1.0); // level=0.0
325            for _ in 0..10_000 {
326                fx.process(0.0);
327            }
328            let out = fx.process(0.4);
329            assert!((out - 0.4).abs() < 0.01, "expected ≈0.4, got {out}");
330        }
331    }
332
333    mod failure_path {
334        use super::*;
335
336        #[test]
337        fn threshold_above_one_is_clamped_to_one() {
338            let fx = distortion(2.0);
339            assert_eq!(fx.threshold(), 1.0);
340        }
341
342        #[test]
343        fn threshold_of_zero_is_clamped_to_minimum() {
344            let fx = distortion(0.0);
345            assert!(fx.threshold() > 0.0);
346        }
347    }
348}