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
//! The [`AudioBackend`] public API contract and its companion stream traits.
//!
//! This module defines the backend abstraction that any concrete audio I/O
//! implementation (the cpal ALSA backend, a raw OSS backend, or a test double)
//! implements. It is deliberately free of concrete-backend types: the streams it
//! hands out are trait objects (`Box<dyn OutputSink>` / `Box<dyn InputSource>`),
//! so the caller never depends on `cpal` or any platform API.

use audio_core_bsd::AudioFrame;

use crate::device::{DeviceInfo, StreamParams};
use crate::Result;

/// Synchronous sink for playback audio.
///
/// An [`OutputSink`] is returned by [`AudioBackend::open_output`] and owned by
/// the caller. It is the write side of an open output stream.
///
/// # Real-time safety boundary
///
/// [`OutputSink::write`] is designed to be **callable from the real-time audio
/// thread**. A concrete implementation (e.g. the cpal backend) therefore MUST
/// NOT allocate, lock, panic, or perform I/O inside `write`. The canonical
/// pattern — see the real-time contract on [`audio_core_bsd::AudioNode`] — is
/// for the backend to own a device output callback that pulls from a lock-free
/// ring buffer; `write` simply *pushes* samples into that ring buffer (a
/// wait-free operation). The heavy lifting (device I/O) happens in the backend's
/// callback thread, not on the caller's RT thread.
///
/// When a caller writes faster than the device consumes, the ring fills up and
/// the backend reports the overflow via [`OutputSink::write`] returning
/// [`crate::IoError::StreamSetup`] (a benign back-pressure signal) rather than
/// blocking or allocating.
pub trait OutputSink: Send {
    /// Push one planar [`AudioFrame`] to the device.
    ///
    /// Returns an error on back-pressure (ring full) or a fatal stream fault.
    ///
    /// # Errors
    ///
    /// - [`crate::IoError::StreamSetup`] when the internal ring is full
    ///   (transient overflow) or the stream has stopped.
    fn write(&mut self, frame: &AudioFrame) -> Result<()>;

    /// Flush any buffered frames to the device.
    ///
    /// Best-effort; default implementation is a no-op.
    ///
    /// # Errors
    ///
    /// Implementations may return a stream fault.
    fn flush(&mut self) -> Result<()> {
        Ok(())
    }
}

/// Synchronous source for capture audio.
///
/// An [`InputSource`] is returned by [`AudioBackend::open_input`]. Unlike
/// [`OutputSink`], it is intended to be read on a **worker thread** (capture
/// buffering is not RT-critical in the same way playback is), keeping capture
/// draining off the RT thread.
pub trait InputSource: Send {
    /// Pull one planar [`AudioFrame`] from the device.
    ///
    /// Returns [`Ok`] with a frame, or an error on a fatal stream fault.
    ///
    /// # Errors
    ///
    /// - [`crate::IoError::StreamSetup`] when the stream has stopped or
    ///   underflowed unrecoverably.
    fn read(&mut self) -> Result<AudioFrame>;

    /// Best-effort reported input latency in milliseconds, when known.
    ///
    /// # Errors
    ///
    /// Implementations may return [`crate::IoError`] when latency is not
    /// measurable.
    fn latency_ms(&self) -> Result<f64> {
        Err(crate::IoError::Backend("latency unavailable".into()))
    }
}

/// Abstract audio I/O backend: enumerate devices and open streams.
///
/// This is the single contract a host (an audio engine, an integration test, or
/// any consumer) uses to reach the kernel audio layer, independent of whether
/// whether the concrete driver is cpal (ALSA → OSS `/dev/dsp` on FreeBSD) or a
/// raw OSS binding. Implementations are `Send + Sync` because device
/// enumeration and stream construction may be invoked from an arbitrary control
/// thread; the returned stream traits carry their own thread-affinity contract.
///
/// # Example
///
/// ```no_run
/// use audio_io_bsd::{AudioBackend, StreamParams};
/// // `backend` is some concrete `AudioBackend` (e.g. CpalBackend).
/// # fn demo(backend: &dyn AudioBackend) -> audio_io_bsd::Result<()> {
/// let params = StreamParams::pcm_48k_stereo();
/// let mut out = backend.open_output("default", params)?;
/// let silence = audio_core_bsd::AudioFrame::silence(2, 256, 48_000);
/// out.write(&silence)?;
/// # Ok(())
/// # }
/// ```
pub trait AudioBackend: Send + Sync {
    /// Enumerate the devices this backend can see.
    ///
    /// Returns at least one entry on a host with audio hardware; an empty `Vec`
    /// signals "no devices". This call may perform I/O and is intended for the
    /// control thread, never the RT thread.
    fn enumerate_devices(&self) -> Vec<DeviceInfo>;

    /// Return the backend's default **output** (playback) device, if any.
    fn default_output(&self) -> Option<DeviceInfo>;

    /// Return the backend's default **input** (capture) device, if any.
    fn default_input(&self) -> Option<DeviceInfo>;

    /// Open a playback stream on the named device.
    ///
    /// `dev` is a device name as reported by [`enumerate_devices`](Self::enumerate_devices);
    /// passing a name that is not enumerated yields [`crate::IoError::DeviceNotFound`].
    ///
    /// # Errors
    ///
    /// - [`crate::IoError::DeviceNotFound`] when `dev` does not exist.
    /// - [`crate::IoError::UnsupportedConfig`] when `params` is not supported.
    /// - [`crate::IoError::StreamSetup`] on a low-level stream build failure.
    fn open_output(&self, dev: &str, params: StreamParams) -> Result<Box<dyn OutputSink>>;

    /// Open a capture stream on the named device.
    ///
    /// # Errors
    ///
    /// See [`open_output`](Self::open_output).
    fn open_input(&self, dev: &str, params: StreamParams) -> Result<Box<dyn InputSource>>;
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::device::{DeviceDirection, DeviceInfo};
    use audio_core_bsd::AudioFrame;

    /// Minimal in-memory backend used only to exercise the trait object surface
    /// and prove the contract compiles for `dyn AudioBackend`.
    struct StubBackend;

    impl AudioBackend for StubBackend {
        fn enumerate_devices(&self) -> Vec<DeviceInfo> {
            vec![DeviceInfo::new(
                "stub",
                DeviceDirection::Output,
                2,
                vec![48_000],
                true,
            )]
        }
        fn default_output(&self) -> Option<DeviceInfo> {
            self.enumerate_devices().into_iter().next()
        }
        fn default_input(&self) -> Option<DeviceInfo> {
            None
        }
        fn open_output(&self, dev: &str, params: StreamParams) -> Result<Box<dyn OutputSink>> {
            params.validate()?;
            if dev != "stub" {
                return Err(crate::IoError::DeviceNotFound(dev.into()));
            }
            Ok(Box::new(StubSink))
        }
        fn open_input(&self, _dev: &str, _params: StreamParams) -> Result<Box<dyn InputSource>> {
            Err(crate::IoError::Backend("no input on stub".into()))
        }
    }

    struct StubSink;
    impl OutputSink for StubSink {
        fn write(&mut self, _frame: &AudioFrame) -> Result<()> {
            Ok(())
        }
    }

    #[test]
    fn stub_backend_enumerates_one_output_device() {
        let b = StubBackend;
        let devs = b.enumerate_devices();
        assert_eq!(devs.len(), 1);
        assert_eq!(devs[0].name, "stub");
    }

    #[test]
    fn dyn_backend_object_compiles_and_works() {
        let b: Box<dyn AudioBackend> = Box::new(StubBackend);
        assert!(b.default_output().is_some());
        assert!(b.default_input().is_none());
    }

    #[test]
    fn open_output_validates_params() {
        let b = StubBackend;
        let bad = StreamParams::pcm_48k_stereo().with_channels(0);
        let err = b.open_output("stub", bad).err().unwrap();
        assert!(err.to_string().contains("invalid channel count"));
    }

    #[test]
    fn open_output_unknown_device_is_not_found() {
        let b = StubBackend;
        let err = b
            .open_output("nope", StreamParams::pcm_48k_stereo())
            .err()
            .unwrap();
        assert!(err.to_string().contains("device not found"));
    }

    #[test]
    fn open_output_returns_writable_sink() {
        let b = StubBackend;
        let mut sink = b
            .open_output("stub", StreamParams::pcm_48k_stereo())
            .unwrap();
        let frame = AudioFrame::silence(2, 4, 48_000);
        sink.write(&frame).unwrap();
        sink.flush().unwrap();
    }

    #[test]
    fn open_input_reports_unsupported_on_stub() {
        let b = StubBackend;
        let err = b
            .open_input("stub", StreamParams::pcm_48k_mono())
            .err()
            .unwrap();
        assert!(err.to_string().contains("backend initialisation failed"));
    }

    #[test]
    fn input_source_default_latency_is_unavailable() {
        // Exercise the InputSource default method via a no-op impl.
        struct NoSrc;
        impl InputSource for NoSrc {
            fn read(&mut self) -> Result<AudioFrame> {
                Ok(AudioFrame::silence(1, 0, 48_000))
            }
        }
        let mut s = NoSrc;
        let frame = s.read().unwrap();
        assert_eq!(frame.num_frames(), 0);
        assert!(s.latency_ms().is_err());
    }
}