audio-io-bsd 0.2.0

Audio I/O backend abstraction (AudioBackend trait) with a cpal ALSA/OSS backend for FreeBSD-first real-time audio
//! Sample-format conversion between the internal `f32` representation and the
//! integer PCM formats FreeBSD OSS drivers negotiate (`s16le`, `s32le`).
//!
//! # Design
//!
//! [`audio_core_bsd::AudioFrame`] stores samples as planar `f32` in `[-1.0,
//! 1.0]`. Some FreeBSD drivers do not accept `AFMT_FLOAT` natively and only
//! support signed-integer little-endian formats. This module performs the
//! conversion with a **deterministic clip (no dithering)** policy — predictable
//! and allocation-free, suitable for the real-time producer path.
//!
//! # Loss model
//!
//! - `s16 → f32 → s16` is **exactly lossless** (16-bit values fit in the `f32`
//!   24-bit mantissa). This is the property the tests assert.
//! - `f32 → s32 → f32` is **high-fidelity** but not bit-exact: the `f32`
//!   mantissa (24 bits) cannot hold a full 32-bit integer, so the round-trip
//!   carries sub-LSB error (well below human audibility). `f32 → s16 → f32` is
//!   inherently quantising.
//! - All conversions **clip** out-of-range `f32` values to `[-1, 1]`; `NaN`
//!   maps to `0`. No dithering (deterministic).
//!
//! # Asymmetric range note
//!
//! `i16` spans `[-32_768, 32_767]`. Scaling by `i16::MAX` (`32_767`) means
//! `f32_to_s16(-1.0)` yields `-32_767`, **not** `i16::MIN`. Values strictly
//! below `-1.0` clip to `i16::MIN`. This mirrors the behaviour of most audio
//! converters and keeps the positive/negative magnitudes symmetric.

// The casts below are intentional: f32→i16/i32 are applied only after a clamp
// to the target range (no unexpected truncation), and i32→f32 deliberately
// uses the f32 mantissa width (documented loss model).
#![allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]

/// Converts one `f32` sample in `[-1, 1]` to an `i16`, clipping out-of-range
/// values. No dithering (deterministic).
#[must_use]
pub fn f32_to_s16(sample: f32) -> i16 {
    let scaled = sample * f32::from(i16::MAX);
    if scaled.is_nan() {
        return 0;
    }
    if scaled >= f32::from(i16::MAX) {
        i16::MAX
    } else if scaled <= f32::from(i16::MIN) {
        i16::MIN
    } else {
        scaled as i16
    }
}

/// Converts one `i16` sample back to `f32` in `[-1, 1]`.
#[must_use]
pub fn s16_to_f32(sample: i16) -> f32 {
    f32::from(sample) / f32::from(i16::MAX)
}

/// Encodes an `i16` as 2 little-endian bytes.
#[must_use]
pub fn s16_to_le_bytes(sample: i16) -> [u8; 2] {
    sample.to_le_bytes()
}

/// Converts one `f32` sample in `[-1, 1]` to an `i32`, clipping out-of-range
/// values. High-fidelity within `f32` mantissa precision.
#[must_use]
pub fn f32_to_s32(sample: f32) -> i32 {
    let scaled = sample * (i32::MAX as f32);
    if scaled.is_nan() {
        return 0;
    }
    if scaled >= i32::MAX as f32 {
        i32::MAX
    } else if scaled <= i32::MIN as f32 {
        i32::MIN
    } else {
        scaled as i32
    }
}

/// Converts one `i32` sample back to `f32` in `[-1, 1]`.
#[must_use]
pub fn s32_to_f32(sample: i32) -> f32 {
    (sample as f32) / (i32::MAX as f32)
}

/// Encodes an `i32` as 4 little-endian bytes.
#[must_use]
pub fn s32_to_le_bytes(sample: i32) -> [u8; 4] {
    sample.to_le_bytes()
}

/// Converts a slice of interleaved `f32` samples to interleaved `i16`,
/// writing into `out` (extra `input` samples are ignored if `out` is shorter).
pub fn f32_interleaved_to_s16(input: &[f32], out: &mut [i16]) {
    let n = input.len().min(out.len());
    for (i, &s) in input.iter().take(n).enumerate() {
        out[i] = f32_to_s16(s);
    }
}

/// Converts a slice of interleaved `i16` samples to interleaved `f32`.
pub fn s16_interleaved_to_f32(input: &[i16], out: &mut [f32]) {
    let n = input.len().min(out.len());
    for (i, &s) in input.iter().take(n).enumerate() {
        out[i] = s16_to_f32(s);
    }
}

/// Converts a slice of interleaved `f32` samples to interleaved `i32`.
pub fn f32_interleaved_to_s32(input: &[f32], out: &mut [i32]) {
    let n = input.len().min(out.len());
    for (i, &s) in input.iter().take(n).enumerate() {
        out[i] = f32_to_s32(s);
    }
}

/// Converts a slice of interleaved `i32` samples to interleaved `f32`.
pub fn s32_interleaved_to_f32(input: &[i32], out: &mut [f32]) {
    let n = input.len().min(out.len());
    for (i, &s) in input.iter().take(n).enumerate() {
        out[i] = s32_to_f32(s);
    }
}

/// Encodes an interleaved `i16` slice into little-endian bytes (`out` must hold
/// `input.len() * 2` bytes).
///
/// # Panics
///
/// Panics (debug) if `out.len() < input.len() * 2`.
pub fn s16_interleaved_to_le_bytes(input: &[i16], out: &mut [u8]) {
    let needed = input.len() * 2;
    debug_assert!(
        out.len() >= needed,
        "out too small: need {needed}, have {}",
        out.len()
    );
    for (i, &s) in input.iter().enumerate() {
        let b = s16_to_le_bytes(s);
        out[i * 2] = b[0];
        out[i * 2 + 1] = b[1];
    }
}

/// Encodes an interleaved `i32` slice into little-endian bytes (`out` must hold
/// `input.len() * 4` bytes).
pub fn s32_interleaved_to_le_bytes(input: &[i32], out: &mut [u8]) {
    let needed = input.len() * 4;
    debug_assert!(
        out.len() >= needed,
        "out too small: need {needed}, have {}",
        out.len()
    );
    for (i, &s) in input.iter().enumerate() {
        let b = s32_to_le_bytes(s);
        out[i * 4] = b[0];
        out[i * 4 + 1] = b[1];
        out[i * 4 + 2] = b[2];
        out[i * 4 + 3] = b[3];
    }
}

// Tests use exact float equality against exact-zero results (0→0.0), which is
// sound; the lossy paths use explicit tolerances.
#[allow(clippy::float_cmp)]
#[cfg(test)]
mod tests {
    use super::*;
    use proptest::prelude::*;

    #[test]
    fn f32_to_s16_clips_above_one() {
        assert_eq!(f32_to_s16(1.0), i16::MAX);
        assert_eq!(f32_to_s16(2.0), i16::MAX);
    }

    #[test]
    fn f32_to_s16_minus_one_is_negative_max() {
        // Asymmetric range: -1.0 maps to -i16::MAX (-32_767), NOT i16::MIN.
        assert_eq!(f32_to_s16(-1.0), -i16::MAX);
    }

    #[test]
    fn f32_to_s16_clips_below_minus_one_to_min() {
        // Values strictly below -1.0 clip to i16::MIN (-32_768).
        assert_eq!(f32_to_s16(-2.0), i16::MIN);
        assert_eq!(f32_to_s16(-100.0), i16::MIN);
    }

    #[test]
    fn f32_to_s16_nan_is_zero() {
        assert_eq!(f32_to_s16(f32::NAN), 0);
    }

    #[test]
    fn s16_to_f32_reconstructs_known_values() {
        assert_eq!(s16_to_f32(0), 0.0);
        assert!((s16_to_f32(i16::MAX) - 1.0).abs() < 1e-6);
    }

    #[test]
    fn s16_round_trip_is_lossless() {
        // s16 → f32 → s16 must be exact for every representable value.
        for s in [0_i16, 1, -1, 100, -100, i16::MAX, i16::MIN, 16_384, -16_384] {
            let back = f32_to_s16(s16_to_f32(s));
            assert_eq!(back, s, "s16 {s} did not round-trip");
        }
    }

    #[test]
    fn f32_to_s32_clips() {
        assert_eq!(f32_to_s32(1.0), i32::MAX);
        assert_eq!(f32_to_s32(-1.0), i32::MIN);
        assert_eq!(f32_to_s32(5.0), i32::MAX);
    }

    #[test]
    fn f32_s32_round_trip_is_high_fidelity() {
        // f32 → s32 → f32 carries sub-LSB error (24-bit mantissa vs 32-bit i32),
        // but stays far below audibility. The audio path (f32 in/out) matters.
        for s in [0.0_f32, 0.5, -0.5, 1.0, -1.0, 0.123_456, -0.987_654] {
            let back = s32_to_f32(f32_to_s32(s));
            assert!((back - s).abs() < 1e-6, "f32 {s} -> s32 -> {back} drifted");
        }
    }

    #[test]
    fn le_byte_encoders_match_to_le_bytes() {
        assert_eq!(s16_to_le_bytes(0x0102), [0x02, 0x01]);
        assert_eq!(s32_to_le_bytes(0x0102_0304), [0x04, 0x03, 0x02, 0x01]);
    }

    #[test]
    fn interleaved_s16_slice_round_trip() {
        let f = vec![0.0_f32, 0.5, -0.5, 1.0, -1.0];
        let mut i = vec![0_i16; f.len()];
        f32_interleaved_to_s16(&f, &mut i);
        let mut back = vec![0.0_f32; f.len()];
        s16_interleaved_to_f32(&i, &mut back);
        assert_eq!(back[0], 0.0);
        assert!((back[3] - 1.0).abs() < 1e-3);
        assert!((back[4] + 1.0).abs() < 1e-3);
    }

    #[test]
    fn s16_interleaved_to_le_bytes_writes_correctly() {
        let i = [0x0102_i16, 0x0304];
        let mut bytes = [0_u8; 4];
        s16_interleaved_to_le_bytes(&i, &mut bytes);
        assert_eq!(bytes, [0x02, 0x01, 0x04, 0x03]);
    }

    #[test]
    fn s32_interleaved_to_le_bytes_writes_correctly() {
        let i = [0x0102_0304_i32];
        let mut bytes = [0_u8; 4];
        s32_interleaved_to_le_bytes(&i, &mut bytes);
        assert_eq!(bytes, [0x04, 0x03, 0x02, 0x01]);
    }

    proptest! {
        /// s16 → f32 → s16 is exactly lossless for every i16 value.
        #[test]
        fn prop_s16_round_trip_exact(s in any::<i16>()) {
            prop_assert_eq!(f32_to_s16(s16_to_f32(s)), s);
        }

        /// Every f32 in [-1,1] maps to a valid i16 (clamped, never panics).
        #[test]
        fn prop_f32_to_s16_never_panics_in_range(s in -1.0f32..=1.0) {
            let v = f32_to_s16(s);
            prop_assert!((i16::MIN..=i16::MAX).contains(&v));
        }

        /// f32 → s16 → f32 stays within one quantisation step (~1/32767).
        #[test]
        fn prop_f32_s16_quantisation_bound(s in -1.0f32..=1.0) {
            let back = s16_to_f32(f32_to_s16(s));
            let step = 1.0 / f32::from(i16::MAX);
            prop_assert!((back - s).abs() <= step + 1e-6);
        }
    }
}