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
use rand::RngExt;

use crate::analyse::{db_to_linear, rms_db};

/// Generate `n_samples` of pink noise at exactly `target_db` RMS.
///
/// Pink noise (1/f spectrum) is the closest approximation to a real recording
/// booth's ambient hiss. It prevents the listener's brain from registering
/// digital silence as an app crash.
///
/// The Voss-McCartney algorithm is used: it sums multiple white-noise generators
/// each running at half the rate of the previous, producing a 1/f power spectrum.
pub fn generate_room_tone(n_samples: usize, target_db: f32) -> Vec<i16> {
    if n_samples == 0 {
        return Vec::new();
    }

    let mut rng = rand::rng();
    let n_stages = 16usize;

    let mut stages = vec![0.0f32; n_stages];
    let mut stage_counters = vec![0u64; n_stages];
    let mut raw: Vec<f32> = Vec::with_capacity(n_samples);

    for i in 0..n_samples {
        // Advance each stage at half the rate of the previous
        for (stage_idx, (counter, stage_val)) in
            stage_counters.iter_mut().zip(stages.iter_mut()).enumerate()
        {
            let period = 1u64 << stage_idx;
            if i as u64 % period == 0 {
                *counter = counter.wrapping_add(1);
                *stage_val = rng.random::<f32>() * 2.0 - 1.0;
            }
        }
        let sum: f32 = stages.iter().sum();
        raw.push(sum / n_stages as f32);
    }

    // Measure and rescale to hit target_db RMS exactly
    let raw_as_i16: Vec<i16> = raw
        .iter()
        .map(|&s| (s * i16::MAX as f32).clamp(i16::MIN as f32, i16::MAX as f32) as i16)
        .collect();

    let measured = rms_db(&raw_as_i16);
    let gain = if measured <= -144.0 {
        1.0
    } else {
        db_to_linear(target_db - measured)
    };

    raw_as_i16
        .into_iter()
        .map(|s| {
            (s as f32 * gain)
                .round()
                .clamp(i16::MIN as f32, i16::MAX as f32) as i16
        })
        .collect()
}