Skip to main content

oximedia_effects/
lib.rs

1//! Professional audio effects suite for `OxiMedia`.
2//!
3//! This crate provides production-quality implementations of professional audio effects
4//! used in music production, post-production, and broadcast applications.
5//!
6//! # Effect Categories
7//!
8//! ## Reverb
9//! - **Freeverb** - Algorithmic reverb based on Schroeder reverb architecture
10//! - **Plate Reverb** - Simulation of mechanical plate reverb
11//! - **Convolution Reverb** - Impulse response-based reverb for realistic spaces
12//!
13//! ## Delay/Echo
14//! - **Delay** - Simple delay with feedback
15//! - **Multi-tap Delay** - Multiple delay taps with independent controls
16//! - **Ping-pong Delay** - Stereo ping-pong delay effect
17//!
18//! ## Modulation
19//! - **Chorus** - Multi-voice chorus effect
20//! - **Flanger** - Flanging with feedback
21//! - **Phaser** - All-pass filter cascade phasing
22//! - **Tremolo** - Amplitude modulation
23//! - **Vibrato** - Frequency modulation
24//! - **Ring Modulator** - Ring modulation effect
25//!
26//! ## Distortion
27//! - **Overdrive** - Soft clipping overdrive
28//! - **Fuzz** - Hard clipping fuzz distortion
29//! - **Bit Crusher** - Bit depth and sample rate reduction
30//!
31//! ## Dynamics
32//! - **Gate** - Noise gate with threshold and hysteresis
33//! - **Expander** - Upward and downward expansion
34//!
35//! ## Filters
36//! - **Biquad** - Second-order IIR filters (low-pass, high-pass, band-pass, notch, shelving)
37//! - **State Variable Filter** - Multi-mode state-variable filter
38//! - **Moog Ladder** - Classic Moog ladder filter simulation
39//!
40//! ## Pitch/Time
41//! - **Pitch Shifter** - Time-domain and frequency-domain pitch shifting
42//! - **Time Stretch** - Tempo change without pitch change
43//! - **Harmonizer** - Pitch shifting with formant preservation
44//!
45//! ## Vocoding/Correction
46//! - **Vocoder** - Channel vocoder
47//! - **Auto-tune** - Basic pitch correction
48//!
49//! # Architecture
50//!
51//! All effects implement the `AudioEffect` trait, which provides a unified interface
52//! for real-time audio processing with support for both mono and stereo operation.
53//!
54//! Effects are designed to be:
55//! - **Real-time capable** - Low latency, no allocations in process loops
56//! - **Sample-accurate** - Parameter changes are smoothed to avoid artifacts
57//! - **Efficient** - Optimized for CPU efficiency
58//! - **Safe** - No unsafe code, enforced by `#![forbid(unsafe_code)]`
59//!
60//! # Example
61//!
62//! ```ignore
63//! use oximedia_effects::{AudioEffect, reverb::Freeverb, ReverbConfig};
64//!
65//! let config = ReverbConfig::default()
66//!     .with_room_size(0.8)
67//!     .with_damping(0.5)
68//!     .with_wet(0.3);
69//!
70//! let mut reverb = Freeverb::new(config, 48000.0);
71//!
72//! // Process stereo audio
73//! let mut left = vec![0.0; 1024];
74//! let mut right = vec![0.0; 1024];
75//! reverb.process_stereo(&mut left, &mut right);
76//! ```
77
78#![forbid(unsafe_code)]
79#![warn(missing_docs)]
80
81pub mod analog_delay;
82pub mod auto_pan;
83pub mod barrel_lens;
84pub mod bass_enhancer;
85pub mod bitcrusher;
86pub mod blend;
87pub mod chorus;
88pub mod chorus_flanger;
89pub mod color_grade;
90pub mod composite;
91pub mod compressor;
92pub mod compressor_look;
93pub mod deesser;
94pub mod delay;
95pub mod delay_line;
96pub mod distort;
97pub mod distortion;
98pub mod ducking;
99pub mod dynamics;
100pub mod eq;
101pub mod filter;
102pub mod filter_bank;
103pub mod flanger;
104pub mod fundsp_adapter;
105pub mod glitch;
106pub mod harmonic_exciter;
107pub mod keying;
108pub mod lookahead_limiter;
109pub mod lufs_meter;
110pub mod luma_key;
111pub mod mix;
112pub mod modulation;
113pub mod multiband_compressor;
114pub mod parametric_eq;
115pub mod pitch;
116pub mod reverb;
117pub mod reverb_hall;
118pub mod ring_mod;
119pub mod room_reverb;
120pub mod saturation;
121pub mod spatial_audio;
122pub mod stereo_upmix;
123pub mod stereo_widener;
124pub mod stereo_wider;
125pub mod tape_echo;
126pub mod tape_sat;
127pub mod time_stretch;
128pub mod transient_shaper;
129pub mod tremolo;
130pub mod utils;
131pub mod vibrato;
132pub mod video;
133pub mod vocoder;
134pub mod warp;
135pub mod waveshaper;
136pub mod wet_dry;
137
138use thiserror::Error;
139
140/// Error types for audio effects.
141#[derive(Debug, Error)]
142pub enum EffectError {
143    /// Invalid parameter value.
144    #[error("Invalid parameter: {0}")]
145    InvalidParameter(String),
146
147    /// Invalid sample rate.
148    #[error("Invalid sample rate: {0}")]
149    InvalidSampleRate(f32),
150
151    /// Buffer size mismatch.
152    #[error("Buffer size mismatch: expected {expected}, got {actual}")]
153    BufferSizeMismatch {
154        /// Expected buffer size.
155        expected: usize,
156        /// Actual buffer size.
157        actual: usize,
158    },
159
160    /// Insufficient buffer size.
161    #[error("Insufficient buffer size: need at least {required}, got {actual}")]
162    InsufficientBuffer {
163        /// Required buffer size.
164        required: usize,
165        /// Actual buffer size.
166        actual: usize,
167    },
168
169    /// Effect not initialized.
170    #[error("Effect not initialized")]
171    NotInitialized,
172
173    /// Processing error.
174    #[error("Processing error: {0}")]
175    ProcessingError(String),
176}
177
178/// Result type for effect operations.
179pub type Result<T> = std::result::Result<T, EffectError>;
180
181/// Core trait for audio effects.
182///
183/// All effects implement this trait to provide a unified interface for
184/// real-time audio processing.
185///
186/// ## Wet/Dry Mix
187///
188/// Every implementor can optionally override `set_wet_dry` and
189/// `wet_dry` to expose real-time wet/dry mix control. The default
190/// implementations are no-ops (full wet signal).  Effects that maintain
191/// their own wet/dry internally (e.g. `MonoDelay`) are encouraged to
192/// override these methods so callers can use a uniform API.
193pub trait AudioEffect {
194    /// Unique string identifier for this effect type (used for FunDSP adapter dispatch).
195    ///
196    /// Implementors should use a stable `snake_case` slug matching the struct name.
197    /// Example: `AnalogDelay` → `"analog_delay"`.
198    const EFFECT_ID: &'static str;
199
200    /// Process a single mono sample.
201    fn process_sample(&mut self, input: f32) -> f32;
202
203    /// Process a buffer of mono samples in-place.
204    fn process(&mut self, buffer: &mut [f32]) {
205        for sample in buffer {
206            *sample = self.process_sample(*sample);
207        }
208    }
209
210    /// Process stereo samples (left and right channels).
211    fn process_stereo(&mut self, left: &mut [f32], right: &mut [f32]) {
212        let len = left.len().min(right.len());
213        for i in 0..len {
214            let (l, r) = self.process_sample_stereo(left[i], right[i]);
215            left[i] = l;
216            right[i] = r;
217        }
218    }
219
220    /// Process a single stereo sample pair.
221    fn process_sample_stereo(&mut self, left: f32, right: f32) -> (f32, f32) {
222        (self.process_sample(left), self.process_sample(right))
223    }
224
225    /// Reset the effect state (clear buffers, reset LFOs, etc.).
226    fn reset(&mut self);
227
228    /// Get the latency introduced by this effect in samples.
229    fn latency_samples(&self) -> usize {
230        0
231    }
232
233    /// Set the sample rate (if the effect supports it).
234    fn set_sample_rate(&mut self, _sample_rate: f32) {}
235
236    /// Set the wet/dry mix ratio.
237    ///
238    /// `wet` is in `[0.0, 1.0]` where `0.0` = 100% dry, `1.0` = 100% wet.
239    /// `dry` is automatically computed as `1.0 - wet`.
240    ///
241    /// Effects that manage wet/dry internally should override this method.
242    /// The default implementation is a no-op (the effect's internal mix
243    /// remains unchanged).
244    fn set_wet_dry(&mut self, _wet: f32) {}
245
246    /// Return the current wet mix level in `[0.0, 1.0]`.
247    ///
248    /// Returns `1.0` (fully wet) by default if the effect does not support
249    /// wet/dry reporting.
250    fn wet_dry(&self) -> f32 {
251        1.0
252    }
253
254    /// Set the wet mix level in `[0.0, 1.0]`.
255    ///
256    /// Alias for [`set_wet_dry`](Self::set_wet_dry).  Provided so that
257    /// implementations that store a field named `wet_mix` can satisfy the
258    /// trait without renaming the field.  The default delegates to
259    /// `set_wet_dry`.
260    fn set_wet_mix(&mut self, wet: f32) {
261        self.set_wet_dry(wet);
262    }
263
264    /// Return the current wet mix level in `[0.0, 1.0]`.
265    ///
266    /// Alias for [`wet_dry`](Self::wet_dry).  Provided so that implementations
267    /// that store a field named `wet_mix` can satisfy the trait without
268    /// renaming the field.  The default delegates to `wet_dry`.
269    fn wet_mix(&self) -> f32 {
270        self.wet_dry()
271    }
272
273    /// Process `input` through this effect and blend the result with the dry
274    /// signal according to `wet` in `[0.0, 1.0]`.
275    ///
276    /// The wet level is applied **in-call only**; the effect's stored
277    /// `wet_dry` field is **not** modified.  This lets callers temporarily
278    /// override the mix without permanently changing the effect state.
279    ///
280    /// The `output` slice must be at least as long as `input`; any extra
281    /// elements are left unchanged.
282    fn process_with_wet_dry(&mut self, input: &[f32], output: &mut [f32], wet: f32) {
283        let wet = wet.clamp(0.0, 1.0);
284        let dry = 1.0 - wet;
285        let len = input.len().min(output.len());
286        for i in 0..len {
287            let processed = self.process_sample(input[i]);
288            output[i] = processed * wet + input[i] * dry;
289        }
290    }
291}
292
293/// A lightweight wrapper that adds wet/dry mix control to any `AudioEffect`.
294///
295/// Use this when an underlying effect does not natively support wet/dry mix,
296/// or when you want a single consistent control surface.
297///
298/// # Example
299/// ```ignore
300/// use oximedia_effects::{WetDryWrapper, AudioEffect};
301/// use oximedia_effects::reverb::Freeverb;
302///
303/// let mut wrapped = WetDryWrapper::new(Freeverb::default(), 0.4);
304/// let out = wrapped.process_sample(0.5);
305/// ```
306pub struct WetDryWrapper<E: AudioEffect> {
307    inner: E,
308    wet: f32,
309    dry: f32,
310}
311
312impl<E: AudioEffect> WetDryWrapper<E> {
313    /// Wrap an effect with the given initial wet level `[0.0, 1.0]`.
314    #[must_use]
315    pub fn new(inner: E, wet: f32) -> Self {
316        let wet = wet.clamp(0.0, 1.0);
317        Self {
318            inner,
319            wet,
320            dry: 1.0 - wet,
321        }
322    }
323
324    /// Access the inner effect.
325    #[must_use]
326    pub fn inner(&self) -> &E {
327        &self.inner
328    }
329
330    /// Access the inner effect mutably.
331    pub fn inner_mut(&mut self) -> &mut E {
332        &mut self.inner
333    }
334
335    /// Consume the wrapper, returning the inner effect.
336    #[must_use]
337    pub fn into_inner(self) -> E {
338        self.inner
339    }
340}
341
342impl<E: AudioEffect> AudioEffect for WetDryWrapper<E> {
343    const EFFECT_ID: &'static str = E::EFFECT_ID;
344
345    fn process_sample(&mut self, input: f32) -> f32 {
346        let wet_out = self.inner.process_sample(input);
347        wet_out * self.wet + input * self.dry
348    }
349
350    fn process_sample_stereo(&mut self, left: f32, right: f32) -> (f32, f32) {
351        let (wl, wr) = self.inner.process_sample_stereo(left, right);
352        (
353            wl * self.wet + left * self.dry,
354            wr * self.wet + right * self.dry,
355        )
356    }
357
358    fn reset(&mut self) {
359        self.inner.reset();
360    }
361
362    fn latency_samples(&self) -> usize {
363        self.inner.latency_samples()
364    }
365
366    fn set_sample_rate(&mut self, sample_rate: f32) {
367        self.inner.set_sample_rate(sample_rate);
368    }
369
370    fn set_wet_dry(&mut self, wet: f32) {
371        self.wet = wet.clamp(0.0, 1.0);
372        self.dry = 1.0 - self.wet;
373    }
374
375    fn wet_dry(&self) -> f32 {
376        self.wet
377    }
378}
379
380/// Configuration for reverb effects.
381#[derive(Debug, Clone)]
382pub struct ReverbConfig {
383    /// Room size (0.0 - 1.0).
384    pub room_size: f32,
385    /// Damping/high-frequency absorption (0.0 - 1.0).
386    pub damping: f32,
387    /// Wet signal level (0.0 - 1.0).
388    pub wet: f32,
389    /// Dry signal level (0.0 - 1.0).
390    pub dry: f32,
391    /// Stereo width (0.0 - 1.0).
392    pub width: f32,
393    /// Pre-delay in milliseconds.
394    pub predelay_ms: f32,
395}
396
397impl Default for ReverbConfig {
398    fn default() -> Self {
399        Self {
400            room_size: 0.5,
401            damping: 0.5,
402            wet: 0.33,
403            dry: 0.67,
404            width: 1.0,
405            predelay_ms: 0.0,
406        }
407    }
408}
409
410impl ReverbConfig {
411    /// Create a new reverb configuration with custom parameters.
412    #[must_use]
413    pub fn new(room_size: f32, damping: f32, wet: f32) -> Self {
414        Self {
415            room_size: room_size.clamp(0.0, 1.0),
416            damping: damping.clamp(0.0, 1.0),
417            wet: wet.clamp(0.0, 1.0),
418            dry: (1.0 - wet).clamp(0.0, 1.0),
419            width: 1.0,
420            predelay_ms: 0.0,
421        }
422    }
423
424    /// Set room size.
425    #[must_use]
426    pub fn with_room_size(mut self, room_size: f32) -> Self {
427        self.room_size = room_size.clamp(0.0, 1.0);
428        self
429    }
430
431    /// Set damping.
432    #[must_use]
433    pub fn with_damping(mut self, damping: f32) -> Self {
434        self.damping = damping.clamp(0.0, 1.0);
435        self
436    }
437
438    /// Set wet level.
439    #[must_use]
440    pub fn with_wet(mut self, wet: f32) -> Self {
441        self.wet = wet.clamp(0.0, 1.0);
442        self
443    }
444
445    /// Set dry level.
446    #[must_use]
447    pub fn with_dry(mut self, dry: f32) -> Self {
448        self.dry = dry.clamp(0.0, 1.0);
449        self
450    }
451
452    /// Set stereo width.
453    #[must_use]
454    pub fn with_width(mut self, width: f32) -> Self {
455        self.width = width.clamp(0.0, 1.0);
456        self
457    }
458
459    /// Set pre-delay in milliseconds.
460    #[must_use]
461    pub fn with_predelay(mut self, predelay_ms: f32) -> Self {
462        self.predelay_ms = predelay_ms.max(0.0);
463        self
464    }
465
466    /// Small room preset.
467    #[must_use]
468    pub fn small_room() -> Self {
469        Self::new(0.3, 0.4, 0.2)
470    }
471
472    /// Medium room preset.
473    #[must_use]
474    pub fn medium_room() -> Self {
475        Self::new(0.5, 0.5, 0.3)
476    }
477
478    /// Large hall preset.
479    #[must_use]
480    pub fn hall() -> Self {
481        Self::new(0.8, 0.6, 0.4).with_predelay(20.0)
482    }
483
484    /// Cathedral preset.
485    #[must_use]
486    pub fn cathedral() -> Self {
487        Self::new(0.95, 0.7, 0.5).with_predelay(40.0)
488    }
489
490    /// Chamber preset.
491    #[must_use]
492    pub fn chamber() -> Self {
493        Self::new(0.6, 0.4, 0.35).with_predelay(10.0)
494    }
495}
496
497#[cfg(test)]
498mod tests {
499    use super::*;
500
501    #[test]
502    fn test_reverb_config_defaults() {
503        let config = ReverbConfig::default();
504        assert_eq!(config.room_size, 0.5);
505        assert_eq!(config.damping, 0.5);
506        assert_eq!(config.wet, 0.33);
507    }
508
509    #[test]
510    fn test_reverb_config_builder() {
511        let config = ReverbConfig::default()
512            .with_room_size(0.8)
513            .with_damping(0.6)
514            .with_wet(0.4);
515        assert_eq!(config.room_size, 0.8);
516        assert_eq!(config.damping, 0.6);
517        assert_eq!(config.wet, 0.4);
518    }
519
520    #[test]
521    fn test_reverb_config_clamping() {
522        let config = ReverbConfig::new(1.5, -0.5, 2.0);
523        assert_eq!(config.room_size, 1.0);
524        assert_eq!(config.damping, 0.0);
525        assert_eq!(config.wet, 1.0);
526    }
527
528    #[test]
529    fn test_reverb_presets() {
530        let small = ReverbConfig::small_room();
531        assert!(small.room_size < 0.5);
532
533        let hall = ReverbConfig::hall();
534        assert!(hall.room_size > 0.7);
535        assert!(hall.predelay_ms > 0.0);
536    }
537}
538
539#[cfg(test)]
540mod wet_dry_tests {
541    //! Tests for wet/dry mix control across all `AudioEffect` implementations.
542    use super::*;
543    use crate::chorus::{ChorusParams, ChorusProcessor};
544    use crate::distortion::fuzz::{Fuzz, FuzzConfig};
545    use crate::distortion::overdrive::{Overdrive, OverdriveConfig};
546    use crate::flanger::{Flanger, FlangerConfig};
547    use crate::reverb::Freeverb;
548
549    // ── WetDryWrapper ────────────────────────────────────────────────────────
550
551    #[test]
552    fn test_wrapper_wet_zero_returns_dry() {
553        let mut wrapped = WetDryWrapper::new(Fuzz::new(FuzzConfig::default()), 0.0);
554        let out = wrapped.process_sample(0.5);
555        assert!(
556            (out - 0.5).abs() < 1e-5,
557            "wet=0 should return dry signal, got {out}"
558        );
559    }
560
561    #[test]
562    fn test_wrapper_wet_one_returns_processed() {
563        // With wet=1, WetDryWrapper contributes 0 dry, so output == processed.
564        let inner = Fuzz::new(FuzzConfig {
565            fuzz: 1.0,
566            level: 1.0,
567        });
568        let mut wrapped = WetDryWrapper::new(inner, 1.0);
569        // Input 0.5, fuzz=1.0 → hard_clip(0.5) * 1.0 = 0.5 → same as input in this case
570        let out = wrapped.process_sample(0.5);
571        assert!(out.is_finite());
572    }
573
574    #[test]
575    fn test_wrapper_wet_half_blends() {
576        // Use an effect that transforms the signal predictably.
577        // Fuzz with fuzz=100 and level=1 → hard_clip(input*100) = ±1.0 for nonzero input.
578        let inner = Fuzz::new(FuzzConfig {
579            fuzz: 100.0,
580            level: 1.0,
581        });
582        let mut wrapped = WetDryWrapper::new(inner, 0.5);
583        let out = wrapped.process_sample(0.5);
584        // Expected: processed=1.0, dry=0.5, blend = 0.5*1.0 + 0.5*0.5 = 0.75
585        assert!((out - 0.75).abs() < 1e-5, "blend mismatch: got {out}");
586    }
587
588    #[test]
589    fn test_wrapper_set_wet_dry_updates() {
590        let inner = Fuzz::new(FuzzConfig::default());
591        let mut wrapped = WetDryWrapper::new(inner, 0.3);
592        assert!((wrapped.wet_dry() - 0.3).abs() < 1e-5);
593        wrapped.set_wet_dry(0.8);
594        assert!((wrapped.wet_dry() - 0.8).abs() < 1e-5);
595    }
596
597    #[test]
598    fn test_wrapper_set_wet_dry_clamps() {
599        let inner = Fuzz::new(FuzzConfig::default());
600        let mut wrapped = WetDryWrapper::new(inner, 0.5);
601        wrapped.set_wet_dry(2.0);
602        assert!((wrapped.wet_dry() - 1.0).abs() < 1e-5);
603        wrapped.set_wet_dry(-1.0);
604        assert!((wrapped.wet_dry() - 0.0).abs() < 1e-5);
605    }
606
607    // ── process_with_wet_dry default method ──────────────────────────────────
608
609    #[test]
610    fn test_process_with_wet_dry_zero_equals_input() {
611        let mut fuzz = Fuzz::new(FuzzConfig {
612            fuzz: 100.0,
613            level: 1.0,
614        });
615        let input = vec![0.3_f32, -0.5, 0.7];
616        let mut output = vec![0.0_f32; 3];
617        fuzz.process_with_wet_dry(&input, &mut output, 0.0);
618        for (i, (&inp, &out)) in input.iter().zip(output.iter()).enumerate() {
619            assert!(
620                (out - inp).abs() < 1e-5,
621                "output[{i}]={out} should equal input {inp}"
622            );
623        }
624    }
625
626    #[test]
627    fn test_process_with_wet_dry_one_equals_processed() {
628        // Identity fuzz: fuzz=1.0, level=1.0 → hard_clip(x*1)= x for |x|<1
629        let mut fuzz = Fuzz::new(FuzzConfig {
630            fuzz: 1.0,
631            level: 1.0,
632        });
633        let input = vec![0.3_f32, -0.4, 0.2];
634        let mut output = vec![0.0_f32; 3];
635        fuzz.process_with_wet_dry(&input, &mut output, 1.0);
636        // with wet=0 on fuzz itself (default 1.0), processed = hard_clip(x) = x
637        // process_with_wet_dry at wet=1 → output == processed
638        for &s in &output {
639            assert!(s.is_finite());
640        }
641    }
642
643    #[test]
644    fn test_process_with_wet_dry_half_blends() {
645        let mut fuzz = Fuzz::new(FuzzConfig {
646            fuzz: 100.0,
647            level: 1.0,
648        });
649        let input = vec![0.5_f32];
650        let mut output = vec![0.0_f32; 1];
651        fuzz.process_with_wet_dry(&input, &mut output, 0.5);
652        // processed by fuzz at wet=1 (default): hard_clip(50)=1.0 → wet_out=1.0
653        // blend at 0.5: 0.5*1.0 + 0.5*0.5 = 0.75
654        assert!((output[0] - 0.75).abs() < 0.01, "blend={}", output[0]);
655    }
656
657    // ── Overdrive wet/dry ─────────────────────────────────────────────────────
658
659    #[test]
660    fn test_overdrive_wet_dry_default_is_one() {
661        let od = Overdrive::new(OverdriveConfig::default());
662        assert!((od.wet_dry() - 1.0).abs() < f32::EPSILON);
663    }
664
665    #[test]
666    fn test_overdrive_wet_zero_passes_dry() {
667        let mut od = Overdrive::new(OverdriveConfig::default());
668        od.set_wet_dry(0.0);
669        let out = od.process_sample(0.4);
670        assert!((out - 0.4).abs() < 1e-5, "dry pass-through failed: {out}");
671    }
672
673    #[test]
674    fn test_overdrive_set_wet_dry_clamps() {
675        let mut od = Overdrive::new(OverdriveConfig::default());
676        od.set_wet_dry(5.0);
677        assert!((od.wet_dry() - 1.0).abs() < f32::EPSILON);
678        od.set_wet_dry(-2.0);
679        assert!((od.wet_dry() - 0.0).abs() < f32::EPSILON);
680    }
681
682    // ── Fuzz wet/dry ─────────────────────────────────────────────────────────
683
684    #[test]
685    fn test_fuzz_wet_zero_passes_dry() {
686        let mut f = Fuzz::new(FuzzConfig::default());
687        f.set_wet_dry(0.0);
688        let out = f.process_sample(0.6);
689        assert!((out - 0.6).abs() < 1e-5, "fuzz dry failed: {out}");
690    }
691
692    #[test]
693    fn test_fuzz_wet_one_full_effect() {
694        let mut f = Fuzz::new(FuzzConfig {
695            fuzz: 50.0,
696            level: 1.0,
697        });
698        f.set_wet_dry(1.0);
699        let out = f.process_sample(0.5);
700        // hard_clip(25)*1.0 = 1.0
701        assert!((out - 1.0).abs() < 1e-5, "full wet fuzz: {out}");
702    }
703
704    // ── Flanger wet/dry ───────────────────────────────────────────────────────
705
706    #[test]
707    fn test_flanger_wet_dry_stores() {
708        let mut fl = Flanger::new(FlangerConfig::default(), 48_000.0);
709        fl.set_wet_dry(0.3);
710        assert!((fl.wet_dry() - 0.3).abs() < 1e-5);
711    }
712
713    #[test]
714    fn test_flanger_wet_zero_bypasses() {
715        let mut fl = Flanger::new(
716            FlangerConfig {
717                feedback: 0.0,
718                ..FlangerConfig::default()
719            },
720            48_000.0,
721        );
722        fl.set_wet_dry(0.0);
723        let out = fl.process_sample(0.5);
724        assert!((out - 0.5).abs() < 1e-5, "flanger dry bypass: {out}");
725    }
726
727    // ── ChorusProcessor wet/dry ───────────────────────────────────────────────
728
729    #[test]
730    fn test_chorus_wet_zero_passes_dry() {
731        let mut cp = ChorusProcessor::new(48_000.0, ChorusParams::default());
732        cp.set_wet_dry(0.0);
733        // process_sample on AudioEffect casts f64→f32
734        let out: f32 = crate::AudioEffect::process_sample(&mut cp, 0.7);
735        assert!((out - 0.7).abs() < 1e-4, "chorus dry: {out}");
736    }
737
738    #[test]
739    fn test_chorus_wet_dry_stores() {
740        let mut cp = ChorusProcessor::new(48_000.0, ChorusParams::default());
741        cp.set_wet_dry(0.6);
742        assert!((cp.wet_dry() - 0.6).abs() < 1e-5);
743    }
744
745    // ── Freeverb wet/dry ──────────────────────────────────────────────────────
746
747    #[test]
748    fn test_freeverb_wet_dry_stores() {
749        let mut rv = Freeverb::new(ReverbConfig::default(), 48_000.0);
750        rv.set_wet_dry(0.5);
751        assert!((rv.wet_dry() - 0.5).abs() < 1e-5);
752    }
753
754    #[test]
755    fn test_freeverb_wet_dry_clamps() {
756        let mut rv = Freeverb::new(ReverbConfig::default(), 48_000.0);
757        rv.set_wet_dry(2.0);
758        assert!((rv.wet_dry() - 1.0).abs() < f32::EPSILON);
759        rv.set_wet_dry(-1.0);
760        assert!((rv.wet_dry() - 0.0).abs() < f32::EPSILON);
761    }
762
763    #[test]
764    fn test_freeverb_wet_zero_output_is_dry() {
765        // With wet=0 and dry=1, the reverb tail should not appear in output.
766        let mut rv = Freeverb::new(ReverbConfig::default(), 48_000.0);
767        rv.set_wet_dry(0.0);
768        // First sample: an impulse of 1.0.
769        let out = rv.process_sample(1.0);
770        // With wet=0 (config.wet=0) and dry=1 (config.dry=1), output ≈ input.
771        assert!(
772            out.is_finite(),
773            "freeverb wet=0 should produce finite output"
774        );
775        // After setting wet=0 the config.wet=0, dry=1; the reverb tails carry 0 wet.
776        // So output should equal input * dry (1.0 * 1.0 = 1.0 approximately).
777        assert!(
778            (out - 1.0).abs() < 0.05,
779            "freeverb wet=0: expected ~1.0, got {out}"
780        );
781    }
782}