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
//! Temporal silence validation: dead air, head/tail bookends, and digital zero detection.

use crate::analyse::rms_db;
use time::Duration;

/// Maximum contiguous silence allowed inside a chapter body.
pub const DEAD_AIR_LIMIT: Duration = Duration::seconds(10);

/// Head bookend: first 1.0 s must be at room-tone energy or below.
pub const HEAD_DURATION: Duration = Duration::seconds(1);
/// Tail bookend: last 3.0 s must be at room-tone energy or below.
pub const TAIL_DURATION: Duration = Duration::seconds(3);

/// Vocal content threshold — above this the bookend is considered "live audio".
pub const BOOKEND_MAX_RMS_DB: f32 = -45.0;

/// A contiguous block of silence that exceeds [`DEAD_AIR_LIMIT`].
#[derive(Debug, Clone)]
pub struct DeadAirViolation {
    /// Start offset in samples.
    pub start_sample: usize,
    /// Duration of the silence block.
    pub duration: Duration,
}

/// Minimum run of consecutive exact-zero samples to constitute a "digital zero" event.
pub const DIGITAL_ZERO_MIN_RUN: usize = 24; // 1 ms @ 24 kHz

const SCAN_WINDOW_MS: usize = 10;

/// Detect contiguous blocks of silence longer than [`DEAD_AIR_LIMIT`].
///
/// Uses 10 ms scanning windows; a window is "silent" if its RMS is below
/// `silence_threshold_db`.
pub fn detect_dead_air(
    samples: &[i16],
    sample_rate: u32,
    silence_threshold_db: f32,
) -> Vec<DeadAirViolation> {
    let window = (sample_rate as usize * SCAN_WINDOW_MS) / 1000;
    if window == 0 {
        return Vec::new();
    }

    let limit_windows = (DEAD_AIR_LIMIT.whole_milliseconds() as usize) / SCAN_WINDOW_MS;
    let mut violations = Vec::new();
    let mut run_start: Option<usize> = None;
    let mut run_len = 0usize;

    for (win_idx, chunk) in samples.chunks(window).enumerate() {
        let is_silent = rms_db(chunk) < silence_threshold_db;

        if is_silent {
            if run_start.is_none() {
                run_start = Some(win_idx * window);
            }
            run_len += 1;
        } else {
            if let Some(start) = run_start.take() {
                if run_len > limit_windows {
                    violations.push(DeadAirViolation {
                        start_sample: start,
                        duration: Duration::milliseconds((run_len * SCAN_WINDOW_MS) as i64),
                    });
                }
            }
            run_len = 0;
        }
    }

    // Catch a run that ends at EOF
    if let Some(start) = run_start {
        if run_len > limit_windows {
            violations.push(DeadAirViolation {
                start_sample: start,
                duration: Duration::milliseconds((run_len * SCAN_WINDOW_MS) as i64),
            });
        }
    }

    violations
}

/// Check that the head (first 1 s) and tail (last 3 s) are at bookend energy level.
///
/// Returns `(head_ok, tail_ok)`. A section fails if its RMS exceeds
/// [`BOOKEND_MAX_RMS_DB`] — meaning live audio starts or ends too abruptly.
pub fn check_bookends(samples: &[i16], sample_rate: u32) -> (bool, bool) {
    let head_samples = (sample_rate as i64 * HEAD_DURATION.whole_seconds()) as usize;
    let tail_samples = (sample_rate as i64 * TAIL_DURATION.whole_seconds()) as usize;

    let head_ok = if samples.len() >= head_samples {
        rms_db(&samples[..head_samples]) <= BOOKEND_MAX_RMS_DB
    } else {
        rms_db(samples) <= BOOKEND_MAX_RMS_DB
    };

    let tail_ok = if samples.len() >= tail_samples {
        rms_db(&samples[samples.len() - tail_samples..]) <= BOOKEND_MAX_RMS_DB
    } else {
        rms_db(samples) <= BOOKEND_MAX_RMS_DB
    };

    (head_ok, tail_ok)
}

/// Count runs of consecutive exact-zero samples of length ≥ [`DIGITAL_ZERO_MIN_RUN`].
pub fn count_digital_zero_runs(samples: &[i16]) -> usize {
    let mut count = 0usize;
    let mut run = 0usize;
    let mut in_run = false;

    for &s in samples {
        if s == 0 {
            run += 1;
            if run >= DIGITAL_ZERO_MIN_RUN && !in_run {
                count += 1;
                in_run = true;
            }
        } else {
            run = 0;
            in_run = false;
        }
    }
    count
}

/// Measure the longest contiguous silence block.
pub fn max_dead_air(samples: &[i16], sample_rate: u32, silence_threshold_db: f32) -> Duration {
    detect_dead_air(samples, sample_rate, silence_threshold_db)
        .into_iter()
        .map(|v| v.duration)
        .max()
        .unwrap_or(Duration::ZERO)
}

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

    const SR: u32 = 24_000;
    const THRESHOLD: f32 = -60.0;

    fn silence(secs: f32) -> Vec<i16> {
        vec![0i16; (SR as f32 * secs) as usize]
    }

    fn loud(secs: f32) -> Vec<i16> {
        let n = (SR as f32 * secs) as usize;
        (0..n)
            .map(|i| {
                let v = 8000.0 * (2.0 * std::f32::consts::PI * 440.0 * i as f32 / SR as f32).sin();
                v as i16
            })
            .collect()
    }

    #[test]
    fn dead_air_within_limit_is_not_flagged() {
        // 5 s of silence — within the 10 s limit
        let mut samples = loud(1.0);
        samples.extend(silence(5.0));
        samples.extend(loud(1.0));
        let violations = detect_dead_air(&samples, SR, THRESHOLD);
        assert!(
            violations.is_empty(),
            "Expected no violations for 5 s silence"
        );
    }

    #[test]
    fn dead_air_exceeding_limit_is_flagged() {
        // 12 s of silence — exceeds 10 s limit
        let mut samples = loud(0.5);
        samples.extend(silence(12.0));
        samples.extend(loud(0.5));
        let violations = detect_dead_air(&samples, SR, THRESHOLD);
        assert!(
            !violations.is_empty(),
            "Expected dead-air violation for 12 s silence"
        );
        assert!(violations[0].duration > DEAD_AIR_LIMIT);
    }

    #[test]
    fn multiple_dead_air_blocks_detected() {
        let mut samples = loud(0.3);
        samples.extend(silence(11.0));
        samples.extend(loud(0.3));
        samples.extend(silence(15.0));
        samples.extend(loud(0.3));
        let violations = detect_dead_air(&samples, SR, THRESHOLD);
        assert_eq!(violations.len(), 2, "Expected two violations");
    }

    #[test]
    fn empty_track_has_no_dead_air() {
        assert!(detect_dead_air(&[], SR, THRESHOLD).is_empty());
    }

    #[test]
    fn head_with_loud_audio_fails() {
        // Loud audio right at the start — head should fail
        let mut samples = loud(2.0);
        samples.extend(silence(2.0));
        let (head_ok, _) = check_bookends(&samples, SR);
        assert!(!head_ok, "Expected head to fail with loud audio at start");
    }

    #[test]
    fn silence_head_passes() {
        let mut samples = silence(2.0);
        samples.extend(loud(1.0));
        samples.extend(silence(4.0)); // tail
        let (head_ok, tail_ok) = check_bookends(&samples, SR);
        assert!(head_ok, "Expected head to pass with silence");
        assert!(tail_ok, "Expected tail to pass with silence");
    }

    #[test]
    fn loud_tail_fails() {
        let mut samples = silence(2.0);
        samples.extend(loud(5.0)); // loud right to the end
        let (_, tail_ok) = check_bookends(&samples, SR);
        assert!(!tail_ok, "Expected tail to fail with loud audio at end");
    }

    #[test]
    fn digital_zero_run_detected() {
        // 100 consecutive zeros — should be flagged
        let mut samples = vec![1i16; 100];
        samples.extend(vec![0i16; 100]);
        samples.extend(vec![1i16; 100]);
        assert_eq!(count_digital_zero_runs(&samples), 1);
    }

    #[test]
    fn short_zero_gap_not_flagged() {
        // Only 5 zeros — below DIGITAL_ZERO_MIN_RUN (24)
        let mut samples = vec![1i16; 50];
        samples.extend(vec![0i16; 5]);
        samples.extend(vec![1i16; 50]);
        assert_eq!(count_digital_zero_runs(&samples), 0);
    }

    #[test]
    fn multiple_zero_runs_counted() {
        let mut samples = Vec::new();
        for _ in 0..3 {
            samples.extend(vec![1i16; 10]);
            samples.extend(vec![0i16; 50]); // each run ≥ DIGITAL_ZERO_MIN_RUN
        }
        assert_eq!(count_digital_zero_runs(&samples), 3);
    }

    #[test]
    fn max_dead_air_returns_longest() {
        let mut samples = loud(0.2);
        samples.extend(silence(11.0));
        samples.extend(loud(0.2));
        samples.extend(silence(14.0));
        samples.extend(loud(0.2));
        let max = max_dead_air(&samples, SR, THRESHOLD);
        assert!(
            max > Duration::seconds(13) && max < Duration::seconds(15),
            "Unexpected max dead air: {:?}",
            max
        );
    }
}