audio_batch_speedup/
lib.rs

1#![warn(clippy::cargo)]
2
3use bitflags::bitflags;
4use indicatif::{ParallelProgressIterator, ProgressBar, ProgressStyle};
5use log::{debug, error};
6use rayon::prelude::*;
7use std::fs::File;
8use std::io::Read;
9use std::path::Path;
10use std::process::Command;
11use std::sync::atomic::{AtomicUsize, Ordering};
12use walkdir::WalkDir;
13
14bitflags! {
15    /// Represents the supported audio formats for processing.
16    #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
17    pub struct AudioFormat: u32 {
18        /// Ogg Vorbis format.
19        const OGG = 1 << 0;
20        /// MPEG Audio Layer III (MP3) format.
21        const MP3 = 1 << 1;
22        /// Waveform Audio File Format (WAV).
23        const WAV = 1 << 2;
24        /// Free Lossless Audio Codec (FLAC) format.
25        const FLAC = 1 << 3;
26        /// Advanced Audio Coding (AAC) format (often in MP4 containers).
27        const AAC = 1 << 4;
28        /// Opus Interactive Audio Codec (often in Ogg or WebM containers).
29        const OPUS = 1 << 5;
30        /// Apple Lossless Audio Codec (ALAC) format.
31        const ALAC = 1 << 6;
32        /// Windows Media Audio (WMA) format.
33        const WMA = 1 << 7;
34        /// All supported formats.
35        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();
36    }
37}
38
39impl Default for AudioFormat {
40    fn default() -> Self {
41        Self::ALL
42    }
43}
44
45/// Detects the audio format of a file based on its magic bytes or file extension.
46///
47/// # Arguments
48///
49/// * `path` - The path to the audio file.
50///
51/// # Returns
52///
53/// * `Option<AudioFormat>` - The detected audio format, or `None` if it cannot be determined.
54fn detect_audio_format(path: &Path) -> Option<AudioFormat> {
55    // Try to detect by magic bytes first
56    let mut file = File::open(path).ok()?;
57    let mut buffer = [0; 12]; // Read enough bytes for common headers
58    file.read_exact(&mut buffer).ok()?;
59
60    // OGG (OggS)
61    if &buffer[0..4] == b"OggS" {
62        return Some(AudioFormat::OGG);
63    }
64    // MP3 (ID3 tag or starts with 0xFF FB/FA)
65    if &buffer[0..3] == b"ID3" || (buffer[0] == 0xFF && (buffer[1] & 0xF6) == 0xF2) {
66        return Some(AudioFormat::MP3);
67    }
68    // WAV (RIFF header with WAVE)
69    if &buffer[0..4] == b"RIFF" && &buffer[8..12] == b"WAVE" {
70        return Some(AudioFormat::WAV);
71    }
72    // FLAC (fLaC)
73    if &buffer[0..4] == b"fLaC" {
74        return Some(AudioFormat::FLAC);
75    }
76    // AAC (often in MP4/M4A containers, which start with 'ftyp' or 'moov')
77    // This is harder to detect purely by magic bytes without parsing the container.
78    // We'll rely more on extension for AAC/M4A.
79    // OPUS (often in Ogg containers, so OggS will catch it, or WebM)
80    // ALAC (often in MP4/M4A containers)
81    // WMA (ASF header)
82    if buffer[0..4] == [0x30, 0x26, 0xB2, 0x75] {
83        // GUID for ASF header
84        return Some(AudioFormat::WMA);
85    }
86
87    // Fallback to file extension
88    if let Some(extension) = path.extension().and_then(|s| s.to_str()) {
89        match extension.to_lowercase().as_str() {
90            "ogg" => return Some(AudioFormat::OGG),
91            "mp3" => return Some(AudioFormat::MP3),
92            "wav" => return Some(AudioFormat::WAV),
93            "flac" => return Some(AudioFormat::FLAC),
94            "m4a" | "aac" => return Some(AudioFormat::AAC),
95            "opus" => return Some(AudioFormat::OPUS),
96            "alac" => return Some(AudioFormat::ALAC),
97            "wma" => return Some(AudioFormat::WMA),
98            _ => {}
99        }
100    }
101
102    None
103}
104
105/// Process all audio files in the specified folder recursively with the given speed multiplier.
106///
107/// # Arguments
108///
109/// * `folder` - Path to the folder containing audio files
110/// * `speed` - Speed multiplier (e.g., 1.5 for 1.5x speed)
111/// * `formats` - A bitflags object indicating which audio formats to process.
112///
113/// # Returns
114///
115/// * `Result<()>` - Ok(()) if successful, or an error if processing fails
116///
117/// # Example
118///
119/// ```no_run
120/// use std::path::Path;
121/// use audio_batch_speedup::{process_audio_files, AudioFormat};
122///
123/// let folder = Path::new("path/to/audio/files");
124/// let speed = 1.5;
125/// let formats = AudioFormat::OGG | AudioFormat::MP3;
126/// process_audio_files(folder, speed, formats).unwrap();
127/// ```
128pub fn process_audio_files(
129    folder: impl AsRef<Path>,
130    speed: f32,
131    formats: AudioFormat,
132) -> std::io::Result<()> {
133    let folder = folder.as_ref();
134
135    // Collect all files that need to be processed
136    let files: Vec<_> = WalkDir::new(folder)
137        .into_iter()
138        .filter_map(|e| e.ok())
139        .filter(|e| e.path().is_file()) // Only count files for the progress bar
140        .collect();
141
142    let process_pb = ProgressBar::new(files.len() as u64);
143    process_pb.set_style(
144        ProgressStyle::default_bar()
145            .template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} ({eta}) {msg}")
146            .expect("Internal Error: Failed to set progress bar style")
147            .progress_chars("#>-"),
148    );
149
150    let error_count = AtomicUsize::new(0);
151    let skipped_count = AtomicUsize::new(0);
152
153    // Process all files in parallel
154    files
155        .into_par_iter()
156        .progress_with(process_pb.clone())
157        .for_each(|entry| {
158            let path = entry.path();
159            if !path.is_file() {
160                return;
161            }
162
163            let detected_format = detect_audio_format(path);
164
165            let Some(detected_format) = detected_format else {
166                debug!("Skipping file (format not detected): {}", path.display());
167                skipped_count.fetch_add(1, Ordering::AcqRel);
168                return;
169            };
170
171            if !formats.contains(detected_format) {
172                debug!("Skipping file (format not selected): {}", path.display());
173                skipped_count.fetch_add(1, Ordering::AcqRel);
174                return;
175            }
176
177            let file_name = match path.file_name().and_then(|s| s.to_str()) {
178                Some(name) => name,
179                None => {
180                    error!("Failed to get file name for {}", path.display());
181                    error_count.fetch_add(1, Ordering::AcqRel);
182                    return;
183                }
184            };
185
186            let output_file = path.with_file_name(format!("temp_{}", file_name));
187
188            let input_path_str = match path.to_str() {
189                Some(s) => s,
190                None => {
191                    error!("Failed to convert input path to string: {}", path.display());
192                    error_count.fetch_add(1, Ordering::AcqRel);
193                    return;
194                }
195            };
196
197            let output_file_str = match output_file.to_str() {
198                Some(s) => s,
199                None => {
200                    error!(
201                        "Failed to convert output path to string: {}",
202                        output_file.display()
203                    );
204                    error_count.fetch_add(1, Ordering::AcqRel);
205                    return;
206                }
207            };
208
209            let status = Command::new("ffmpeg")
210                .args([
211                    "-i",
212                    input_path_str,
213                    "-filter:a",
214                    &format!("atempo={}", speed),
215                    "-vn",
216                    "-map_metadata",
217                    "0",
218                    output_file_str,
219                    "-y",
220                    "-loglevel",
221                    "error",
222                ])
223                .status();
224
225            match status {
226                Ok(exit_status) => {
227                    if exit_status.success() {
228                        if let Err(e) = std::fs::rename(&output_file, path) {
229                            error!(
230                                "Error renaming file from {} to {}: {}",
231                                output_file.display(),
232                                path.display(),
233                                e
234                            );
235                            error_count.fetch_add(1, Ordering::AcqRel);
236                        }
237                    } else {
238                        error!(
239                            "ffmpeg failed for {}. Exit code: {:?}",
240                            path.display(),
241                            exit_status.code()
242                        );
243                        error_count.fetch_add(1, Ordering::AcqRel);
244                        // Ensure temp file is removed if ffmpeg failed
245                        if output_file.exists()
246                            && let Err(e) = std::fs::remove_file(&output_file)
247                        {
248                            error!("Error removing temp file {}: {}", output_file.display(), e);
249                        }
250                    }
251                }
252                Err(e) => {
253                    error!("Error executing ffmpeg for {}: {}", path.display(), e);
254                    error_count.fetch_add(1, Ordering::AcqRel);
255                    // Ensure temp file is removed if ffmpeg execution failed
256                    if output_file.exists()
257                        && let Err(e) = std::fs::remove_file(&output_file)
258                    {
259                        error!("Error removing temp file {}: {}", output_file.display(), e);
260                    }
261                }
262            }
263        });
264
265    process_pb.finish_with_message("Processing complete!");
266
267    let errors = error_count.load(Ordering::Relaxed);
268    let skipped = skipped_count.load(Ordering::Relaxed);
269
270    if errors > 0 {
271        log::error!("Finished with {} errors.", errors);
272    }
273    if skipped > 0 {
274        log::info!("Skipped {} files.", skipped);
275    }
276
277    Ok(())
278}