koan_core/audio/
cpal_backend.rs1use std::ptr;
2use std::sync::Arc;
3use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
4
5use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
6
7use super::backend::{AudioBackend, AudioEngineHandle, BackendError, DeviceInfo};
8
9fn suppress_stderr<F: FnOnce() -> T, T>(f: F) -> T {
13 use std::os::fd::AsRawFd;
14
15 let devnull = std::fs::File::open("/dev/null").ok();
17 let saved_fd = unsafe { nix_dup(2) };
18
19 if let Some(ref null) = devnull
20 && saved_fd >= 0
21 {
22 unsafe { nix_dup2(null.as_raw_fd(), 2) };
23 }
24
25 let result = f();
26
27 if saved_fd >= 0 {
28 unsafe {
29 nix_dup2(saved_fd, 2);
30 nix_close(saved_fd);
31 }
32 }
33
34 result
35}
36
37unsafe fn nix_dup(fd: i32) -> i32 {
39 unsafe extern "C" {
40 safe fn dup(fd: i32) -> i32;
41 }
42 dup(fd)
43}
44unsafe fn nix_dup2(oldfd: i32, newfd: i32) -> i32 {
45 unsafe extern "C" {
46 safe fn dup2(oldfd: i32, newfd: i32) -> i32;
47 }
48 dup2(oldfd, newfd)
49}
50unsafe fn nix_close(fd: i32) -> i32 {
51 unsafe extern "C" {
52 safe fn close(fd: i32) -> i32;
53 }
54 close(fd)
55}
56
57pub struct CpalBackend {
63 host: cpal::Host,
64}
65
66impl Default for CpalBackend {
67 fn default() -> Self {
68 Self::new()
69 }
70}
71
72impl CpalBackend {
73 pub fn new() -> Self {
74 let host = suppress_stderr(cpal::default_host);
79 Self { host }
80 }
81
82 fn resolve_device(&self, info: &DeviceInfo) -> Result<cpal::Device, BackendError> {
84 let devices = self
85 .host
86 .output_devices()
87 .map_err(|e| BackendError::Platform(e.to_string()))?;
88
89 for dev in devices {
90 if let Ok(name) = dev.name()
91 && name == info.name
92 {
93 return Ok(dev);
94 }
95 }
96
97 Err(BackendError::DeviceNotFound(info.name.clone()))
98 }
99
100 fn device_info_from_cpal(dev: &cpal::Device, index: u64) -> Option<DeviceInfo> {
101 let name = dev.name().ok()?;
102 let configs = dev.supported_output_configs().ok()?;
103 let mut rates: Vec<f64> = Vec::new();
104 for cfg in configs {
105 let min = cfg.min_sample_rate().0 as f64;
107 let max = cfg.max_sample_rate().0 as f64;
108 if !rates.contains(&min) {
109 rates.push(min);
110 }
111 if !rates.contains(&max) {
112 rates.push(max);
113 }
114 for &common in &[44100.0, 48000.0, 88200.0, 96000.0, 176400.0, 192000.0] {
116 if common >= min && common <= max && !rates.contains(&common) {
117 rates.push(common);
118 }
119 }
120 }
121 rates.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
122 rates.dedup();
123
124 Some(DeviceInfo {
125 name,
126 sample_rates: rates,
127 platform_id: index,
128 })
129 }
130}
131
132impl AudioBackend for CpalBackend {
133 fn list_devices(&self) -> Result<Vec<DeviceInfo>, BackendError> {
134 let devices = suppress_stderr(|| self.host.output_devices())
135 .map_err(|e| BackendError::Platform(e.to_string()))?;
136
137 let mut result = Vec::new();
138 for (i, dev) in devices.enumerate() {
139 if let Some(info) = Self::device_info_from_cpal(&dev, i as u64) {
140 result.push(info);
141 }
142 }
143 Ok(result)
144 }
145
146 fn default_device(&self) -> Result<DeviceInfo, BackendError> {
147 let dev =
148 suppress_stderr(|| self.host.default_output_device()).ok_or(BackendError::NoDevices)?;
149 suppress_stderr(|| Self::device_info_from_cpal(&dev, 0)).ok_or(BackendError::NoDevices)
150 }
151
152 fn supported_sample_rates(&self, device: &DeviceInfo) -> Result<Vec<f64>, BackendError> {
153 Ok(device.sample_rates.clone())
154 }
155
156 fn get_device_sample_rate(&self, device: &DeviceInfo) -> Result<f64, BackendError> {
157 let dev = self.resolve_device(device)?;
158 let config = suppress_stderr(|| dev.default_output_config())
159 .map_err(|e| BackendError::Platform(e.to_string()))?;
160 Ok(config.sample_rate().0 as f64)
161 }
162
163 fn set_device_sample_rate(&self, device: &DeviceInfo, rate: f64) -> Result<f64, BackendError> {
164 let _ = device;
168 Ok(rate)
169 }
170
171 fn create_engine(
172 &self,
173 device: &DeviceInfo,
174 sample_rate: f64,
175 channels: u32,
176 consumer: rtrb::Consumer<f32>,
177 samples_played: Arc<AtomicU64>,
178 ) -> Result<Box<dyn AudioEngineHandle>, BackendError> {
179 let dev = self.resolve_device(device)?;
180
181 let config = cpal::StreamConfig {
182 channels: channels as u16,
183 sample_rate: cpal::SampleRate(sample_rate as u32),
184 buffer_size: cpal::BufferSize::Default,
185 };
186
187 let running = Arc::new(AtomicBool::new(false));
188 let running_cb = running.clone();
189
190 let consumer = std::sync::Mutex::new(consumer);
195
196 let stream = suppress_stderr(|| {
197 dev.build_output_stream(
198 &config,
199 move |data: &mut [f32], _info: &cpal::OutputCallbackInfo| {
200 if !running_cb.load(Ordering::Acquire) {
201 data.fill(0.0);
202 return;
203 }
204
205 let Ok(mut consumer) = consumer.try_lock() else {
206 data.fill(0.0);
209 return;
210 };
211
212 let total_samples = data.len();
213 let available = consumer.slots();
214 let to_read = available.min(total_samples);
215
216 if to_read > 0
217 && let Ok(chunk) = consumer.read_chunk(to_read)
218 {
219 let (first, second) = chunk.as_slices();
220 let ring_total = first.len() + second.len();
221 let copy_total = ring_total.min(total_samples);
222 let first_copy = first.len().min(copy_total);
223 let second_copy = copy_total.saturating_sub(first_copy).min(second.len());
224 unsafe {
225 ptr::copy_nonoverlapping(first.as_ptr(), data.as_mut_ptr(), first_copy);
226 if second_copy > 0 {
227 ptr::copy_nonoverlapping(
228 second.as_ptr(),
229 data.as_mut_ptr().add(first_copy),
230 second_copy,
231 );
232 }
233 }
234 chunk.commit_all();
235 samples_played.fetch_add(copy_total as u64, Ordering::AcqRel);
236 }
237
238 if to_read < total_samples {
240 data[to_read..].fill(0.0);
241 }
242 },
243 |err| {
244 log::error!("cpal audio stream error: {}", err);
245 },
246 None,
247 )
248 .map_err(|e| BackendError::StreamCreation(e.to_string()))
249 })?;
250
251 Ok(Box::new(CpalEngineHandle { stream, running }))
252 }
253}
254
255struct CpalEngineHandle {
257 stream: cpal::Stream,
258 running: Arc<AtomicBool>,
259}
260
261unsafe impl Send for CpalEngineHandle {}
264
265impl AudioEngineHandle for CpalEngineHandle {
266 fn start(&self) -> Result<(), BackendError> {
267 self.running.store(true, Ordering::Release);
268 self.stream
269 .play()
270 .map_err(|e| BackendError::Platform(e.to_string()))
271 }
272
273 fn stop(&self) -> Result<(), BackendError> {
274 self.running.store(false, Ordering::Release);
275 self.stream
276 .pause()
277 .map_err(|e| BackendError::Platform(e.to_string()))
278 }
279
280 fn is_running(&self) -> bool {
281 self.running.load(Ordering::Acquire)
282 }
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288
289 #[test]
290 fn cpal_backend_constructs() {
291 let _backend = CpalBackend::new();
292 }
293
294 #[test]
295 fn cpal_list_devices_no_panic() {
296 let backend = CpalBackend::new();
297 let result = backend.list_devices();
298 assert!(result.is_ok());
299 }
300
301 #[test]
302 fn cpal_set_sample_rate_is_noop() {
303 let backend = CpalBackend::new();
304 let dummy = DeviceInfo {
305 name: "nonexistent".into(),
306 sample_rates: vec![44100.0],
307 platform_id: 0,
308 };
309 let result = backend.set_device_sample_rate(&dummy, 96000.0);
310 assert!(result.is_ok());
311 assert!((result.unwrap() - 96000.0).abs() < 0.1);
312 }
313
314 #[test]
315 fn cpal_device_info_has_sample_rates() {
316 let backend = CpalBackend::new();
317 if let Ok(devices) = backend.list_devices() {
318 for dev in &devices {
319 if !dev.sample_rates.is_empty() {
320 assert!(dev.sample_rates[0] > 0.0);
321 }
322 }
323 }
324 }
325
326 #[test]
327 fn cpal_resolve_nonexistent_device_fails() {
328 let backend = CpalBackend::new();
329 let dummy = DeviceInfo {
330 name: "this device does not exist xyzzy".into(),
331 sample_rates: vec![],
332 platform_id: 9999,
333 };
334 assert!(backend.resolve_device(&dummy).is_err());
335 }
336}