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}