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), cpal (Linux)
62/// and AAudio (Android).
63/// The decode pipeline (rtrb ring buffer, Symphonia, `PlaybackTimeline`) is
64/// completely decoupled — backends are dumb consumers that drain the ring buffer.
65pub trait AudioBackend: Send + Sync {
66    /// List available output devices.
67    fn list_devices(&self) -> Result<Vec<DeviceInfo>, BackendError>;
68
69    /// Get the default output device.
70    fn default_device(&self) -> Result<DeviceInfo, BackendError>;
71
72    /// Query supported sample rates for a device.
73    fn supported_sample_rates(&self, device: &DeviceInfo) -> Result<Vec<f64>, BackendError>;
74
75    /// Get the current nominal sample rate of a device.
76    fn get_device_sample_rate(&self, device: &DeviceInfo) -> Result<f64, BackendError>;
77
78    /// Set the nominal sample rate of a device (for bit-perfect matching).
79    /// Returns the actual device rate after the switch (may differ if unsupported).
80    /// On Linux/cpal this is a no-op — the rate is set at stream creation.
81    fn set_device_sample_rate(&self, device: &DeviceInfo, rate: f64) -> Result<f64, BackendError>;
82
83    /// Prepare the device, now at `current`, for a stream of this format, and
84    /// answer the rate it will run at. Most platforms switch only the rate,
85    /// and only when it differs; Android also chooses the sample encoding and
86    /// whether its mixer is bypassed, which depends on the channels and the
87    /// source's bit depth, whatever the rate.
88    fn prepare_output(
89        &self,
90        device: &DeviceInfo,
91        current: f64,
92        rate: f64,
93        _channels: u16,
94        _bit_depth: Option<u16>,
95    ) -> Result<f64, BackendError> {
96        if (current - rate).abs() > 0.1 {
97            self.set_device_sample_rate(device, rate)
98        } else {
99            Ok(current)
100        }
101    }
102
103    /// Whether the system mixes this output with other apps' on its way to
104    /// the device, where the platform can say: Android's mixer converts and
105    /// applies volume unless koan holds a USB DAC bit-perfect. `None` where
106    /// the device rate is the whole story.
107    fn output_mixed(&self) -> Option<bool> {
108        None
109    }
110
111    /// The most integer bits a sample keeps on its way to the device, where
112    /// the output narrows them: a gapless run cannot carry a deeper track
113    /// through it. `None` where nothing is narrowed.
114    fn output_depth(&self) -> Option<u16> {
115        None
116    }
117
118    /// Whether `output_depth` may narrow, so a session must assume it does
119    /// until the output is open: the decoder runs ahead of it.
120    fn narrows_depth(&self) -> bool {
121        false
122    }
123
124    /// Be told when the output stops for good underneath the player, so it
125    /// can be rebuilt: Android disconnects every stream when its audio server
126    /// restarts. Called on a thread of the platform's; it must only send.
127    fn on_disconnect(&self, _rebuild: Arc<dyn Fn() + Send + Sync>) {}
128
129    /// Subscribe to nominal sample rate changes on a device.
130    ///
131    /// The rate is device-wide and anyone can move it — another app, Audio MIDI
132    /// Setup, the vendor's control panel. Whatever koan settled on at engine
133    /// creation is only true until one of them does, so the front ends need to
134    /// hear about it rather than re-reading a snapshot. Dropping the returned
135    /// watch unsubscribes. `None` where the platform has no such notification.
136    fn watch_device_sample_rate(
137        &self,
138        _device: &DeviceInfo,
139        _on_change: Box<dyn Fn(f64) + Send + Sync>,
140    ) -> Option<Box<dyn SampleRateWatch>> {
141        None
142    }
143
144    /// Create an audio engine targeting a device at a specific format.
145    /// Takes ownership of the rtrb consumer.
146    fn create_engine(
147        &self,
148        device: &DeviceInfo,
149        sample_rate: f64,
150        channels: u32,
151        consumer: rtrb::Consumer<f32>,
152        samples_played: Arc<AtomicU64>,
153    ) -> Result<Box<dyn AudioEngineHandle>, BackendError>;
154}
155
156/// The samples an engine has played as of now, for an output that hands
157/// over samples in chunks too coarse to read the playhead from directly.
158pub type Presentation = Arc<dyn Fn() -> u64 + Send + Sync>;
159
160/// A live sample rate subscription. Unsubscribes on drop.
161pub trait SampleRateWatch: Send + Sync {}
162
163/// Handle to a running audio engine. Start/stop control.
164///
165/// `start` and `stop` are immediate. `fade_out` and `fade_in` are what pause
166/// and resume use: the output ramps rather than cuts, and the unit keeps
167/// running through a fade out until `is_silent`, when it can be stopped.
168pub trait AudioEngineHandle: Send {
169    fn start(&self) -> Result<(), BackendError>;
170    fn stop(&self) -> Result<(), BackendError>;
171    fn is_running(&self) -> bool;
172    fn fade_out(&self);
173    /// A sleep timer's gain on the output, glided to, or with `snap` taken
174    /// at once. Nothing where the output has no fader.
175    fn set_sleep_gain(&self, _gain: f32, _snap: bool) {}
176    /// Ramp back to full volume, starting the unit if it was stopped.
177    fn fade_in(&self) -> Result<(), BackendError>;
178    /// `fade_out` and `fade_in` over a few milliseconds: the dip a DSP change
179    /// plays through. As the ordinary ones where the output has no fader.
180    fn fade_out_quickly(&self) {
181        self.fade_out()
182    }
183    fn fade_in_quickly(&self) -> Result<(), BackendError> {
184        self.fade_in()
185    }
186    /// How long one render callback lasts: how late a fade can begin after
187    /// it is asked for. Zero where the output does not say.
188    fn period(&self) -> std::time::Duration {
189        std::time::Duration::ZERO
190    }
191    fn is_silent(&self) -> bool;
192    /// A fade out asked for and no fade in since. Unlike `is_silent`, true
193    /// for a fade the callback never finished, as when the system stopped the
194    /// unit partway.
195    fn is_faded_out(&self) -> bool {
196        self.is_silent()
197    }
198    /// Play `frames` of silence before anything from the ring, without
199    /// counting them as played. A device that has just changed rate is
200    /// relocking its clock, and many mute while they do: what is sent then is
201    /// never heard. Nothing reports when the clock has locked, so the wait is
202    /// a length of time. Cleared by `fade_in`.
203    fn lead_in(&self, _frames: u64) {}
204    /// Where the playhead is between callbacks, when the output's callbacks
205    /// are far enough apart that the count they keep would read as a playhead
206    /// moving in jumps. `None` where the count is fine as it is.
207    fn presentation(&self) -> Option<Presentation> {
208        None
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    #[test]
217    fn device_info_construction() {
218        let info = DeviceInfo {
219            name: "Test DAC".into(),
220            sample_rates: vec![44100.0, 48000.0, 96000.0],
221            platform_id: 42,
222            kind: Default::default(),
223        };
224        assert_eq!(info.name, "Test DAC");
225        assert_eq!(info.sample_rates.len(), 3);
226        assert_eq!(info.platform_id, 42);
227    }
228
229    #[test]
230    fn backend_error_formatting() {
231        let err = BackendError::NoDevices;
232        assert_eq!(err.to_string(), "no output devices found");
233
234        let err = BackendError::DeviceNotFound("Missing".into());
235        assert!(err.to_string().contains("Missing"));
236
237        let err = BackendError::UnsupportedSampleRate(192000.0);
238        assert!(err.to_string().contains("192000"));
239    }
240
241    #[test]
242    fn platform_backend_constructs() {
243        // Verify the platform backend can be created without panicking.
244        let _backend = super::super::platform_backend();
245    }
246
247    #[test]
248    fn platform_backend_lists_devices() {
249        let backend = super::super::platform_backend();
250        // Should not panic. May return empty on CI (no audio hardware).
251        let result = backend.list_devices();
252        assert!(result.is_ok());
253    }
254
255    #[test]
256    fn platform_backend_has_default_device() {
257        let backend = super::super::platform_backend();
258        // On real hardware this should succeed. On CI it might fail (no device).
259        // We just verify it doesn't panic.
260        let _ = backend.default_device();
261    }
262
263    #[test]
264    fn engine_create_with_ring_buffer() {
265        let backend = super::super::platform_backend();
266        let device = match backend.default_device() {
267            Ok(d) => d,
268            Err(_) => return, // no audio device (CI) — skip
269        };
270
271        let (producer, consumer) = rtrb::RingBuffer::new(4096);
272        let samples_played = Arc::new(AtomicU64::new(0));
273
274        let rate = device.sample_rates.first().copied().unwrap_or(44100.0);
275
276        let engine = backend.create_engine(&device, rate, 2, consumer, samples_played);
277        // Should create without panicking on real hardware.
278        // May fail on CI — that's fine, we're testing the code path not the hardware.
279        if let Ok(engine) = engine {
280            assert!(!engine.is_running());
281            // Don't start — no point playing silence in a test.
282            drop(engine);
283        }
284        drop(producer); // keep producer alive until after engine
285    }
286}