Skip to main content

pite_audio/
lib.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2use 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        // Prefer real output; fall back to silent simulation where no
247        // device exists (CI, containers) so games still run everywhere.
248        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        // OGG
492        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        // MP3
499        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        // FLAC
506        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}