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 crate::analyse::db_to_linear;

/// Attack time for gain smoothing — fast enough to catch transients.
const ATTACK_MS: f32 = 5.0;
/// Release time — slow enough to avoid pumping on normal speech.
const RELEASE_MS: f32 = 100.0;
/// Lookahead window in milliseconds — lets the limiter anticipate peaks.
const LOOKAHEAD_MS: u32 = 5;

/// Brickwall true-peak limiter.
///
/// Uses a lookahead buffer and exponential gain smoothing to attenuate any
/// frame that would breach `ceiling_db` without introducing clicks.
pub fn limit(samples: &mut [i16], sample_rate: u32, ceiling_db: f32) {
    let ceiling_linear = db_to_linear(ceiling_db) * i16::MAX as f32;

    let attack_coeff = attack_coeff(sample_rate);
    let release_coeff = release_coeff(sample_rate);
    let lookahead = ((sample_rate as f32 * LOOKAHEAD_MS as f32) / 1000.0) as usize;

    let mut gain: f32 = 1.0;

    for i in 0..samples.len() {
        // Look ahead to find the worst case peak in the upcoming window
        let lookahead_end = (i + lookahead).min(samples.len());
        let peak_ahead = samples[i..lookahead_end]
            .iter()
            .map(|&s| (s as f32).abs())
            .fold(0.0f32, f32::max);

        // Target gain needed to bring this peak within the ceiling
        let target_gain = if peak_ahead > ceiling_linear {
            ceiling_linear / peak_ahead
        } else {
            1.0
        };

        // Smooth the gain envelope
        if target_gain < gain {
            gain = gain * attack_coeff + target_gain * (1.0 - attack_coeff);
        } else {
            gain = gain * release_coeff + target_gain * (1.0 - release_coeff);
        }

        // Clamp to the ceiling, not to i16::MAX. The smooth gain envelope handles
        // transparency; this hard clip is the brickwall guarantee that no output
        // sample ever exceeds ceiling_db regardless of attack convergence speed.
        let limited = (samples[i] as f32 * gain)
            .round()
            .clamp(-ceiling_linear, ceiling_linear);
        samples[i] = limited as i16;
    }
}

fn attack_coeff(sample_rate: u32) -> f32 {
    let attack_samples = sample_rate as f32 * ATTACK_MS / 1000.0;
    (-1.0f32 / attack_samples).exp()
}

fn release_coeff(sample_rate: u32) -> f32 {
    let release_samples = sample_rate as f32 * RELEASE_MS / 1000.0;
    (-1.0f32 / release_samples).exp()
}