koan-core 0.63.0

Core library for koan — bit-perfect music player. Audio engine, player, database, format strings.
Documentation
use std::sync::Arc;
use std::sync::atomic::AtomicU64;

use thiserror::Error;

#[derive(Debug, Error)]
pub enum BackendError {
    #[error("no output devices found")]
    NoDevices,
    #[error("device not found: {0}")]
    DeviceNotFound(String),
    #[error("unsupported sample rate: {0}")]
    UnsupportedSampleRate(f64),
    #[error("platform error: {0}")]
    Platform(String),
    #[error("stream creation failed: {0}")]
    StreamCreation(String),
}

/// Platform-agnostic output device descriptor.
#[derive(Debug, Clone)]
pub struct DeviceInfo {
    pub name: String,
    pub sample_rates: Vec<f64>,
    /// Opaque platform-specific ID. CoreAudio: AudioDeviceID, cpal: index.
    pub platform_id: u64,
    pub kind: OutputKind,
}

/// How a device is connected, so a picker can show it for what it is.
/// CoreAudio says; other platforms answer `Other`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum OutputKind {
    BuiltIn,
    Usb,
    Bluetooth,
    AirPlay,
    /// HDMI or DisplayPort: a display's speakers, or what it passes on.
    Display,
    Virtual,
    #[default]
    Other,
}

impl OutputKind {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::BuiltIn => "builtin",
            Self::Usb => "usb",
            Self::Bluetooth => "bluetooth",
            Self::AirPlay => "airplay",
            Self::Display => "display",
            Self::Virtual => "virtual",
            Self::Other => "other",
        }
    }
}

/// Trait abstracting platform audio output.
///
/// Implementations exist for CoreAudio (macOS), RemoteIO (iOS), cpal (Linux)
/// and AAudio (Android).
/// The decode pipeline (rtrb ring buffer, Symphonia, `PlaybackTimeline`) is
/// completely decoupled — backends are dumb consumers that drain the ring buffer.
pub trait AudioBackend: Send + Sync {
    /// List available output devices.
    fn list_devices(&self) -> Result<Vec<DeviceInfo>, BackendError>;

    /// Get the default output device.
    fn default_device(&self) -> Result<DeviceInfo, BackendError>;

    /// Query supported sample rates for a device.
    fn supported_sample_rates(&self, device: &DeviceInfo) -> Result<Vec<f64>, BackendError>;

    /// Get the current nominal sample rate of a device.
    fn get_device_sample_rate(&self, device: &DeviceInfo) -> Result<f64, BackendError>;

    /// Set the nominal sample rate of a device (for bit-perfect matching).
    /// Returns the actual device rate after the switch (may differ if unsupported).
    /// On Linux/cpal this is a no-op — the rate is set at stream creation.
    fn set_device_sample_rate(&self, device: &DeviceInfo, rate: f64) -> Result<f64, BackendError>;

    /// Prepare the device, now at `current`, for a stream of this format, and
    /// answer the rate it will run at. Most platforms switch only the rate,
    /// and only when it differs; Android also chooses the sample encoding and
    /// whether its mixer is bypassed, which depends on the channels and the
    /// source's bit depth, whatever the rate.
    fn prepare_output(
        &self,
        device: &DeviceInfo,
        current: f64,
        rate: f64,
        _channels: u16,
        _bit_depth: Option<u16>,
    ) -> Result<f64, BackendError> {
        if (current - rate).abs() > 0.1 {
            self.set_device_sample_rate(device, rate)
        } else {
            Ok(current)
        }
    }

    /// Whether the system mixes this output with other apps' on its way to
    /// the device, where the platform can say: Android's mixer converts and
    /// applies volume unless koan holds a USB DAC bit-perfect. `None` where
    /// the device rate is the whole story.
    fn output_mixed(&self) -> Option<bool> {
        None
    }

    /// The most integer bits a sample keeps on its way to the device, where
    /// the output narrows them: a gapless run cannot carry a deeper track
    /// through it. `None` where nothing is narrowed.
    fn output_depth(&self) -> Option<u16> {
        None
    }

    /// Whether `output_depth` may narrow, so a session must assume it does
    /// until the output is open: the decoder runs ahead of it.
    fn narrows_depth(&self) -> bool {
        false
    }

    /// Be told when the output stops for good underneath the player, so it
    /// can be rebuilt: Android disconnects every stream when its audio server
    /// restarts. Called on a thread of the platform's; it must only send.
    fn on_disconnect(&self, _rebuild: Arc<dyn Fn() + Send + Sync>) {}

    /// Subscribe to nominal sample rate changes on a device.
    ///
    /// The rate is device-wide and anyone can move it — another app, Audio MIDI
    /// Setup, the vendor's control panel. Whatever koan settled on at engine
    /// creation is only true until one of them does, so the front ends need to
    /// hear about it rather than re-reading a snapshot. Dropping the returned
    /// watch unsubscribes. `None` where the platform has no such notification.
    fn watch_device_sample_rate(
        &self,
        _device: &DeviceInfo,
        _on_change: Box<dyn Fn(f64) + Send + Sync>,
    ) -> Option<Box<dyn SampleRateWatch>> {
        None
    }

    /// Create an audio engine targeting a device at a specific format.
    /// Takes ownership of the rtrb consumer.
    fn create_engine(
        &self,
        device: &DeviceInfo,
        sample_rate: f64,
        channels: u32,
        consumer: rtrb::Consumer<f32>,
        samples_played: Arc<AtomicU64>,
    ) -> Result<Box<dyn AudioEngineHandle>, BackendError>;
}

/// The samples an engine has played as of now, for an output that hands
/// over samples in chunks too coarse to read the playhead from directly.
pub type Presentation = Arc<dyn Fn() -> u64 + Send + Sync>;

/// A live sample rate subscription. Unsubscribes on drop.
pub trait SampleRateWatch: Send + Sync {}

/// Handle to a running audio engine. Start/stop control.
///
/// `start` and `stop` are immediate. `fade_out` and `fade_in` are what pause
/// and resume use: the output ramps rather than cuts, and the unit keeps
/// running through a fade out until `is_silent`, when it can be stopped.
pub trait AudioEngineHandle: Send {
    fn start(&self) -> Result<(), BackendError>;
    fn stop(&self) -> Result<(), BackendError>;
    fn is_running(&self) -> bool;
    fn fade_out(&self);
    /// A sleep timer's gain on the output, glided to, or with `snap` taken
    /// at once. Nothing where the output has no fader.
    fn set_sleep_gain(&self, _gain: f32, _snap: bool) {}
    /// Ramp back to full volume, starting the unit if it was stopped.
    fn fade_in(&self) -> Result<(), BackendError>;
    /// `fade_out` and `fade_in` over a few milliseconds: the dip a DSP change
    /// plays through. As the ordinary ones where the output has no fader.
    fn fade_out_quickly(&self) {
        self.fade_out()
    }
    fn fade_in_quickly(&self) -> Result<(), BackendError> {
        self.fade_in()
    }
    /// How long one render callback lasts: how late a fade can begin after
    /// it is asked for. Zero where the output does not say.
    fn period(&self) -> std::time::Duration {
        std::time::Duration::ZERO
    }
    fn is_silent(&self) -> bool;
    /// Play `frames` of silence before anything from the ring, without
    /// counting them as played. A device that has just changed rate is
    /// relocking its clock, and many mute while they do: what is sent then is
    /// never heard. Nothing reports when the clock has locked, so the wait is
    /// a length of time. Cleared by `fade_in`.
    fn lead_in(&self, _frames: u64) {}
    /// Where the playhead is between callbacks, when the output's callbacks
    /// are far enough apart that the count they keep would read as a playhead
    /// moving in jumps. `None` where the count is fine as it is.
    fn presentation(&self) -> Option<Presentation> {
        None
    }
}

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

    #[test]
    fn device_info_construction() {
        let info = DeviceInfo {
            name: "Test DAC".into(),
            sample_rates: vec![44100.0, 48000.0, 96000.0],
            platform_id: 42,
            kind: Default::default(),
        };
        assert_eq!(info.name, "Test DAC");
        assert_eq!(info.sample_rates.len(), 3);
        assert_eq!(info.platform_id, 42);
    }

    #[test]
    fn backend_error_formatting() {
        let err = BackendError::NoDevices;
        assert_eq!(err.to_string(), "no output devices found");

        let err = BackendError::DeviceNotFound("Missing".into());
        assert!(err.to_string().contains("Missing"));

        let err = BackendError::UnsupportedSampleRate(192000.0);
        assert!(err.to_string().contains("192000"));
    }

    #[test]
    fn platform_backend_constructs() {
        // Verify the platform backend can be created without panicking.
        let _backend = super::super::platform_backend();
    }

    #[test]
    fn platform_backend_lists_devices() {
        let backend = super::super::platform_backend();
        // Should not panic. May return empty on CI (no audio hardware).
        let result = backend.list_devices();
        assert!(result.is_ok());
    }

    #[test]
    fn platform_backend_has_default_device() {
        let backend = super::super::platform_backend();
        // On real hardware this should succeed. On CI it might fail (no device).
        // We just verify it doesn't panic.
        let _ = backend.default_device();
    }

    #[test]
    fn engine_create_with_ring_buffer() {
        let backend = super::super::platform_backend();
        let device = match backend.default_device() {
            Ok(d) => d,
            Err(_) => return, // no audio device (CI) — skip
        };

        let (producer, consumer) = rtrb::RingBuffer::new(4096);
        let samples_played = Arc::new(AtomicU64::new(0));

        let rate = device.sample_rates.first().copied().unwrap_or(44100.0);

        let engine = backend.create_engine(&device, rate, 2, consumer, samples_played);
        // Should create without panicking on real hardware.
        // May fail on CI — that's fine, we're testing the code path not the hardware.
        if let Ok(engine) = engine {
            assert!(!engine.is_running());
            // Don't start — no point playing silence in a test.
            drop(engine);
        }
        drop(producer); // keep producer alive until after engine
    }
}