Skip to main content

koan_core/audio/
backend.rs

1use std::sync::Arc;
2use std::sync::atomic::AtomicU64;
3
4use thiserror::Error;
5
6#[derive(Debug, Error)]
7pub enum BackendError {
8    #[error("no output devices found")]
9    NoDevices,
10    #[error("device not found: {0}")]
11    DeviceNotFound(String),
12    #[error("unsupported sample rate: {0}")]
13    UnsupportedSampleRate(f64),
14    #[error("platform error: {0}")]
15    Platform(String),
16    #[error("stream creation failed: {0}")]
17    StreamCreation(String),
18}
19
20/// Platform-agnostic output device descriptor.
21#[derive(Debug, Clone)]
22pub struct DeviceInfo {
23    pub name: String,
24    pub sample_rates: Vec<f64>,
25    /// Opaque platform-specific ID. CoreAudio: AudioDeviceID, cpal: index.
26    pub platform_id: u64,
27}
28
29/// Trait abstracting platform audio output.
30///
31/// Implementations exist for CoreAudio (macOS) and cpal (Linux).
32/// The decode pipeline (rtrb ring buffer, Symphonia, `PlaybackTimeline`) is
33/// completely decoupled — backends are dumb consumers that drain the ring buffer.
34pub trait AudioBackend: Send + Sync {
35    /// List available output devices.
36    fn list_devices(&self) -> Result<Vec<DeviceInfo>, BackendError>;
37
38    /// Get the default output device.
39    fn default_device(&self) -> Result<DeviceInfo, BackendError>;
40
41    /// Query supported sample rates for a device.
42    fn supported_sample_rates(&self, device: &DeviceInfo) -> Result<Vec<f64>, BackendError>;
43
44    /// Get the current nominal sample rate of a device.
45    fn get_device_sample_rate(&self, device: &DeviceInfo) -> Result<f64, BackendError>;
46
47    /// Set the nominal sample rate of a device (for bit-perfect matching).
48    /// Returns the actual device rate after the switch (may differ if unsupported).
49    /// On Linux/cpal this is a no-op — the rate is set at stream creation.
50    fn set_device_sample_rate(&self, device: &DeviceInfo, rate: f64) -> Result<f64, BackendError>;
51
52    /// Subscribe to nominal sample rate changes on a device.
53    ///
54    /// The rate is device-wide and anyone can move it — another app, Audio MIDI
55    /// Setup, the vendor's control panel. Whatever koan settled on at engine
56    /// creation is only true until one of them does, so the front ends need to
57    /// hear about it rather than re-reading a snapshot. Dropping the returned
58    /// watch unsubscribes. `None` where the platform has no such notification.
59    fn watch_device_sample_rate(
60        &self,
61        _device: &DeviceInfo,
62        _on_change: Box<dyn Fn(f64) + Send + Sync>,
63    ) -> Option<Box<dyn SampleRateWatch>> {
64        None
65    }
66
67    /// Create an audio engine targeting a device at a specific format.
68    /// Takes ownership of the rtrb consumer.
69    fn create_engine(
70        &self,
71        device: &DeviceInfo,
72        sample_rate: f64,
73        channels: u32,
74        consumer: rtrb::Consumer<f32>,
75        samples_played: Arc<AtomicU64>,
76    ) -> Result<Box<dyn AudioEngineHandle>, BackendError>;
77}
78
79/// A live sample rate subscription. Unsubscribes on drop.
80pub trait SampleRateWatch: Send + Sync {}
81
82/// Handle to a running audio engine. Start/stop control.
83pub trait AudioEngineHandle: Send {
84    fn start(&self) -> Result<(), BackendError>;
85    fn stop(&self) -> Result<(), BackendError>;
86    fn is_running(&self) -> bool;
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    #[test]
94    fn device_info_construction() {
95        let info = DeviceInfo {
96            name: "Test DAC".into(),
97            sample_rates: vec![44100.0, 48000.0, 96000.0],
98            platform_id: 42,
99        };
100        assert_eq!(info.name, "Test DAC");
101        assert_eq!(info.sample_rates.len(), 3);
102        assert_eq!(info.platform_id, 42);
103    }
104
105    #[test]
106    fn backend_error_formatting() {
107        let err = BackendError::NoDevices;
108        assert_eq!(err.to_string(), "no output devices found");
109
110        let err = BackendError::DeviceNotFound("Missing".into());
111        assert!(err.to_string().contains("Missing"));
112
113        let err = BackendError::UnsupportedSampleRate(192000.0);
114        assert!(err.to_string().contains("192000"));
115    }
116
117    #[test]
118    fn platform_backend_constructs() {
119        // Verify the platform backend can be created without panicking.
120        let _backend = super::super::platform_backend();
121    }
122
123    #[test]
124    fn platform_backend_lists_devices() {
125        let backend = super::super::platform_backend();
126        // Should not panic. May return empty on CI (no audio hardware).
127        let result = backend.list_devices();
128        assert!(result.is_ok());
129    }
130
131    #[test]
132    fn platform_backend_has_default_device() {
133        let backend = super::super::platform_backend();
134        // On real hardware this should succeed. On CI it might fail (no device).
135        // We just verify it doesn't panic.
136        let _ = backend.default_device();
137    }
138
139    #[test]
140    fn engine_create_with_ring_buffer() {
141        let backend = super::super::platform_backend();
142        let device = match backend.default_device() {
143            Ok(d) => d,
144            Err(_) => return, // no audio device (CI) — skip
145        };
146
147        let (producer, consumer) = rtrb::RingBuffer::new(4096);
148        let samples_played = Arc::new(AtomicU64::new(0));
149
150        let rate = device.sample_rates.first().copied().unwrap_or(44100.0);
151
152        let engine = backend.create_engine(&device, rate, 2, consumer, samples_played);
153        // Should create without panicking on real hardware.
154        // May fail on CI — that's fine, we're testing the code path not the hardware.
155        if let Ok(engine) = engine {
156            assert!(!engine.is_running());
157            // Don't start — no point playing silence in a test.
158            drop(engine);
159        }
160        drop(producer); // keep producer alive until after engine
161    }
162}