audiobook-creation-exchange 0.1.0

ACX-compliant audio post-processing: normalisation, limiting, gating, LUFS measurement, and spectral analysis for AI-generated speech audio.
Documentation
//! CLI tool: read raw L16-LE mono PCM, apply the ACX pipeline, write WAV files, print report.
//!
//! Usage:
//! ```text
//! acx_process <file.pcm> [--sample-rate <hz>]
//! ```
//!
//! Outputs:
//! - `<stem>.original.wav`   — the input wrapped in a WAV header
//! - `<stem>.processed.wav`  — after DC removal, normalisation, limiting, and gating
//!
//! Example:
//! ```text
//! cargo run --bin acx_process -- /path/to/chapter01.pcm --sample-rate 24000
//! ```

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);
    }

    // --- pre-processing report ---
    let pre = acx::validate(&pcm_bytes, sample_rate).unwrap_or_else(|e| {
        eprintln!("error: validate failed: {:?}", e);
        process::exit(1);
    });

    // --- pipeline (no compliance gate — always produces output) ---
    let processed_bytes = apply_pipeline(&pcm_bytes, sample_rate);

    // --- post-processing report ---
    let post = acx::validate(&processed_bytes, sample_rate).unwrap_or_else(|e| {
        eprintln!("error: post-validate failed: {:?}", e);
        process::exit(1);
    });

    // --- write WAV files ---
    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 ---
    print_report(&pre, &post);

    if !post.acx_compliant {
        process::exit(2);
    }
}

// ── pipeline ──────────────────────────────────────────────────────────────────

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()
}

// ── WAV writer ────────────────────────────────────────────────────────────────

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()); // fmt chunk size
    header.extend_from_slice(&1u16.to_le_bytes()); // PCM
    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);
    });
}

// ── report ────────────────────────────────────────────────────────────────────

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)
}

// ── arg parsing ───────────────────────────────────────────────────────────────

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)
}