Skip to main content

rustriff_lib/services/effects/distortion/
sc_distortion.rs

1use crate::domain::audio_processor::AudioProcessor;
2use crate::domain::dto::effect::effect_dto::EffectDto;
3use crate::domain::dto::effect::scdistortion_dto::ScDistortionDto;
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
11pub struct SCDistortion {
12    id: u32,
13    name: String,
14    is_active: Arc<AtomicBool>,
15    /// Clip level in `[0.0, 1.0]`. Lower = heavier distortion.
16    /// Shared with command infrastructure via [`f32_params`](Self::f32_params).
17    limit: Arc<AtomicF32>,
18    /// Internal gain atomic shared with `level_gain`. Stores gain in range `[1.0, 2.0]`.
19    /// Accessed externally via normalised [`level`](Self::level) method.
20    level: Arc<AtomicF32>,
21    /// GainProcessor that applies smoothed level boost after hard clipping.
22    /// Reads gain value from `level` atomic lock-free on each sample.
23    level_gain: GainProcessor,
24    /// Smoothing factor `[1.0,10.0]`. Lower = more smoothing
25    /// Shared with command infrastructure via [`f32_params`](Self::f32_params).
26    smoothing: Arc<AtomicF32>,
27    /// UI chassis colour (hex string, e.g. `"#e67e22"`).
28    color: String,
29}
30
31impl SCDistortion {
32    pub fn new(
33        id: u32,
34        name: String,
35        is_active: bool,
36        threshold: f32,
37        level: f32,
38        smoothing: f32,
39        color: String,
40    ) -> Self {
41        let gain_value = 1.0 + level.clamp(0.0, 1.0); // map [0,1] → [1,2]
42        let level_arc = Arc::new(AtomicF32::new(gain_value));
43        let level_gain = GainProcessor::new(Arc::clone(&level_arc));
44        Self {
45            id,
46            name,
47            is_active: Arc::new(AtomicBool::new(is_active)),
48            limit: Arc::new(AtomicF32::new(threshold.clamp(0.001, 1.0))),
49            level: level_arc,
50            level_gain,
51            smoothing: Arc::new(AtomicF32::new(smoothing.clamp(1.0, 10.0))),
52            color,
53        }
54    }
55
56    /// Returns the current clipping threshold in range `[0.0, 1.0]`.
57    ///
58    /// # Returns
59    ///
60    /// The threshold value; lower values produce heavier clipping.
61    pub fn threshold(&self) -> f32 {
62        self.limit.load(Ordering::Relaxed)
63    }
64
65    /// Sets the clipping threshold. Value is clamped to `[0.001, 1.0]`.
66    ///
67    /// The change takes effect on the very next audio sample — no synchronisation needed.
68    ///
69    /// # Parameters
70    ///
71    /// * `threshold` — New clipping level in `(0.0, 1.0]`
72    pub fn set_threshold(&self, threshold: f32) {
73        self.limit
74            .store(threshold.clamp(0.001, 1.0), Ordering::Relaxed);
75    }
76
77    /// Returns the normalised output level in range `[0.0, 1.0]`.
78    ///
79    /// Internally the gain is stored as `[1.0, 2.0]`; this method reverses that mapping
80    /// to give the external normalised value.
81    ///
82    /// # Returns
83    ///
84    /// Normalised level: `0.0` = no boost (unity gain), `1.0` = ×2.0 boost
85    pub fn level(&self) -> f32 {
86        (self.level.load(Ordering::Relaxed) - 1.0).clamp(0.0, 1.0)
87    }
88
89    /// Sets the output level from a normalised value `[0.0, 1.0]`.
90    ///
91    /// Internally maps to gain `[1.0, 2.0]` and stores it in the atomic.
92    /// The change takes effect on the very next audio sample — no synchronisation needed.
93    ///
94    /// # Parameters
95    ///
96    /// * `level` — Normalised level in `[0.0, 1.0]`. Will be clamped.
97    pub fn set_level(&self, level: f32) {
98        self.level
99            .store(1.0 + level.clamp(0.0, 1.0), Ordering::Relaxed);
100    }
101
102    pub fn smoothing(&self) -> f32 {
103        self.smoothing.load(Ordering::Relaxed)
104    }
105
106    pub fn set_smoothing(&self, smoothing: f32) {
107        self.smoothing
108            .store(smoothing.clamp(1.0, 10.0), Ordering::Relaxed);
109    }
110}
111
112impl AudioProcessor for SCDistortion {
113    /// Processes a single audio sample through soft clipping and level boost.
114    ///
115    /// # Algorithm
116    ///
117    /// 1. **Load clipping threshold & the smoothing factor** atomically (lock-free)
118    /// 2. **Normalize the sample** to the clipping threshold. Needs to be done because smoothing algorithm smooths towards 1.0 and -1.0
119    /// 3. **Smooth the curve** towards the limit
120    /// 4. **Denormalize the sample** to get back to the desired amplitude
121    /// 5. **Apply gain boost** via the [`GainProcessor`] with smoothed transitions
122    ///
123    /// # Parameters
124    ///
125    /// * `sample` — audio sample, typically `-1.0` to `1.0`
126    ///
127    /// # Returns
128    /// Processed sample: clipped, smoothed and boosted by the level knob
129    fn process(&mut self, sample: f32) -> f32 {
130        let limit = self.limit.load(Ordering::Relaxed);
131        let smoothing = self.smoothing.load(Ordering::Relaxed);
132        let normalized_sample = sample / limit;
133        let abs_normalized_sample = normalized_sample.abs();
134        let smoothed =
135            normalized_sample / (1.0 + abs_normalized_sample.powf(smoothing)).powf(1.0 / smoothing);
136        let denormalized_sample = smoothed * limit;
137        self.level_gain.process(denormalized_sample)
138    }
139}
140
141impl Effect for SCDistortion {
142    fn id(&self) -> u32 {
143        self.id
144    }
145    fn name(&self) -> &str {
146        &self.name
147    }
148    fn get_color(&self) -> String {
149        self.color.clone()
150    }
151    /// Converts this effect into its serialisable DTO representation.
152    ///
153    /// Called when sending effect state to the frontend or external clients.
154    ///
155    /// # Returns
156    ///
157    /// [`EffectDto::SCDistortion`] with all current parameters
158    fn to_dto(&self) -> EffectDto {
159        EffectDto::SCDistortion(ScDistortionDto {
160            id: self.id,
161            name: self.name.clone(),
162            is_active: self.is_active.load(Ordering::Relaxed),
163            color: self.color.clone(),
164            threshold: self.limit.load(Ordering::Relaxed),
165            level: self.level(),
166            smoothing: self.smoothing.load(Ordering::Relaxed),
167        })
168    }
169
170    fn active_flag(&self) -> Arc<AtomicBool> {
171        Arc::clone(&self.is_active)
172    }
173
174    /// Returns a map of named f32 parameters for command infrastructure.
175    ///
176    /// This enables the generic command dispatcher to update effect parameters
177    /// without needing to know about specific effect types.
178    ///
179    /// # Returns
180    ///
181    /// HashMap with keys:
182    /// * `"threshold"` — points to `limit` atomic; external code can write new thresholds
183    /// * `"level"` — points to internal gain atomic `[1.0, 2.0]`
184    ///
185    /// # Note
186    ///
187    /// The `"level"` key stores the raw gain value. Command handlers should convert
188    /// the external normalised `[0, 1]` range to internal gain `[1, 2]` before writing.
189    fn f32_params(&self) -> HashMap<&'static str, Arc<AtomicF32>> {
190        let mut map = HashMap::new();
191        map.insert("threshold", Arc::clone(&self.limit));
192        // "level" stores the internal gain [1, 2]; the command converts [0,1] before writing.
193        map.insert("level", Arc::clone(&self.level));
194        map.insert("smoothing", Arc::clone(&self.smoothing));
195        map
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    fn distortion(threshold: f32, smoothing: f32) -> SCDistortion {
204        SCDistortion::new(
205            0,
206            "SC".to_string(),
207            true,
208            threshold,
209            0.0,
210            smoothing,
211            "#e67e22".to_string(),
212        )
213    }
214
215    mod success_path {
216        use super::*;
217
218        #[test]
219        fn sample_within_threshold_is_slightly_compressed() {
220            let mut fx = distortion(1.0, 1.0);
221            for _ in 0..10_000 {
222                fx.process(0.0);
223            }
224
225            let input = 0.1;
226            let output = fx.process(input);
227
228            assert!(output < input);
229            assert!((output - input).abs() < 0.01);
230        }
231
232        #[test]
233        fn sample_is_pushed_towards_limit() {
234            let limit = 0.5;
235            let mut fx = distortion(limit, 5.0);
236            for _ in 0..10_000 {
237                fx.process(0.0);
238            }
239
240            let out = fx.process(100.0);
241
242            assert!(out <= limit);
243            assert!((out - limit).abs() < 0.01);
244        }
245
246        #[test]
247        fn smoothing_parameter_affects_curve() {
248            let mut soft_fx = distortion(1.0, 1.0); // n=1 (Very soft)
249            let mut hard_fx = distortion(1.0, 10.0); // n=10 (Harder)
250
251            for _ in 0..10_000 {
252                soft_fx.process(0.0);
253                hard_fx.process(0.0);
254            }
255
256            let input = 0.5;
257            let soft_out = soft_fx.process(input);
258            let hard_out = hard_fx.process(input);
259
260            assert!(soft_out < hard_out);
261        }
262
263        #[test]
264        fn process_if_active_passes_through_when_inactive() {
265            let mut fx = distortion(0.5, 10.0);
266            fx.set_active(false);
267            assert_eq!(fx.process_if_active(0.9), 0.9);
268        }
269
270        #[test]
271        fn set_threshold_updates_clip_level() {
272            let mut fx = distortion(0.8, 10.0);
273
274            fx.set_threshold(0.3);
275            assert!((fx.threshold() - 0.3).abs() < 1e-6);
276
277            for _ in 0..10_000 {
278                fx.process(0.0);
279            }
280
281            let output = fx.process(0.9);
282
283            assert!(output < 0.9);
284            assert!(
285                (output - 0.3).abs() < 0.05,
286                "Expected output to be near 0.3, got {}",
287                output
288            );
289
290            let massive_input = 100.0;
291            let limited_output = fx.process(massive_input);
292            assert!(limited_output <= 0.30001);
293        }
294
295        #[test]
296        fn level_boost_doubles_output_at_max() {
297            let mut fx = SCDistortion::new(
298                0,
299                "HC".to_string(),
300                true,
301                1.0,
302                1.0,
303                10.0,
304                "#e67e22".to_string(),
305            );
306            // Converge gain processor to ×2.0
307            for _ in 0..20_000 {
308                fx.process(0.0);
309            }
310            let out = fx.process(0.3);
311            assert!((out - 0.6).abs() < 0.01, "expected ≈0.6, got {out}");
312        }
313
314        #[test]
315        fn level_unity_at_zero() {
316            let mut fx = distortion(1.0, 10.0); // level=0.0
317            for _ in 0..10_000 {
318                fx.process(0.0);
319            }
320            let out = fx.process(0.4);
321            assert!((out - 0.4).abs() < 0.01, "expected ≈0.4, got {out}");
322        }
323    }
324
325    mod failure_path {
326        use super::*;
327        #[test]
328        fn threshold_above_one_is_clamped_to_one() {
329            let fx = distortion(2.0, 1.0);
330            assert_eq!(fx.threshold(), 1.0);
331        }
332
333        #[test]
334        fn threshold_of_zero_is_clamped_to_minimum() {
335            let fx = distortion(0.0, 1.0);
336            assert!(fx.threshold() > 0.0);
337        }
338
339        #[test]
340        fn smoothing_above_ten_is_clamped_to_ten() {
341            let fx = distortion(1.0, 11.0);
342            assert_eq!(fx.smoothing(), 10.0);
343        }
344
345        #[test]
346        fn smoothing_of_zero_is_clamped_to_minimum() {
347            let fx = distortion(1.0, 0.0);
348            assert!(fx.smoothing() > 0.0);
349        }
350    }
351}