decibri 4.3.2

Cross-platform audio capture, playback, and voice activity detection for Rust
Documentation
/// Convert f32 samples (range [-1.0, 1.0]) to 16-bit signed integer little-endian bytes.
///
/// Each f32 sample produces 2 bytes (i16 LE). Values are clamped to [-1.0, 1.0].
/// Matches the JS convention: `sample * 32768` then clamp to i16 range.
pub fn f32_to_i16_le_bytes(samples: &[f32]) -> Vec<u8> {
    let mut out = Vec::with_capacity(samples.len() * 2);
    for &s in samples {
        let clamped = s.clamp(-1.0, 1.0);
        // Scale to i16 range. Use 32768.0 (not 32767.0) to match the JS convention
        // where computeRMS divides by 32768.
        let scaled = (clamped * 32768.0) as i32;
        // Clamp to valid i16 range [-32768, 32767]
        let val = scaled.clamp(-32768, 32767) as i16;
        out.extend_from_slice(&val.to_le_bytes());
    }
    out
}

/// Convert f32 samples to 32-bit IEEE 754 float little-endian bytes.
///
/// Each f32 sample produces 4 bytes (f32 LE). No clamping; values pass through as-is.
pub fn f32_to_f32_le_bytes(samples: &[f32]) -> Vec<u8> {
    let mut out = Vec::with_capacity(samples.len() * 4);
    for &s in samples {
        out.extend_from_slice(&s.to_le_bytes());
    }
    out
}

/// Convert i16 samples to f32 (range [-1.0, 1.0]).
///
/// Divides by 32768.0, matching the JS convention.
pub fn i16_to_f32(samples: &[i16]) -> Vec<f32> {
    samples.iter().map(|&s| s as f32 / 32768.0).collect()
}

/// Convert 16-bit signed integer little-endian bytes to f32 samples.
///
/// Each pair of bytes is interpreted as an i16 LE value, then divided by 32768.0.
/// Used by the output path: JS sends i16 LE bytes, Rust needs f32 for cpal.
///
/// A trailing odd byte (when `bytes.len()` is odd) is ignored. The public
/// bindings only ever pass even-length PCM buffers, so this is a defensive
/// note rather than a reachable case.
pub fn i16_le_bytes_to_f32(bytes: &[u8]) -> Vec<f32> {
    debug_assert!(
        bytes.len().is_multiple_of(2),
        "i16_le_bytes_to_f32 expects an even-length buffer; a trailing odd byte is dropped"
    );
    bytes
        .chunks_exact(2)
        .map(|c| {
            let val = i16::from_le_bytes([c[0], c[1]]);
            val as f32 / 32768.0
        })
        .collect()
}

/// Convert 32-bit IEEE 754 float little-endian bytes to f32 samples.
///
/// Each group of 4 bytes is interpreted as an f32 LE value.
/// Used by the output path: JS sends f32 LE bytes, Rust needs f32 for cpal.
pub fn f32_le_bytes_to_f32(bytes: &[u8]) -> Vec<f32> {
    bytes
        .chunks_exact(4)
        .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
        .collect()
}

/// Downmix interleaved multichannel audio to mono by averaging channels.
///
/// `samples` holds interleaved f32 frames of `channels` samples each
/// (`[c0, c1, .., c0, c1, ..]`). Each frame collapses to one mono sample equal
/// to the arithmetic mean of its channels, so `N` interleaved channels produce
/// `samples.len() / channels` mono samples.
///
/// `channels <= 1` returns the input unchanged (already mono, no allocation of a
/// reshaped buffer beyond the copy). Any trailing partial frame (when
/// `samples.len()` is not a multiple of `channels`) is dropped, matching the
/// frame-exact buffering the capture path guarantees.
///
/// Used to feed mono audio to the Silero VAD, which models a single channel:
/// without this, interleaved samples are misread as consecutive mono samples.
/// The interleaved data delivered to the consumer is left untouched; only the
/// VAD's private input is downmixed.
pub fn downmix_to_mono(samples: &[f32], channels: u16) -> Vec<f32> {
    if channels <= 1 {
        return samples.to_vec();
    }
    let channels = channels as usize;
    samples
        .chunks_exact(channels)
        .map(|frame| frame.iter().sum::<f32>() / channels as f32)
        .collect()
}

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

    #[test]
    fn test_silence() {
        let bytes = f32_to_i16_le_bytes(&[0.0]);
        assert_eq!(bytes, [0x00, 0x00]);
    }

    #[test]
    fn test_max_positive() {
        let bytes = f32_to_i16_le_bytes(&[1.0]);
        // 1.0 * 32768 = 32768, clamped to 32767 = 0x7FFF
        let val = i16::from_le_bytes([bytes[0], bytes[1]]);
        assert_eq!(val, 32767);
    }

    #[test]
    fn test_max_negative() {
        let bytes = f32_to_i16_le_bytes(&[-1.0]);
        // -1.0 * 32768 = -32768 = 0x8000
        let val = i16::from_le_bytes([bytes[0], bytes[1]]);
        assert_eq!(val, -32768);
    }

    #[test]
    fn test_half_amplitude() {
        let bytes = f32_to_i16_le_bytes(&[0.5]);
        let val = i16::from_le_bytes([bytes[0], bytes[1]]);
        // 0.5 * 32768 = 16384
        assert_eq!(val, 16384);
    }

    #[test]
    fn test_clamp_above_one() {
        let bytes = f32_to_i16_le_bytes(&[1.5]);
        let val = i16::from_le_bytes([bytes[0], bytes[1]]);
        assert_eq!(val, 32767);
    }

    #[test]
    fn test_clamp_below_negative_one() {
        let bytes = f32_to_i16_le_bytes(&[-1.5]);
        let val = i16::from_le_bytes([bytes[0], bytes[1]]);
        assert_eq!(val, -32768);
    }

    #[test]
    fn test_roundtrip_i16() {
        let original: Vec<f32> = vec![0.0, 0.25, -0.25, 0.5, -0.5, 0.99, -0.99];
        let bytes = f32_to_i16_le_bytes(&original);

        // Reconstruct i16 values
        let i16_samples: Vec<i16> = bytes
            .chunks_exact(2)
            .map(|c| i16::from_le_bytes([c[0], c[1]]))
            .collect();

        // Convert back to f32
        let reconstructed = i16_to_f32(&i16_samples);

        // Verify within quantization tolerance (1/32768 ≈ 0.0000305)
        for (orig, recon) in original.iter().zip(reconstructed.iter()) {
            let diff = (orig - recon).abs();
            assert!(
                diff < 1.0 / 32768.0 + f32::EPSILON,
                "Roundtrip error too large: {orig} -> {recon} (diff: {diff})"
            );
        }
    }

    #[test]
    fn test_f32_le_roundtrip() {
        let original: Vec<f32> = vec![0.0, 0.25, -0.25, 1.0, -1.0, 0.001, -0.999];
        let bytes = f32_to_f32_le_bytes(&original);

        let reconstructed: Vec<f32> = bytes
            .chunks_exact(4)
            .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
            .collect();

        assert_eq!(original, reconstructed);
    }

    #[test]
    fn test_multiple_samples() {
        let samples = vec![0.0, 0.5, -0.5];
        let bytes = f32_to_i16_le_bytes(&samples);
        assert_eq!(bytes.len(), 6); // 3 samples * 2 bytes each

        let f32_bytes = f32_to_f32_le_bytes(&samples);
        assert_eq!(f32_bytes.len(), 12); // 3 samples * 4 bytes each
    }

    // ── Reverse conversion tests (bytes → f32, used by output) ───────────

    #[test]
    fn test_i16_le_bytes_to_f32_silence() {
        let bytes = vec![0x00, 0x00];
        let samples = i16_le_bytes_to_f32(&bytes);
        assert_eq!(samples, vec![0.0]);
    }

    #[test]
    fn test_i16_le_bytes_to_f32_max() {
        // 32767 = 0xFF7F in LE
        let bytes = vec![0xFF, 0x7F];
        let samples = i16_le_bytes_to_f32(&bytes);
        assert!((samples[0] - 32767.0 / 32768.0).abs() < f32::EPSILON);
    }

    #[test]
    fn test_i16_le_bytes_to_f32_roundtrip() {
        // f32 → i16 LE bytes → f32: verify roundtrip within quantization tolerance
        let original: Vec<f32> = vec![0.0, 0.25, -0.25, 0.5, -0.5, 0.99, -0.99];
        let bytes = f32_to_i16_le_bytes(&original);
        let reconstructed = i16_le_bytes_to_f32(&bytes);

        for (orig, recon) in original.iter().zip(reconstructed.iter()) {
            let diff = (orig - recon).abs();
            assert!(
                diff < 1.0 / 32768.0 + f32::EPSILON,
                "i16 LE roundtrip error: {orig} -> {recon} (diff: {diff})"
            );
        }
    }

    #[test]
    fn test_f32_le_bytes_to_f32_roundtrip() {
        let original: Vec<f32> = vec![0.0, 0.25, -0.25, 1.0, -1.0, 0.001, -0.999];
        let bytes = f32_to_f32_le_bytes(&original);
        let reconstructed = f32_le_bytes_to_f32(&bytes);
        assert_eq!(original, reconstructed);
    }

    #[test]
    fn test_i16_le_bytes_to_f32_empty() {
        let bytes: Vec<u8> = vec![];
        let samples = i16_le_bytes_to_f32(&bytes);
        assert!(samples.is_empty());
    }

    #[test]
    fn test_f32_le_bytes_to_f32_empty() {
        let bytes: Vec<u8> = vec![];
        let samples = f32_le_bytes_to_f32(&bytes);
        assert!(samples.is_empty());
    }

    // ── downmix_to_mono (multichannel -> mono for the VAD feed) ───────────

    #[test]
    fn test_downmix_stereo_averages_and_halves() {
        // L R L R: two stereo frames -> two mono samples, each the average.
        let stereo = vec![0.5, 0.3, 0.4, 0.6];
        let mono = downmix_to_mono(&stereo, 2);
        // N interleaved channels produce 1/N the sample count.
        assert_eq!(mono.len(), stereo.len() / 2);
        assert!((mono[0] - 0.4).abs() < 1e-6); // (0.5 + 0.3) / 2
        assert!((mono[1] - 0.5).abs() < 1e-6); // (0.4 + 0.6) / 2
    }

    #[test]
    fn test_downmix_mono_passthrough() {
        // channels == 1 is already mono: returns the samples unchanged.
        let samples = vec![0.1, -0.2, 0.3];
        assert_eq!(downmix_to_mono(&samples, 1), samples);
        // channels == 0 is degenerate; treated as already-mono (no panic / div0).
        assert_eq!(downmix_to_mono(&samples, 0), samples);
    }

    #[test]
    fn test_downmix_six_channel() {
        // One 5.1 frame averages all six channels into a single sample.
        let frame = vec![0.0, 0.6, 0.3, -0.3, 1.2, -1.8];
        let mono = downmix_to_mono(&frame, 6);
        assert_eq!(mono.len(), 1);
        assert!((mono[0] - 0.0).abs() < 1e-6); // sum is 0.0 -> mean 0.0
    }

    #[test]
    fn test_downmix_preserves_sign_and_magnitude() {
        // Opposite-phase channels cancel; equal channels pass through.
        assert_eq!(downmix_to_mono(&[1.0, -1.0], 2), vec![0.0]);
        assert_eq!(downmix_to_mono(&[-0.5, -0.5], 2), vec![-0.5]);
    }

    #[test]
    fn test_downmix_drops_trailing_partial_frame() {
        // 5 samples at 2 channels: two full frames, the trailing sample dropped.
        let stereo = vec![0.2, 0.4, 0.6, 0.8, 0.9];
        let mono = downmix_to_mono(&stereo, 2);
        assert_eq!(mono.len(), 2);
        assert!((mono[0] - 0.3).abs() < 1e-6);
        assert!((mono[1] - 0.7).abs() < 1e-6);
    }

    #[test]
    fn test_downmix_empty() {
        let empty: Vec<f32> = vec![];
        assert!(downmix_to_mono(&empty, 2).is_empty());
    }
}