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.
186    #[must_use]
187    pub fn amix(mut self, inputs: usize) -> Self {
188        self.steps.push(FilterStep::Amix(inputs));
189        self
190    }
191
192    /// Apply a multi-band parametric equalizer.
193    ///
194    /// Each [`EqBand`] maps to one `FFmpeg` filter node chained in sequence:
195    /// - [`EqBand::LowShelf`] → `lowshelf`
196    /// - [`EqBand::HighShelf`] → `highshelf`
197    /// - [`EqBand::Peak`] → `equalizer`
198    ///
199    /// [`build`](Self::build) returns [`FilterError::InvalidConfig`] if `bands`
200    /// is empty.
201    #[must_use]
202    pub fn equalizer(mut self, bands: Vec<EqBand>) -> Self {
203        self.steps.push(FilterStep::ParametricEq { bands });
204        self
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[test]
213    fn filter_step_volume_should_produce_correct_args() {
214        let step = FilterStep::Volume(-6.0);
215        assert_eq!(step.filter_name(), "volume");
216        assert_eq!(step.args(), "volume=-6dB");
217    }
218
219    #[test]
220    fn volume_should_convert_db_to_ffmpeg_string() {
221        assert_eq!(FilterStep::Volume(-6.0).args(), "volume=-6dB");
222        assert_eq!(FilterStep::Volume(6.0).args(), "volume=6dB");
223        assert_eq!(FilterStep::Volume(0.0).args(), "volume=0dB");
224    }
225
226    #[test]
227    fn filter_step_amix_should_produce_correct_args() {
228        let step = FilterStep::Amix(3);
229        assert_eq!(step.filter_name(), "amix");
230        assert_eq!(step.args(), "inputs=3");
231    }
232
233    #[test]
234    fn filter_step_parametric_eq_should_have_filter_name_equalizer() {
235        let step = FilterStep::ParametricEq {
236            bands: vec![EqBand::Peak {
237                freq_hz: 1000.0,
238                gain_db: 3.0,
239                q: 1.0,
240            }],
241        };
242        assert_eq!(step.filter_name(), "equalizer");
243    }
244
245    #[test]
246    fn eq_band_peak_should_produce_correct_args() {
247        let band = EqBand::Peak {
248            freq_hz: 1000.0,
249            gain_db: 3.0,
250            q: 1.0,
251        };
252        assert_eq!(band.args(), "f=1000:g=3:width_type=q:width=1");
253    }
254
255    #[test]
256    fn eq_band_low_shelf_should_produce_correct_args() {
257        let band = EqBand::LowShelf {
258            freq_hz: 200.0,
259            gain_db: -3.0,
260            slope: 1.0,
261        };
262        assert_eq!(band.args(), "f=200:g=-3:s=1");
263    }
264
265    #[test]
266    fn eq_band_high_shelf_should_produce_correct_args() {
267        let band = EqBand::HighShelf {
268            freq_hz: 8000.0,
269            gain_db: 2.0,
270            slope: 0.5,
271        };
272        assert_eq!(band.args(), "f=8000:g=2:s=0.5");
273    }
274
275    #[test]
276    fn builder_equalizer_with_single_peak_band_should_succeed() {
277        let result = FilterGraph::builder()
278            .equalizer(vec![EqBand::Peak {
279                freq_hz: 1000.0,
280                gain_db: 3.0,
281                q: 1.0,
282            }])
283            .build();
284        assert!(
285            result.is_ok(),
286            "equalizer with single Peak band must build successfully, got {result:?}"
287        );
288    }
289
290    #[test]
291    fn builder_equalizer_with_multiple_bands_should_succeed() {
292        let result = FilterGraph::builder()
293            .equalizer(vec![
294                EqBand::LowShelf {
295                    freq_hz: 200.0,
296                    gain_db: -2.0,
297                    slope: 1.0,
298                },
299                EqBand::Peak {
300                    freq_hz: 1000.0,
301                    gain_db: 3.0,
302                    q: 1.4,
303                },
304                EqBand::HighShelf {
305                    freq_hz: 8000.0,
306                    gain_db: 1.0,
307                    slope: 0.5,
308                },
309            ])
310            .build();
311        assert!(
312            result.is_ok(),
313            "equalizer with three bands must build successfully, got {result:?}"
314        );
315    }
316
317    #[test]
318    fn builder_equalizer_with_empty_bands_should_return_invalid_config() {
319        let result = FilterGraph::builder().equalizer(vec![]).build();
320        assert!(
321            matches!(result, Err(FilterError::InvalidConfig { .. })),
322            "expected InvalidConfig for empty bands, got {result:?}"
323        );
324    }
325
326    #[test]
327    fn filter_step_afade_in_should_have_correct_filter_name() {
328        let step = FilterStep::AFadeIn {
329            start: 0.0,
330            duration: 1.0,
331        };
332        assert_eq!(step.filter_name(), "afade");
333    }
334
335    #[test]
336    fn filter_step_afade_out_should_have_correct_filter_name() {
337        let step = FilterStep::AFadeOut {
338            start: 4.0,
339            duration: 1.0,
340        };
341        assert_eq!(step.filter_name(), "afade");
342    }
343
344    #[test]
345    fn filter_step_afade_in_should_produce_correct_args() {
346        let step = FilterStep::AFadeIn {
347            start: 0.0,
348            duration: 1.0,
349        };
350        assert_eq!(step.args(), "type=in:start_time=0:duration=1");
351    }
352
353    #[test]
354    fn filter_step_afade_out_should_produce_correct_args() {
355        let step = FilterStep::AFadeOut {
356            start: 4.0,
357            duration: 1.0,
358        };
359        assert_eq!(step.args(), "type=out:start_time=4:duration=1");
360    }
361
362    #[test]
363    fn builder_afade_in_with_valid_params_should_succeed() {
364        let result = FilterGraph::builder().afade_in(0.0, 1.0).build();
365        assert!(
366            result.is_ok(),
367            "afade_in(0.0, 1.0) must build successfully, got {result:?}"
368        );
369    }
370
371    #[test]
372    fn builder_afade_out_with_valid_params_should_succeed() {
373        let result = FilterGraph::builder().afade_out(4.0, 1.0).build();
374        assert!(
375            result.is_ok(),
376            "afade_out(4.0, 1.0) must build successfully, got {result:?}"
377        );
378    }
379
380    #[test]
381    fn builder_afade_in_with_zero_duration_should_return_invalid_config() {
382        let result = FilterGraph::builder().afade_in(0.0, 0.0).build();
383        assert!(
384            matches!(result, Err(FilterError::InvalidConfig { .. })),
385            "expected InvalidConfig for duration=0.0, got {result:?}"
386        );
387    }
388
389    #[test]
390    fn builder_afade_out_with_negative_duration_should_return_invalid_config() {
391        let result = FilterGraph::builder().afade_out(4.0, -1.0).build();
392        assert!(
393            matches!(result, Err(FilterError::InvalidConfig { .. })),
394            "expected InvalidConfig for duration=-1.0, got {result:?}"
395        );
396    }
397
398    #[test]
399    fn filter_step_areverse_should_produce_correct_filter_name_and_empty_args() {
400        let step = FilterStep::AReverse;
401        assert_eq!(step.filter_name(), "areverse");
402        assert_eq!(step.args(), "");
403    }
404
405    #[test]
406    fn builder_areverse_should_succeed() {
407        let result = FilterGraph::builder().areverse().build();
408        assert!(
409            result.is_ok(),
410            "areverse must build successfully, got {result:?}"
411        );
412    }
413
414    #[test]
415    fn filter_step_loudness_normalize_should_produce_correct_filter_name() {
416        let step = FilterStep::LoudnessNormalize {
417            target_lufs: -23.0,
418            true_peak_db: -1.0,
419            lra: 7.0,
420        };
421        assert_eq!(step.filter_name(), "ebur128");
422    }
423
424    #[test]
425    fn filter_step_loudness_normalize_should_produce_correct_args() {
426        let step = FilterStep::LoudnessNormalize {
427            target_lufs: -23.0,
428            true_peak_db: -1.0,
429            lra: 7.0,
430        };
431        assert_eq!(step.args(), "peak=true:metadata=1");
432    }
433
434    #[test]
435    fn builder_loudness_normalize_with_valid_params_should_succeed() {
436        let result = FilterGraph::builder()
437            .loudness_normalize(-23.0, -1.0, 7.0)
438            .build();
439        assert!(
440            result.is_ok(),
441            "loudness_normalize(-23.0, -1.0, 7.0) must build successfully, got {result:?}"
442        );
443    }
444
445    #[test]
446    fn builder_loudness_normalize_with_zero_target_lufs_should_return_invalid_config() {
447        let result = FilterGraph::builder()
448            .loudness_normalize(0.0, -1.0, 7.0)
449            .build();
450        assert!(
451            matches!(result, Err(FilterError::InvalidConfig { .. })),
452            "expected InvalidConfig for target_lufs=0.0, got {result:?}"
453        );
454    }
455
456    #[test]
457    fn builder_loudness_normalize_with_positive_target_lufs_should_return_invalid_config() {
458        let result = FilterGraph::builder()
459            .loudness_normalize(5.0, -1.0, 7.0)
460            .build();
461        assert!(
462            matches!(result, Err(FilterError::InvalidConfig { .. })),
463            "expected InvalidConfig for target_lufs=5.0, got {result:?}"
464        );
465    }
466
467    #[test]
468    fn builder_loudness_normalize_with_positive_true_peak_should_return_invalid_config() {
469        let result = FilterGraph::builder()
470            .loudness_normalize(-23.0, 1.0, 7.0)
471            .build();
472        assert!(
473            matches!(result, Err(FilterError::InvalidConfig { .. })),
474            "expected InvalidConfig for true_peak_db=1.0, got {result:?}"
475        );
476    }
477
478    #[test]
479    fn builder_loudness_normalize_with_zero_lra_should_return_invalid_config() {
480        let result = FilterGraph::builder()
481            .loudness_normalize(-23.0, -1.0, 0.0)
482            .build();
483        assert!(
484            matches!(result, Err(FilterError::InvalidConfig { .. })),
485            "expected InvalidConfig for lra=0.0, got {result:?}"
486        );
487    }
488
489    #[test]
490    fn builder_loudness_normalize_with_negative_lra_should_return_invalid_config() {
491        let result = FilterGraph::builder()
492            .loudness_normalize(-23.0, -1.0, -7.0)
493            .build();
494        assert!(
495            matches!(result, Err(FilterError::InvalidConfig { .. })),
496            "expected InvalidConfig for lra=-7.0, got {result:?}"
497        );
498    }
499
500    #[test]
501    fn filter_step_normalize_peak_should_have_correct_filter_name() {
502        let step = FilterStep::NormalizePeak { target_db: -1.0 };
503        assert_eq!(step.filter_name(), "astats");
504    }
505
506    #[test]
507    fn filter_step_normalize_peak_should_have_correct_args() {
508        let step = FilterStep::NormalizePeak { target_db: -1.0 };
509        assert_eq!(step.args(), "metadata=1");
510    }
511
512    #[test]
513    fn builder_normalize_peak_valid_should_build_successfully() {
514        let result = FilterGraph::builder().normalize_peak(-1.0).build();
515        assert!(
516            result.is_ok(),
517            "normalize_peak(-1.0) must build successfully, got {result:?}"
518        );
519    }
520
521    #[test]
522    fn builder_normalize_peak_with_zero_target_db_should_build_successfully() {
523        // 0.0 dBFS is the maximum allowed value (digital full scale).
524        let result = FilterGraph::builder().normalize_peak(0.0).build();
525        assert!(
526            result.is_ok(),
527            "normalize_peak(0.0) must build successfully, got {result:?}"
528        );
529    }
530
531    #[test]
532    fn builder_normalize_peak_with_positive_target_db_should_return_invalid_config() {
533        let result = FilterGraph::builder().normalize_peak(1.0).build();
534        assert!(
535            matches!(result, Err(FilterError::InvalidConfig { .. })),
536            "expected InvalidConfig for target_db=1.0, got {result:?}"
537        );
538    }
539
540    #[test]
541    fn filter_step_agate_should_have_correct_filter_name() {
542        let step = FilterStep::ANoiseGate {
543            threshold_db: -40.0,
544            attack_ms: 10.0,
545            release_ms: 100.0,
546        };
547        assert_eq!(step.filter_name(), "agate");
548    }
549
550    #[test]
551    fn filter_step_agate_should_produce_correct_args_for_minus_40_db() {
552        let step = FilterStep::ANoiseGate {
553            threshold_db: -40.0,
554            attack_ms: 10.0,
555            release_ms: 100.0,
556        };
557        // 10^(-40/20) = 10^(-2) = 0.01
558        let args = step.args();
559        assert!(
560            args.starts_with("threshold=0.010000:"),
561            "expected args to start with threshold=0.010000:, got {args}"
562        );
563        assert!(
564            args.contains("attack=10:"),
565            "expected attack=10: in args, got {args}"
566        );
567        assert!(
568            args.contains("release=100"),
569            "expected release=100 in args, got {args}"
570        );
571    }
572
573    #[test]
574    fn filter_step_agate_should_produce_correct_args_for_zero_db() {
575        let step = FilterStep::ANoiseGate {
576            threshold_db: 0.0,
577            attack_ms: 5.0,
578            release_ms: 50.0,
579        };
580        // 10^(0/20) = 1.0
581        let args = step.args();
582        assert!(
583            args.starts_with("threshold=1.000000:"),
584            "expected threshold=1.000000: in args, got {args}"
585        );
586    }
587
588    #[test]
589    fn builder_agate_valid_should_build_successfully() {
590        let result = FilterGraph::builder().agate(-40.0, 10.0, 100.0).build();
591        assert!(
592            result.is_ok(),
593            "agate(-40.0, 10.0, 100.0) must build successfully, got {result:?}"
594        );
595    }
596
597    #[test]
598    fn builder_agate_with_zero_attack_should_return_invalid_config() {
599        let result = FilterGraph::builder().agate(-40.0, 0.0, 100.0).build();
600        assert!(
601            matches!(result, Err(FilterError::InvalidConfig { .. })),
602            "expected InvalidConfig for attack_ms=0.0, got {result:?}"
603        );
604    }
605
606    #[test]
607    fn builder_agate_with_negative_attack_should_return_invalid_config() {
608        let result = FilterGraph::builder().agate(-40.0, -1.0, 100.0).build();
609        assert!(
610            matches!(result, Err(FilterError::InvalidConfig { .. })),
611            "expected InvalidConfig for attack_ms=-1.0, got {result:?}"
612        );
613    }
614
615    #[test]
616    fn builder_agate_with_zero_release_should_return_invalid_config() {
617        let result = FilterGraph::builder().agate(-40.0, 10.0, 0.0).build();
618        assert!(
619            matches!(result, Err(FilterError::InvalidConfig { .. })),
620            "expected InvalidConfig for release_ms=0.0, got {result:?}"
621        );
622    }
623
624    #[test]
625    fn builder_agate_with_negative_release_should_return_invalid_config() {
626        let result = FilterGraph::builder().agate(-40.0, 10.0, -50.0).build();
627        assert!(
628            matches!(result, Err(FilterError::InvalidConfig { .. })),
629            "expected InvalidConfig for release_ms=-50.0, got {result:?}"
630        );
631    }
632
633    #[test]
634    fn filter_step_compressor_should_have_correct_filter_name() {
635        let step = FilterStep::ACompressor {
636            threshold_db: -20.0,
637            ratio: 4.0,
638            attack_ms: 10.0,
639            release_ms: 100.0,
640            makeup_db: 6.0,
641        };
642        assert_eq!(step.filter_name(), "acompressor");
643    }
644
645    #[test]
646    fn filter_step_compressor_should_produce_correct_args() {
647        let step = FilterStep::ACompressor {
648            threshold_db: -20.0,
649            ratio: 4.0,
650            attack_ms: 10.0,
651            release_ms: 100.0,
652            makeup_db: 6.0,
653        };
654        assert_eq!(
655            step.args(),
656            "threshold=-20dB:ratio=4:attack=10:release=100:makeup=6dB"
657        );
658    }
659
660    #[test]
661    fn builder_compressor_valid_should_build_successfully() {
662        let result = FilterGraph::builder()
663            .compressor(-20.0, 4.0, 10.0, 100.0, 6.0)
664            .build();
665        assert!(
666            result.is_ok(),
667            "compressor(-20.0, 4.0, 10.0, 100.0, 6.0) must build successfully, got {result:?}"
668        );
669    }
670
671    #[test]
672    fn builder_compressor_with_unity_ratio_should_build_successfully() {
673        // ratio=1.0 is the minimum valid value (no compression)
674        let result = FilterGraph::builder()
675            .compressor(-20.0, 1.0, 10.0, 100.0, 0.0)
676            .build();
677        assert!(
678            result.is_ok(),
679            "compressor with ratio=1.0 must build successfully, got {result:?}"
680        );
681    }
682
683    #[test]
684    fn builder_compressor_with_ratio_below_one_should_return_invalid_config() {
685        let result = FilterGraph::builder()
686            .compressor(-20.0, 0.5, 10.0, 100.0, 0.0)
687            .build();
688        assert!(
689            matches!(result, Err(FilterError::InvalidConfig { .. })),
690            "expected InvalidConfig for ratio=0.5, got {result:?}"
691        );
692    }
693
694    #[test]
695    fn builder_compressor_with_zero_attack_should_return_invalid_config() {
696        let result = FilterGraph::builder()
697            .compressor(-20.0, 4.0, 0.0, 100.0, 0.0)
698            .build();
699        assert!(
700            matches!(result, Err(FilterError::InvalidConfig { .. })),
701            "expected InvalidConfig for attack_ms=0.0, got {result:?}"
702        );
703    }
704
705    #[test]
706    fn builder_compressor_with_zero_release_should_return_invalid_config() {
707        let result = FilterGraph::builder()
708            .compressor(-20.0, 4.0, 10.0, 0.0, 0.0)
709            .build();
710        assert!(
711            matches!(result, Err(FilterError::InvalidConfig { .. })),
712            "expected InvalidConfig for release_ms=0.0, got {result:?}"
713        );
714    }
715
716    #[test]
717    fn filter_step_stereo_to_mono_should_have_correct_filter_name() {
718        assert_eq!(FilterStep::StereoToMono.filter_name(), "pan");
719    }
720
721    #[test]
722    fn filter_step_stereo_to_mono_should_produce_correct_args() {
723        assert_eq!(FilterStep::StereoToMono.args(), "mono|c0=0.5*c0+0.5*c1");
724    }
725
726    #[test]
727    fn builder_stereo_to_mono_should_build_successfully() {
728        let result = FilterGraph::builder().stereo_to_mono().build();
729        assert!(
730            result.is_ok(),
731            "stereo_to_mono() must build successfully, got {result:?}"
732        );
733    }
734
735    #[test]
736    fn filter_step_channel_map_should_have_correct_filter_name() {
737        let step = FilterStep::ChannelMap {
738            mapping: "FR|FL".to_string(),
739        };
740        assert_eq!(step.filter_name(), "channelmap");
741    }
742
743    #[test]
744    fn filter_step_channel_map_should_produce_correct_args() {
745        let step = FilterStep::ChannelMap {
746            mapping: "FR|FL".to_string(),
747        };
748        assert_eq!(step.args(), "map=FR|FL");
749    }
750
751    #[test]
752    fn builder_channel_map_valid_should_build_successfully() {
753        let result = FilterGraph::builder().channel_map("FR|FL").build();
754        assert!(
755            result.is_ok(),
756            "channel_map(\"FR|FL\") must build successfully, got {result:?}"
757        );
758    }
759
760    #[test]
761    fn builder_channel_map_with_empty_mapping_should_return_invalid_config() {
762        let result = FilterGraph::builder().channel_map("").build();
763        assert!(
764            matches!(result, Err(FilterError::InvalidConfig { .. })),
765            "expected InvalidConfig for empty mapping, got {result:?}"
766        );
767    }
768
769    #[test]
770    fn filter_step_audio_delay_positive_should_have_correct_filter_name() {
771        let step = FilterStep::AudioDelay { ms: 100.0 };
772        assert_eq!(step.filter_name(), "adelay");
773    }
774
775    #[test]
776    fn filter_step_audio_delay_negative_should_have_correct_filter_name() {
777        // filter_name() always returns "adelay" (used for validation only);
778        // the build loop dispatches to "atrim" at runtime.
779        let step = FilterStep::AudioDelay { ms: -100.0 };
780        assert_eq!(step.filter_name(), "adelay");
781    }
782
783    #[test]
784    fn filter_step_audio_delay_positive_should_produce_adelay_args() {
785        let step = FilterStep::AudioDelay { ms: 100.0 };
786        assert_eq!(step.args(), "delays=100:all=1");
787    }
788
789    #[test]
790    fn filter_step_audio_delay_zero_should_produce_adelay_args() {
791        let step = FilterStep::AudioDelay { ms: 0.0 };
792        assert_eq!(step.args(), "delays=0:all=1");
793    }
794
795    #[test]
796    fn filter_step_audio_delay_negative_should_produce_atrim_args() {
797        let step = FilterStep::AudioDelay { ms: -100.0 };
798        // -(-100) / 1000 = 0.1 seconds
799        assert_eq!(step.args(), "start=0.1");
800    }
801
802    #[test]
803    fn builder_audio_delay_positive_should_build_successfully() {
804        let result = FilterGraph::builder().audio_delay(100.0).build();
805        assert!(
806            result.is_ok(),
807            "audio_delay(100.0) must build successfully, got {result:?}"
808        );
809    }
810
811    #[test]
812    fn builder_audio_delay_zero_should_build_successfully() {
813        let result = FilterGraph::builder().audio_delay(0.0).build();
814        assert!(
815            result.is_ok(),
816            "audio_delay(0.0) must build successfully, got {result:?}"
817        );
818    }
819
820    #[test]
821    fn builder_audio_delay_negative_should_build_successfully() {
822        let result = FilterGraph::builder().audio_delay(-100.0).build();
823        assert!(
824            result.is_ok(),
825            "audio_delay(-100.0) must build successfully, got {result:?}"
826        );
827    }
828
829    #[test]
830    fn filter_step_concat_audio_should_have_correct_filter_name() {
831        let step = FilterStep::ConcatAudio { n: 2 };
832        assert_eq!(step.filter_name(), "concat");
833    }
834
835    #[test]
836    fn filter_step_concat_audio_should_produce_correct_args_for_n2() {
837        let step = FilterStep::ConcatAudio { n: 2 };
838        assert_eq!(step.args(), "n=2:v=0:a=1");
839    }
840
841    #[test]
842    fn filter_step_concat_audio_should_produce_correct_args_for_n3() {
843        let step = FilterStep::ConcatAudio { n: 3 };
844        assert_eq!(step.args(), "n=3:v=0:a=1");
845    }
846
847    #[test]
848    fn builder_concat_audio_valid_should_build_successfully() {
849        let result = FilterGraph::builder().concat_audio(2).build();
850        assert!(
851            result.is_ok(),
852            "concat_audio(2) must build successfully, got {result:?}"
853        );
854    }
855
856    #[test]
857    fn builder_concat_audio_with_n1_should_return_invalid_config() {
858        let result = FilterGraph::builder().concat_audio(1).build();
859        assert!(
860            matches!(result, Err(FilterError::InvalidConfig { .. })),
861            "expected InvalidConfig for n=1, got {result:?}"
862        );
863    }
864
865    #[test]
866    fn builder_concat_audio_with_n0_should_return_invalid_config() {
867        let result = FilterGraph::builder().concat_audio(0).build();
868        assert!(
869            matches!(result, Err(FilterError::InvalidConfig { .. })),
870            "expected InvalidConfig for n=0, got {result:?}"
871        );
872    }
873}