1use std::path::Path;
4
5use crate::error::FilterError;
6use crate::graph::FilterGraph;
7use crate::graph::filter_step::FilterStep;
8
9#[derive(Debug, Clone, Copy, PartialEq)]
11pub enum NoiseType {
12 White,
14 Pink,
16 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 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 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 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 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 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 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 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 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}