1use 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
18struct 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
53fn 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
81fn 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 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 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}