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 #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
16 pub struct AudioFormat: u32 {
17 const OGG = 1 << 0;
19 const MP3 = 1 << 1;
21 const WAV = 1 << 2;
23 const FLAC = 1 << 3;
25 const AAC = 1 << 4;
27 const OPUS = 1 << 5;
29 const ALAC = 1 << 6;
31 const WMA = 1 << 7;
33 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 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
68fn detect_audio_format(path: &Path) -> Option<AudioFormat> {
78 let mut file = File::open(path).ok()?;
80 let mut buffer = [0; 12]; file.read_exact(&mut buffer).ok()?;
82
83 if &buffer[0..4] == b"OggS" {
85 return Some(AudioFormat::OGG);
86 }
87 if &buffer[0..3] == b"ID3" || (buffer[0] == 0xFF && (buffer[1] & 0xE0) == 0xE0) {
89 return Some(AudioFormat::MP3);
90 }
91 if &buffer[0..4] == b"RIFF" && &buffer[8..12] == b"WAVE" {
93 return Some(AudioFormat::WAV);
94 }
95 if &buffer[0..4] == b"fLaC" {
97 return Some(AudioFormat::FLAC);
98 }
99 if buffer[0..4] == [0x30, 0x26, 0xB2, 0x75] {
106 return Some(AudioFormat::WMA);
108 }
109
110 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
121fn 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
147fn 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 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
193const ATEMPO_MIN: f32 = 0.5;
195const ATEMPO_MAX: f32 = 100.0;
196
197fn 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
221pub 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 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 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 let files: Vec<_> = WalkDir::new(folder)
281 .into_iter()
282 .filter_map(|e| e.ok())
283 .filter(|e| e.path().is_file()) .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 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 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 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 }
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 }
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}