1use std::collections::HashMap;
3use std::fs::File;
4use std::path::{Path, PathBuf};
5use std::sync::{LazyLock, Mutex};
6
7use anyhow::{Context, Result};
8use symphonia::core::codecs::audio::AudioDecoderOptions;
9use symphonia::core::codecs::CodecParameters;
10use symphonia::core::errors::Error as SymphoniaError;
11use symphonia::core::formats::probe::Hint;
12use symphonia::core::formats::{FormatOptions, TrackType};
13use symphonia::core::io::MediaSourceStream;
14use symphonia::core::meta::MetadataOptions;
15
16pub mod device;
17pub use device::CpalBackend;
18
19pub const POLL_CHUNK: usize = 4096;
20
21#[derive(Debug, Clone)]
22pub struct DecodedAudio {
23 pub samples: Vec<f32>,
24 pub sample_rate: u32,
25 pub channels: u16,
26}
27
28impl DecodedAudio {
29 pub fn len(&self) -> usize {
30 self.samples.len()
31 }
32
33 pub fn is_empty(&self) -> bool {
34 self.samples.is_empty()
35 }
36}
37
38pub mod fixtures {
39 pub const FIXTURE_OGG: &[u8] = include_bytes!("../fixtures/sine.ogg");
40 pub const FIXTURE_MP3: &[u8] = include_bytes!("../fixtures/sine.mp3");
41 pub const FIXTURE_FLAC: &[u8] = include_bytes!("../fixtures/sine.flac");
42}
43
44pub trait AudioBackend: Send {
45 fn play(&mut self, audio: &DecodedAudio, volume: f32) -> u64;
46 fn stop(&mut self, id: u64);
47 fn set_volume(&mut self, id: u64, volume: f32);
48 fn is_playing(&self, id: u64) -> bool;
49 fn poll(&mut self);
50}
51
52#[derive(Debug)]
53struct Voice {
54 len: usize,
55 cursor: usize,
56 volume: f32,
57}
58
59#[derive(Debug, Default)]
60pub struct WavBackend {
61 next_id: u64,
62 voices: HashMap<u64, Voice>,
63}
64
65impl WavBackend {
66 pub fn new() -> Self {
67 Self::default()
68 }
69
70 fn clamp_volume(volume: f32) -> f32 {
71 volume.clamp(0.0, 1.0)
72 }
73}
74
75impl AudioBackend for WavBackend {
76 fn play(&mut self, audio: &DecodedAudio, volume: f32) -> u64 {
77 self.next_id += 1;
78 let id = self.next_id;
79 self.voices.insert(
80 id,
81 Voice {
82 len: audio.len(),
83 cursor: 0,
84 volume: Self::clamp_volume(volume),
85 },
86 );
87 id
88 }
89
90 fn stop(&mut self, id: u64) {
91 self.voices.remove(&id);
92 }
93
94 fn set_volume(&mut self, id: u64, volume: f32) {
95 if let Some(voice) = self.voices.get_mut(&id) {
96 voice.volume = Self::clamp_volume(volume);
97 }
98 }
99
100 fn is_playing(&self, id: u64) -> bool {
101 self.voices.contains_key(&id)
102 }
103
104 fn poll(&mut self) {
105 let finished: Vec<u64> = self
106 .voices
107 .iter_mut()
108 .filter_map(|(id, voice)| {
109 voice.cursor = (voice.cursor + POLL_CHUNK).min(voice.len);
110 if voice.cursor >= voice.len {
111 Some(*id)
112 } else {
113 None
114 }
115 })
116 .collect();
117 for id in finished {
118 self.voices.remove(&id);
119 }
120 }
121}
122
123pub fn is_supported_audio_ext(ext: &str) -> bool {
124 matches!(
125 ext.to_ascii_lowercase().as_str(),
126 "wav" | "ogg" | "mp3" | "flac"
127 )
128}
129
130pub fn decode_audio(path: &Path) -> Result<DecodedAudio> {
131 let file = File::open(path).with_context(|| format!("cannot open {}", path.display()))?;
132 let mss = MediaSourceStream::new(Box::new(file), Default::default());
133
134 let mut hint = Hint::new();
135 if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
136 hint.with_extension(ext);
137 }
138
139 let format_opts = FormatOptions::default();
140 let metadata_opts = MetadataOptions::default();
141
142 let mut format = symphonia::default::get_probe()
143 .probe(&hint, mss, format_opts, metadata_opts)
144 .with_context(|| format!("cannot probe audio format: {}", path.display()))?;
145
146 let track = format
147 .default_track(TrackType::Audio)
148 .ok_or_else(|| anyhow::anyhow!("no audio track found in {}", path.display()))?;
149
150 let track_id = track.id;
151 let codec_params = match &track.codec_params {
152 Some(CodecParameters::Audio(params)) => params,
153 _ => anyhow::bail!(
154 "audio track in {} has no audio codec parameters",
155 path.display()
156 ),
157 };
158
159 let decoder_opts = AudioDecoderOptions::default();
160 let mut decoder = symphonia::default::get_codecs()
161 .make_audio_decoder(codec_params, &decoder_opts)
162 .with_context(|| format!("cannot create audio decoder for {}", path.display()))?;
163
164 let mut samples = Vec::new();
165 let mut sample_rate = 0;
166 let mut channels = 0;
167 let mut byte_vec = Vec::new();
168
169 loop {
170 let packet = match format.next_packet() {
171 Ok(Some(packet)) => packet,
172 Ok(None) => break,
173 Err(SymphoniaError::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
174 break;
175 }
176 Err(SymphoniaError::ResetRequired) => continue,
177 Err(e) => {
178 return Err(anyhow::anyhow!(
179 "error reading audio stream {}: {e:#}",
180 path.display()
181 ));
182 }
183 };
184
185 if packet.track_id != track_id {
186 continue;
187 }
188
189 match decoder.decode(&packet) {
190 Ok(buf_ref) => {
191 if sample_rate == 0 {
192 let spec = buf_ref.spec();
193 sample_rate = spec.rate();
194 channels = spec.channels().count() as u16;
195 }
196
197 byte_vec.clear();
198 buf_ref.copy_bytes_to_vec_interleaved_as::<f32>(&mut byte_vec);
199 for chunk in byte_vec.chunks_exact(4) {
200 let sample = f32::from_ne_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
201 samples.push(sample);
202 }
203 }
204 Err(SymphoniaError::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
205 break;
206 }
207 Err(SymphoniaError::DecodeError(e)) => {
208 tracing::warn!("decode warning in {}: {e}", path.display());
209 continue;
210 }
211 Err(SymphoniaError::ResetRequired) => {
212 decoder.reset();
213 continue;
214 }
215 Err(e) => {
216 return Err(anyhow::anyhow!(
217 "error decoding audio packet in {}: {e:#}",
218 path.display()
219 ));
220 }
221 }
222 }
223
224 if samples.is_empty() || sample_rate == 0 || channels == 0 {
225 anyhow::bail!("{}: no audio samples decoded", path.display());
226 }
227
228 Ok(DecodedAudio {
229 samples,
230 sample_rate,
231 channels,
232 })
233}
234
235pub fn decode_wav(path: &Path) -> Result<DecodedAudio> {
236 decode_audio(path)
237}
238
239pub struct AudioEngine {
240 backend: Box<dyn AudioBackend>,
241 project_dir: Option<PathBuf>,
242}
243
244impl AudioEngine {
245 fn new() -> Self {
246 let backend: Box<dyn AudioBackend> = match CpalBackend::new() {
249 Ok(device) => {
250 tracing::info!("audio: playing through the default output device");
251 Box::new(device)
252 }
253 Err(e) => {
254 tracing::warn!("audio: no output device ({e:#}); simulating playback");
255 Box::new(WavBackend::new())
256 }
257 };
258 Self {
259 backend,
260 project_dir: None,
261 }
262 }
263
264 pub fn resolve(&self, asset_ref: &str) -> Option<PathBuf> {
265 if let Some(rel) = asset_ref.strip_prefix("res://") {
266 let root = match &self.project_dir {
267 Some(dir) => dir.clone(),
268 None => std::env::current_dir().ok()?,
269 };
270 Some(root.join(rel))
271 } else {
272 let path = PathBuf::from(asset_ref);
273 if path.is_absolute() {
274 Some(path)
275 } else {
276 std::env::current_dir().ok().map(|cwd| cwd.join(path))
277 }
278 }
279 }
280}
281
282static ENGINE: LazyLock<Mutex<AudioEngine>> = LazyLock::new(|| Mutex::new(AudioEngine::new()));
283
284pub fn set_backend(backend: Box<dyn AudioBackend>) {
285 ENGINE.lock().unwrap().backend = backend;
286}
287
288pub fn set_project_dir(dir: Option<PathBuf>) {
289 ENGINE.lock().unwrap().project_dir = dir;
290}
291
292pub fn decode_asset(asset_ref: &str) -> Result<(PathBuf, DecodedAudio)> {
293 let engine = ENGINE.lock().unwrap();
294 let path = engine
295 .resolve(asset_ref)
296 .ok_or_else(|| anyhow::anyhow!("{asset_ref:?} uses res:// but no project is known"))?;
297 drop(engine);
298 if !path.is_file() {
299 anyhow::bail!("audio asset not found: {}", path.display());
300 }
301 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
302 if !is_supported_audio_ext(ext) {
303 anyhow::bail!(
304 "{}: only .wav, .ogg, .mp3, .flac supported, got .{ext}",
305 path.display()
306 );
307 }
308 let audio = decode_audio(&path)?;
309 Ok((path, audio))
310}
311
312pub fn play_file(asset_ref: &str, volume: f32) -> Result<u64> {
313 let (_, audio) = decode_asset(asset_ref)?;
314 Ok(ENGINE.lock().unwrap().backend.play(&audio, volume))
315}
316
317pub fn stop_voice(id: u64) {
318 ENGINE.lock().unwrap().backend.stop(id);
319}
320
321pub fn set_voice_volume(id: u64, volume: f32) {
322 ENGINE.lock().unwrap().backend.set_volume(id, volume);
323}
324
325pub fn voice_playing(id: u64) -> bool {
326 ENGINE.lock().unwrap().backend.is_playing(id)
327}
328
329pub fn poll_audio() {
330 ENGINE.lock().unwrap().backend.poll();
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336
337 static SERIAL: Mutex<()> = Mutex::new(());
338
339 struct MockBackend {
340 pub plays: Vec<(usize, f32)>,
341 pub stops: Vec<u64>,
342 pub volumes: Vec<(u64, f32)>,
343 pub live: HashMap<u64, bool>,
344 next: u64,
345 }
346
347 impl MockBackend {
348 fn new() -> Self {
349 Self {
350 plays: Vec::new(),
351 stops: Vec::new(),
352 volumes: Vec::new(),
353 live: HashMap::new(),
354 next: 0,
355 }
356 }
357 }
358
359 impl AudioBackend for MockBackend {
360 fn play(&mut self, audio: &DecodedAudio, volume: f32) -> u64 {
361 self.next += 1;
362 self.plays.push((audio.len(), volume));
363 self.live.insert(self.next, true);
364 self.next
365 }
366
367 fn stop(&mut self, id: u64) {
368 self.stops.push(id);
369 self.live.remove(&id);
370 }
371
372 fn set_volume(&mut self, id: u64, volume: f32) {
373 self.volumes.push((id, volume));
374 }
375
376 fn is_playing(&self, id: u64) -> bool {
377 self.live.contains_key(&id)
378 }
379
380 fn poll(&mut self) {}
381 }
382
383 fn write_wav(path: &Path, samples: &[i16]) {
384 let spec = hound::WavSpec {
385 channels: 1,
386 sample_rate: 22050,
387 bits_per_sample: 16,
388 sample_format: hound::SampleFormat::Int,
389 };
390 let mut writer = hound::WavWriter::create(path, spec).unwrap();
391 for sample in samples {
392 writer.write_sample(*sample).unwrap();
393 }
394 writer.finalize().unwrap();
395 }
396
397 fn fixture_wav(tag: &str, len: usize) -> (PathBuf, PathBuf) {
398 let dir = std::env::temp_dir().join(format!("pite-aud-{tag}-{}", std::process::id()));
399 std::fs::create_dir_all(&dir).unwrap();
400 let path = dir.join("blip.wav");
401 let samples: Vec<i16> = (0..len)
402 .map(|i| (i as f64 * 0.1).sin() as i16 * 1000)
403 .collect();
404 write_wav(&path, &samples);
405 (dir, path)
406 }
407
408 #[test]
409 fn backend_voice_lifecycle() {
410 let mut backend = WavBackend::new();
411 let audio = DecodedAudio {
412 samples: vec![0.0; POLL_CHUNK * 2 + 10],
413 sample_rate: 22050,
414 channels: 1,
415 };
416 let id = backend.play(&audio, 1.5);
417 assert!(backend.is_playing(id));
418 backend.set_volume(id, 2.0);
419 backend.poll();
420 assert!(backend.is_playing(id));
421 backend.poll();
422 assert!(backend.is_playing(id));
423 backend.stop(id);
424 assert!(!backend.is_playing(id));
425 assert!(!backend.is_playing(999));
426 }
427
428 #[test]
429 fn play_stop_state_through_mock() {
430 let _guard = SERIAL.lock().unwrap();
431 set_backend(Box::new(MockBackend::new()));
432 let (dir, _) = fixture_wav("mock", 100);
433 set_project_dir(Some(dir.clone()));
434 let id = play_file("res://blip.wav", 0.8).unwrap();
435 assert!(voice_playing(id));
436 set_voice_volume(id, 0.5);
437 stop_voice(id);
438 assert!(!voice_playing(id));
439 assert!(!voice_playing(4242));
440 set_backend(Box::new(WavBackend::new()));
441 std::fs::remove_dir_all(&dir).ok();
442 }
443
444 #[test]
445 fn missing_file_fails_loudly() {
446 let _guard = SERIAL.lock().unwrap();
447 set_backend(Box::new(MockBackend::new()));
448 let dir = std::env::temp_dir().join(format!("pite-aud-missing-{}", std::process::id()));
449 std::fs::create_dir_all(&dir).unwrap();
450 set_project_dir(Some(dir.clone()));
451 let err = play_file("res://nope.wav", 1.0).unwrap_err();
452 assert!(err.to_string().contains("not found"), "got: {err:#}");
453 let err = play_file("res://", 1.0).unwrap_err();
454 assert!(!err.to_string().is_empty());
455 set_backend(Box::new(WavBackend::new()));
456 std::fs::remove_dir_all(&dir).ok();
457 }
458
459 #[test]
460 fn unsupported_extension_fails_loudly() {
461 let _guard = SERIAL.lock().unwrap();
462 set_backend(Box::new(MockBackend::new()));
463 let dir = std::env::temp_dir().join(format!("pite-aud-ext-{}", std::process::id()));
464 std::fs::create_dir_all(&dir).unwrap();
465 std::fs::write(dir.join("song.aac"), b"AAC").unwrap();
466 set_project_dir(Some(dir.clone()));
467 let err = play_file("res://song.aac", 1.0).unwrap_err();
468 assert!(
469 err.to_string().contains("only .wav, .ogg, .mp3, .flac"),
470 "got: {err:#}"
471 );
472 set_backend(Box::new(WavBackend::new()));
473 std::fs::remove_dir_all(&dir).ok();
474 }
475
476 const FIXTURE_OGG: &[u8] = include_bytes!("../fixtures/sine.ogg");
477 const FIXTURE_MP3: &[u8] = include_bytes!("../fixtures/sine.mp3");
478 const FIXTURE_FLAC: &[u8] = include_bytes!("../fixtures/sine.flac");
479
480 #[test]
481 fn decode_and_play_multiformat() {
482 let _guard = SERIAL.lock().unwrap();
483 set_backend(Box::new(MockBackend::new()));
484 let dir = std::env::temp_dir().join(format!("pite-aud-formats-{}", std::process::id()));
485 std::fs::create_dir_all(&dir).unwrap();
486 std::fs::write(dir.join("tone.ogg"), FIXTURE_OGG).unwrap();
487 std::fs::write(dir.join("tone.mp3"), FIXTURE_MP3).unwrap();
488 std::fs::write(dir.join("tone.flac"), FIXTURE_FLAC).unwrap();
489 set_project_dir(Some(dir.clone()));
490
491 let id_ogg = play_file("res://tone.ogg", 0.7).unwrap();
493 assert!(voice_playing(id_ogg));
494 let (_, audio_ogg) = decode_asset("res://tone.ogg").unwrap();
495 assert!(!audio_ogg.is_empty());
496 assert_eq!(audio_ogg.sample_rate, 44100);
497
498 let id_mp3 = play_file("res://tone.mp3", 0.8).unwrap();
500 assert!(voice_playing(id_mp3));
501 let (_, audio_mp3) = decode_asset("res://tone.mp3").unwrap();
502 assert!(!audio_mp3.is_empty());
503 assert_eq!(audio_mp3.sample_rate, 44100);
504
505 let id_flac = play_file("res://tone.flac", 0.9).unwrap();
507 assert!(voice_playing(id_flac));
508 let (_, audio_flac) = decode_asset("res://tone.flac").unwrap();
509 assert!(!audio_flac.is_empty());
510 assert_eq!(audio_flac.sample_rate, 44100);
511
512 set_backend(Box::new(WavBackend::new()));
513 std::fs::remove_dir_all(&dir).ok();
514 }
515
516 #[test]
517 fn decode_wav_float_and_various_bitdepths() {
518 let dir = std::env::temp_dir().join(format!("pite-aud-wav-depth-{}", std::process::id()));
519 std::fs::create_dir_all(&dir).unwrap();
520 let path = dir.join("float.wav");
521 let spec = hound::WavSpec {
522 channels: 1,
523 sample_rate: 44100,
524 bits_per_sample: 32,
525 sample_format: hound::SampleFormat::Float,
526 };
527 let mut writer = hound::WavWriter::create(&path, spec).unwrap();
528 for i in 0..100 {
529 writer.write_sample((i as f32 * 0.1).sin()).unwrap();
530 }
531 writer.finalize().unwrap();
532
533 let decoded = decode_audio(&path).unwrap();
534 assert_eq!(decoded.len(), 100);
535 assert_eq!(decoded.sample_rate, 44100);
536 assert_eq!(decoded.channels, 1);
537
538 std::fs::remove_dir_all(&dir).ok();
539 }
540}