Skip to main content

audio_batch_speedup/
lib.rs

1#![warn(clippy::cargo)]
2
3use bitflags::bitflags;
4use indicatif::{ParallelProgressIterator, ProgressBar, ProgressStyle};
5use rayon::prelude::*;
6use std::fs::File;
7use std::io::Read;
8use std::path::Path;
9use std::process::Command;
10use std::sync::atomic::{AtomicUsize, Ordering};
11use walkdir::WalkDir;
12
13bitflags! {
14    /// Represents the supported audio formats for processing.
15    #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
16    pub struct AudioFormat: u32 {
17        /// Ogg Vorbis format.
18        const OGG = 1 << 0;
19        /// MPEG Audio Layer III (MP3) format.
20        const MP3 = 1 << 1;
21        /// Waveform Audio File Format (WAV).
22        const WAV = 1 << 2;
23        /// Free Lossless Audio Codec (FLAC) format.
24        const FLAC = 1 << 3;
25        /// Advanced Audio Coding (AAC) format (often in MP4 containers).
26        const AAC = 1 << 4;
27        /// Opus Interactive Audio Codec (often in Ogg or WebM containers).
28        const OPUS = 1 << 5;
29        /// Apple Lossless Audio Codec (ALAC) format.
30        const ALAC = 1 << 6;
31        /// Windows Media Audio (WMA) format.
32        const WMA = 1 << 7;
33        /// All supported formats.
34        const ALL = Self::OGG.bits() | Self::MP3.bits() | Self::WAV.bits() | Self::FLAC.bits() | Self::AAC.bits() | Self::OPUS.bits() | Self::ALAC.bits() | Self::WMA.bits();
35    }
36}
37
38impl Default for AudioFormat {
39    fn default() -> Self {
40        Self::ALL
41    }
42}
43
44impl std::str::FromStr for AudioFormat {
45    type Err = String;
46
47    /// Parses a format name (case-insensitive). Accepts codec names, common
48    /// file extensions (`m4a`), and `all` for every supported format.
49    fn from_str(s: &str) -> Result<Self, Self::Err> {
50        match s.trim().to_lowercase().as_str() {
51            "ogg" => Ok(Self::OGG),
52            "mp3" => Ok(Self::MP3),
53            "wav" => Ok(Self::WAV),
54            "flac" => Ok(Self::FLAC),
55            "aac" | "m4a" => Ok(Self::AAC),
56            "opus" => Ok(Self::OPUS),
57            "alac" => Ok(Self::ALAC),
58            "wma" => Ok(Self::WMA),
59            "all" => Ok(Self::ALL),
60            other => Err(format!(
61                "Unsupported format: {other}. Supported formats are: \
62                 ogg, mp3, wav, flac, aac, opus, alac, wma, all."
63            )),
64        }
65    }
66}
67
68/// Detects the audio format of a file based on its magic bytes or file extension.
69///
70/// # Arguments
71///
72/// * `path` - The path to the audio file.
73///
74/// # Returns
75///
76/// * `Option<AudioFormat>` - The detected audio format, or `None` if it cannot be determined.
77fn detect_audio_format(path: &Path) -> Option<AudioFormat> {
78    // Try to detect by magic bytes first
79    let mut file = File::open(path).ok()?;
80    let mut buffer = [0; 12]; // Read enough bytes for common headers
81    file.read_exact(&mut buffer).ok()?;
82
83    // OGG (OggS)
84    if &buffer[0..4] == b"OggS" {
85        return Some(AudioFormat::OGG);
86    }
87    // MP3 (ID3 tag or MPEG frame sync: 11 sync bits set, i.e. 0xFF Ex/FA/FB)
88    if &buffer[0..3] == b"ID3" || (buffer[0] == 0xFF && (buffer[1] & 0xE0) == 0xE0) {
89        return Some(AudioFormat::MP3);
90    }
91    // WAV (RIFF header with WAVE)
92    if &buffer[0..4] == b"RIFF" && &buffer[8..12] == b"WAVE" {
93        return Some(AudioFormat::WAV);
94    }
95    // FLAC (fLaC)
96    if &buffer[0..4] == b"fLaC" {
97        return Some(AudioFormat::FLAC);
98    }
99    // AAC (often in MP4/M4A containers, which start with 'ftyp' or 'moov')
100    // This is harder to detect purely by magic bytes without parsing the container.
101    // We'll rely more on extension for AAC/M4A.
102    // OPUS (often in Ogg containers, so OggS will catch it, or WebM)
103    // ALAC (often in MP4/M4A containers)
104    // WMA (ASF header)
105    if buffer[0..4] == [0x30, 0x26, 0xB2, 0x75] {
106        // GUID for ASF header
107        return Some(AudioFormat::WMA);
108    }
109
110    // Fallback to file extension
111    if let Some(extension) = path.extension().and_then(|s| s.to_str())
112        && let Ok(format) = extension.parse::<AudioFormat>()
113        && format != AudioFormat::ALL
114    {
115        return Some(format);
116    }
117
118    None
119}
120
121/// Probes the audio bitrate (bits/s) of a file with ffprobe.
122///
123/// Returns `None` if the bitrate cannot be determined (ffprobe failure,
124/// missing stream, or an unparseable/`N/A` value).
125fn probe_audio_bitrate(path: &Path) -> Option<u64> {
126    let output = Command::new("ffprobe")
127        .arg("-v")
128        .arg("error")
129        .arg("-select_streams")
130        .arg("a:0")
131        .arg("-show_entries")
132        .arg("stream=bit_rate:format=bit_rate")
133        .arg("-of")
134        .arg("csv=p=0")
135        .arg(path)
136        .output()
137        .ok()?;
138    if !output.status.success() {
139        return None;
140    }
141    String::from_utf8_lossy(&output.stdout)
142        .lines()
143        .filter_map(|line| line.trim().trim_matches(',').parse::<u64>().ok())
144        .find(|&bitrate| bitrate > 0)
145}
146
147/// Returns ffmpeg encoder arguments for the given format.
148///
149/// Speeding up requires decoding + re-encoding (a filter cannot run on a
150/// copied stream), so ffmpeg's default per-container encoder settings would
151/// silently degrade quality. Lossless formats are re-encoded losslessly.
152/// Lossy formats use high-quality settings to minimize generation loss, but
153/// the bitrate is capped at `source_bitrate` (when known) so that the output
154/// can never grow beyond what the speed change alone would yield.
155fn encoder_args(format: AudioFormat, source_bitrate: Option<u64>) -> Vec<String> {
156    if format == AudioFormat::FLAC {
157        return vec!["-c:a".into(), "flac".into()];
158    }
159    if format == AudioFormat::WAV {
160        return vec!["-c:a".into(), "pcm_s24le".into()];
161    }
162    if format == AudioFormat::ALAC {
163        return vec!["-c:a".into(), "alac".into()];
164    }
165
166    // (encoder, default quality args, approximate default bitrate in kbps)
167    let (codec, default_args, default_kbps): (&str, &[&str], u64) =
168        if format == AudioFormat::MP3 {
169            ("libmp3lame", &["-q:a", "2"], 190)
170        } else if format == AudioFormat::OGG {
171            ("libvorbis", &["-q:a", "6"], 192)
172        } else if format == AudioFormat::OPUS {
173            ("libopus", &["-b:a", "160k"], 160)
174        } else if format == AudioFormat::AAC {
175            ("aac", &["-b:a", "192k"], 192)
176        } else if format == AudioFormat::WMA {
177            ("wmav2", &["-b:a", "192k"], 192)
178        } else {
179            return Vec::new();
180        };
181
182    let mut args = vec!["-c:a".to_string(), codec.to_string()];
183    match source_bitrate {
184        Some(bitrate) if bitrate < default_kbps * 1000 => {
185            args.push("-b:a".to_string());
186            args.push(bitrate.to_string());
187        }
188        _ => args.extend(default_args.iter().map(|&s| s.to_string())),
189    }
190    args
191}
192
193/// The minimum/maximum tempo a single `atempo` filter instance accepts.
194const ATEMPO_MIN: f32 = 0.5;
195const ATEMPO_MAX: f32 = 100.0;
196
197/// Builds an ffmpeg audio filter chain for the given speed multiplier.
198///
199/// A single `atempo` instance only accepts values in `[0.5, 100]`, so
200/// out-of-range speeds are decomposed into a chain of `atempo` filters.
201///
202/// Returns `None` if `speed` is not a positive finite number.
203fn build_atempo_filter(speed: f32) -> Option<String> {
204    if !speed.is_finite() || speed <= 0.0 {
205        return None;
206    }
207    let mut parts = Vec::new();
208    let mut remaining = speed;
209    while remaining > ATEMPO_MAX {
210        parts.push(format!("atempo={ATEMPO_MAX}"));
211        remaining /= ATEMPO_MAX;
212    }
213    while remaining < ATEMPO_MIN {
214        parts.push(format!("atempo={ATEMPO_MIN}"));
215        remaining /= ATEMPO_MIN;
216    }
217    parts.push(format!("atempo={remaining}"));
218    Some(parts.join(","))
219}
220
221/// Process all audio files in the specified folder recursively with the given speed multiplier.
222///
223/// # Arguments
224///
225/// * `folder` - Path to the folder containing audio files
226/// * `speed` - Speed multiplier (e.g., 1.5 for 1.5x speed)
227/// * `formats` - A bitflags object indicating which audio formats to process.
228///
229/// # Returns
230///
231/// * `Result<()>` - Ok(()) if successful, or an error if processing fails
232///
233/// # Example
234///
235/// ```no_run
236/// use std::path::Path;
237/// use audio_batch_speedup::{process_audio_files, AudioFormat};
238///
239/// let folder = Path::new("path/to/audio/files");
240/// let speed = 1.5;
241/// let formats = AudioFormat::OGG | AudioFormat::MP3;
242/// process_audio_files(folder, speed, formats).unwrap();
243/// ```
244pub fn process_audio_files(
245    folder: impl AsRef<Path>,
246    speed: f32,
247    formats: AudioFormat,
248) -> std::io::Result<()> {
249    let folder = folder.as_ref();
250
251    let Some(atempo_filter) = build_atempo_filter(speed) else {
252        return Err(std::io::Error::new(
253            std::io::ErrorKind::InvalidInput,
254            format!("Invalid speed multiplier: {speed}. Must be a positive finite number."),
255        ));
256    };
257
258    // Fail fast if ffmpeg is unavailable instead of erroring once per file.
259    match Command::new("ffmpeg").arg("-version").output() {
260        Ok(output) if output.status.success() => {}
261        _ => {
262            return Err(std::io::Error::new(
263                std::io::ErrorKind::NotFound,
264                "ffmpeg is not installed or not available in PATH",
265            ));
266        }
267    }
268
269    // ffprobe is used to cap lossy output bitrate at the source bitrate. It
270    // is optional: without it we fall back to the default encoder settings.
271    let can_probe = Command::new("ffprobe")
272        .arg("-version")
273        .output()
274        .is_ok_and(|output| output.status.success());
275    if !can_probe {
276        log::warn!("ffprobe not found in PATH; falling back to default encoder settings");
277    }
278
279    // Collect all files that need to be processed
280    let files: Vec<_> = WalkDir::new(folder)
281        .into_iter()
282        .filter_map(|e| e.ok())
283        .filter(|e| e.path().is_file()) // Only count files for the progress bar
284        .collect();
285
286    let process_pb = ProgressBar::new(files.len() as u64);
287    process_pb.set_style(
288        ProgressStyle::default_bar()
289            .template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} ({eta}) {msg}")
290            .expect("Internal Error: Failed to set progress bar style")
291            .progress_chars("#>-"),
292    );
293
294    let error_count = AtomicUsize::new(0);
295    let skipped_count = AtomicUsize::new(0);
296
297    // Suspend the progress bar while logging so log lines don't interleave
298    // with the bar's redraw.
299    let log_pb = process_pb.clone();
300    macro_rules! log_suspended {
301        ($level:ident, $($arg:tt)*) => {
302            log_pb.suspend(|| log::$level!($($arg)*))
303        };
304    }
305
306    // Process all files in parallel
307    files
308        .into_par_iter()
309        .progress_with(process_pb.clone())
310        .for_each(|entry| {
311            let path = entry.path();
312
313            let detected_format = detect_audio_format(path);
314
315            let Some(detected_format) = detected_format else {
316                log_suspended!(debug, "Skipping file (format not detected): {}", path.display());
317                skipped_count.fetch_add(1, Ordering::Relaxed);
318                return;
319            };
320
321            if !formats.contains(detected_format) {
322                log_suspended!(debug, "Skipping file (format not selected): {}", path.display());
323                skipped_count.fetch_add(1, Ordering::Relaxed);
324                return;
325            }
326
327            // Create a uniquely-named temp file in the same directory (so the
328            // final rename stays on one filesystem). The original extension is
329            // kept as the suffix so ffmpeg can infer the output container.
330            let suffix = path
331                .extension()
332                .map(|e| format!(".{}", e.to_string_lossy()))
333                .unwrap_or_default();
334            let temp_file = match tempfile::Builder::new()
335                .prefix(".abs_")
336                .suffix(&suffix)
337                .tempfile_in(path.parent().unwrap_or(Path::new(".")))
338            {
339                Ok(f) => f,
340                Err(e) => {
341                    log_suspended!(
342                        error,
343                        "Failed to create temp file for {}: {}",
344                        path.display(),
345                        e
346                    );
347                    error_count.fetch_add(1, Ordering::Relaxed);
348                    return;
349                }
350            };
351            let output_file = temp_file.path().to_path_buf();
352
353            let source_bitrate = can_probe.then(|| probe_audio_bitrate(path)).flatten();
354
355            let status = Command::new("ffmpeg")
356                .arg("-i")
357                .arg(path)
358                .arg("-filter:a")
359                .arg(&atempo_filter)
360                .arg("-vn")
361                .args(encoder_args(detected_format, source_bitrate))
362                .arg("-map_metadata")
363                .arg("0")
364                .arg(&output_file)
365                .arg("-y")
366                .arg("-loglevel")
367                .arg("error")
368                .status();
369
370            match status {
371                Ok(exit_status) => {
372                    if exit_status.success() {
373                        if let Err(e) = std::fs::rename(&output_file, path) {
374                            log_suspended!(
375                                error,
376                                "Error renaming file from {} to {}: {}",
377                                output_file.display(),
378                                path.display(),
379                                e
380                            );
381                            error_count.fetch_add(1, Ordering::Relaxed);
382                        }
383                    } else {
384                        log_suspended!(
385                            error,
386                            "ffmpeg failed for {}. Exit code: {:?}",
387                            path.display(),
388                            exit_status.code()
389                        );
390                        error_count.fetch_add(1, Ordering::Relaxed);
391                        // The temp file is removed automatically on drop.
392                    }
393                }
394                Err(e) => {
395                    log_suspended!(
396                        error,
397                        "Error executing ffmpeg for {}: {}",
398                        path.display(),
399                        e
400                    );
401                    error_count.fetch_add(1, Ordering::Relaxed);
402                    // The temp file is removed automatically on drop.
403                }
404            }
405        });
406
407    process_pb.finish_with_message("Processing complete!");
408
409    let errors = error_count.load(Ordering::Relaxed);
410    let skipped = skipped_count.load(Ordering::Relaxed);
411
412    if errors > 0 {
413        log::error!("Finished with {} errors.", errors);
414        return Err(std::io::Error::other(format!(
415            "{errors} file(s) failed to process"
416        )));
417    }
418    if skipped > 0 {
419        log::info!("Skipped {} files.", skipped);
420    }
421
422    Ok(())
423}