Skip to main content

ff_filter/graph/builder/
audio.rs

1//! Audio filter methods for [`FilterGraphBuilder`].
2
3#[allow(clippy::wildcard_imports)]
4use super::*;
5
6impl FilterGraphBuilder {
7    // Audio filters
8
9    /// Audio fade-in from silence, starting at `start_sec` seconds and reaching
10    /// full volume after `duration_sec` seconds.
11    ///
12    /// [`build`](Self::build) returns [`FilterError::InvalidConfig`] if
13    /// `duration_sec` is ≤ 0.0.
14    #[must_use]
15    pub fn afade_in(mut self, start_sec: f64, duration_sec: f64) -> Self {
16        self.steps.push(FilterStep::AFadeIn {
17            start: start_sec,
18            duration: duration_sec,
19        });
20        self
21    }
22
23    /// Audio fade-out to silence, starting at `start_sec` seconds and reaching
24    /// full silence after `duration_sec` seconds.
25    ///
26    /// [`build`](Self::build) returns [`FilterError::InvalidConfig`] if
27    /// `duration_sec` is ≤ 0.0.
28    #[must_use]
29    pub fn afade_out(mut self, start_sec: f64, duration_sec: f64) -> Self {
30        self.steps.push(FilterStep::AFadeOut {
31            start: start_sec,
32            duration: duration_sec,
33        });
34        self
35    }
36
37    /// Reverse audio playback using `FFmpeg`'s `areverse` filter.
38    ///
39    /// **Warning**: `areverse` buffers the entire clip in memory before producing
40    /// any output. Only use this on short clips to avoid excessive memory usage.
41    #[must_use]
42    pub fn areverse(mut self) -> Self {
43        self.steps.push(FilterStep::AReverse);
44        self
45    }
46
47    /// Apply EBU R128 two-pass loudness normalization.
48    ///
49    /// `target_lufs` is the target integrated loudness (e.g. `−23.0`),
50    /// `true_peak_db` is the true-peak ceiling (e.g. `−1.0`), and
51    /// `lra` is the target loudness range in LU (e.g. `7.0`).
52    ///
53    /// Pass 1 measures integrated loudness with the `ebur128` filter.
54    /// Pass 2 applies a linear `volume` correction.  All audio frames are
55    /// buffered in memory between the two passes — use only for clips that
56    /// fit comfortably in RAM.
57    ///
58    /// [`build`](Self::build) returns [`FilterError::InvalidConfig`] if
59    /// `target_lufs >= 0.0`, `true_peak_db > 0.0`, or `lra <= 0.0`.
60    #[must_use]
61    pub fn loudness_normalize(mut self, target_lufs: f32, true_peak_db: f32, lra: f32) -> Self {
62        self.steps.push(FilterStep::LoudnessNormalize {
63            target_lufs,
64            true_peak_db,
65            lra,
66        });
67        self
68    }
69
70    /// Normalize the audio peak level to `target_db` dBFS using a two-pass approach.
71    ///
72    /// Pass 1 measures the true peak with `astats=metadata=1`.
73    /// Pass 2 applies `volume={gain}dB` so the output peak reaches `target_db`.
74    /// All audio frames are buffered in memory between the two passes — use only
75    /// for clips that fit comfortably in RAM.
76    ///
77    /// [`build`](Self::build) returns [`FilterError::InvalidConfig`] if
78    /// `target_db > 0.0` (cannot normalize above digital full scale).
79    #[must_use]
80    pub fn normalize_peak(mut self, target_db: f32) -> Self {
81        self.steps.push(FilterStep::NormalizePeak { target_db });
82        self
83    }
84
85    /// Apply a noise gate to suppress audio below a given threshold.
86    ///
87    /// Uses `FFmpeg`'s `agate` filter. Audio below `threshold_db` (dBFS) is
88    /// attenuated; audio above it passes through unmodified. The threshold is
89    /// converted from dBFS to the linear amplitude ratio expected by `agate`.
90    ///
91    /// [`build`](Self::build) returns [`FilterError::InvalidConfig`] if
92    /// `attack_ms` or `release_ms` is ≤ 0.0.
93    #[must_use]
94    pub fn agate(mut self, threshold_db: f32, attack_ms: f32, release_ms: f32) -> Self {
95        self.steps.push(FilterStep::ANoiseGate {
96            threshold_db,
97            attack_ms,
98            release_ms,
99        });
100        self
101    }
102
103    /// Apply a dynamic range compressor to the audio.
104    ///
105    /// Uses `FFmpeg`'s `acompressor` filter. Audio peaks above `threshold_db`
106    /// (dBFS) are reduced by `ratio`:1.  `makeup_db` applies additional gain
107    /// after compression to restore perceived loudness.
108    ///
109    /// [`build`](Self::build) returns [`FilterError::InvalidConfig`] if
110    /// `ratio < 1.0`, `attack_ms ≤ 0.0`, or `release_ms ≤ 0.0`.
111    #[must_use]
112    pub fn compressor(
113        mut self,
114        threshold_db: f32,
115        ratio: f32,
116        attack_ms: f32,
117        release_ms: f32,
118        makeup_db: f32,
119    ) -> Self {
120        self.steps.push(FilterStep::ACompressor {
121            threshold_db,
122            ratio,
123            attack_ms,
124            release_ms,
125            makeup_db,
126        });
127        self
128    }
129
130    /// Downmix stereo audio to mono by equally mixing both channels.
131    ///
132    /// Uses `FFmpeg`'s `pan` filter with the expression
133    /// `mono|c0=0.5*c0+0.5*c1`.  The output has a single channel.
134    #[must_use]
135    pub fn stereo_to_mono(mut self) -> Self {
136        self.steps.push(FilterStep::StereoToMono);
137        self
138    }
139
140    /// Remap audio channels using `FFmpeg`'s `channelmap` filter.
141    ///
142    /// `mapping` is a `|`-separated list of output channel names taken from
143    /// input channels, e.g. `"FR|FL"` swaps left and right.
144    ///
145    /// [`build`](Self::build) returns [`FilterError::InvalidConfig`] if
146    /// `mapping` is empty.
147    #[must_use]
148    pub fn channel_map(mut self, mapping: &str) -> Self {
149        self.steps.push(FilterStep::ChannelMap {
150            mapping: mapping.to_string(),
151        });
152        self
153    }
154
155    /// Shift audio for A/V sync correction.
156    ///
157    /// Positive `ms`: uses `FFmpeg`'s `adelay` filter to delay the audio
158    /// (audio plays later). Negative `ms`: uses `FFmpeg`'s `atrim` filter to
159    /// advance the audio by trimming the start (audio plays earlier).
160    /// Zero `ms` is a no-op.
161    #[must_use]
162    pub fn audio_delay(mut self, ms: f64) -> Self {
163        self.steps.push(FilterStep::AudioDelay { ms });
164        self
165    }
166
167    /// Concatenate `n_segments` sequential audio inputs using `FFmpeg`'s `concat` filter.
168    ///
169    /// Requires `n_segments` audio input slots (push to slots 0 through
170    /// `n_segments - 1` in order). [`build`](Self::build) returns
171    /// [`FilterError::InvalidConfig`] if `n_segments < 2`.
172    #[must_use]
173    pub fn concat_audio(mut self, n_segments: u32) -> Self {
174        self.steps.push(FilterStep::ConcatAudio { n: n_segments });
175        self
176    }
177
178    /// Adjust audio volume by `gain_db` decibels (negative = quieter).
179    #[must_use]
180    pub fn volume(mut self, gain_db: f64) -> Self {
181        self.steps.push(FilterStep::Volume(gain_db));
182        self
183    }
184
185    /// Mix `inputs` audio streams together (additive: the inputs are summed, not
186    /// averaged; push each stream to its slot `0..inputs`).
187    #[must_use]
188    pub fn amix(mut self, inputs: usize) -> Self {
189        self.steps.push(FilterStep::Amix(inputs));
190        self
191    }
192
193    /// Apply a multi-band parametric equalizer.
194    ///
195    /// Each [`EqBand`] maps to one `FFmpeg` filter node chained in sequence:
196    /// - [`EqBand::LowShelf`] → `lowshelf`
197    /// - [`EqBand::HighShelf`] → `highshelf`
198    /// - [`EqBand::Peak`] → `equalizer`
199    ///
200    /// [`build`](Self::build) returns [`FilterError::InvalidConfig`] if `bands`
201    /// is empty.
202    #[must_use]
203    pub fn equalizer(mut self, bands: Vec<EqBand>) -> Self {
204        self.steps.push(FilterStep::ParametricEq { bands });
205        self
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    #[test]
214    fn filter_step_volume_should_produce_correct_args() {
215        let step = FilterStep::Volume(-6.0);
216        assert_eq!(step.filter_name(), "volume");
217        assert_eq!(step.args(), "volume=-6dB");
218    }
219
220    #[test]
221    fn volume_should_convert_db_to_ffmpeg_string() {
222        assert_eq!(FilterStep::Volume(-6.0).args(), "volume=-6dB");
223        assert_eq!(FilterStep::Volume(6.0).args(), "volume=6dB");
224        assert_eq!(FilterStep::Volume(0.0).args(), "volume=0dB");
225    }
226
227    #[test]
228    fn filter_step_amix_should_produce_correct_args() {
229        let step = FilterStep::Amix(3);
230        assert_eq!(step.filter_name(), "amix");
231        assert_eq!(step.args(), "inputs=3:normalize=0");
232    }
233
234    #[test]
235    fn filter_step_parametric_eq_should_have_filter_name_equalizer() {
236        let step = FilterStep::ParametricEq {
237            bands: vec![EqBand::Peak {
238                freq_hz: 1000.0,
239                gain_db: 3.0,
240                q: 1.0,
241            }],
242        };
243        assert_eq!(step.filter_name(), "equalizer");
244    }
245
246    #[test]
247    fn eq_band_peak_should_produce_correct_args() {
248        let band = EqBand::Peak {
249            freq_hz: 1000.0,
250            gain_db: 3.0,
251            q: 1.0,
252        };
253        assert_eq!(band.args(), "f=1000:g=3:width_type=q:width=1");
254    }
255
256    #[test]
257    fn eq_band_low_shelf_should_produce_correct_args() {
258        let band = EqBand::LowShelf {
259            freq_hz: 200.0,
260            gain_db: -3.0,
261            slope: 1.0,
262        };
263        assert_eq!(band.args(), "f=200:g=-3:s=1");
264    }
265
266    #[test]
267    fn eq_band_high_shelf_should_produce_correct_args() {
268        let band = EqBand::HighShelf {
269            freq_hz: 8000.0,
270            gain_db: 2.0,
271            slope: 0.5,
272        };
273        assert_eq!(band.args(), "f=8000:g=2:s=0.5");
274    }
275
276    #[test]
277    fn builder_equalizer_with_single_peak_band_should_succeed() {
278        let result = FilterGraph::builder()
279            .equalizer(vec![EqBand::Peak {
280                freq_hz: 1000.0,
281                gain_db: 3.0,
282                q: 1.0,
283            }])
284            .build();
285        assert!(
286            result.is_ok(),
287            "equalizer with single Peak band must build successfully, got {result:?}"
288        );
289    }
290
291    #[test]
292    fn builder_equalizer_with_multiple_bands_should_succeed() {
293        let result = FilterGraph::builder()
294            .equalizer(vec![
295                EqBand::LowShelf {
296                    freq_hz: 200.0,
297                    gain_db: -2.0,
298                    slope: 1.0,
299                },
300                EqBand::Peak {
301                    freq_hz: 1000.0,
302                    gain_db: 3.0,
303                    q: 1.4,
304                },
305                EqBand::HighShelf {
306                    freq_hz: 8000.0,
307                    gain_db: 1.0,
308                    slope: 0.5,
309                },
310            ])
311            .build();
312        assert!(
313            result.is_ok(),
314            "equalizer with three bands must build successfully, got {result:?}"
315        );
316    }
317
318    #[test]
319    fn builder_equalizer_with_empty_bands_should_return_invalid_config() {
320        let result = FilterGraph::builder().equalizer(vec![]).build();
321        assert!(
322            matches!(result, Err(FilterError::InvalidConfig { .. })),
323            "expected InvalidConfig for empty bands, got {result:?}"
324        );
325    }
326
327    #[test]
328    fn filter_step_afade_in_should_have_correct_filter_name() {
329        let step = FilterStep::AFadeIn {
330            start: 0.0,
331            duration: 1.0,
332        };
333        assert_eq!(step.filter_name(), "afade");
334    }
335
336    #[test]
337    fn filter_step_afade_out_should_have_correct_filter_name() {
338        let step = FilterStep::AFadeOut {
339            start: 4.0,
340            duration: 1.0,
341        };
342        assert_eq!(step.filter_name(), "afade");
343    }
344
345    #[test]
346    fn filter_step_afade_in_should_produce_correct_args() {
347        let step = FilterStep::AFadeIn {
348            start: 0.0,
349            duration: 1.0,
350        };
351        assert_eq!(step.args(), "type=in:start_time=0:duration=1");
352    }
353
354    #[test]
355    fn filter_step_afade_out_should_produce_correct_args() {
356        let step = FilterStep::AFadeOut {
357            start: 4.0,
358            duration: 1.0,
359        };
360        assert_eq!(step.args(), "type=out:start_time=4:duration=1");
361    }
362
363    #[test]
364    fn builder_afade_in_with_valid_params_should_succeed() {
365        let result = FilterGraph::builder().afade_in(0.0, 1.0).build();
366        assert!(
367            result.is_ok(),
368            "afade_in(0.0, 1.0) must build successfully, got {result:?}"
369        );
370    }
371
372    #[test]
373    fn builder_afade_out_with_valid_params_should_succeed() {
374        let result = FilterGraph::builder().afade_out(4.0, 1.0).build();
375        assert!(
376            result.is_ok(),
377            "afade_out(4.0, 1.0) must build successfully, got {result:?}"
378        );
379    }
380
381    #[test]
382    fn builder_afade_in_with_zero_duration_should_return_invalid_config() {
383        let result = FilterGraph::builder().afade_in(0.0, 0.0).build();
384        assert!(
385            matches!(result, Err(FilterError::InvalidConfig { .. })),
386            "expected InvalidConfig for duration=0.0, got {result:?}"
387        );
388    }
389
390    #[test]
391    fn builder_afade_out_with_negative_duration_should_return_invalid_config() {
392        let result = FilterGraph::builder().afade_out(4.0, -1.0).build();
393        assert!(
394            matches!(result, Err(FilterError::InvalidConfig { .. })),
395            "expected InvalidConfig for duration=-1.0, got {result:?}"
396        );
397    }
398
399    #[test]
400    fn filter_step_areverse_should_produce_correct_filter_name_and_empty_args() {
401        let step = FilterStep::AReverse;
402        assert_eq!(step.filter_name(), "areverse");
403        assert_eq!(step.args(), "");
404    }
405
406    #[test]
407    fn builder_areverse_should_succeed() {
408        let result = FilterGraph::builder().areverse().build();
409        assert!(
410            result.is_ok(),
411            "areverse must build successfully, got {result:?}"
412        );
413    }
414
415    #[test]
416    fn filter_step_loudness_normalize_should_produce_correct_filter_name() {
417        let step = FilterStep::LoudnessNormalize {
418            target_lufs: -23.0,
419            true_peak_db: -1.0,
420            lra: 7.0,
421        };
422        assert_eq!(step.filter_name(), "ebur128");
423    }
424
425    #[test]
426    fn filter_step_loudness_normalize_should_produce_correct_args() {
427        let step = FilterStep::LoudnessNormalize {
428            target_lufs: -23.0,
429            true_peak_db: -1.0,
430            lra: 7.0,
431        };
432        assert_eq!(step.args(), "peak=true:metadata=1");
433    }
434
435    #[test]
436    fn builder_loudness_normalize_with_valid_params_should_succeed() {
437        let result = FilterGraph::builder()
438            .loudness_normalize(-23.0, -1.0, 7.0)
439            .build();
440        assert!(
441            result.is_ok(),
442            "loudness_normalize(-23.0, -1.0, 7.0) must build successfully, got {result:?}"
443        );
444    }
445
446    #[test]
447    fn builder_loudness_normalize_with_zero_target_lufs_should_return_invalid_config() {
448        let result = FilterGraph::builder()
449            .loudness_normalize(0.0, -1.0, 7.0)
450            .build();
451        assert!(
452            matches!(result, Err(FilterError::InvalidConfig { .. })),
453            "expected InvalidConfig for target_lufs=0.0, got {result:?}"
454        );
455    }
456
457    #[test]
458    fn builder_loudness_normalize_with_positive_target_lufs_should_return_invalid_config() {
459        let result = FilterGraph::builder()
460            .loudness_normalize(5.0, -1.0, 7.0)
461            .build();
462        assert!(
463            matches!(result, Err(FilterError::InvalidConfig { .. })),
464            "expected InvalidConfig for target_lufs=5.0, got {result:?}"
465        );
466    }
467
468    #[test]
469    fn builder_loudness_normalize_with_positive_true_peak_should_return_invalid_config() {
470        let result = FilterGraph::builder()
471            .loudness_normalize(-23.0, 1.0, 7.0)
472            .build();
473        assert!(
474            matches!(result, Err(FilterError::InvalidConfig { .. })),
475            "expected InvalidConfig for true_peak_db=1.0, got {result:?}"
476        );
477    }
478
479    #[test]
480    fn builder_loudness_normalize_with_zero_lra_should_return_invalid_config() {
481        let result = FilterGraph::builder()
482            .loudness_normalize(-23.0, -1.0, 0.0)
483            .build();
484        assert!(
485            matches!(result, Err(FilterError::InvalidConfig { .. })),
486            "expected InvalidConfig for lra=0.0, got {result:?}"
487        );
488    }
489
490    #[test]
491    fn builder_loudness_normalize_with_negative_lra_should_return_invalid_config() {
492        let result = FilterGraph::builder()
493            .loudness_normalize(-23.0, -1.0, -7.0)
494            .build();
495        assert!(
496            matches!(result, Err(FilterError::InvalidConfig { .. })),
497            "expected InvalidConfig for lra=-7.0, got {result:?}"
498        );
499    }
500
501    #[test]
502    fn filter_step_normalize_peak_should_have_correct_filter_name() {
503        let step = FilterStep::NormalizePeak { target_db: -1.0 };
504        assert_eq!(step.filter_name(), "astats");
505    }
506
507    #[test]
508    fn filter_step_normalize_peak_should_have_correct_args() {
509        let step = FilterStep::NormalizePeak { target_db: -1.0 };
510        assert_eq!(step.args(), "metadata=1");
511    }
512
513    #[test]
514    fn builder_normalize_peak_valid_should_build_successfully() {
515        let result = FilterGraph::builder().normalize_peak(-1.0).build();
516        assert!(
517            result.is_ok(),
518            "normalize_peak(-1.0) must build successfully, got {result:?}"
519        );
520    }
521
522    #[test]
523    fn builder_normalize_peak_with_zero_target_db_should_build_successfully() {
524        // 0.0 dBFS is the maximum allowed value (digital full scale).
525        let result = FilterGraph::builder().normalize_peak(0.0).build();
526        assert!(
527            result.is_ok(),
528            "normalize_peak(0.0) must build successfully, got {result:?}"
529        );
530    }
531
532    #[test]
533    fn builder_normalize_peak_with_positive_target_db_should_return_invalid_config() {
534        let result = FilterGraph::builder().normalize_peak(1.0).build();
535        assert!(
536            matches!(result, Err(FilterError::InvalidConfig { .. })),
537            "expected InvalidConfig for target_db=1.0, got {result:?}"
538        );
539    }
540
541    #[test]
542    fn filter_step_agate_should_have_correct_filter_name() {
543        let step = FilterStep::ANoiseGate {
544            threshold_db: -40.0,
545            attack_ms: 10.0,
546            release_ms: 100.0,
547        };
548        assert_eq!(step.filter_name(), "agate");
549    }
550
551    #[test]
552    fn filter_step_agate_should_produce_correct_args_for_minus_40_db() {
553        let step = FilterStep::ANoiseGate {
554            threshold_db: -40.0,
555            attack_ms: 10.0,
556            release_ms: 100.0,
557        };
558        // 10^(-40/20) = 10^(-2) = 0.01
559        let args = step.args();
560        assert!(
561            args.starts_with("threshold=0.010000:"),
562            "expected args to start with threshold=0.010000:, got {args}"
563        );
564        assert!(
565            args.contains("attack=10:"),
566            "expected attack=10: in args, got {args}"
567        );
568        assert!(
569            args.contains("release=100"),
570            "expected release=100 in args, got {args}"
571        );
572    }
573
574    #[test]
575    fn filter_step_agate_should_produce_correct_args_for_zero_db() {
576        let step = FilterStep::ANoiseGate {
577            threshold_db: 0.0,
578            attack_ms: 5.0,
579            release_ms: 50.0,
580        };
581        // 10^(0/20) = 1.0
582        let args = step.args();
583        assert!(
584            args.starts_with("threshold=1.000000:"),
585            "expected threshold=1.000000: in args, got {args}"
586        );
587    }
588
589    #[test]
590    fn builder_agate_valid_should_build_successfully() {
591        let result = FilterGraph::builder().agate(-40.0, 10.0, 100.0).build();
592        assert!(
593            result.is_ok(),
594            "agate(-40.0, 10.0, 100.0) must build successfully, got {result:?}"
595        );
596    }
597
598    #[test]
599    fn builder_agate_with_zero_attack_should_return_invalid_config() {
600        let result = FilterGraph::builder().agate(-40.0, 0.0, 100.0).build();
601        assert!(
602            matches!(result, Err(FilterError::InvalidConfig { .. })),
603            "expected InvalidConfig for attack_ms=0.0, got {result:?}"
604        );
605    }
606
607    #[test]
608    fn builder_agate_with_negative_attack_should_return_invalid_config() {
609        let result = FilterGraph::builder().agate(-40.0, -1.0, 100.0).build();
610        assert!(
611            matches!(result, Err(FilterError::InvalidConfig { .. })),
612            "expected InvalidConfig for attack_ms=-1.0, got {result:?}"
613        );
614    }
615
616    #[test]
617    fn builder_agate_with_zero_release_should_return_invalid_config() {
618        let result = FilterGraph::builder().agate(-40.0, 10.0, 0.0).build();
619        assert!(
620            matches!(result, Err(FilterError::InvalidConfig { .. })),
621            "expected InvalidConfig for release_ms=0.0, got {result:?}"
622        );
623    }
624
625    #[test]
626    fn builder_agate_with_negative_release_should_return_invalid_config() {
627        let result = FilterGraph::builder().agate(-40.0, 10.0, -50.0).build();
628        assert!(
629            matches!(result, Err(FilterError::InvalidConfig { .. })),
630            "expected InvalidConfig for release_ms=-50.0, got {result:?}"
631        );
632    }
633
634    #[test]
635    fn filter_step_compressor_should_have_correct_filter_name() {
636        let step = FilterStep::ACompressor {
637            threshold_db: -20.0,
638            ratio: 4.0,
639            attack_ms: 10.0,
640            release_ms: 100.0,
641            makeup_db: 6.0,
642        };
643        assert_eq!(step.filter_name(), "acompressor");
644    }
645
646    #[test]
647    fn filter_step_compressor_should_produce_correct_args() {
648        let step = FilterStep::ACompressor {
649            threshold_db: -20.0,
650            ratio: 4.0,
651            attack_ms: 10.0,
652            release_ms: 100.0,
653            makeup_db: 6.0,
654        };
655        assert_eq!(
656            step.args(),
657            "threshold=-20dB:ratio=4:attack=10:release=100:makeup=6dB"
658        );
659    }
660
661    #[test]
662    fn builder_compressor_valid_should_build_successfully() {
663        let result = FilterGraph::builder()
664            .compressor(-20.0, 4.0, 10.0, 100.0, 6.0)
665            .build();
666        assert!(
667            result.is_ok(),
668            "compressor(-20.0, 4.0, 10.0, 100.0, 6.0) must build successfully, got {result:?}"
669        );
670    }
671
672    #[test]
673    fn builder_compressor_with_unity_ratio_should_build_successfully() {
674        // ratio=1.0 is the minimum valid value (no compression)
675        let result = FilterGraph::builder()
676            .compressor(-20.0, 1.0, 10.0, 100.0, 0.0)
677            .build();
678        assert!(
679            result.is_ok(),
680            "compressor with ratio=1.0 must build successfully, got {result:?}"
681        );
682    }
683
684    #[test]
685    fn builder_compressor_with_ratio_below_one_should_return_invalid_config() {
686        let result = FilterGraph::builder()
687            .compressor(-20.0, 0.5, 10.0, 100.0, 0.0)
688            .build();
689        assert!(
690            matches!(result, Err(FilterError::InvalidConfig { .. })),
691            "expected InvalidConfig for ratio=0.5, got {result:?}"
692        );
693    }
694
695    #[test]
696    fn builder_compressor_with_zero_attack_should_return_invalid_config() {
697        let result = FilterGraph::builder()
698            .compressor(-20.0, 4.0, 0.0, 100.0, 0.0)
699            .build();
700        assert!(
701            matches!(result, Err(FilterError::InvalidConfig { .. })),
702            "expected InvalidConfig for attack_ms=0.0, got {result:?}"
703        );
704    }
705
706    #[test]
707    fn builder_compressor_with_zero_release_should_return_invalid_config() {
708        let result = FilterGraph::builder()
709            .compressor(-20.0, 4.0, 10.0, 0.0, 0.0)
710            .build();
711        assert!(
712            matches!(result, Err(FilterError::InvalidConfig { .. })),
713            "expected InvalidConfig for release_ms=0.0, got {result:?}"
714        );
715    }
716
717    #[test]
718    fn filter_step_stereo_to_mono_should_have_correct_filter_name() {
719        assert_eq!(FilterStep::StereoToMono.filter_name(), "pan");
720    }
721
722    #[test]
723    fn filter_step_stereo_to_mono_should_produce_correct_args() {
724        assert_eq!(FilterStep::StereoToMono.args(), "mono|c0=0.5*c0+0.5*c1");
725    }
726
727    #[test]
728    fn builder_stereo_to_mono_should_build_successfully() {
729        let result = FilterGraph::builder().stereo_to_mono().build();
730        assert!(
731            result.is_ok(),
732            "stereo_to_mono() must build successfully, got {result:?}"
733        );
734    }
735
736    #[test]
737    fn filter_step_channel_map_should_have_correct_filter_name() {
738        let step = FilterStep::ChannelMap {
739            mapping: "FR|FL".to_string(),
740        };
741        assert_eq!(step.filter_name(), "channelmap");
742    }
743
744    #[test]
745    fn filter_step_channel_map_should_produce_correct_args() {
746        let step = FilterStep::ChannelMap {
747            mapping: "FR|FL".to_string(),
748        };
749        assert_eq!(step.args(), "map=FR|FL");
750    }
751
752    #[test]
753    fn builder_channel_map_valid_should_build_successfully() {
754        let result = FilterGraph::builder().channel_map("FR|FL").build();
755        assert!(
756            result.is_ok(),
757            "channel_map(\"FR|FL\") must build successfully, got {result:?}"
758        );
759    }
760
761    #[test]
762    fn builder_channel_map_with_empty_mapping_should_return_invalid_config() {
763        let result = FilterGraph::builder().channel_map("").build();
764        assert!(
765            matches!(result, Err(FilterError::InvalidConfig { .. })),
766            "expected InvalidConfig for empty mapping, got {result:?}"
767        );
768    }
769
770    #[test]
771    fn filter_step_audio_delay_positive_should_have_correct_filter_name() {
772        let step = FilterStep::AudioDelay { ms: 100.0 };
773        assert_eq!(step.filter_name(), "adelay");
774    }
775
776    #[test]
777    fn filter_step_audio_delay_negative_should_have_correct_filter_name() {
778        // filter_name() always returns "adelay" (used for validation only);
779        // the build loop dispatches to "atrim" at runtime.
780        let step = FilterStep::AudioDelay { ms: -100.0 };
781        assert_eq!(step.filter_name(), "adelay");
782    }
783
784    #[test]
785    fn filter_step_audio_delay_positive_should_produce_adelay_args() {
786        let step = FilterStep::AudioDelay { ms: 100.0 };
787        assert_eq!(step.args(), "delays=100:all=1");
788    }
789
790    #[test]
791    fn filter_step_audio_delay_zero_should_produce_adelay_args() {
792        let step = FilterStep::AudioDelay { ms: 0.0 };
793        assert_eq!(step.args(), "delays=0:all=1");
794    }
795
796    #[test]
797    fn filter_step_audio_delay_negative_should_produce_atrim_args() {
798        let step = FilterStep::AudioDelay { ms: -100.0 };
799        // -(-100) / 1000 = 0.1 seconds
800        assert_eq!(step.args(), "start=0.1");
801    }
802
803    #[test]
804    fn builder_audio_delay_positive_should_build_successfully() {
805        let result = FilterGraph::builder().audio_delay(100.0).build();
806        assert!(
807            result.is_ok(),
808            "audio_delay(100.0) must build successfully, got {result:?}"
809        );
810    }
811
812    #[test]
813    fn builder_audio_delay_zero_should_build_successfully() {
814        let result = FilterGraph::builder().audio_delay(0.0).build();
815        assert!(
816            result.is_ok(),
817            "audio_delay(0.0) must build successfully, got {result:?}"
818        );
819    }
820
821    #[test]
822    fn builder_audio_delay_negative_should_build_successfully() {
823        let result = FilterGraph::builder().audio_delay(-100.0).build();
824        assert!(
825            result.is_ok(),
826            "audio_delay(-100.0) must build successfully, got {result:?}"
827        );
828    }
829
830    #[test]
831    fn filter_step_concat_audio_should_have_correct_filter_name() {
832        let step = FilterStep::ConcatAudio { n: 2 };
833        assert_eq!(step.filter_name(), "concat");
834    }
835
836    #[test]
837    fn filter_step_concat_audio_should_produce_correct_args_for_n2() {
838        let step = FilterStep::ConcatAudio { n: 2 };
839        assert_eq!(step.args(), "n=2:v=0:a=1");
840    }
841
842    #[test]
843    fn filter_step_concat_audio_should_produce_correct_args_for_n3() {
844        let step = FilterStep::ConcatAudio { n: 3 };
845        assert_eq!(step.args(), "n=3:v=0:a=1");
846    }
847
848    #[test]
849    fn builder_concat_audio_valid_should_build_successfully() {
850        let result = FilterGraph::builder().concat_audio(2).build();
851        assert!(
852            result.is_ok(),
853            "concat_audio(2) must build successfully, got {result:?}"
854        );
855    }
856
857    #[test]
858    fn builder_concat_audio_with_n1_should_return_invalid_config() {
859        let result = FilterGraph::builder().concat_audio(1).build();
860        assert!(
861            matches!(result, Err(FilterError::InvalidConfig { .. })),
862            "expected InvalidConfig for n=1, got {result:?}"
863        );
864    }
865
866    #[test]
867    fn builder_concat_audio_with_n0_should_return_invalid_config() {
868        let result = FilterGraph::builder().concat_audio(0).build();
869        assert!(
870            matches!(result, Err(FilterError::InvalidConfig { .. })),
871            "expected InvalidConfig for n=0, got {result:?}"
872        );
873    }
874}