#![warn(clippy::cargo)]
use bitflags::bitflags;
use indicatif::{ParallelProgressIterator, ProgressBar, ProgressStyle};
use rayon::prelude::*;
use std::fs::File;
use std::io::Read;
use std::path::Path;
use std::process::Command;
use std::sync::atomic::{AtomicUsize, Ordering};
use walkdir::WalkDir;
bitflags! {
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct AudioFormat: u32 {
const OGG = 1 << 0;
const MP3 = 1 << 1;
const WAV = 1 << 2;
const FLAC = 1 << 3;
const AAC = 1 << 4;
const OPUS = 1 << 5;
const ALAC = 1 << 6;
const WMA = 1 << 7;
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();
}
}
impl Default for AudioFormat {
fn default() -> Self {
Self::ALL
}
}
impl std::str::FromStr for AudioFormat {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.trim().to_lowercase().as_str() {
"ogg" => Ok(Self::OGG),
"mp3" => Ok(Self::MP3),
"wav" => Ok(Self::WAV),
"flac" => Ok(Self::FLAC),
"aac" | "m4a" => Ok(Self::AAC),
"opus" => Ok(Self::OPUS),
"alac" => Ok(Self::ALAC),
"wma" => Ok(Self::WMA),
"all" => Ok(Self::ALL),
other => Err(format!(
"Unsupported format: {other}. Supported formats are: \
ogg, mp3, wav, flac, aac, opus, alac, wma, all."
)),
}
}
}
fn detect_audio_format(path: &Path) -> Option<AudioFormat> {
let mut file = File::open(path).ok()?;
let mut buffer = [0; 12]; file.read_exact(&mut buffer).ok()?;
if &buffer[0..4] == b"OggS" {
return Some(AudioFormat::OGG);
}
if &buffer[0..3] == b"ID3" || (buffer[0] == 0xFF && (buffer[1] & 0xE0) == 0xE0) {
return Some(AudioFormat::MP3);
}
if &buffer[0..4] == b"RIFF" && &buffer[8..12] == b"WAVE" {
return Some(AudioFormat::WAV);
}
if &buffer[0..4] == b"fLaC" {
return Some(AudioFormat::FLAC);
}
if buffer[0..4] == [0x30, 0x26, 0xB2, 0x75] {
return Some(AudioFormat::WMA);
}
if let Some(extension) = path.extension().and_then(|s| s.to_str())
&& let Ok(format) = extension.parse::<AudioFormat>()
&& format != AudioFormat::ALL
{
return Some(format);
}
None
}
fn probe_audio_bitrate(path: &Path) -> Option<u64> {
let output = Command::new("ffprobe")
.arg("-v")
.arg("error")
.arg("-select_streams")
.arg("a:0")
.arg("-show_entries")
.arg("stream=bit_rate:format=bit_rate")
.arg("-of")
.arg("csv=p=0")
.arg(path)
.output()
.ok()?;
if !output.status.success() {
return None;
}
String::from_utf8_lossy(&output.stdout)
.lines()
.filter_map(|line| line.trim().trim_matches(',').parse::<u64>().ok())
.find(|&bitrate| bitrate > 0)
}
fn encoder_args(format: AudioFormat, source_bitrate: Option<u64>) -> Vec<String> {
if format == AudioFormat::FLAC {
return vec!["-c:a".into(), "flac".into()];
}
if format == AudioFormat::WAV {
return vec!["-c:a".into(), "pcm_s24le".into()];
}
if format == AudioFormat::ALAC {
return vec!["-c:a".into(), "alac".into()];
}
let (codec, default_args, default_kbps): (&str, &[&str], u64) =
if format == AudioFormat::MP3 {
("libmp3lame", &["-q:a", "2"], 190)
} else if format == AudioFormat::OGG {
("libvorbis", &["-q:a", "6"], 192)
} else if format == AudioFormat::OPUS {
("libopus", &["-b:a", "160k"], 160)
} else if format == AudioFormat::AAC {
("aac", &["-b:a", "192k"], 192)
} else if format == AudioFormat::WMA {
("wmav2", &["-b:a", "192k"], 192)
} else {
return Vec::new();
};
let mut args = vec!["-c:a".to_string(), codec.to_string()];
match source_bitrate {
Some(bitrate) if bitrate < default_kbps * 1000 => {
args.push("-b:a".to_string());
args.push(bitrate.to_string());
}
_ => args.extend(default_args.iter().map(|&s| s.to_string())),
}
args
}
const ATEMPO_MIN: f32 = 0.5;
const ATEMPO_MAX: f32 = 100.0;
fn build_atempo_filter(speed: f32) -> Option<String> {
if !speed.is_finite() || speed <= 0.0 {
return None;
}
let mut parts = Vec::new();
let mut remaining = speed;
while remaining > ATEMPO_MAX {
parts.push(format!("atempo={ATEMPO_MAX}"));
remaining /= ATEMPO_MAX;
}
while remaining < ATEMPO_MIN {
parts.push(format!("atempo={ATEMPO_MIN}"));
remaining /= ATEMPO_MIN;
}
parts.push(format!("atempo={remaining}"));
Some(parts.join(","))
}
pub fn process_audio_files(
folder: impl AsRef<Path>,
speed: f32,
formats: AudioFormat,
) -> std::io::Result<()> {
let folder = folder.as_ref();
let Some(atempo_filter) = build_atempo_filter(speed) else {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("Invalid speed multiplier: {speed}. Must be a positive finite number."),
));
};
match Command::new("ffmpeg").arg("-version").output() {
Ok(output) if output.status.success() => {}
_ => {
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"ffmpeg is not installed or not available in PATH",
));
}
}
let can_probe = Command::new("ffprobe")
.arg("-version")
.output()
.is_ok_and(|output| output.status.success());
if !can_probe {
log::warn!("ffprobe not found in PATH; falling back to default encoder settings");
}
let files: Vec<_> = WalkDir::new(folder)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.path().is_file()) .collect();
let process_pb = ProgressBar::new(files.len() as u64);
process_pb.set_style(
ProgressStyle::default_bar()
.template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} ({eta}) {msg}")
.expect("Internal Error: Failed to set progress bar style")
.progress_chars("#>-"),
);
let error_count = AtomicUsize::new(0);
let skipped_count = AtomicUsize::new(0);
let log_pb = process_pb.clone();
macro_rules! log_suspended {
($level:ident, $($arg:tt)*) => {
log_pb.suspend(|| log::$level!($($arg)*))
};
}
files
.into_par_iter()
.progress_with(process_pb.clone())
.for_each(|entry| {
let path = entry.path();
let detected_format = detect_audio_format(path);
let Some(detected_format) = detected_format else {
log_suspended!(debug, "Skipping file (format not detected): {}", path.display());
skipped_count.fetch_add(1, Ordering::Relaxed);
return;
};
if !formats.contains(detected_format) {
log_suspended!(debug, "Skipping file (format not selected): {}", path.display());
skipped_count.fetch_add(1, Ordering::Relaxed);
return;
}
let suffix = path
.extension()
.map(|e| format!(".{}", e.to_string_lossy()))
.unwrap_or_default();
let temp_file = match tempfile::Builder::new()
.prefix(".abs_")
.suffix(&suffix)
.tempfile_in(path.parent().unwrap_or(Path::new(".")))
{
Ok(f) => f,
Err(e) => {
log_suspended!(
error,
"Failed to create temp file for {}: {}",
path.display(),
e
);
error_count.fetch_add(1, Ordering::Relaxed);
return;
}
};
let output_file = temp_file.path().to_path_buf();
let source_bitrate = can_probe.then(|| probe_audio_bitrate(path)).flatten();
let status = Command::new("ffmpeg")
.arg("-i")
.arg(path)
.arg("-filter:a")
.arg(&atempo_filter)
.arg("-vn")
.args(encoder_args(detected_format, source_bitrate))
.arg("-map_metadata")
.arg("0")
.arg(&output_file)
.arg("-y")
.arg("-loglevel")
.arg("error")
.status();
match status {
Ok(exit_status) => {
if exit_status.success() {
if let Err(e) = std::fs::rename(&output_file, path) {
log_suspended!(
error,
"Error renaming file from {} to {}: {}",
output_file.display(),
path.display(),
e
);
error_count.fetch_add(1, Ordering::Relaxed);
}
} else {
log_suspended!(
error,
"ffmpeg failed for {}. Exit code: {:?}",
path.display(),
exit_status.code()
);
error_count.fetch_add(1, Ordering::Relaxed);
}
}
Err(e) => {
log_suspended!(
error,
"Error executing ffmpeg for {}: {}",
path.display(),
e
);
error_count.fetch_add(1, Ordering::Relaxed);
}
}
});
process_pb.finish_with_message("Processing complete!");
let errors = error_count.load(Ordering::Relaxed);
let skipped = skipped_count.load(Ordering::Relaxed);
if errors > 0 {
log::error!("Finished with {} errors.", errors);
return Err(std::io::Error::other(format!(
"{errors} file(s) failed to process"
)));
}
if skipped > 0 {
log::info!("Skipped {} files.", skipped);
}
Ok(())
}