Skip to main content

pebble/
audio.rs

1//! Audio playback — `AudioPlugin` inserts [`AudioOutput`] as a resource,
2//! opening the default output device once at startup, exactly like
3//! [`crate::time::TimePlugin`]. `App::new()` already builds this in, so
4//! `Res<AudioOutput>` works without registering anything yourself.
5//!
6//! **No wasm32 support** — the underlying `rodio`/`cpal` stack doesn't
7//! build on `wasm32-unknown-unknown` at all. A known gap, not solved here.
8//!
9//! Backend-agnostic, same as [`crate::time`]/[`crate::gamepad`] — nothing
10//! here depends on `pebble::wgpu` or any particular rendering backend.
11//! `Sound` needs no [`Asset`](crate::assets::upload::Asset)/`Handle`/GPU
12//! pipeline — it's not a GPU resource, and decoding it is a synchronous,
13//! one-shot call, not something worth an async pipeline (same reasoning as
14//! `wgpu::gltf_loader::load_gltf`).
15
16use rodio::Source;
17
18use crate::ecs::plugin::Plugin;
19
20#[derive(Debug)]
21pub enum AudioError {
22    Io(std::io::Error),
23    /// Wraps `rodio::decoder::DecoderError`'s `Display` output — `rodio`
24    /// stays an implementation detail, not exposed in this crate's own
25    /// error type.
26    Decode(String),
27    /// Wraps `rodio::stream::DeviceSinkError`'s `Display` output — e.g. no
28    /// output device found.
29    Device(String),
30}
31
32impl std::fmt::Display for AudioError {
33    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34        match self {
35            Self::Io(e) => write!(f, "failed to read sound file: {e}"),
36            Self::Decode(msg) => write!(f, "failed to decode audio: {msg}"),
37            Self::Device(msg) => write!(f, "audio output device error: {msg}"),
38        }
39    }
40}
41
42impl std::error::Error for AudioError {
43    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
44        match self {
45            Self::Io(e) => Some(e),
46            _ => None,
47        }
48    }
49}
50
51impl From<std::io::Error> for AudioError {
52    fn from(e: std::io::Error) -> Self {
53        Self::Io(e)
54    }
55}
56
57/// Decoded sound data, ready to play — cheap to clone (an `Arc`-backed
58/// sample buffer internally) and to play more than once or simultaneously.
59/// Plain data — the only way to construct one is [`SoundBuilder`].
60#[derive(Clone)]
61pub struct Sound(rodio::buffer::SamplesBuffer);
62
63/// Builds a [`Sound`] by decoding an audio file. Fully decodes up front
64/// (not a streaming decoder) — fine for sound effects and short music
65/// clips; a very long track means a correspondingly large in-memory buffer.
66pub struct SoundBuilder;
67
68impl SoundBuilder {
69    /// Supports whatever formats `rodio`'s default decoder does (wav, mp3,
70    /// flac, vorbis, ...). Reads and decodes the whole file synchronously —
71    /// this is a one-shot call, not part of any retry pipeline, so a
72    /// missing file or unsupported/corrupt format is a real `Err` you
73    /// handle immediately, not a silent forever-retry.
74    pub fn from_file(path: &str) -> Result<Sound, AudioError> {
75        let file = std::fs::File::open(path)?;
76        let decoder = rodio::Decoder::new(std::io::BufReader::new(file))
77            .map_err(|e| AudioError::Decode(e.to_string()))?;
78        let channels = decoder.channels();
79        let sample_rate = decoder.sample_rate();
80        let samples: Vec<f32> = decoder.collect();
81        Ok(Sound(rodio::buffer::SamplesBuffer::new(channels, sample_rate, samples)))
82    }
83}
84
85/// A sound actively playing, with volume/pause/stop control — returned by
86/// [`AudioOutput::play_controlled`]. **Dropping this stops playback** (the
87/// same RAII behavior as closing an audio stream elsewhere) — for
88/// "play it and don't worry about the handle," use
89/// [`AudioOutput::play`]/[`AudioOutput::play_looped`] instead, which don't
90/// hand back anything to accidentally drop.
91pub struct PlayingSound(rodio::Player);
92
93impl PlayingSound {
94    pub fn set_volume(&self, volume: f32) {
95        self.0.set_volume(volume);
96    }
97
98    pub fn pause(&self) {
99        self.0.pause();
100    }
101
102    pub fn resume(&self) {
103        self.0.play();
104    }
105
106    pub fn stop(&self) {
107        self.0.stop();
108    }
109
110    /// True once playback has finished (or after [`stop`](Self::stop)).
111    pub fn is_finished(&self) -> bool {
112        self.0.empty()
113    }
114}
115
116/// The audio output device — a resource, inserted by [`AudioPlugin`].
117pub struct AudioOutput {
118    sink: rodio::MixerDeviceSink,
119}
120
121impl AudioOutput {
122    fn new() -> Result<Self, AudioError> {
123        let sink = rodio::DeviceSinkBuilder::open_default_sink().map_err(|e| AudioError::Device(e.to_string()))?;
124        Ok(Self { sink })
125    }
126
127    /// Fire-and-forget playback — plays out fully regardless of whether you
128    /// keep anything around afterward. For volume/pause/stop control, use
129    /// [`play_controlled`](Self::play_controlled) instead.
130    pub fn play(&self, sound: &Sound) {
131        let player = rodio::Player::connect_new(self.sink.mixer());
132        player.append(sound.0.clone());
133        player.play();
134        player.detach();
135    }
136
137    /// Same as [`play`](Self::play), looped forever — stop it early via
138    /// [`play_controlled`](Self::play_controlled) instead if you need to be
139    /// able to turn it off.
140    pub fn play_looped(&self, sound: &Sound) {
141        let player = rodio::Player::connect_new(self.sink.mixer());
142        player.append(sound.0.clone().repeat_infinite());
143        player.play();
144        player.detach();
145    }
146
147    /// Same as [`play`](Self::play), but returns a [`PlayingSound`] handle
148    /// for volume/pause/stop control. Remember: dropping the handle stops
149    /// playback — keep it alive (e.g. as a component/resource field) for as
150    /// long as you want the sound to keep playing.
151    pub fn play_controlled(&self, sound: &Sound) -> PlayingSound {
152        let player = rodio::Player::connect_new(self.sink.mixer());
153        player.append(sound.0.clone());
154        player.play();
155        PlayingSound(player)
156    }
157}
158
159/// Registers [`AudioOutput`] as a resource, opening the default output
160/// device once at startup.
161///
162/// If no output device is available at all, `build` logs a
163/// `tracing::error!` and does not insert [`AudioOutput`] — take
164/// `Option<Res<AudioOutput>>` in systems that need to keep working either
165/// way.
166///
167/// `App::new()` already builds this in, so registering it again yourself
168/// (harmless, but unnecessary) does not open a second output stream —
169/// idempotent the same way `TimePlugin` is.
170pub struct AudioPlugin;
171
172impl AudioPlugin {
173    pub fn new() -> Self {
174        Self
175    }
176}
177
178impl Default for AudioPlugin {
179    fn default() -> Self {
180        Self::new()
181    }
182}
183
184/// Cheap marker inserted before the (expensive, fallible) real work, so a
185/// second `AudioPlugin::build` call can check "already handled" without
186/// opening a second output stream just to discard it.
187struct AudioPluginRan;
188
189impl Plugin for AudioPlugin {
190    fn build(&self, app: &mut crate::prelude::App) {
191        if !app.try_insert_resource(AudioPluginRan) {
192            return;
193        }
194        match AudioOutput::new() {
195            Ok(output) => {
196                app.add_resource(output);
197            }
198            Err(e) => tracing::error!("AudioPlugin: failed to open the default audio output device: {e}"),
199        }
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    #[test]
208    fn from_file_on_a_missing_path_returns_a_real_err() {
209        let result = SoundBuilder::from_file("does/not/exist.wav");
210        assert!(result.is_err());
211    }
212
213    #[test]
214    fn from_file_decodes_a_valid_wav_fixture() {
215        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/audio/tone.wav");
216        let sound = SoundBuilder::from_file(path).expect("fixture should decode cleanly");
217        // Just confirms decoding actually produced audio, without needing a
218        // real output device (Sound holds no device handle at all).
219        assert!(sound.0.total_duration().is_some_and(|d| !d.is_zero()));
220    }
221}