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
//! Device enumeration types and stream parameter description.
//!
//! This module defines the plain data that flows across the
//! [`AudioBackend`](crate::AudioBackend) boundary: a [`StreamParams`] describes
//! *what* a caller wants to open, and a [`DeviceInfo`] describes *what* a device
//! offers. Neither type leaks a concrete backend's types (no `cpal` here), so
//! the core contract compiles and tests without any system audio library.

use audio_core_bsd::{AudioError, SampleFormat};

use crate::Result;

/// Re-export of the shared sample-format enum, so consumers of this crate do not
/// need to depend on `audio-core-bsd` just to name a sample format.
pub use audio_core_bsd::SampleFormat as CoreSampleFormat;

/// Buffer-size policy requested when opening a stream.
///
/// Backends translate this into a concrete period/buffer size. A fixed frame
/// count is preferred for real-time determinism; a latency target lets the
/// backend pick the closest supported size.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BufferSize {
    /// Request an exact per-channel frame count per period.
    Fixed(usize),
    /// Request the backend's default period for the device.
    Default,
}

/// Parameters describing the audio stream a caller wants to open.
///
/// All fields are validated by [`StreamParams::validate`] before a backend uses
/// them; invalid parameters yield [`AudioError`] wrapped in [`crate::IoError`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StreamParams {
    /// Sample rate in Hz (`48000` is the common baseline).
    pub sample_rate: u32,
    /// Number of channels (`1` = mono, `2` = stereo, …).
    pub channels: u16,
    /// Numeric format of each sample. `F32` is the default and the format used
    /// by [`audio_core_bsd::AudioFrame`].
    pub sample_format: SampleFormat,
    /// Requested buffer-size policy.
    pub buffer_size: BufferSize,
}

impl StreamParams {
    /// Convenience constructor: 48 kHz, stereo, F32, default buffer.
    #[must_use]
    pub fn pcm_48k_stereo() -> Self {
        Self {
            sample_rate: 48_000,
            channels: 2,
            sample_format: SampleFormat::F32,
            buffer_size: BufferSize::Default,
        }
    }

    /// Convenience constructor: 48 kHz, mono, F32, default buffer.
    #[must_use]
    pub fn pcm_48k_mono() -> Self {
        Self {
            sample_rate: 48_000,
            channels: 1,
            sample_format: SampleFormat::F32,
            buffer_size: BufferSize::Default,
        }
    }

    /// Builder-style: set the sample rate.
    #[must_use]
    pub fn with_sample_rate(mut self, sample_rate: u32) -> Self {
        self.sample_rate = sample_rate;
        self
    }

    /// Builder-style: set the channel count.
    #[must_use]
    pub fn with_channels(mut self, channels: u16) -> Self {
        self.channels = channels;
        self
    }

    /// Builder-style: set the sample format.
    #[must_use]
    pub fn with_sample_format(mut self, sample_format: SampleFormat) -> Self {
        self.sample_format = sample_format;
        self
    }

    /// Builder-style: set a fixed buffer size.
    #[must_use]
    pub fn with_fixed_buffer(mut self, frames: usize) -> Self {
        self.buffer_size = BufferSize::Fixed(frames);
        self
    }

    /// Validates the parameters, returning `Err` for an invalid sample rate or
    /// channel count (or a zero fixed buffer size).
    ///
    /// # Errors
    ///
    /// - [`AudioError::InvalidSampleRate`] when `sample_rate == 0`.
    /// - [`AudioError::InvalidChannelCount`] when `channels == 0`.
    /// - [`AudioError::BufferTooSmall`] when a `BufferSize::Fixed(0)` is
    ///   requested.
    pub fn validate(&self) -> Result<()> {
        if self.sample_rate == 0 {
            return Err(AudioError::InvalidSampleRate(0).into());
        }
        if self.channels == 0 {
            return Err(AudioError::InvalidChannelCount(0).into());
        }
        if let BufferSize::Fixed(n) = self.buffer_size {
            if n == 0 {
                return Err(AudioError::BufferTooSmall { needed: 1, have: 0 }.into());
            }
        }
        Ok(())
    }
}

/// Whether a device is used for capture, playback, or both.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum DeviceDirection {
    /// Capture (input / recording).
    Input,
    /// Playback (output).
    Output,
    /// Full-duplex (capture and playback on one device).
    Duplex,
}

/// Static description of an audio device discovered by a backend.
///
/// Populated by [`AudioBackend::enumerate_devices`](crate::AudioBackend::enumerate_devices)
/// and used both to pick a device and to validate [`StreamParams`] against its
/// capabilities.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeviceInfo {
    /// Human-readable device name (e.g. `"default"`, `"/dev/dsp0"`).
    pub name: String,
    /// Capture / playback / duplex.
    pub direction: DeviceDirection,
    /// Number of channels the device supports.
    pub channels: u16,
    /// Sample rates the device advertises (may be non-exhaustive).
    pub sample_rates: Vec<u32>,
    /// Whether this is the backend's default device for its direction.
    pub is_default: bool,
}

impl DeviceInfo {
    /// Creates a new device descriptor from its raw fields.
    #[must_use]
    pub fn new(
        name: impl Into<String>,
        direction: DeviceDirection,
        channels: u16,
        sample_rates: Vec<u32>,
        is_default: bool,
    ) -> Self {
        Self {
            name: name.into(),
            direction,
            channels,
            sample_rates,
            is_default,
        }
    }

    /// Returns `true` when the device advertises support for `rate`.
    ///
    /// An empty `sample_rates` list is treated as "unknown / accepts anything",
    /// so callers cannot use a missing capability list to reject a device.
    #[must_use]
    pub fn supports_rate(&self, rate: u32) -> bool {
        self.sample_rates.is_empty() || self.sample_rates.contains(&rate)
    }
}

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

    #[test]
    fn pcm_48k_stereo_has_expected_defaults() {
        let p = StreamParams::pcm_48k_stereo();
        assert_eq!(p.sample_rate, 48_000);
        assert_eq!(p.channels, 2);
        assert_eq!(p.sample_format, SampleFormat::F32);
        assert_eq!(p.buffer_size, BufferSize::Default);
    }

    #[test]
    fn pcm_48k_mono_has_expected_defaults() {
        let p = StreamParams::pcm_48k_mono();
        assert_eq!(p.channels, 1);
    }

    #[test]
    fn builders_chain_into_custom_params() {
        let p = StreamParams::pcm_48k_stereo()
            .with_sample_rate(44_100)
            .with_channels(6)
            .with_sample_format(SampleFormat::I16)
            .with_fixed_buffer(256);
        assert_eq!(p.sample_rate, 44_100);
        assert_eq!(p.channels, 6);
        assert_eq!(p.sample_format, SampleFormat::I16);
        assert_eq!(p.buffer_size, BufferSize::Fixed(256));
    }

    #[test]
    fn validate_accepts_well_formed_params() {
        StreamParams::pcm_48k_stereo().validate().unwrap();
    }

    #[test]
    fn validate_rejects_zero_sample_rate() {
        let p = StreamParams::pcm_48k_stereo().with_sample_rate(0);
        let err = p.validate().unwrap_err();
        assert!(err.to_string().contains("invalid sample rate"));
    }

    #[test]
    fn validate_rejects_zero_channels() {
        let p = StreamParams::pcm_48k_stereo().with_channels(0);
        let err = p.validate().unwrap_err();
        assert!(err.to_string().contains("invalid channel count"));
    }

    #[test]
    fn validate_rejects_zero_fixed_buffer() {
        let p = StreamParams::pcm_48k_stereo().with_fixed_buffer(0);
        let err = p.validate().unwrap_err();
        assert!(err.to_string().contains("buffer too small"));
    }

    #[test]
    fn buffer_size_variants_are_distinct() {
        assert_ne!(BufferSize::Default, BufferSize::Fixed(256));
        assert_ne!(BufferSize::Fixed(128), BufferSize::Fixed(256));
    }

    #[test]
    fn device_info_new_populates_all_fields() {
        let d = DeviceInfo::new(
            "/dev/dsp0",
            DeviceDirection::Output,
            2,
            vec![44_100, 48_000],
            true,
        );
        assert_eq!(d.name, "/dev/dsp0");
        assert_eq!(d.direction, DeviceDirection::Output);
        assert_eq!(d.channels, 2);
        assert!(d.is_default);
    }

    #[test]
    fn supports_rate_true_when_listed() {
        let d = DeviceInfo::new("d", DeviceDirection::Output, 2, vec![48_000], false);
        assert!(d.supports_rate(48_000));
        assert!(!d.supports_rate(44_100));
    }

    #[test]
    fn supports_rate_accepts_anything_when_unknown() {
        // Empty rate list => unknown capabilities => accept (never reject).
        let d = DeviceInfo::new("d", DeviceDirection::Input, 1, vec![], false);
        assert!(d.supports_rate(8_000));
        assert!(d.supports_rate(192_000));
    }

    #[test]
    fn device_direction_variants_are_distinct() {
        assert_ne!(DeviceDirection::Input, DeviceDirection::Output);
        assert_ne!(DeviceDirection::Output, DeviceDirection::Duplex);
    }

    proptest! {
        /// Any well-formed params validate successfully, and field round-trips
        /// survive builder composition.
        #[test]
        fn prop_valid_params_round_trip(
            rate in 1u32..=192_000,
            channels in 1u16..=32u16,
            buf in 1usize..=8192,
        ) {
            let p = StreamParams::pcm_48k_stereo()
                .with_sample_rate(rate)
                .with_channels(channels)
                .with_fixed_buffer(buf);
            prop_assert!(p.validate().is_ok());
        }

        /// Zero sample rate and zero channels are always rejected.
        #[test]
        fn prop_invalid_params_rejected(channels in 0u16..=32u16) {
            let p = StreamParams::pcm_48k_stereo()
                .with_sample_rate(0)
                .with_channels(channels.max(1));
            prop_assert!(p.validate().is_err());

            let p2 = StreamParams::pcm_48k_stereo().with_channels(0);
            prop_assert!(p2.validate().is_err());
        }
    }
}