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
//! Inter-episode loudness consistency check.
//!
//! When distributing a multi-segment audio series, the RMS level of
//! each segment should stay close to the others. This module computes the mean
//! RMS across all supplied segments and flags any that deviate beyond a tolerance.

use crate::analyse::rms_db;

/// Default allowed deviation from the mean RMS across episodes.
pub const DEFAULT_TOLERANCE_DB: f32 = 2.0;

/// Per-batch consistency report.
#[derive(Debug, Clone)]
pub struct ConsistencyReport {
    /// Measured RMS (dBFS) for each episode in the same order as the input.
    pub episode_rms_db: Vec<f32>,
    /// Mean RMS across all episodes.
    pub mean_rms_db: f32,
    /// Largest absolute deviation from the mean (the "worst" episode).
    pub max_deviation_db: f32,
    /// `true` if every episode is within `tolerance_db` of the mean.
    pub compliant: bool,
}

/// Check that all `episodes` have similar loudness levels.
///
/// Each episode is a slice of i16 samples.  Use [`crate::bytes_to_samples`]
/// to convert raw PCM bytes before calling this function.
///
/// # Example
/// ```
/// use audiobook_creation_exchange::consistency::{consistency_check, DEFAULT_TOLERANCE_DB};
///
/// let ep1 = vec![1000i16; 24_000]; // 1 s of constant signal
/// let ep2 = vec![1000i16; 24_000];
/// let report = consistency_check(&[&ep1, &ep2], DEFAULT_TOLERANCE_DB);
/// assert!(report.compliant);
/// ```
pub fn consistency_check(episodes: &[&[i16]], tolerance_db: f32) -> ConsistencyReport {
    if episodes.is_empty() {
        return ConsistencyReport {
            episode_rms_db: Vec::new(),
            mean_rms_db: -144.0,
            max_deviation_db: 0.0,
            compliant: true,
        };
    }

    let rms_values: Vec<f32> = episodes.iter().map(|e| rms_db(e)).collect();
    let mean = rms_values.iter().sum::<f32>() / rms_values.len() as f32;
    let max_dev = rms_values
        .iter()
        .map(|&r| (r - mean).abs())
        .fold(0f32, f32::max);

    ConsistencyReport {
        episode_rms_db: rms_values,
        mean_rms_db: mean,
        max_deviation_db: max_dev,
        compliant: max_dev <= tolerance_db,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn identical_episodes_are_compliant() {
        let ep: Vec<i16> = (0..24_000).map(|i| ((i % 100) as i16) * 300).collect();
        let report = consistency_check(&[&ep, &ep, &ep], DEFAULT_TOLERANCE_DB);
        assert!(report.compliant);
        // Float rounding in mean calculation can produce tiny non-zero deviations.
        assert!(
            report.max_deviation_db < 0.001,
            "max deviation should be near zero, got {}",
            report.max_deviation_db
        );
    }

    #[test]
    fn large_rms_difference_is_flagged() {
        let loud: Vec<i16> = vec![10_000i16; 24_000];
        let quiet: Vec<i16> = vec![1_000i16; 24_000];
        let report = consistency_check(&[&loud, &quiet], DEFAULT_TOLERANCE_DB);
        assert!(!report.compliant, "expected non-compliant for 20 dB spread");
        assert!(report.max_deviation_db > DEFAULT_TOLERANCE_DB);
    }

    #[test]
    fn empty_episode_list_is_compliant() {
        let report = consistency_check(&[], DEFAULT_TOLERANCE_DB);
        assert!(report.compliant);
    }

    #[test]
    fn single_episode_is_always_compliant() {
        let ep: Vec<i16> = vec![5_000i16; 24_000];
        let report = consistency_check(&[&ep], DEFAULT_TOLERANCE_DB);
        assert!(report.compliant);
        assert_eq!(report.max_deviation_db, 0.0);
    }
}