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}