use std::{env, fs, path::Path, process};
use acx::{dc_offset, gate, limiter, normalise, room_tone};
use audiobook_creation_exchange as acx;
fn main() {
let (pcm_path, sample_rate) = parse_args();
let pcm_bytes = fs::read(&pcm_path).unwrap_or_else(|e| {
eprintln!("error: cannot read '{}': {}", pcm_path, e);
process::exit(1);
});
if pcm_bytes.is_empty() {
eprintln!("error: file is empty");
process::exit(1);
}
if pcm_bytes.len() % 2 != 0 {
eprintln!("error: file has odd byte length — not valid L16-LE PCM");
process::exit(1);
}
let pre = acx::validate(&pcm_bytes, sample_rate).unwrap_or_else(|e| {
eprintln!("error: validate failed: {:?}", e);
process::exit(1);
});
let processed_bytes = apply_pipeline(&pcm_bytes, sample_rate);
let post = acx::validate(&processed_bytes, sample_rate).unwrap_or_else(|e| {
eprintln!("error: post-validate failed: {:?}", e);
process::exit(1);
});
let stem = Path::new(&pcm_path)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("audio");
let dir = Path::new(&pcm_path)
.parent()
.unwrap_or_else(|| Path::new("."));
let original_wav = dir.join(format!("{}.original.wav", stem));
let processed_wav = dir.join(format!("{}.processed.wav", stem));
write_wav(&original_wav, &pcm_bytes, sample_rate);
write_wav(&processed_wav, &processed_bytes, sample_rate);
println!("Wrote: {}", original_wav.display());
println!("Wrote: {}", processed_wav.display());
println!();
print_report(&pre, &post);
if !post.acx_compliant {
process::exit(2);
}
}
fn apply_pipeline(pcm_bytes: &[u8], sample_rate: u32) -> Vec<u8> {
let cfg = acx::AcxConfig::default();
let mut samples: Vec<i16> = pcm_bytes
.chunks_exact(2)
.map(|c| i16::from_le_bytes([c[0], c[1]]))
.collect();
if dc_offset::has_offset(&samples) {
dc_offset::remove(&mut samples);
}
normalise::normalise(&mut samples, cfg.rms_target_db);
limiter::limit(&mut samples, sample_rate, cfg.peak_ceiling_db);
let tone = room_tone::generate_room_tone(sample_rate as usize / 2, cfg.room_tone_db);
gate::gate_to_room_tone(&mut samples, sample_rate, cfg.silence_threshold_db, &tone);
gate::pad_bookends(&mut samples, sample_rate, &tone);
samples.iter().flat_map(|&s| s.to_le_bytes()).collect()
}
fn write_wav(path: &Path, pcm_bytes: &[u8], sample_rate: u32) {
let channels: u16 = 1;
let bits_per_sample: u16 = 16;
let byte_rate = sample_rate * channels as u32 * bits_per_sample as u32 / 8;
let block_align = channels * bits_per_sample / 8;
let data_len = pcm_bytes.len() as u32;
let chunk_size = 36 + data_len;
let mut header = Vec::with_capacity(44);
header.extend_from_slice(b"RIFF");
header.extend_from_slice(&chunk_size.to_le_bytes());
header.extend_from_slice(b"WAVE");
header.extend_from_slice(b"fmt ");
header.extend_from_slice(&16u32.to_le_bytes()); header.extend_from_slice(&1u16.to_le_bytes()); header.extend_from_slice(&channels.to_le_bytes());
header.extend_from_slice(&sample_rate.to_le_bytes());
header.extend_from_slice(&byte_rate.to_le_bytes());
header.extend_from_slice(&block_align.to_le_bytes());
header.extend_from_slice(&bits_per_sample.to_le_bytes());
header.extend_from_slice(b"data");
header.extend_from_slice(&data_len.to_le_bytes());
let mut wav = header;
wav.extend_from_slice(pcm_bytes);
fs::write(path, &wav).unwrap_or_else(|e| {
eprintln!("error: cannot write '{}': {}", path.display(), e);
process::exit(1);
});
}
fn print_report(pre: &acx::DiagnosticReport, post: &acx::DiagnosticReport) {
let check = |ok: bool| if ok { "pass" } else { "FAIL" };
let yn = |b: bool| if b { "yes" } else { "no" };
println!("{:<28} {:>12} {:>12}", "Metric", "Before", "After");
println!("{}", "-".repeat(54));
println!(
"{:<28} {:>11.2} {:>11.2}",
"RMS (dBFS)", pre.rms_db, post.rms_db
);
println!(
"{:<28} {:>11.2} {:>11.2}",
"True-peak (dBFS)", pre.peak_db, post.peak_db
);
println!(
"{:<28} {:>11.2} {:>11.2}",
"Noise floor (dBFS)", pre.noise_floor_db, post.noise_floor_db
);
println!(
"{:<28} {:>11.2} {:>11.2}",
"Integrated LUFS", pre.integrated_lufs, post.integrated_lufs
);
println!(
"{:<28} {:>11.2} {:>11.2}",
"Loudness range (LU)", pre.loudness_range, post.loudness_range
);
println!(
"{:<28} {:>11.4} {:>11.4}",
"DC offset (fraction)", pre.dc_offset, post.dc_offset
);
println!(
"{:<28} {:>12} {:>12}",
"Has DC offset",
yn(pre.has_dc_offset),
yn(post.has_dc_offset)
);
println!(
"{:<28} {:>12} {:>12}",
"Head silence ok",
yn(pre.head_ok),
yn(post.head_ok)
);
println!(
"{:<28} {:>12} {:>12}",
"Tail silence ok",
yn(pre.tail_ok),
yn(post.tail_ok)
);
println!(
"{:<28} {:>12} {:>12}",
"Dead air violations",
pre.dead_air_violations.len(),
post.dead_air_violations.len()
);
println!(
"{:<28} {:>12} {:>12}",
"Digital zero runs", pre.digital_zero_runs, post.digital_zero_runs
);
println!(
"{:<28} {:>12} {:>12}",
"Spectral violations",
pre.spectral_violations.len(),
post.spectral_violations.len()
);
let (pre_sib, pre_plo) = count_spectral(&pre.spectral_violations);
let (post_sib, post_plo) = count_spectral(&post.spectral_violations);
println!("{:<28} {:>12} {:>12}", " Sibilance", pre_sib, post_sib);
println!("{:<28} {:>12} {:>12}", " Plosive", pre_plo, post_plo);
println!("{}", "-".repeat(54));
println!(
"{:<28} {:>12} {:>12}",
"ACX compliant",
check(pre.acx_compliant),
check(post.acx_compliant)
);
}
fn count_spectral(violations: &[acx::SpectralViolation]) -> (usize, usize) {
use acx::SpectralViolationKind;
let sib = violations
.iter()
.filter(|v| matches!(v.kind, SpectralViolationKind::Sibilance))
.count();
let plo = violations
.iter()
.filter(|v| matches!(v.kind, SpectralViolationKind::Plosive))
.count();
(sib, plo)
}
fn parse_args() -> (String, u32) {
let args: Vec<String> = env::args().collect();
if args.len() < 2 {
eprintln!("Usage: {} <file.pcm> [--sample-rate <hz>]", args[0]);
process::exit(1);
}
let path = args[1].clone();
let mut sample_rate: u32 = 24_000;
let mut i = 2;
while i < args.len() {
if args[i] == "--sample-rate" {
i += 1;
if i >= args.len() {
eprintln!("error: --sample-rate requires a value");
process::exit(1);
}
sample_rate = args[i].parse().unwrap_or_else(|_| {
eprintln!("error: invalid sample rate '{}'", args[i]);
process::exit(1);
});
}
i += 1;
}
(path, sample_rate)
}