audio-opus-bsd 0.1.0

Opus codec (RFC 6716) encoder/decoder for the sonicbrew audio-toolkit — libopus binding, worker-thread encode/decode emitting planar audio-core-bsd AudioFrames
//! Opus codec (RFC 6716) encoder and decoder.
//!
//! This crate wraps [libopus] via the [`opus`] crate (FFI) and exposes
//! worker-thread [`AudioEncoder`]/[`AudioDecoder`] implementations that emit
//! and consume planar [`audio_core_bsd::AudioFrame`]s. The core encode/decode
//! types are deliberately **not** real-time safe (they perform FFI and heap
//! allocation); the [`worker`] module moves them onto dedicated worker threads
//! whose output is handed to the RT audio thread via lock-free `rtrb` rings.
//!
//! ## Planar ↔ interleaved conversion
//!
//! libopus speaks **interleaved** PCM (`[L0, R0, L1, R1, …]`), while
//! [`audio_core_bsd::AudioFrame`] is **planar** (`[ch0…, ch1…]`). The
//! canonical conversion lives in the [`layout`] module and is used at the
//! [`OpusEncoder::encode_frame`] and [`OpusDecoder::decode_frame`] boundaries.
//! The stride math is the single hottest silent-SNR-killer in any Opus
//! binding; it is unit-tested in isolation and via round-trip property tests
//! in [`layout`].
//!
//! ## Frame-size constraint
//!
//! libopus only accepts packets encoded from 2.5/5/10/20/40/60 ms frames. At
//! 48 kHz the canonical block size used here is **960 samples/channel (20 ms)**.
//!
//! ## Worker-thread layer
//!
//! [`OpusDecodeWorker`] and [`OpusEncodeWorker`] (in the [`worker`] module)
//! run all Opus FFI on a dedicated thread. The RT audio thread interacts only
//! via wait-free `rtrb::Consumer::pop` — the sole RT-safe entry point.
//!
//! ## Dependency licensing
//!
//! This crate depends on the [`opus`] crate (**MIT/Apache-2.0**, a pure-Rust
//! binding) which in turn links the system library **libopus**
//! (**BSD-3-Clause**). Both licenses are BSD-2-Clause compatible — no copyleft
//! contamination reaches this crate or its consumers.
//!
//! [`opus`]: https://crates.io/crates/opus
//! [libopus]: https://opus-codec.org/
//!
//! # Example
//!
//! Encode a planar [`AudioFrame`](audio_core_bsd::AudioFrame) to an Opus packet and decode it back:
//!
//! ```rust,no_run
//! use audio_core_bsd::AudioFrame;
//! use audio_opus_bsd::{AudioDecoder, AudioEncoder, OpusDecoder, OpusEncoder};
//!
//! let mut enc = OpusEncoder::new(48_000, 1, opus::Application::Audio)?;
//! let mut dec = OpusDecoder::new(48_000, 1)?;
//!
//! let frame = AudioFrame::silence(1, 960, 48_000); // 20 ms mono @ 48 kHz
//! let packet = enc.encode_frame(&frame)?;
//! let decoded = dec.decode_frame(&packet)?;
//! assert_eq!(decoded.channels, 1);
//! # Ok::<(), audio_opus_bsd::OpusError>(())
//! ```

#![cfg_attr(docsrs, feature(doc_cfg))]
#![warn(missing_docs)]
#![warn(clippy::all, clippy::pedantic)]

pub mod decoder;
pub mod encoder;
pub mod error;
pub mod layout;
pub mod worker;

pub use decoder::{AudioDecoder, OpusDecoder};
pub use encoder::{AudioEncoder, OpusEncoder};
pub use error::{OpusError, Result};
pub use worker::{OpusDecodeWorker, OpusEncodeWorker};

#[cfg(test)]
#[allow(clippy::cast_precision_loss)] // test-only: small-range loop indices cast to f32
mod tests {
    use super::*;
    use audio_core_bsd::AudioFrame;

    /// Canonical Opus block: 960 samples/channel = 20 ms @ 48 kHz.
    const FRAME_SIZE: usize = 960;
    const SAMPLE_RATE: u32 = 48_000;

    /// Generate `FRAME_SIZE` mono samples of a 440 Hz sine at -6 dBFS.
    fn sine_440() -> Vec<f32> {
        let phase_step = 2.0 * std::f32::consts::PI * 440.0 / SAMPLE_RATE as f32;
        (0..FRAME_SIZE)
            .map(|i| 0.5 * (i as f32 * phase_step).sin())
            .collect()
    }

    #[test]
    fn encode_then_decode_round_trips_canonical_block() {
        let pcm = sine_440();
        let mut enc = OpusEncoder::new(SAMPLE_RATE, 1, opus::Application::Audio).unwrap();
        let mut dec = OpusDecoder::new(SAMPLE_RATE, 1).unwrap();

        let packet = enc.encode(&pcm).unwrap();
        assert!(!packet.is_empty(), "Opus packet must not be empty");

        let out = dec.decode(&packet).unwrap();
        assert_eq!(
            out.len(),
            FRAME_SIZE,
            "decoded mono frame count must equal the canonical 960-sample block",
        );
    }

    #[test]
    fn encode_frame_then_decode_frame_round_trips_shape() {
        // The full AudioFrame path: planar→interleaved (encode) → packet →
        // interleaved→planar (decode). Samples are lossy (Opus is a lossy
        // codec), but the structural shape (channels + num_frames) must
        // round-trip exactly.
        let mut enc = OpusEncoder::new(SAMPLE_RATE, 2, opus::Application::Audio).unwrap();
        let mut dec = OpusDecoder::new(SAMPLE_RATE, 2).unwrap();

        let frame = AudioFrame::from_planar(2, SAMPLE_RATE, sine_440_for_stereo());
        let packet = enc.encode_frame(&frame).unwrap();
        assert!(!packet.is_empty());

        let decoded = dec.decode_frame(&packet).unwrap();
        assert_eq!(decoded.channels, 2);
        assert_eq!(decoded.sample_rate, SAMPLE_RATE);
        assert_eq!(decoded.num_frames(), FRAME_SIZE);
    }

    /// Build a 2-channel planar buffer: left = 440 Hz sine, right = silence.
    fn sine_440_for_stereo() -> Vec<f32> {
        let mono = sine_440();
        let mut planar = Vec::with_capacity(FRAME_SIZE * 2);
        planar.extend_from_slice(&mono);
        planar.extend(std::iter::repeat_n(0.0f32, FRAME_SIZE));
        planar
    }

    // ===== planar ↔ interleaved bit-exact round-trip (the #1 SNR-killer) =====
    // Composes the two canonical `layout` conversions to prove that no sample
    // is dropped, duplicated, or channel-swapped — independent of the lossy
    // Opus codec itself. (`layout`'s own proptest module covers the property
    // exhaustively; this is a fixed stereo sanity check at the crate root.)

    #[test]
    fn planar_interleaved_round_trip_is_bit_exact_stereo() {
        let original: Vec<f32> = (0..1920).map(|i| (i as f32) * 0.001).collect();
        let inter = layout::planar_to_interleaved(2, &original).unwrap();
        assert_eq!(inter.len(), original.len());
        let back = layout::interleaved_to_planar(2, &inter).unwrap();
        assert_eq!(back, original);
    }

    // ===== layout module: AudioFrame-bound planar ↔ interleaved round-trip =====
    // Proves the standalone conversion helpers preserve a full
    // `AudioFrame`'s sample buffer exactly, independent of Opus.

    #[test]
    fn layout_round_trip_preserves_audioframe_samples() {
        use layout::{interleaved_to_planar as i2p, planar_to_interleaved as p2i};
        let frame = AudioFrame::silence(2, 480, SAMPLE_RATE);
        // Silence round-trips to itself.
        let inter = p2i(2, &frame.samples).unwrap();
        let back = i2p(2, &inter).unwrap();
        assert_eq!(back, frame.samples);
        // A non-silent stereo frame with distinct per-channel ramps.
        let mut src = AudioFrame::silence(2, 480, SAMPLE_RATE);
        for (i, s) in src.channel_slice_mut(0).iter_mut().enumerate() {
            *s = (i as f32) * 0.001;
        }
        let inter = p2i(2, &src.samples).unwrap();
        assert_eq!(inter.len(), src.samples.len());
        let back = i2p(2, &inter).unwrap();
        assert_eq!(back, src.samples);
    }
}