Skip to main content

pite_audio/
device.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Real audio output through the OS default device (cpal).
3//!
4//! Written against the cpal docs without a local build (this container
5//! has no ALSA headers, no crates access, and no audio device). The cpal
6//! surface used is deliberately small and long-stable: `default_host`,
7//! `default_output_device`, `default_output_config`, `build_output_stream`,
8//! `Stream::play`. If the compiler disagrees anywhere, that line is the bug.
9
10use std::collections::HashMap;
11use std::sync::{Arc, Mutex};
12
13use anyhow::{Context, Result};
14use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
15
16use super::{AudioBackend, DecodedAudio};
17
18/// One live voice, resampled at `play()` time to the device rate with
19/// device-channel interleaved samples. `cursor` counts frames.
20struct Voice {
21    samples: Vec<f32>,
22    frames: usize,
23    cursor: usize,
24    volume: f32,
25}
26
27struct Shared {
28    voices: HashMap<u64, Voice>,
29    next_id: u64,
30}
31
32impl Shared {
33    fn play(&mut self, samples: Vec<f32>, frames: usize, volume: f32) -> u64 {
34        self.next_id += 1;
35        let id = self.next_id;
36        self.voices.insert(
37            id,
38            Voice {
39                samples,
40                frames,
41                cursor: 0,
42                volume: volume.clamp(0.0, 1.0),
43            },
44        );
45        id
46    }
47
48    fn reap(&mut self) {
49        self.voices.retain(|_, v| v.cursor < v.frames);
50    }
51}
52
53/// Linear-resample `audio` to (`out_rate`, `out_channels`), interleaved.
54/// Output channel `c` reads source channel `c % in_channels`, so mono
55/// duplicates onto stereo and anything wider folds round-robin.
56fn resample(audio: &DecodedAudio, out_rate: u32, out_channels: u16) -> Vec<f32> {
57    let in_rate = audio.sample_rate.max(1);
58    let in_ch = audio.channels.max(1) as usize;
59    let out_ch = out_channels.max(1) as usize;
60    let frames_in = audio.samples.len() / in_ch;
61    if frames_in == 0 {
62        return Vec::new();
63    }
64    let frames_out = ((frames_in as u64 * out_rate as u64) / in_rate as u64) as usize;
65    let mut out = Vec::with_capacity(frames_out * out_ch);
66    for f in 0..frames_out {
67        let pos = f as f64 * in_rate as f64 / out_rate as f64;
68        let i0 = (pos.floor() as usize).min(frames_in - 1);
69        let i1 = (i0 + 1).min(frames_in - 1);
70        let frac = (pos - pos.floor()) as f32;
71        for c in 0..out_ch {
72            let src = c % in_ch;
73            let a = audio.samples[i0 * in_ch + src];
74            let b = audio.samples[i1 * in_ch + src];
75            out.push(a + (b - a) * frac);
76        }
77    }
78    out
79}
80
81/// Mix one callback buffer from `shared`. The cpal data callbacks for every
82/// sample format funnel through here, which keeps this unit-testable with
83/// no audio hardware present.
84fn mix_into(shared: &Mutex<Shared>, out: &mut [f32], channels: usize) {
85    let ch = channels.max(1);
86    let mut state = shared.lock().unwrap();
87    for frame in out.chunks_mut(ch) {
88        for slot in frame.iter_mut() {
89            *slot = 0.0;
90        }
91        for voice in state.voices.values_mut() {
92            if voice.cursor >= voice.frames {
93                continue;
94            }
95            let base = voice.cursor * ch;
96            for (i, slot) in frame.iter_mut().enumerate() {
97                *slot += voice.samples[base + i] * voice.volume;
98            }
99            voice.cursor += 1;
100        }
101        for slot in frame.iter_mut() {
102            *slot = slot.clamp(-1.0, 1.0);
103        }
104    }
105}
106
107pub struct CpalBackend {
108    shared: Arc<Mutex<Shared>>,
109    channels: u16,
110    rate: u32,
111    _stream: cpal::Stream,
112}
113
114impl CpalBackend {
115    pub fn new() -> Result<Self> {
116        let host = cpal::default_host();
117        let device = host
118            .default_output_device()
119            .context("no default output device")?;
120        let supported = device
121            .default_output_config()
122            .context("no default output config")?;
123        let format = supported.sample_format();
124        if !matches!(
125            format,
126            cpal::SampleFormat::F32 | cpal::SampleFormat::I16 | cpal::SampleFormat::U16
127        ) {
128            anyhow::bail!("unsupported sample format {format}");
129        }
130        let config: cpal::StreamConfig = supported.into();
131        let (channels, rate) = (config.channels, config.sample_rate);
132        let shared = Arc::new(Mutex::new(Shared {
133            voices: HashMap::new(),
134            next_id: 0,
135        }));
136        let err_fn = |err| tracing::error!("audio output stream error: {err}");
137        let stream = match format {
138            cpal::SampleFormat::F32 => device.build_output_stream(
139                config.clone(),
140                Self::data_fn::<f32>(shared.clone(), channels),
141                err_fn,
142                None,
143            ),
144            cpal::SampleFormat::I16 => device.build_output_stream(
145                config.clone(),
146                Self::data_fn::<i16>(shared.clone(), channels),
147                err_fn,
148                None,
149            ),
150            _ => device.build_output_stream(
151                config.clone(),
152                Self::data_fn::<u16>(shared.clone(), channels),
153                err_fn,
154                None,
155            ),
156        }
157        .context("cannot open output stream")?;
158        stream.play().context("cannot start output stream")?;
159        Ok(Self {
160            shared,
161            channels,
162            rate,
163            _stream: stream,
164        })
165    }
166
167    fn data_fn<T>(
168        shared: Arc<Mutex<Shared>>,
169        channels: u16,
170    ) -> impl FnMut(&mut [T], &cpal::OutputCallbackInfo) + Send + 'static
171    where
172        T: cpal::Sample + cpal::FromSample<f32>,
173    {
174        move |data: &mut [T], _| {
175            let mut mixed = vec![0.0f32; data.len()];
176            mix_into(&shared, &mut mixed, channels as usize);
177            for (slot, value) in data.iter_mut().zip(mixed) {
178                *slot = T::from_sample(value);
179            }
180        }
181    }
182}
183
184impl AudioBackend for CpalBackend {
185    fn play(&mut self, audio: &DecodedAudio, volume: f32) -> u64 {
186        let samples = resample(audio, self.rate, self.channels);
187        let frames = samples.len() / self.channels.max(1) as usize;
188        self.shared.lock().unwrap().play(samples, frames, volume)
189    }
190
191    fn stop(&mut self, id: u64) {
192        self.shared.lock().unwrap().voices.remove(&id);
193    }
194
195    fn set_volume(&mut self, id: u64, volume: f32) {
196        if let Some(voice) = self.shared.lock().unwrap().voices.get_mut(&id) {
197            voice.volume = volume.clamp(0.0, 1.0);
198        }
199    }
200
201    fn is_playing(&self, id: u64) -> bool {
202        self.shared
203            .lock()
204            .unwrap()
205            .voices
206            .get(&id)
207            .map(|v| v.cursor < v.frames)
208            .unwrap_or(false)
209    }
210
211    fn poll(&mut self) {
212        self.shared.lock().unwrap().reap();
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    fn tone(rate: u32, channels: u16, frames: usize) -> DecodedAudio {
221        let mut samples = Vec::with_capacity(frames * channels as usize);
222        for f in 0..frames {
223            for c in 0..channels as usize {
224                samples.push(f as f32 * 0.01 * (c as f32 + 1.0));
225            }
226        }
227        DecodedAudio {
228            samples,
229            sample_rate: rate,
230            channels,
231        }
232    }
233
234    #[test]
235    fn resample_same_rate_is_transparent() {
236        let audio = tone(44100, 1, 8);
237        assert_eq!(resample(&audio, 44100, 1), audio.samples);
238    }
239
240    #[test]
241    fn resample_mono_duplicates_onto_stereo() {
242        let audio = tone(44100, 1, 4);
243        let stereo = resample(&audio, 44100, 2);
244        assert_eq!(stereo.len(), 8);
245        for (i, chunk) in stereo.chunks_exact(2).enumerate() {
246            assert_eq!(chunk, &[audio.samples[i], audio.samples[i]]);
247        }
248    }
249
250    #[test]
251    fn mixer_adds_voices_at_full_volume() {
252        let shared = Mutex::new(Shared {
253            voices: HashMap::new(),
254            next_id: 0,
255        });
256        let audio = tone(44100, 1, 4);
257        shared.lock().unwrap().play(audio.samples.clone(), 4, 1.0);
258        let mut out = vec![0.0f32; 4];
259        mix_into(&shared, &mut out, 1);
260        assert_eq!(out, audio.samples);
261        // Voice is spent: next buffer is silence until poll reaps it.
262        let mut out2 = vec![9.0f32; 4];
263        mix_into(&shared, &mut out2, 1);
264        assert_eq!(out2, vec![0.0f32; 4]);
265        shared.lock().unwrap().reap();
266        assert!(shared.lock().unwrap().voices.is_empty());
267    }
268
269    #[test]
270    fn mixer_scales_by_volume() {
271        let shared = Mutex::new(Shared {
272            voices: HashMap::new(),
273            next_id: 0,
274        });
275        shared.lock().unwrap().play(vec![1.0, 1.0], 2, 0.5);
276        let mut out = vec![0.0f32; 2];
277        mix_into(&shared, &mut out, 1);
278        assert_eq!(out, vec![0.5, 0.5]);
279    }
280
281    #[test]
282    fn device_opens_when_present() {
283        let Ok(mut backend) = CpalBackend::new() else {
284            eprintln!("skipped: no output device in this environment");
285            return;
286        };
287        // Volume 0 stays silent on real speakers; this only proves the
288        // open/play/poll path works against actual hardware.
289        let audio = tone(22050, 1, 2205);
290        let id = backend.play(&audio, 0.0);
291        assert!(backend.is_playing(id));
292        backend.set_volume(id, 0.5);
293        backend.stop(id);
294        assert!(!backend.is_playing(id));
295    }
296}