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
//! An in-memory, device-free [`AudioBackend`] for testing.
//!
//! [`NullBackend`] produces no real audio and captures none: every frame
//! written to an output sink is discarded, and every read from an input source
//! returns silence. It is an in-memory test double — it lets unit and
//! integration tests exercise the [`AudioBackend`] / [`OutputSink`] /
//! [`InputSource`] contract without any hardware or system audio library.
//!
//! [`NullBackend`] is always available (no `cpal-backend` feature required), so
//! it compiles and runs on any host.

use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;

use audio_core_bsd::AudioFrame;

use crate::backend::{AudioBackend, InputSource, OutputSink};
use crate::device::{DeviceDirection, DeviceInfo, StreamParams};
use crate::error::Result;

/// A device-free [`AudioBackend`] that discards output and synthesises silence
/// for input.
///
/// Counters ([`NullBackend::output_writes`], [`NullBackend::input_reads`]) make
/// it useful for assertions in tests. They are shared with the sinks/sources via
/// an [`Arc`], so the count survives even after a sink is dropped.
#[derive(Debug, Default)]
pub struct NullBackend {
    output_writes: Arc<AtomicU64>,
    input_reads: Arc<AtomicU64>,
}

impl NullBackend {
    /// Creates a new null backend with zeroed counters.
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Number of [`OutputSink::write`] calls observed across all sinks opened
    /// from this backend.
    #[must_use]
    pub fn output_writes(&self) -> u64 {
        self.output_writes.load(Ordering::Relaxed)
    }

    /// Number of [`InputSource::read`] calls observed across all sources opened
    /// from this backend.
    #[must_use]
    pub fn input_reads(&self) -> u64 {
        self.input_reads.load(Ordering::Relaxed)
    }

    /// The single virtual device name this backend advertises.
    pub const DEVICE_NAME: &'static str = "null";

    fn device() -> DeviceInfo {
        DeviceInfo::new(
            Self::DEVICE_NAME,
            DeviceDirection::Duplex,
            2,
            vec![44_100, 48_000, 96_000],
            true,
        )
    }
}

impl AudioBackend for NullBackend {
    fn enumerate_devices(&self) -> Vec<DeviceInfo> {
        vec![Self::device()]
    }

    fn default_output(&self) -> Option<DeviceInfo> {
        Some(Self::device())
    }

    fn default_input(&self) -> Option<DeviceInfo> {
        Some(Self::device())
    }

    fn open_output(&self, dev: &str, params: StreamParams) -> Result<Box<dyn OutputSink>> {
        params.validate()?;
        if dev != Self::DEVICE_NAME {
            return Err(crate::IoError::DeviceNotFound(dev.into()));
        }
        Ok(Box::new(NullSink {
            writes: Arc::clone(&self.output_writes),
            channels: params.channels,
            sample_rate: params.sample_rate,
        }))
    }

    fn open_input(&self, dev: &str, params: StreamParams) -> Result<Box<dyn InputSource>> {
        params.validate()?;
        if dev != Self::DEVICE_NAME {
            return Err(crate::IoError::DeviceNotFound(dev.into()));
        }
        Ok(Box::new(NullSource {
            reads: Arc::clone(&self.input_reads),
            channels: params.channels,
            sample_rate: params.sample_rate,
            frames_per_read: 256,
        }))
    }
}

/// Output sink that discards every written frame and counts the writes.
pub struct NullSink {
    writes: Arc<AtomicU64>,
    channels: u16,
    sample_rate: u32,
}

impl OutputSink for NullSink {
    fn write(&mut self, frame: &AudioFrame) -> Result<()> {
        // RT-safe: a single relaxed atomic add, no allocation. The frame is
        // dropped immediately (output discarded).
        let _ = (self.channels, self.sample_rate, frame);
        self.writes.fetch_add(1, Ordering::Relaxed);
        Ok(())
    }
}

/// Input source that synthesises silence on every read and counts the reads.
pub struct NullSource {
    reads: Arc<AtomicU64>,
    channels: u16,
    sample_rate: u32,
    frames_per_read: usize,
}

impl InputSource for NullSource {
    fn read(&mut self) -> Result<AudioFrame> {
        self.reads.fetch_add(1, Ordering::Relaxed);
        Ok(AudioFrame::silence(
            self.channels,
            self.frames_per_read,
            self.sample_rate,
        ))
    }
}

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

    #[test]
    fn null_backend_advertises_one_duplex_device() {
        let b = NullBackend::new();
        let devs = b.enumerate_devices();
        assert_eq!(devs.len(), 1);
        assert_eq!(devs[0].name, NullBackend::DEVICE_NAME);
        assert_eq!(devs[0].direction, DeviceDirection::Duplex);
    }

    #[test]
    fn null_backend_has_default_output_and_input() {
        let b = NullBackend::new();
        assert!(b.default_output().is_some());
        assert!(b.default_input().is_some());
    }

    #[test]
    fn open_output_discards_writes_and_counts() {
        let b = NullBackend::new();
        let mut sink = b
            .open_output(NullBackend::DEVICE_NAME, StreamParams::pcm_48k_stereo())
            .unwrap();
        let frame = AudioFrame::silence(2, 256, 48_000);
        sink.write(&frame).unwrap();
        sink.write(&frame).unwrap();
        assert_eq!(b.output_writes(), 2);
    }

    #[test]
    fn open_input_returns_silence_and_counts() {
        let b = NullBackend::new();
        let mut src = b
            .open_input(NullBackend::DEVICE_NAME, StreamParams::pcm_48k_mono())
            .unwrap();
        let frame = src.read().unwrap();
        assert_eq!(frame.channels, 1);
        assert!(frame.samples.iter().all(|&s| s == 0.0));
        assert_eq!(b.input_reads(), 1);
    }

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

    #[test]
    fn open_output_validates_params() {
        let b = NullBackend::new();
        let err = b
            .open_output(
                NullBackend::DEVICE_NAME,
                StreamParams::pcm_48k_stereo().with_sample_rate(0),
            )
            .err()
            .unwrap();
        assert!(err.to_string().contains("invalid sample rate"));
    }
}