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
//! Chapter crossfade using equal-power (constant-power) fade curves.
//!
//! Produces a seamless transition between two consecutive audio segments.
//! The output is:  a[..-fade]  ‖  crossfade_region  ‖  b[fade..]
//!
//! Equal-power curves (cos²/sin²) keep combined RMS constant throughout the
//! transition, avoiding the perceived "dip in the middle" of a linear crossfade.

/// Crossfade the end of `a` with the beginning of `b`.
///
/// The crossfade occupies `duration_ms` milliseconds.  The returned buffer
/// contains `a.len() + b.len() - fade_samples` samples total.
///
/// If `duration_ms` is zero, the segments are concatenated without blending.
pub fn crossfade(a: &[i16], b: &[i16], duration_ms: u32, sample_rate: u32) -> Vec<i16> {
    let fade_samples = ((sample_rate as usize * duration_ms as usize) / 1000)
        .min(a.len())
        .min(b.len());

    if fade_samples == 0 {
        let mut out = a.to_vec();
        out.extend_from_slice(b);
        return out;
    }

    let a_body = a.len() - fade_samples;
    let mut out = Vec::with_capacity(a.len() + b.len() - fade_samples);

    out.extend_from_slice(&a[..a_body]);

    for i in 0..fade_samples {
        let t = i as f32 / fade_samples as f32;
        // Equal-power: gain_a² + gain_b² = 1 at all t.
        let angle = t * std::f32::consts::FRAC_PI_2;
        let gain_a = angle.cos(); // 1 → 0
        let gain_b = angle.sin(); // 0 → 1

        let sample = (a[a_body + i] as f32 * gain_a + b[i] as f32 * gain_b)
            .round()
            .clamp(i16::MIN as f32, i16::MAX as f32);
        out.push(sample as i16);
    }

    out.extend_from_slice(&b[fade_samples..]);
    out
}

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

    const SR: u32 = 24_000;

    fn constant(value: i16, n: usize) -> Vec<i16> {
        vec![value; n]
    }

    #[test]
    fn zero_duration_concatenates() {
        let a = constant(100, 1000);
        let b = constant(200, 1000);
        let out = crossfade(&a, &b, 0, SR);
        assert_eq!(out.len(), 2000);
        assert_eq!(out[999], 100);
        assert_eq!(out[1000], 200);
    }

    #[test]
    fn output_length_is_sum_minus_fade() {
        let a = constant(100, 24_000);
        let b = constant(200, 24_000);
        let fade_ms = 50u32;
        let fade_samples = SR as usize * fade_ms as usize / 1000;
        let out = crossfade(&a, &b, fade_ms, SR);
        assert_eq!(out.len(), 24_000 + 24_000 - fade_samples);
    }

    #[test]
    fn fade_region_is_between_the_two_signals() {
        let n = 24_000usize;
        let a: Vec<i16> = (0..n).map(|_| 10_000i16).collect();
        let b: Vec<i16> = (0..n).map(|_| -10_000i16).collect();
        let fade_ms = 100u32;
        let out = crossfade(&a, &b, fade_ms, SR);

        let fade_samples = SR as usize * fade_ms as usize / 1000;
        let a_body = n - fade_samples;

        // Mid-fade sample should be between the two extremes.
        let mid = out[a_body + fade_samples / 2];
        assert!(
            mid < 10_000 && mid > -10_000,
            "mid-fade sample {} not between signals",
            mid
        );
    }

    #[test]
    fn crossfade_into_empty_slice_works() {
        let a = constant(100, 1000);
        let out = crossfade(&a, &[], 50, SR);
        assert_eq!(out.len(), a.len());
    }
}