1#![allow(clippy::needless_range_loop)]
2
3pub mod error;
5pub mod save;
7
8use std::{
9 collections::HashMap,
10 fs,
11 path::{Path, PathBuf},
12};
13
14use ebur128::{EbuR128, Mode};
15use indicatif::{ParallelProgressIterator, ProgressBar, ProgressStyle};
16use log::{debug, error, info, warn};
17use rand::seq::IndexedRandom as _;
18use rayon::prelude::*;
19use strum_macros::Display;
20use symphonia::core::{
21 audio::{AudioBufferRef, SignalSpec},
22 errors::Error as SymphoniaError,
23 io::MediaSourceStream,
24 probe::Hint,
25};
26use walkdir::WalkDir;
27
28use crate::{
29 error::{Error, MeasurementError, ProcessingError},
30 save::{stream_to_ogg_writer, stream_to_wav_writer},
31};
32
33#[derive(Debug, PartialEq, Display, Clone)]
35#[strum(serialize_all = "camelCase")]
36pub enum AudioFormats {
37 Wav,
38 Mp3,
39 Flac,
40 Ogg,
41 M4a,
42 Aac,
43 Opus,
44}
45
46impl AudioFormats {
47 #[inline]
49 pub fn supported_extensions() -> &'static [&'static str] {
50 &["wav", "mp3", "flac", "ogg", "m4a", "aac", "opus"]
51 }
52
53 #[inline]
55 pub fn from_path(value: impl AsRef<Path>) -> Option<Self> {
56 Some(
57 match value
58 .as_ref()
59 .extension()
60 .unwrap_or_default()
61 .to_string_lossy()
62 .to_lowercase()
63 .as_ref()
64 {
65 "wav" => Self::Wav,
66 "mp3" => Self::Mp3,
67 "flac" => Self::Flac,
68 "ogg" => Self::Ogg,
69 "m4a" => Self::M4a,
70 "aac" => Self::Aac,
71 "opus" => Self::Opus,
72 _ => return None,
73 },
74 )
75 }
76}
77
78#[derive(Debug, Clone)]
80pub struct NormalizationOptions {
81 pub input_dir: PathBuf,
83 pub output_dir: Option<PathBuf>,
86 pub sample_percentage: f64,
89 pub trim_percentage: f64,
92 pub target_lufs: Option<f64>,
94 pub true_peak_db: f64,
96 pub num_threads: Option<usize>,
98}
99
100impl Default for NormalizationOptions {
101 fn default() -> Self {
102 NormalizationOptions {
103 input_dir: PathBuf::from("."),
104 output_dir: None,
105 sample_percentage: 0.30,
106 trim_percentage: 0.30,
107 target_lufs: None,
108 true_peak_db: -1.5,
109 num_threads: None,
110 }
111 }
112}
113
114#[derive(Debug)]
116struct AudioFile {
117 path: PathBuf,
118}
119
120pub fn normalize_folder_loudness(options: &NormalizationOptions) -> Result<(), Error> {
122 if let Some(num_threads) = options.num_threads {
123 if num_threads > 0 {
124 let rayon_init_result = rayon::ThreadPoolBuilder::new()
125 .num_threads(num_threads)
126 .build_global();
127 if let Err(e) = rayon_init_result {
128 warn!(
129 "Failed to configure Rayon thread pool: {e}. Using default number of threads."
130 );
131 } else {
132 info!("Using {num_threads} threads for processing.");
133 }
134 } else {
135 info!("Using default number of threads.");
136 }
137 } else {
138 info!("Using default number of threads.");
139 }
140
141 let mut loudness_measurements_cache = None;
142
143 validate_options(options)?;
145
146 info!("Discovering audio files in {:?}...", options.input_dir);
148 let all_audio_files = find_audio_files(&options.input_dir)?;
149 if all_audio_files.is_empty() {
150 info!("No audio files found.");
151 return Ok(());
152 }
153 info!("Found {} audio files.", all_audio_files.len());
154
155 let target_lufs = match options.target_lufs {
157 Some(t) => {
158 info!("Using user-provided Target Loudness: {t:.2} LUFS");
159 t
160 }
161 None => {
162 let sample_size =
164 (all_audio_files.len() as f64 * options.sample_percentage).ceil() as usize;
165 if sample_size == 0 {
166 return Err(Error::InvalidOptions(
167 "Sample size is 0, please check your sample percentage.".to_string(),
168 ));
169 }
170 let sampled_files: Vec<&AudioFile> = if sample_size >= all_audio_files.len() {
171 all_audio_files.iter().collect()
172 } else {
173 let mut rng = rand::rng();
174 all_audio_files
175 .choose_multiple(&mut rng, sample_size)
176 .collect()
177 };
178 info!(
179 "Selected {} files for target loudness calculation.",
180 sampled_files.len()
181 );
182
183 info!("Measuring loudness of sampled files...");
185 let sample_pb = ProgressBar::new(sampled_files.len() as u64);
186 sample_pb.set_style(ProgressStyle::default_bar()
187 .template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} ({eta}) {msg}").expect("Internal Error: Failed to set progress bar style")
188 .progress_chars("#>-"));
189 sample_pb.set_message("Measuring samples");
190
191 let loudness_measurements: HashMap<PathBuf, Result<f64, MeasurementError>> =
192 sampled_files
193 .par_iter()
194 .progress_with(sample_pb.clone())
195 .map(|audio_file| {
196 let loudness_result = measure_single_file_loudness(&audio_file.path);
197 if let Err(e) = &loudness_result {
198 warn!(
199 "Failed to measure loudness for sample {:?}: {}",
200 audio_file.path.file_name().unwrap_or_default(),
201 e
202 );
203 }
204 (audio_file.path.clone(), loudness_result)
205 })
206 .collect();
207 sample_pb.finish_with_message("Sample measurement done");
208
209 let calculated_target =
211 calculate_target_loudness(&loudness_measurements, options.trim_percentage)
212 .map_err(|e| Error::Processing {
213 path: options.input_dir.to_path_buf(),
214 source: e,
215 })?;
216 info!(
217 "Calculated Target Loudness ({}% trimmed mean): {:.2} LUFS",
218 options.trim_percentage * 100.0,
219 calculated_target
220 );
221
222 loudness_measurements_cache = Some(loudness_measurements);
223
224 calculated_target
225 }
226 };
227
228 info!(
230 "Processing all {} files to target {:.2} LUFS / {:.1} dBTP...",
231 all_audio_files.len(),
232 target_lufs,
233 options.true_peak_db
234 );
235 let process_pb = ProgressBar::new(all_audio_files.len() as u64);
236 process_pb.set_style(ProgressStyle::default_bar()
237 .template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} ({eta}) {msg}").expect("Internal Error: Failed to set progress bar style")
238 .progress_chars("#>-"));
239 process_pb.set_message("Processing files");
240
241 let results: Vec<Result<(), Error>> = all_audio_files
242 .par_iter()
243 .progress_with(process_pb.clone())
244 .map(|audio_file| {
245 process_single_file(
246 &audio_file.path,
247 target_lufs,
248 options.true_peak_db,
249 &options.input_dir,
250 &options.output_dir,
251 &loudness_measurements_cache,
252 )
253 })
254 .collect();
255 process_pb.finish_with_message("Processing done");
256
257 let mut success_count = 0;
259 let mut error_count = 0;
260 for result in results {
261 match result {
262 Ok(_) => success_count += 1,
263 Err(e) => {
264 error!("Error: {e}"); error_count += 1;
266 }
267 }
268 }
269
270 info!("Processing complete. {success_count} files succeeded, {error_count} files failed.");
271
272 if error_count > 0 {
273 Err(Error::Processing {
274 path: options.input_dir.to_path_buf(),
275 source: ProcessingError::FilesFailed(error_count),
276 })
277 } else {
278 Ok(())
279 }
280}
281
282pub fn process_single_file(
285 input_path: impl AsRef<Path>,
286 target_lufs: f64,
287 target_peak_db: f64,
288 input_base_dir: impl AsRef<Path>,
289 output_base_dir: &Option<impl AsRef<Path>>,
290 cache: &Option<HashMap<PathBuf, Result<f64, MeasurementError>>>,
291) -> Result<(), Error> {
292 let input_path = input_path.as_ref();
293 let file_format = AudioFormats::from_path(input_path).ok_or_else(|| Error::Processing {
294 path: input_path.to_path_buf(),
295 source: ProcessingError::UnsupportedFormat,
296 })?;
297 let file_name_str = input_path.file_name().unwrap_or_default().to_string_lossy();
298 debug!("Processing: {file_name_str}");
299
300 let current_lufs = match cache.as_ref().and_then(|x| x.get(input_path)) {
304 Some(Ok(lufs_result)) => *lufs_result,
305 _ => measure_single_file_loudness(input_path).map_err(|e| Error::Measurement {
306 path: input_path.to_path_buf(),
307 source: e,
308 })?,
309 };
310
311 if !current_lufs.is_finite() {
313 let reason = if current_lufs.is_infinite() && current_lufs.is_sign_negative() {
314 "silent file"
315 } else {
316 "non-finite loudness"
317 };
318 info!("Skipping processing for {reason}: {file_name_str}");
319 return Ok(());
320 }
321
322 let loudness_gain_db = target_lufs - current_lufs;
324 let loudness_linear_gain = 10.0_f64.powf(loudness_gain_db / 20.0);
325
326 let (spec, initial_peak_db) = measure_peak_after_gain(input_path, loudness_linear_gain)
329 .map_err(|e| Error::Processing {
330 path: input_path.to_path_buf(),
331 source: e,
332 })?;
333
334 let peak_headroom_db = target_peak_db - initial_peak_db;
336 let peak_limiting_gain_db = if peak_headroom_db < 0.0 {
337 peak_headroom_db
338 } else {
339 0.0
340 };
341 let peak_limiting_linear_gain = 10.0_f64.powf(peak_limiting_gain_db / 20.0);
342
343 let final_linear_gain = loudness_linear_gain * peak_limiting_linear_gain;
345
346 debug!(
347 " -> File: {file_name_str}, Current LUFS: {current_lufs:.2}, Target LUFS: {target_lufs:.2}, Loudness Gain: {loudness_gain_db:.2} dB"
348 );
349 debug!(
350 " -> Initial Peak: {initial_peak_db:.2} dBTP, Target Peak: {target_peak_db:.1} dBTP, Limiting Gain: {peak_limiting_gain_db:.2} dB"
351 );
352 debug!(" -> Final Combined Linear Gain: {final_linear_gain:.3}x");
353
354 let mut output_path = match output_base_dir.as_ref() {
358 Some(obd) => {
359 let relative_path =
360 pathdiff::diff_paths(input_path, input_base_dir).ok_or_else(|| Error::Io {
361 path: input_path.to_path_buf(),
362 source: std::io::Error::other("Failed to calculate relative path"),
363 })?;
364 obd.as_ref().join(relative_path)
365 }
366 None => input_path.to_path_buf(),
367 };
368
369 if !matches!(file_format, AudioFormats::Wav | AudioFormats::Ogg) {
371 output_path.set_extension("wav");
372 }
373
374 if let Some(parent) = output_path.parent() {
376 fs::create_dir_all(parent).map_err(|e| Error::Io {
377 path: parent.to_path_buf(),
378 source: e,
379 })?;
380 }
381
382 decode_apply_gain_and_save(
384 input_path,
385 &output_path,
386 &file_format,
387 spec,
388 final_linear_gain,
389 )?;
390
391 debug!("Successfully wrote normalized file to {output_path:?}");
392 Ok(())
393}
394
395fn measure_peak_after_gain(
405 path: impl AsRef<Path>,
406 linear_gain: f64,
407) -> Result<(SignalSpec, f64), ProcessingError> {
408 let path = path.as_ref();
409 let file = fs::File::open(path)?;
410 let mss = MediaSourceStream::new(Box::new(file), Default::default());
411 let probed = symphonia::default::get_probe().format(
412 &Hint::new(),
413 mss,
414 &Default::default(),
415 &Default::default(),
416 )?;
417
418 let mut format = probed.format;
419 let track = format
420 .tracks()
421 .iter()
422 .find(|t| t.codec_params.codec != symphonia::core::codecs::CODEC_TYPE_NULL)
423 .ok_or_else(|| ProcessingError::NoTrack(path.to_path_buf()))?;
424
425 let spec = SignalSpec::new(
426 track
427 .codec_params
428 .sample_rate
429 .ok_or(ProcessingError::MissingSampleRate)?,
430 track
431 .codec_params
432 .channels
433 .ok_or(ProcessingError::MissingChannelSpec)?,
434 );
435
436 let mut decoder =
437 symphonia::default::get_codecs().make(&track.codec_params, &Default::default())?;
438
439 let channels_count = spec.channels.count();
440 let mut ebu_state_true_peak = EbuR128::new(channels_count as u32, spec.rate, Mode::TRUE_PEAK)
441 .map_err(ProcessingError::EbuR128)?;
442
443 loop {
444 match format.next_packet() {
445 Ok(packet) => match decoder.decode(&packet) {
446 Ok(decoded) => {
447 let current_planar = convert_buffer_to_planar_f32(&decoded)?;
448
449 let gained_planar: Vec<Vec<f32>> = current_planar
451 .iter()
452 .map(|plane| plane.iter().map(|&s| s * linear_gain as f32).collect())
453 .collect();
454
455 let plane_slices: Vec<&[f32]> =
457 gained_planar.iter().map(|v| v.as_slice()).collect();
458 ebu_state_true_peak.add_frames_planar_f32(&plane_slices)?;
459 }
460 Err(SymphoniaError::DecodeError(e)) => {
461 warn!("Decode error during peak measurement: {e}")
462 }
463 Err(e) => return Err(ProcessingError::Symphonia(e)),
464 },
465 Err(SymphoniaError::IoError(ref e))
466 if e.kind() == std::io::ErrorKind::UnexpectedEof =>
467 {
468 break;
469 }
470 Err(e) => return Err(ProcessingError::Symphonia(e)),
471 }
472 }
473
474 let max_true_peak_db = (0..channels_count)
476 .map(|i| ebu_state_true_peak.true_peak(i as u32))
477 .collect::<Result<Vec<_>, _>>()?
478 .into_iter()
479 .fold(f64::NEG_INFINITY, f64::max);
480
481 Ok((spec, max_true_peak_db))
482}
483
484fn decode_apply_gain_and_save(
487 input_path: &Path,
488 output_path: &Path,
489 file_format: &AudioFormats,
490 spec: SignalSpec,
491 final_linear_gain: f64,
492) -> Result<(), Error> {
493 let file = fs::File::open(input_path).map_err(|e| Error::Io {
494 path: input_path.to_path_buf(),
495 source: e,
496 })?;
497 let mss = MediaSourceStream::new(Box::new(file), Default::default());
498 let mut format = symphonia::default::get_probe()
499 .format(&Hint::new(), mss, &Default::default(), &Default::default())
500 .map_err(|e| Error::Processing {
501 path: input_path.to_path_buf(),
502 source: e.into(),
503 })?
504 .format;
505
506 let track = format
507 .tracks()
508 .iter()
509 .find(|t| t.codec_params.codec != symphonia::core::codecs::CODEC_TYPE_NULL)
510 .ok_or_else(|| Error::Processing {
511 path: input_path.to_path_buf(),
512 source: ProcessingError::NoTrack(input_path.to_path_buf()),
513 })?;
514
515 let mut decoder = symphonia::default::get_codecs()
516 .make(&track.codec_params, &Default::default())
517 .map_err(|e| Error::Processing {
518 path: input_path.to_path_buf(),
519 source: e.into(),
520 })?;
521
522 match file_format {
524 AudioFormats::Ogg => {
525 stream_to_ogg_writer(
526 &mut *format,
527 &mut *decoder,
528 final_linear_gain,
529 output_path,
530 spec,
531 )?;
532 }
533 _ => {
535 stream_to_wav_writer(
536 &mut *format,
537 &mut *decoder,
538 final_linear_gain,
539 output_path,
540 spec,
541 )?;
542 }
543 }
544
545 Ok(())
546}
547
548pub fn measure_single_file_loudness(path: impl AsRef<Path>) -> Result<f64, MeasurementError> {
550 let path = path.as_ref();
551 let file = fs::File::open(path).map_err(MeasurementError::Io)?;
552 let mss = MediaSourceStream::new(Box::new(file), Default::default());
553 let probed = symphonia::default::get_probe()
554 .format(&Hint::new(), mss, &Default::default(), &Default::default())
555 .map_err(MeasurementError::Symphonia)?;
556
557 let mut format = probed.format;
558 let track = format
559 .tracks()
560 .iter()
561 .find(|t| t.codec_params.codec != symphonia::core::codecs::CODEC_TYPE_NULL)
562 .ok_or(MeasurementError::NoTrack)?;
563
564 let sample_rate = track
565 .codec_params
566 .sample_rate
567 .ok_or(MeasurementError::UnsupportedFormat)?;
568 let channels = track
569 .codec_params
570 .channels
571 .ok_or(MeasurementError::UnsupportedFormat)?;
572 let channel_count = channels.count();
573
574 let mut ebu_state = EbuR128::new(channel_count as u32, sample_rate, Mode::I)
575 .map_err(MeasurementError::EbuR128)?;
576 let mut decoder = symphonia::default::get_codecs()
577 .make(&track.codec_params, &Default::default())
578 .map_err(MeasurementError::Symphonia)?;
579
580 let mut rms_sum_of_squares: f64 = 0.0;
581 let mut rms_total_samples: u64 = 0;
582
583 loop {
584 match format.next_packet() {
585 Ok(packet) => match decoder.decode(&packet) {
586 Ok(decoded) => match convert_buffer_to_planar_f32(&decoded) {
587 Ok(planar_f32) => {
588 let plane_slices: Vec<&[f32]> =
589 planar_f32.iter().map(|v| v.as_slice()).collect();
590 if let Err(e) = ebu_state.add_frames_planar_f32(&plane_slices) {
591 warn!("EBU R128 add_frames failed: {e}. Skipping chunk.");
592 }
593 update_rms_accumulators(
594 &planar_f32,
595 &mut rms_sum_of_squares,
596 &mut rms_total_samples,
597 );
598 }
599 Err(e) => warn!("Buffer conversion failed: {e}. Skipping chunk."),
600 },
601 Err(SymphoniaError::DecodeError(e)) => warn!("Decode error: {e}. Skipping packet."),
602 Err(e) => return Err(MeasurementError::Symphonia(e)),
603 },
604 Err(SymphoniaError::IoError(ref e))
605 if e.kind() == std::io::ErrorKind::UnexpectedEof =>
606 {
607 break;
608 }
609 Err(e) => return Err(MeasurementError::Symphonia(e)),
610 }
611 }
612
613 match ebu_state.loudness_global() {
614 Ok(lufs) if lufs.is_finite() => Ok(lufs),
615 _ => {
616 debug!("EBU R128 failed or gave non-finite value. Falling back to RMS.");
617 Ok(calculate_rms_dbfs(rms_sum_of_squares, rms_total_samples))
618 }
619 }
620}
621
622fn update_rms_accumulators(
623 planar_f32: &[Vec<f32>],
624 sum_of_squares: &mut f64,
625 total_samples: &mut u64,
626) {
627 for channel_buffer in planar_f32 {
628 for sample in channel_buffer {
629 *sum_of_squares += (*sample as f64) * (*sample as f64);
630 }
631 }
632 if let Some(first_channel_buffer) = planar_f32.first() {
633 *total_samples += first_channel_buffer.len() as u64;
634 }
635}
636
637fn calculate_rms_dbfs(sum_of_squares: f64, total_samples: u64) -> f64 {
638 if total_samples == 0 || sum_of_squares <= 0.0 {
639 return f64::NEG_INFINITY;
640 }
641 let mean_square = sum_of_squares / total_samples as f64;
642 20.0 * mean_square.sqrt().log10()
643}
644
645fn calculate_target_loudness(
646 measurements: &HashMap<PathBuf, Result<f64, MeasurementError>>,
647 trim_percentage: f64,
648) -> Result<f64, ProcessingError> {
649 let mut valid_loudnesses: Vec<f64> = measurements
650 .values()
651 .filter_map(|r| r.as_ref().ok().filter(|l| l.is_finite()))
652 .copied()
653 .collect();
654
655 if valid_loudnesses.is_empty() {
656 return Err(ProcessingError::TargetLoudnessCalculationFailed(
657 "No valid finite loudness measurements available.".to_string(),
658 ));
659 }
660
661 valid_loudnesses.sort_by(|a, b| a.partial_cmp(b).unwrap());
662 let count = valid_loudnesses.len();
663 let trim_count = (count as f64 * trim_percentage).floor() as usize;
664
665 let trimmed_slice = if count > trim_count * 2 {
666 &valid_loudnesses[trim_count..count - trim_count]
667 } else {
668 warn!("Not enough samples to perform trimming. Using mean of all valid samples.");
669 &valid_loudnesses[..]
670 };
671
672 if trimmed_slice.is_empty() {
673 return Err(ProcessingError::TargetLoudnessCalculationFailed(
674 "Trimmed slice is empty.".to_string(),
675 ));
676 }
677
678 let trimmed_slice_len = trimmed_slice.len();
679 let mean: f64 = trimmed_slice.iter().sum::<f64>() / trimmed_slice_len as f64;
680 if !mean.is_finite() {
681 return Err(ProcessingError::TargetLoudnessCalculationFailed(format!(
682 "Calculated target loudness is not a finite number ({mean:.2})."
683 )));
684 }
685 Ok(mean)
686}
687
688fn convert_buffer_to_planar_f32(
689 decoded: &AudioBufferRef<'_>,
690) -> Result<Vec<Vec<f32>>, ProcessingError> {
691 let num_channels = decoded.spec().channels.count();
692 let mut planar_output: Vec<Vec<f32>> = Vec::with_capacity(num_channels);
693
694 match decoded {
695 AudioBufferRef::F32(buf) => {
696 for plane in buf.planes().planes() {
697 planar_output.push(plane.to_vec());
698 }
699 }
700 AudioBufferRef::F64(buf) => {
701 for plane in buf.planes().planes() {
702 planar_output.push(plane.iter().map(|&s| s as f32).collect());
703 }
704 }
705 AudioBufferRef::S32(buf) => {
706 for plane in buf.planes().planes() {
707 planar_output.push(
708 plane
709 .iter()
710 .map(|&s| (s as f32) / (i32::MAX as f32))
711 .collect(),
712 );
713 }
714 }
715 AudioBufferRef::S24(buf) => {
716 for plane in buf.planes().planes() {
717 let max_value = 8388607.0; planar_output.push(
719 plane
720 .iter()
721 .map(|&s| s.inner() as f32 / max_value)
722 .collect(),
723 );
724 }
725 }
726 AudioBufferRef::S16(buf) => {
727 for plane in buf.planes().planes() {
728 planar_output.push(
730 plane
731 .iter()
732 .map(|&s| (s as f32) / (i16::MAX as f32))
733 .collect(),
734 );
735 }
736 }
737 AudioBufferRef::U8(buf) => {
738 for plane in buf.planes().planes() {
739 planar_output.push(
742 plane
743 .iter()
744 .map(|&s| ((s as i16 - 128) as f32) / 128.0)
745 .collect(),
746 );
747 }
748 }
749 _ => return Err(ProcessingError::UnsupportedFormat),
750 }
751 Ok(planar_output)
752}
753
754fn validate_options(options: &NormalizationOptions) -> Result<(), Error> {
759 if !options.input_dir.is_dir() {
760 return Err(Error::InvalidOptions(format!(
761 "Input path is not a valid directory: {:?}",
762 options.input_dir
763 )));
764 }
765 if let Some(output_dir) = &options.output_dir {
766 if !output_dir.exists() {
767 fs::create_dir_all(output_dir).map_err(|e| Error::Io {
768 path: output_dir.to_path_buf(),
769 source: e,
770 })?;
771 info!("Created output directory: {output_dir:?}");
772 } else if !output_dir.is_dir() {
773 return Err(Error::InvalidOptions(format!(
774 "Output path exists but is not a directory: {output_dir:?}"
775 )));
776 }
777 }
778
779 if !(0.0..0.5).contains(&options.trim_percentage) {
780 return Err(Error::InvalidOptions(format!(
781 "Trim percentage must be between 0.0 and 0.5 (exclusive of 0.5): {}",
782 options.trim_percentage
783 )));
784 }
785 if options.true_peak_db > 0.0 {
786 warn!(
787 "Target true peak {:.1} dBTP is above 0 dBFS. This will likely cause clipping in standard formats.",
788 options.true_peak_db
789 );
790 }
791 Ok(())
792}
793
794fn find_audio_files(input_dir: impl AsRef<Path>) -> Result<Vec<AudioFile>, Error> {
802 let mut audio_files = Vec::new();
803 for entry in WalkDir::new(input_dir)
806 .into_iter()
807 .filter_map(|e| e.ok()) .filter(|e| e.file_type().is_file())
809 {
810 let path = entry.path();
811 if let Some(ext) = path
812 .extension()
813 .and_then(|os| os.to_str())
814 .map(|s| s.to_lowercase())
815 && AudioFormats::supported_extensions().contains(&ext.as_str())
816 {
817 audio_files.push(AudioFile {
818 path: path.to_path_buf(),
819 });
820 }
821 }
822 Ok(audio_files)
823}