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}