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    pub kind: OutputKind,
28}
29
30/// How a device is connected, so a picker can show it for what it is.
31/// CoreAudio says; other platforms answer `Other`.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
33pub enum OutputKind {
34    BuiltIn,
35    Usb,
36    Bluetooth,
37    AirPlay,
38    /// HDMI or DisplayPort: a display's speakers, or what it passes on.
39    Display,
40    Virtual,
41    #[default]
42    Other,
43}
44
45impl OutputKind {
46    pub fn as_str(self) -> &'static str {
47        match self {
48            Self::BuiltIn => "builtin",
49            Self::Usb => "usb",
50            Self::Bluetooth => "bluetooth",
51            Self::AirPlay => "airplay",
52            Self::Display => "display",
53            Self::Virtual => "virtual",
54            Self::Other => "other",
55        }
56    }
57}
58
59/// Trait abstracting platform audio output.
60///
61/// Implementations exist for CoreAudio (macOS), RemoteIO (iOS) and cpal (Linux).
62/// The decode pipeline (rtrb ring buffer, Symphonia, `PlaybackTimeline`) is
63/// completely decoupled — backends are dumb consumers that drain the ring buffer.
64pub trait AudioBackend: Send + Sync {
65    /// List available output devices.
66    fn list_devices(&self) -> Result<Vec<DeviceInfo>, BackendError>;
67
68    /// Get the default output device.
69    fn default_device(&self) -> Result<DeviceInfo, BackendError>;
70
71    /// Query supported sample rates for a device.
72    fn supported_sample_rates(&self, device: &DeviceInfo) -> Result<Vec<f64>, BackendError>;
73
74    /// Get the current nominal sample rate of a device.
75    fn get_device_sample_rate(&self, device: &DeviceInfo) -> Result<f64, BackendError>;
76
77    /// Set the nominal sample rate of a device (for bit-perfect matching).
78    /// Returns the actual device rate after the switch (may differ if unsupported).
79    /// On Linux/cpal this is a no-op — the rate is set at stream creation.
80    fn set_device_sample_rate(&self, device: &DeviceInfo, rate: f64) -> Result<f64, BackendError>;
81
82    /// Subscribe to nominal sample rate changes on a device.
83    ///
84    /// The rate is device-wide and anyone can move it — another app, Audio MIDI
85    /// Setup, the vendor's control panel. Whatever koan settled on at engine
86    /// creation is only true until one of them does, so the front ends need to
87    /// hear about it rather than re-reading a snapshot. Dropping the returned
88    /// watch unsubscribes. `None` where the platform has no such notification.
89    fn watch_device_sample_rate(
90        &self,
91        _device: &DeviceInfo,
92        _on_change: Box<dyn Fn(f64) + Send + Sync>,
93    ) -> Option<Box<dyn SampleRateWatch>> {
94        None
95    }
96
97    /// Create an audio engine targeting a device at a specific format.
98    /// Takes ownership of the rtrb consumer.
99    fn create_engine(
100        &self,
101        device: &DeviceInfo,
102        sample_rate: f64,
103        channels: u32,
104        consumer: rtrb::Consumer<f32>,
105        samples_played: Arc<AtomicU64>,
106    ) -> Result<Box<dyn AudioEngineHandle>, BackendError>;
107}
108
109/// A live sample rate subscription. Unsubscribes on drop.
110pub trait SampleRateWatch: Send + Sync {}
111
112/// Handle to a running audio engine. Start/stop control.
113///
114/// `start` and `stop` are immediate. `fade_out` and `fade_in` are what pause
115/// and resume use: the output ramps rather than cuts, and the unit keeps
116/// running through a fade out until `is_silent`, when it can be stopped.
117pub trait AudioEngineHandle: Send {
118    fn start(&self) -> Result<(), BackendError>;
119    fn stop(&self) -> Result<(), BackendError>;
120    fn is_running(&self) -> bool;
121    fn fade_out(&self);
122    /// Ramp back to full volume, starting the unit if it was stopped.
123    fn fade_in(&self) -> Result<(), BackendError>;
124    fn is_silent(&self) -> bool;
125    /// Play `frames` of silence before anything from the ring, without
126    /// counting them as played. A device that has just changed rate is
127    /// relocking its clock, and many mute while they do: what is sent then is
128    /// never heard. Nothing reports when the clock has locked, so the wait is
129    /// a length of time. Cleared by `fade_in`.
130    fn lead_in(&self, _frames: u64) {}
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    #[test]
138    fn device_info_construction() {
139        let info = DeviceInfo {
140            name: "Test DAC".into(),
141            sample_rates: vec![44100.0, 48000.0, 96000.0],
142            platform_id: 42,
143            kind: Default::default(),
144        };
145        assert_eq!(info.name, "Test DAC");
146        assert_eq!(info.sample_rates.len(), 3);
147        assert_eq!(info.platform_id, 42);
148    }
149
150    #[test]
151    fn backend_error_formatting() {
152        let err = BackendError::NoDevices;
153        assert_eq!(err.to_string(), "no output devices found");
154
155        let err = BackendError::DeviceNotFound("Missing".into());
156        assert!(err.to_string().contains("Missing"));
157
158        let err = BackendError::UnsupportedSampleRate(192000.0);
159        assert!(err.to_string().contains("192000"));
160    }
161
162    #[test]
163    fn platform_backend_constructs() {
164        // Verify the platform backend can be created without panicking.
165        let _backend = super::super::platform_backend();
166    }
167
168    #[test]
169    fn platform_backend_lists_devices() {
170        let backend = super::super::platform_backend();
171        // Should not panic. May return empty on CI (no audio hardware).
172        let result = backend.list_devices();
173        assert!(result.is_ok());
174    }
175
176    #[test]
177    fn platform_backend_has_default_device() {
178        let backend = super::super::platform_backend();
179        // On real hardware this should succeed. On CI it might fail (no device).
180        // We just verify it doesn't panic.
181        let _ = backend.default_device();
182    }
183
184    #[test]
185    fn engine_create_with_ring_buffer() {
186        let backend = super::super::platform_backend();
187        let device = match backend.default_device() {
188            Ok(d) => d,
189            Err(_) => return, // no audio device (CI) — skip
190        };
191
192        let (producer, consumer) = rtrb::RingBuffer::new(4096);
193        let samples_played = Arc::new(AtomicU64::new(0));
194
195        let rate = device.sample_rates.first().copied().unwrap_or(44100.0);
196
197        let engine = backend.create_engine(&device, rate, 2, consumer, samples_played);
198        // Should create without panicking on real hardware.
199        // May fail on CI — that's fine, we're testing the code path not the hardware.
200        if let Ok(engine) = engine {
201            assert!(!engine.is_running());
202            // Don't start — no point playing silence in a test.
203            drop(engine);
204        }
205        drop(producer); // keep producer alive until after engine
206    }
207}