Skip to main content

koan_core/audio/
cpal_backend.rs

1use 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
9/// Temporarily redirect stderr to /dev/null while running a closure.
10/// ALSA/JACK/PipeWire C libraries spam stderr when probing unavailable
11/// backends. In TUI mode this corrupts the alternate screen display.
12fn suppress_stderr<F: FnOnce() -> T, T>(f: F) -> T {
13    use std::os::fd::AsRawFd;
14
15    // dup(2) to save original stderr, dup2(/dev/null, 2) to suppress, restore after.
16    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
37// Thin wrappers around libc dup/dup2/close — avoids adding libc as a dep.
38unsafe 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
57/// cpal-based audio backend for Linux (ALSA / PipeWire / PulseAudio).
58///
59/// The callback drains the rtrb consumer identically to the CoreAudio
60/// render callback: read available samples, copy to output, zero-pad
61/// remainder, increment `samples_played`.
62pub 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        // Suppress ALSA/JACK/OSS probe spam on stderr during host initialization.
75        // These C libraries write directly to fd 2 when probing unavailable backends
76        // (JACK not running, OSS /dev/dsp missing, etc.). In TUI mode this bleeds
77        // through the alternate screen and corrupts the display.
78        let host = suppress_stderr(cpal::default_host);
79        Self { host }
80    }
81
82    /// Resolve a `DeviceInfo` back to a cpal `Device` by matching name.
83    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            // Collect min and max sample rates from each config range.
106            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            // Add common rates that fall within the range.
115            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        // On Linux, sample rate is set at stream creation time.
165        // This is a no-op — the rate will be applied in `create_engine`.
166        // Return the requested rate since cpal handles it at stream creation.
167        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        // Wrap consumer in a Mutex so we can move it into the FnMut callback.
191        // RT safety: use try_lock() only — never block the audio thread.
192        // Contention is effectively zero (only this callback touches it),
193        // but try_lock guarantees we never block even if the OS preempts us.
194        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                        // Lock contested — output silence rather than blocking
207                        // the real-time audio thread.
208                        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                    // Zero-pad remainder on underrun — silence > glitches.
239                    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
255/// Handle to a cpal output stream.
256struct CpalEngineHandle {
257    stream: cpal::Stream,
258    running: Arc<AtomicBool>,
259}
260
261// SAFETY: cpal::Stream is Send on all platforms cpal supports.
262// The stream callback captures its own state and is invoked by cpal's audio thread.
263unsafe 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}