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
//! DC offset detection and removal.
//!
//! A DC offset means the waveform's "silence baseline" is shifted away from zero.
//! When two chapters with different offsets are concatenated, the step-change
//! produces a pop or click at the edit point — a common artefact in recorded speech.

/// Maximum allowed DC offset as a fraction of full scale (1%).
pub const DC_OFFSET_THRESHOLD: f32 = 0.01;

/// Measure the DC offset (mean sample value) as a fraction of full scale.
///
/// Returns a value in `[-1.0, 1.0]`. Values outside `±DC_OFFSET_THRESHOLD`
/// indicate a problematic offset.
pub fn measure(samples: &[i16]) -> f32 {
    if samples.is_empty() {
        return 0.0;
    }
    let sum: f64 = samples.iter().map(|&s| s as f64).sum();
    (sum / samples.len() as f64 / i16::MAX as f64) as f32
}

/// Return `true` if the DC offset exceeds the threshold.
pub fn has_offset(samples: &[i16]) -> bool {
    measure(samples).abs() > DC_OFFSET_THRESHOLD
}

/// Remove DC offset by subtracting the mean from every sample.
pub fn remove(samples: &mut [i16]) {
    if samples.is_empty() {
        return;
    }
    let mean: f64 = samples.iter().map(|&s| s as f64).sum::<f64>() / samples.len() as f64;
    for s in samples.iter_mut() {
        let corrected = (*s as f64 - mean)
            .round()
            .clamp(i16::MIN as f64, i16::MAX as f64);
        *s = corrected as i16;
    }
}

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

    fn sine(amplitude: f32, offset: i16, n: usize) -> Vec<i16> {
        (0..n)
            .map(|i| {
                let v = amplitude * (2.0 * std::f32::consts::PI * 440.0 * i as f32 / 24000.0).sin();
                (v as i16).saturating_add(offset)
            })
            .collect()
    }

    #[test]
    fn zero_offset_sine_not_flagged() {
        let samples = sine(1000.0, 0, 24000);
        assert!(measure(&samples).abs() < DC_OFFSET_THRESHOLD);
        assert!(!has_offset(&samples));
    }

    #[test]
    fn large_offset_detected() {
        // +1000 counts ≈ 3% of full scale
        let samples = sine(500.0, 1000, 24000);
        assert!(has_offset(&samples));
    }

    #[test]
    fn removal_brings_offset_to_near_zero() {
        let mut samples = sine(500.0, 1000, 24000);
        remove(&mut samples);
        assert!(
            measure(&samples).abs() < DC_OFFSET_THRESHOLD,
            "After removal, offset is {:.4}",
            measure(&samples)
        );
    }

    #[test]
    fn constant_dc_signal_fully_removed() {
        let mut samples = vec![500i16; 1000];
        remove(&mut samples);
        // All samples should now be ~0
        for &s in &samples {
            assert!(s.abs() <= 1, "Expected ~0 after removal, got {}", s);
        }
    }

    #[test]
    fn empty_slice_is_safe() {
        let mut empty: Vec<i16> = vec![];
        assert_eq!(measure(&empty), 0.0);
        remove(&mut empty);
    }

    #[test]
    fn negative_offset_detected_and_removed() {
        let mut samples = sine(500.0, -1200, 24000);
        assert!(has_offset(&samples));
        remove(&mut samples);
        assert!(!has_offset(&samples));
    }
}