audio_loudness_batch_normalize/
lib.rs

1#![allow(clippy::needless_range_loop)]
2
3/// Module for error handling
4pub mod error;
5/// Module for saving audio files
6pub 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/// Represents supported audio file formats
34#[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    /// Returns a list of supported file extensions
48    #[inline]
49    pub fn supported_extensions() -> &'static [&'static str] {
50        &["wav", "mp3", "flac", "ogg", "m4a", "aac", "opus"]
51    }
52
53    /// Creates an AudioFormats enum from a file path based on its extension
54    #[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/// Configuration options for audio normalization process
79#[derive(Debug, Clone)]
80pub struct NormalizationOptions {
81    /// Input directory containing audio files to process
82    pub input_dir: PathBuf,
83    /// Output directory for normalized audio files. If not set, override the
84    /// audio in input dir.
85    pub output_dir: Option<PathBuf>,
86    /// Percentage of files to sample for calculating target loudness (0.0 to
87    /// 1.0)
88    pub sample_percentage: f64,
89    /// Percentage of measurements to trim when calculating average loudness
90    /// (0.0 to 0.5)
91    pub trim_percentage: f64,
92    /// Target loudness in LUFS (Loudness Units Full Scale)
93    pub target_lufs: Option<f64>,
94    /// Target true peak in dBTP (decibels True Peak)
95    pub true_peak_db: f64,
96    /// Number of threads for parallel processing
97    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/// Represents an audio file to be processed
115#[derive(Debug)]
116struct AudioFile {
117    path: PathBuf,
118}
119
120/// Normalize the loudness of all audio files in a folder
121pub 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    // 1. Validate options
144    validate_options(options)?;
145
146    // 2. Discover audio files
147    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    // --- Target Loudness Calculation ---
156    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            // 3. Select sample files
163            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            // 4. Measure loudness of sampled files (in parallel)
184            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            // 5. Calculate target loudness (trimmed mean)
210            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    // --- Processing All Files ---
229    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    // 7. Report final status and errors
258    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}"); // Log the detailed error
265                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
282/// Main orchestration function for processing a single file using the two-pass
283/// method.
284pub 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    // --- MEASUREMENT ---
301
302    // 1. Measure current loudness (from cache or by reading the file)
303    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    // Handle silence or measurement errors
312    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    // 2. Calculate loudness gain
323    let loudness_gain_db = target_lufs - current_lufs;
324    let loudness_linear_gain = 10.0_f64.powf(loudness_gain_db / 20.0);
325
326    // 3. Measure the true peak level *after* applying the loudness gain (without
327    // storing audio)
328    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    // 4. Calculate peak limiting gain
335    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    // 5. Calculate the final, combined gain factor
344    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    // --- PROCESSING & SAVING ---
355
356    // Determine output path
357    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    // For non-WAV formats that will be converted, change the extension.
370    if !matches!(file_format, AudioFormats::Wav | AudioFormats::Ogg) {
371        output_path.set_extension("wav");
372    }
373
374    // Ensure parent directory exists
375    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 final gain, and save in a streaming manner
383    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
395/// Decodes an audio file, applies gain *in-memory* per chunk,
396/// and measures the resulting true peak without storing the entire file.
397///
398/// # Returns
399///
400/// A tuple containing:
401///
402/// * `SignalSpec` - The signal specification of the decoded audio file
403/// * `f64` - The true peak of the decoded audio file
404fn 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                    // Apply gain to a temporary buffer for measurement
450                    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                    // Feed EBU R128 for true peak measurement
456                    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    // Get true peak from EBU R128 state and find the maximum across all channels
475    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
484/// Decodes the audio file again, applies the final calculated gain, and saves
485/// the result in a streaming fashion to minimize memory usage.
486fn 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    // --- Set up the writer based on format ---
523    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        // All other formats are saved as WAV in a streaming fashion.
534        _ => {
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
548/// Measures the loudness of a single audio file using EBU R128 or RMS fallback
549pub 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; // 2^23 - 1
718                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                // 将有符号16位整数规范化到[-1.0, 1.0]范围
729                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                // 将无符号8位整数规范化到[-1.0, 1.0]范围
740                // u8范围是[0, 255],需要先转换到[-128, 127]
741                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
754/// Validates normalization options for correctness
755///
756/// # Arguments
757/// * `options` - Reference to NormalizationOptions struct
758fn 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
794/// Finds all supported audio files in the specified directory
795///
796/// # Arguments
797/// * `input_dir` - Directory to search for audio files
798///
799/// # Returns
800/// Vector of AudioFile structs representing found audio files
801fn find_audio_files(input_dir: impl AsRef<Path>) -> Result<Vec<AudioFile>, Error> {
802    let mut audio_files = Vec::new();
803    // Define supported extensions (lowercase)
804
805    for entry in WalkDir::new(input_dir)
806        .into_iter()
807        .filter_map(|e| e.ok()) // Filter out directory reading errors
808        .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}