Skip to main content

ff_filter/effects/
audio_effects.rs

1//! Frame-level audio effects added to [`FilterGraph`] after construction.
2
3use std::path::Path;
4
5use crate::error::FilterError;
6use crate::graph::FilterGraph;
7use crate::graph::filter_step::FilterStep;
8
9/// Noise type used as the initial spectral model for `afftdn`.
10#[derive(Debug, Clone, Copy, PartialEq)]
11pub enum NoiseType {
12    /// White noise (flat spectrum).
13    White,
14    /// Pink noise (−3 dB/octave).
15    Pink,
16    /// Brown / red noise (−6 dB/octave).
17    Brown,
18}
19
20impl NoiseType {
21    fn afftdn_flag(self) -> &'static str {
22        match self {
23            NoiseType::White => "w",
24            NoiseType::Pink => "p",
25            NoiseType::Brown => "b",
26        }
27    }
28}
29
30impl FilterGraph {
31    /// Reduce noise using a statistical noise-type model.
32    ///
33    /// `nr_level` is the noise reduction amount in dB, clamped to [0.0, 97.0].
34    ///
35    /// Uses `FFmpeg`'s `afftdn` filter.
36    pub fn noise_reduce(&mut self, nt: NoiseType, nr_level: f32) -> &mut Self {
37        self.inner.push_step(FilterStep::NoiseReduce {
38            noise_type_flag: nt.afftdn_flag().to_string(),
39            nr_level: nr_level.clamp(0.0, 97.0),
40        });
41        self
42    }
43
44    /// Capture a noise profile from the first `profile_duration_secs` seconds,
45    /// then reduce noise in the full stream.
46    ///
47    /// `nr_level` is the reduction amount in dB, clamped to [0.0, 97.0].
48    /// `profile_duration_secs` is clamped to a minimum of 0.1 seconds.
49    ///
50    /// Uses `FFmpeg`'s `afftdn` filter with the `pl` profile-length option.
51    pub fn noise_reduce_profile(&mut self, profile_duration_secs: f32, nr_level: f32) -> &mut Self {
52        self.inner.push_step(FilterStep::NoiseReduceProfile {
53            profile_duration_secs: profile_duration_secs.max(0.1),
54            nr_level: nr_level.clamp(0.0, 97.0),
55        });
56        self
57    }
58
59    /// Change audio speed and pitch simultaneously by `factor`.
60    ///
61    /// Equivalent to playing a tape at a different speed: `factor > 1.0` makes
62    /// audio faster and higher-pitched; `factor < 1.0` makes it slower and lower.
63    ///
64    /// Uses `FFmpeg`'s `asetrate` filter. Range: 0.1–10.0.
65    ///
66    /// # Errors
67    ///
68    /// Returns [`FilterError::Ffmpeg`] if `factor` is outside 0.1–10.0.
69    pub fn speed_change(&mut self, factor: f64) -> Result<&mut Self, FilterError> {
70        if !(0.1..=10.0).contains(&factor) {
71            return Err(FilterError::Ffmpeg {
72                code: 0,
73                message: format!("speed_change factor must be 0.1–10.0, got {factor}"),
74            });
75        }
76        self.inner.push_step(FilterStep::SpeedChange { factor });
77        Ok(self)
78    }
79
80    /// Shift audio pitch by `semitones` without changing playback speed.
81    ///
82    /// Range: −12.0 to +12.0 semitones. Uses `asetrate` to change the
83    /// decoded sample rate followed by `atempo` to restore original duration.
84    ///
85    /// # Errors
86    ///
87    /// Returns [`FilterError::Ffmpeg`] if `semitones` is outside −12.0..=12.0.
88    pub fn pitch_shift(&mut self, semitones: f32) -> Result<&mut Self, FilterError> {
89        if !(-12.0..=12.0).contains(&semitones) {
90            return Err(FilterError::Ffmpeg {
91                code: 0,
92                message: format!("semitones must be in -12..=12, got {semitones}"),
93            });
94        }
95        self.inner.push_step(FilterStep::PitchShift { semitones });
96        Ok(self)
97    }
98
99    /// Stretch or compress audio duration by `factor` without pitch change.
100    ///
101    /// `factor < 1.0` = slower (longer duration); `factor > 1.0` = faster
102    /// (shorter duration). Range: 0.1–10.0.
103    ///
104    /// Uses `FFmpeg`'s `atempo` filter (WSOLA algorithm). Values outside
105    /// [0.5, 2.0] are realised by chaining multiple `atempo` instances.
106    ///
107    /// # Errors
108    ///
109    /// Returns [`FilterError::Ffmpeg`] if `factor` is outside 0.1–10.0.
110    pub fn time_stretch(&mut self, factor: f32) -> Result<&mut Self, FilterError> {
111        if !(0.1..=10.0).contains(&factor) {
112            return Err(FilterError::Ffmpeg {
113                code: 0,
114                message: format!("time_stretch factor must be 0.1–10.0, got {factor}"),
115            });
116        }
117        self.inner.push_step(FilterStep::TimeStretch { factor });
118        Ok(self)
119    }
120
121    /// Add algorithmic echo/reverb with configurable delay taps.
122    ///
123    /// `in_gain` and `out_gain` are amplitude multipliers clamped to [0.0, 1.0].
124    /// `delays` is a list of delay times in milliseconds.
125    /// `decays` is the corresponding decay factor for each delay, clamped to [0.0, 1.0].
126    /// `delays` and `decays` must have equal length (1–8 taps).
127    ///
128    /// Uses `FFmpeg`'s `aecho` filter.
129    ///
130    /// # Errors
131    ///
132    /// Returns [`FilterError::Ffmpeg`] if lengths differ or tap count is outside 1–8.
133    pub fn reverb_echo(
134        &mut self,
135        in_gain: f32,
136        out_gain: f32,
137        delays: &[f32],
138        decays: &[f32],
139    ) -> Result<&mut Self, FilterError> {
140        if delays.len() != decays.len() {
141            return Err(FilterError::Ffmpeg {
142                code: 0,
143                message: "delays and decays must have equal length".into(),
144            });
145        }
146        if !(1..=8).contains(&delays.len()) {
147            return Err(FilterError::Ffmpeg {
148                code: 0,
149                message: format!("tap count must be 1–8, got {}", delays.len()),
150            });
151        }
152        self.inner.push_step(FilterStep::ReverbEcho {
153            in_gain: in_gain.clamp(0.0, 1.0),
154            out_gain: out_gain.clamp(0.0, 1.0),
155            delays: delays.to_vec(),
156            decays: decays.iter().map(|d| d.clamp(0.0, 1.0)).collect(),
157        });
158        Ok(self)
159    }
160
161    /// Reduce background audio level when foreground signal exceeds a threshold
162    /// (audio ducking via sidechain compression).
163    ///
164    /// Push background audio to slot 0 and foreground (sidechain trigger) audio
165    /// to slot 1.  When the foreground signal rises above `threshold_db`, the
166    /// background is attenuated by `ratio`:1 over `attack_ms` milliseconds; it
167    /// recovers over `release_ms` milliseconds when the foreground drops below the
168    /// threshold.
169    ///
170    /// `threshold_db` is the trigger level in dBFS (e.g., −20.0).  It is
171    /// converted to a linear amplitude ratio before being passed to the filter.
172    /// `ratio` must be ≥ 1.0.  `attack_ms` and `release_ms` must be ≥ 0.0.
173    ///
174    /// Uses `FFmpeg`'s `sidechaincompress` filter.
175    ///
176    /// # Errors
177    ///
178    /// Returns [`FilterError::Ffmpeg`] if `ratio < 1.0` or either time value is
179    /// negative.
180    pub fn duck(
181        &mut self,
182        threshold_db: f32,
183        ratio: f32,
184        attack_ms: f32,
185        release_ms: f32,
186    ) -> Result<&mut Self, FilterError> {
187        if ratio < 1.0 {
188            return Err(FilterError::Ffmpeg {
189                code: 0,
190                message: format!("duck ratio must be >= 1.0, got {ratio}"),
191            });
192        }
193        if attack_ms < 0.0 {
194            return Err(FilterError::Ffmpeg {
195                code: 0,
196                message: format!("duck attack_ms must be >= 0.0, got {attack_ms}"),
197            });
198        }
199        if release_ms < 0.0 {
200            return Err(FilterError::Ffmpeg {
201                code: 0,
202                message: format!("duck release_ms must be >= 0.0, got {release_ms}"),
203            });
204        }
205        let threshold_linear = 10f32.powf(threshold_db / 20.0);
206        self.inner.push_step(FilterStep::Duck {
207            threshold_linear,
208            ratio,
209            attack_ms,
210            release_ms,
211        });
212        Ok(self)
213    }
214
215    /// Add convolution reverb using an impulse response (IR) audio file.
216    ///
217    /// `ir_path` is a path to a `.wav` or `.flac` impulse response file.
218    /// `wet` and `dry` are mix levels clamped to [0.0, 1.0].
219    /// `pre_delay_ms` inserts silence before the reverb tail (clamped to 0–500 ms).
220    ///
221    /// Uses `FFmpeg`'s `amovie` (to load the IR) and `afir` (convolution) filters.
222    ///
223    /// Call this method after [`FilterGraph::builder()`] /
224    /// [`FilterGraphBuilder::build()`](crate::FilterGraphBuilder::build) but
225    /// **before** the first [`FilterGraph::push_audio()`] call.
226    ///
227    /// # Errors
228    ///
229    /// Returns [`FilterError::Ffmpeg`] if `ir_path` does not exist.
230    /// The `afir` filter availability is checked at graph build time; if not
231    /// available the graph build returns [`FilterError::BuildFailed`].
232    pub fn reverb_ir(
233        &mut self,
234        ir_path: &Path,
235        wet: f32,
236        dry: f32,
237        pre_delay_ms: u32,
238    ) -> Result<&mut Self, FilterError> {
239        if !ir_path.exists() {
240            return Err(FilterError::Ffmpeg {
241                code: 0,
242                message: format!("ir_path does not exist: {}", ir_path.display()),
243            });
244        }
245        let ir_str = ir_path.display().to_string();
246        self.inner.push_step(FilterStep::ReverbIr {
247            ir_path: ir_str,
248            wet: wet.clamp(0.0, 1.0),
249            dry: dry.clamp(0.0, 1.0),
250            pre_delay_ms: pre_delay_ms.min(500),
251        });
252        Ok(self)
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use crate::effects::audio_effects::NoiseType;
259    use crate::graph::filter_step::FilterStep;
260    use crate::{FilterError, FilterGraph};
261    use std::path::Path;
262
263    #[test]
264    fn noise_reduce_should_push_noise_reduce_step() {
265        let mut graph = FilterGraph::builder().trim(0.0, 1.0).build().unwrap();
266        graph.noise_reduce(NoiseType::White, 50.0);
267        let step = FilterStep::NoiseReduce {
268            noise_type_flag: "w".to_string(),
269            nr_level: 50.0,
270        };
271        assert_eq!(step.filter_name(), "afftdn");
272        assert!(
273            step.args().contains("nt=w"),
274            "args must contain nt=w: {}",
275            step.args()
276        );
277        assert!(
278            step.args().contains("nr=50"),
279            "args must contain nr=50: {}",
280            step.args()
281        );
282    }
283
284    #[test]
285    fn noise_reduce_clamps_nr_level_above_97() {
286        let step = FilterStep::NoiseReduce {
287            noise_type_flag: "p".to_string(),
288            nr_level: 97.0,
289        };
290        assert!(
291            step.args().contains("nr=97"),
292            "nr_level=97.0 must appear in args: {}",
293            step.args()
294        );
295    }
296
297    #[test]
298    fn noise_reduce_profile_args_should_contain_pl_and_nr() {
299        let step = FilterStep::NoiseReduceProfile {
300            profile_duration_secs: 0.5,
301            nr_level: 30.0,
302        };
303        let args = step.args();
304        assert!(args.contains("pl=0.5"), "args must contain pl=0.5: {args}");
305        assert!(args.contains("nr=30"), "args must contain nr=30: {args}");
306        assert!(args.contains("nf=-25"), "args must contain nf=-25: {args}");
307    }
308
309    #[test]
310    fn noise_type_flags_should_match_afftdn_spec() {
311        assert_eq!(NoiseType::White.afftdn_flag(), "w");
312        assert_eq!(NoiseType::Pink.afftdn_flag(), "p");
313        assert_eq!(NoiseType::Brown.afftdn_flag(), "b");
314    }
315
316    #[test]
317    fn speed_change_zero_should_return_ffmpeg_error() {
318        let mut graph = FilterGraph::builder().trim(0.0, 1.0).build().unwrap();
319        let result = graph.speed_change(0.0);
320        assert!(
321            matches!(result, Err(FilterError::Ffmpeg { .. })),
322            "factor=0.0 must return Err(FilterError::Ffmpeg {{ .. }}), got {result:?}"
323        );
324    }
325
326    #[test]
327    fn speed_change_above_range_should_return_ffmpeg_error() {
328        let mut graph = FilterGraph::builder().trim(0.0, 1.0).build().unwrap();
329        let result = graph.speed_change(11.0);
330        assert!(
331            matches!(result, Err(FilterError::Ffmpeg { .. })),
332            "factor=11.0 must return Err(FilterError::Ffmpeg {{ .. }}), got {result:?}"
333        );
334    }
335
336    #[test]
337    fn speed_change_boundary_values_should_succeed() {
338        let mut graph = FilterGraph::builder().trim(0.0, 1.0).build().unwrap();
339        assert!(graph.speed_change(0.1).is_ok(), "factor=0.1 must succeed");
340        let mut graph = FilterGraph::builder().trim(0.0, 1.0).build().unwrap();
341        assert!(graph.speed_change(10.0).is_ok(), "factor=10.0 must succeed");
342        let mut graph = FilterGraph::builder().trim(0.0, 1.0).build().unwrap();
343        assert!(graph.speed_change(1.0).is_ok(), "factor=1.0 must succeed");
344    }
345
346    #[test]
347    fn filter_step_speed_change_should_have_asetrate_filter_name() {
348        let step = FilterStep::SpeedChange { factor: 2.0 };
349        assert_eq!(step.filter_name(), "asetrate");
350    }
351
352    #[test]
353    fn time_stretch_zero_should_return_ffmpeg_error() {
354        let mut graph = FilterGraph::builder().trim(0.0, 1.0).build().unwrap();
355        let result = graph.time_stretch(0.0);
356        assert!(
357            matches!(result, Err(FilterError::Ffmpeg { .. })),
358            "factor=0.0 must return Err(FilterError::Ffmpeg {{ .. }}), got {result:?}"
359        );
360    }
361
362    #[test]
363    fn time_stretch_above_range_should_return_ffmpeg_error() {
364        let mut graph = FilterGraph::builder().trim(0.0, 1.0).build().unwrap();
365        let result = graph.time_stretch(11.0);
366        assert!(
367            matches!(result, Err(FilterError::Ffmpeg { .. })),
368            "factor=11.0 must return Err(FilterError::Ffmpeg {{ .. }}), got {result:?}"
369        );
370    }
371
372    #[test]
373    fn time_stretch_boundary_values_should_succeed() {
374        let mut graph = FilterGraph::builder().trim(0.0, 1.0).build().unwrap();
375        assert!(graph.time_stretch(0.1).is_ok(), "factor=0.1 must succeed");
376        let mut graph = FilterGraph::builder().trim(0.0, 1.0).build().unwrap();
377        assert!(graph.time_stretch(10.0).is_ok(), "factor=10.0 must succeed");
378    }
379
380    #[test]
381    fn filter_step_time_stretch_should_have_atempo_filter_name() {
382        let step = FilterStep::TimeStretch { factor: 1.5 };
383        assert_eq!(step.filter_name(), "atempo");
384    }
385
386    #[test]
387    fn pitch_shift_above_range_should_return_ffmpeg_error() {
388        let mut graph = FilterGraph::builder().trim(0.0, 1.0).build().unwrap();
389        let result = graph.pitch_shift(13.0);
390        assert!(
391            matches!(result, Err(FilterError::Ffmpeg { .. })),
392            "semitones=13.0 must return Err(FilterError::Ffmpeg {{ .. }}), got {result:?}"
393        );
394    }
395
396    #[test]
397    fn pitch_shift_below_range_should_return_ffmpeg_error() {
398        let mut graph = FilterGraph::builder().trim(0.0, 1.0).build().unwrap();
399        let result = graph.pitch_shift(-13.0);
400        assert!(
401            matches!(result, Err(FilterError::Ffmpeg { .. })),
402            "semitones=-13.0 must return Err(FilterError::Ffmpeg {{ .. }}), got {result:?}"
403        );
404    }
405
406    #[test]
407    fn pitch_shift_boundary_values_should_succeed() {
408        let mut graph = FilterGraph::builder().trim(0.0, 1.0).build().unwrap();
409        assert!(
410            graph.pitch_shift(12.0).is_ok(),
411            "semitones=12.0 must succeed"
412        );
413        let mut graph = FilterGraph::builder().trim(0.0, 1.0).build().unwrap();
414        assert!(
415            graph.pitch_shift(-12.0).is_ok(),
416            "semitones=-12.0 must succeed"
417        );
418        let mut graph = FilterGraph::builder().trim(0.0, 1.0).build().unwrap();
419        assert!(graph.pitch_shift(0.0).is_ok(), "semitones=0.0 must succeed");
420    }
421
422    #[test]
423    fn filter_step_pitch_shift_should_have_asetrate_filter_name() {
424        let step = FilterStep::PitchShift { semitones: 7.0 };
425        assert_eq!(step.filter_name(), "asetrate");
426    }
427
428    #[test]
429    fn reverb_echo_mismatched_lengths_should_return_ffmpeg_error() {
430        let mut graph = FilterGraph::builder().trim(0.0, 1.0).build().unwrap();
431        let result = graph.reverb_echo(0.8, 0.9, &[500.0], &[0.5, 0.3]);
432        assert!(
433            matches!(result, Err(FilterError::Ffmpeg { .. })),
434            "mismatched lengths must return Err(FilterError::Ffmpeg {{ .. }}), got {result:?}"
435        );
436    }
437
438    #[test]
439    fn reverb_echo_zero_taps_should_return_ffmpeg_error() {
440        let mut graph = FilterGraph::builder().trim(0.0, 1.0).build().unwrap();
441        let result = graph.reverb_echo(0.8, 0.9, &[], &[]);
442        assert!(
443            matches!(result, Err(FilterError::Ffmpeg { .. })),
444            "zero taps must return Err(FilterError::Ffmpeg {{ .. }}), got {result:?}"
445        );
446    }
447
448    #[test]
449    fn reverb_echo_nine_taps_should_return_ffmpeg_error() {
450        let mut graph = FilterGraph::builder().trim(0.0, 1.0).build().unwrap();
451        let delays = vec![100.0; 9];
452        let decays = vec![0.5; 9];
453        let result = graph.reverb_echo(0.8, 0.9, &delays, &decays);
454        assert!(
455            matches!(result, Err(FilterError::Ffmpeg { .. })),
456            "nine taps must return Err(FilterError::Ffmpeg {{ .. }}), got {result:?}"
457        );
458    }
459
460    #[test]
461    fn filter_step_reverb_echo_should_have_aecho_filter_name() {
462        let step = FilterStep::ReverbEcho {
463            in_gain: 0.8,
464            out_gain: 0.9,
465            delays: vec![500.0],
466            decays: vec![0.5],
467        };
468        assert_eq!(step.filter_name(), "aecho");
469    }
470
471    #[test]
472    fn reverb_echo_args_should_contain_gains_delays_decays() {
473        let step = FilterStep::ReverbEcho {
474            in_gain: 0.8,
475            out_gain: 0.9,
476            delays: vec![500.0],
477            decays: vec![0.5],
478        };
479        let args = step.args();
480        assert!(
481            args.contains("in_gain=0.8"),
482            "args must contain in_gain=0.8: {args}"
483        );
484        assert!(
485            args.contains("out_gain=0.9"),
486            "args must contain out_gain=0.9: {args}"
487        );
488        assert!(
489            args.contains("delays=500"),
490            "args must contain delays=500: {args}"
491        );
492        assert!(
493            args.contains("decays=0.5"),
494            "args must contain decays=0.5: {args}"
495        );
496    }
497
498    #[test]
499    fn reverb_echo_multi_tap_args_should_join_with_pipe() {
500        let step = FilterStep::ReverbEcho {
501            in_gain: 0.8,
502            out_gain: 0.9,
503            delays: vec![500.0, 300.0],
504            decays: vec![0.5, 0.3],
505        };
506        let args = step.args();
507        assert!(
508            args.contains("500|300") || args.contains("500.0|300"),
509            "multi-tap delays must be joined with '|': {args}"
510        );
511        assert!(
512            args.contains("0.5|0.3"),
513            "multi-tap decays must be joined with '|': {args}"
514        );
515    }
516
517    #[test]
518    fn reverb_ir_nonexistent_path_should_return_ffmpeg_error() {
519        let mut graph = FilterGraph::builder().trim(0.0, 1.0).build().unwrap();
520        let result = graph.reverb_ir(Path::new("no_such_file.wav"), 0.8, 0.2, 0);
521        assert!(
522            matches!(result, Err(FilterError::Ffmpeg { .. })),
523            "non-existent ir_path must return Err(FilterError::Ffmpeg {{ .. }}), got {result:?}"
524        );
525    }
526
527    #[test]
528    fn filter_step_reverb_ir_should_have_afir_filter_name() {
529        let step = FilterStep::ReverbIr {
530            ir_path: "hall.wav".to_string(),
531            wet: 0.8,
532            dry: 0.2,
533            pre_delay_ms: 0,
534        };
535        assert_eq!(step.filter_name(), "afir");
536    }
537
538    #[test]
539    fn reverb_ir_args_should_contain_wet_dry_and_ir_path() {
540        let step = FilterStep::ReverbIr {
541            ir_path: "hall.wav".to_string(),
542            wet: 0.8,
543            dry: 0.2,
544            pre_delay_ms: 0,
545        };
546        let args = step.args();
547        assert!(
548            args.contains("hall.wav"),
549            "args must contain ir_path: {args}"
550        );
551        assert!(
552            args.contains("wet=0.8"),
553            "args must contain wet=0.8: {args}"
554        );
555        assert!(
556            args.contains("dry=0.2"),
557            "args must contain dry=0.2: {args}"
558        );
559        assert!(
560            !args.contains("adelay"),
561            "no pre-delay when pre_delay_ms=0: {args}"
562        );
563    }
564
565    #[test]
566    fn reverb_ir_args_with_pre_delay_should_contain_adelay() {
567        let step = FilterStep::ReverbIr {
568            ir_path: "hall.wav".to_string(),
569            wet: 0.8,
570            dry: 0.2,
571            pre_delay_ms: 100,
572        };
573        let args = step.args();
574        assert!(
575            args.contains("adelay=100"),
576            "args must contain adelay=100 when pre_delay_ms=100: {args}"
577        );
578    }
579
580    #[test]
581    fn duck_ratio_below_one_should_return_ffmpeg_error() {
582        let mut graph = FilterGraph::builder().trim(0.0, 1.0).build().unwrap();
583        let result = graph.duck(-20.0, 0.5, 10.0, 200.0);
584        assert!(
585            matches!(result, Err(FilterError::Ffmpeg { .. })),
586            "ratio=0.5 must return Err(FilterError::Ffmpeg {{ .. }}), got {result:?}"
587        );
588    }
589
590    #[test]
591    fn duck_negative_attack_ms_should_return_ffmpeg_error() {
592        let mut graph = FilterGraph::builder().trim(0.0, 1.0).build().unwrap();
593        let result = graph.duck(-20.0, 20.0, -1.0, 200.0);
594        assert!(
595            matches!(result, Err(FilterError::Ffmpeg { .. })),
596            "attack_ms=-1.0 must return Err(FilterError::Ffmpeg {{ .. }}), got {result:?}"
597        );
598    }
599
600    #[test]
601    fn duck_negative_release_ms_should_return_ffmpeg_error() {
602        let mut graph = FilterGraph::builder().trim(0.0, 1.0).build().unwrap();
603        let result = graph.duck(-20.0, 20.0, 10.0, -1.0);
604        assert!(
605            matches!(result, Err(FilterError::Ffmpeg { .. })),
606            "release_ms=-1.0 must return Err(FilterError::Ffmpeg {{ .. }}), got {result:?}"
607        );
608    }
609
610    #[test]
611    fn duck_valid_params_should_push_duck_step() {
612        let mut graph = FilterGraph::builder().trim(0.0, 1.0).build().unwrap();
613        assert!(
614            graph.duck(-20.0, 20.0, 10.0, 200.0).is_ok(),
615            "valid duck params must succeed"
616        );
617    }
618
619    #[test]
620    fn filter_step_duck_should_have_sidechaincompress_filter_name() {
621        let step = FilterStep::Duck {
622            threshold_linear: 0.1,
623            ratio: 20.0,
624            attack_ms: 10.0,
625            release_ms: 200.0,
626        };
627        assert_eq!(step.filter_name(), "sidechaincompress");
628    }
629
630    #[test]
631    fn filter_step_duck_args_should_contain_threshold_ratio_attack_release() {
632        let step = FilterStep::Duck {
633            threshold_linear: 0.1,
634            ratio: 20.0,
635            attack_ms: 10.0,
636            release_ms: 200.0,
637        };
638        let args = step.args();
639        assert!(
640            args.contains("threshold=0.1"),
641            "args must contain threshold=0.1: {args}"
642        );
643        assert!(
644            args.contains("ratio=20"),
645            "args must contain ratio=20: {args}"
646        );
647        assert!(
648            args.contains("attack=10"),
649            "args must contain attack=10: {args}"
650        );
651        assert!(
652            args.contains("release=200"),
653            "args must contain release=200: {args}"
654        );
655    }
656
657    #[test]
658    fn reverb_ir_pre_delay_above_500_should_be_clamped() {
659        let step = FilterStep::ReverbIr {
660            ir_path: "hall.wav".to_string(),
661            wet: 0.8,
662            dry: 0.2,
663            pre_delay_ms: 999,
664        };
665        let args = step.args();
666        assert!(
667            args.contains("adelay=500"),
668            "pre_delay_ms=999 must clamp to 500: {args}"
669        );
670    }
671}