Skip to main content

game_toolkit_audio/
lib.rs

1//! Audio subsystem built on [`kira`].
2//!
3//! Load static sound buffers (from disk or bytes) and play them on the main track. Every play
4//! call returns a [`SoundHandle`] that controls that instance: volume (instant or faded), pause,
5//! resume, stop, and whether it's still playing. [`PlayOptions`] covers looping, a start volume
6//! and deferred start in one call:
7//!
8//! ```no_run
9//! # use game_toolkit_audio::{Audio, PlayOptions};
10//! # fn demo(audio: &mut Audio, music: game_toolkit_audio::SoundId) -> anyhow::Result<()> {
11//! // Background music: loop at 30% volume, starting as soon as audio is available.
12//! let handle = audio.play_with(music, PlayOptions::looped().volume(0.3).deferred())?;
13//! handle.pause();
14//! handle.resume();
15//! handle.fade_volume(0.0, std::time::Duration::from_secs(2));
16//! # Ok(()) }
17//! ```
18//!
19//! Mixer tracks, spatial audio and streaming are not wrapped; [`kira`] is re-exported for
20//! anything beyond this API, and [`SoundHandle::with_kira`] reaches the underlying handle.
21//!
22//! On the web, browsers keep an `AudioContext` suspended until the first user gesture, and kira
23//! offers no way to resume it. [`Audio`] therefore opens the backend lazily there: sounds can be
24//! loaded right away, but playback only starts once [`Audio::start`] has run (the toolkit's core
25//! calls it on the first key press, click or touch). Until then plays fail with
26//! [`AudioError::NotStarted`], unless they were [`PlayOptions::deferred`], which queues them.
27
28#![forbid(unsafe_code)]
29
30use std::collections::HashMap;
31#[cfg(not(target_arch = "wasm32"))]
32use std::path::Path;
33use std::sync::Arc;
34
35use anyhow::{Result, anyhow};
36use kira::sound::static_sound::{StaticSoundData, StaticSoundSettings};
37use kira::{AudioManager, AudioManagerSettings, Decibels, DefaultBackend, Frame, Tween};
38
39mod handle;
40use handle::Slot;
41pub use handle::SoundHandle;
42pub use kira;
43
44#[cfg(feature = "synth")]
45mod synth;
46#[cfg(feature = "synth")]
47pub use synth::Synth;
48#[cfg(feature = "synth")]
49pub use synthie;
50
51#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
52pub struct SoundId(pub u32);
53
54/// Why a sound didn't play.
55#[derive(Debug)]
56#[non_exhaustive]
57pub enum AudioError {
58    /// Audio hasn't started yet: on the web, the player hasn't pressed a key, clicked or
59    /// touched the page. Not a real failure; try again later, check [`Audio::is_started`]
60    /// first, or play with [`PlayOptions::deferred`] to queue the sound.
61    NotStarted,
62    /// The [`SoundId`] wasn't returned by this [`Audio`].
63    UnknownSound(SoundId),
64    /// kira refused to play the sound (e.g. its sound limit was reached).
65    Backend(String),
66}
67
68impl std::fmt::Display for AudioError {
69    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70        match self {
71            Self::NotStarted => f.write_str("audio not started yet (waiting for a user gesture)"),
72            Self::UnknownSound(id) => write!(f, "unknown SoundId {id:?}"),
73            Self::Backend(e) => write!(f, "kira play: {e}"),
74        }
75    }
76}
77
78impl std::error::Error for AudioError {}
79
80/// How to play a sound: see [`Audio::play_with`]. The default plays once at the sound's own
81/// volume, and fails with [`AudioError::NotStarted`] before audio is available.
82#[derive(Copy, Clone, Debug, PartialEq)]
83pub struct PlayOptions {
84    /// Loop the whole sound until stopped.
85    pub looped: bool,
86    /// Linear amplitude (`1.0` is the sound's own level, `0.0` silence).
87    pub volume: f32,
88    /// If audio hasn't started yet (web, before the first user gesture), queue the sound and
89    /// start it once audio starts, instead of failing with [`AudioError::NotStarted`]. Meant
90    /// for music and ambience; a one-shot effect would play late, out of context.
91    pub deferred: bool,
92}
93
94impl Default for PlayOptions {
95    fn default() -> Self {
96        Self {
97            looped: false,
98            volume: 1.0,
99            deferred: false,
100        }
101    }
102}
103
104impl PlayOptions {
105    /// Options for a sound that loops until stopped.
106    pub fn looped() -> Self {
107        Self {
108            looped: true,
109            ..Self::default()
110        }
111    }
112
113    /// Set [`PlayOptions::volume`].
114    pub fn volume(self, volume: f32) -> Self {
115        Self { volume, ..self }
116    }
117
118    /// Set [`PlayOptions::deferred`].
119    pub fn deferred(self) -> Self {
120        Self {
121            deferred: true,
122            ..self
123        }
124    }
125}
126
127pub struct Audio {
128    manager: Option<AudioManager<DefaultBackend>>,
129    sounds: HashMap<SoundId, StaticSoundData>,
130    next_id: u32,
131    /// Main-track gain, remembered so a volume set before [`Audio::start`] still applies.
132    master_volume: Decibels,
133    /// [`PlayOptions::deferred`] sounds waiting for [`Audio::start`].
134    queued: Vec<(StaticSoundData, SoundHandle)>,
135}
136
137impl Audio {
138    /// Create the audio subsystem. On native targets this opens the output device immediately;
139    /// on the web it defers that to [`Audio::start`].
140    pub fn new() -> Result<Self> {
141        let mut audio = Self {
142            manager: None,
143            sounds: HashMap::new(),
144            next_id: 1,
145            master_volume: Decibels::IDENTITY,
146            queued: Vec::new(),
147        };
148        if !cfg!(target_arch = "wasm32") {
149            audio.start()?;
150        }
151        Ok(audio)
152    }
153
154    /// Open the output device if it isn't open yet, and start any [`PlayOptions::deferred`]
155    /// sounds queued until then. Idempotent. On the web this must run inside a user-gesture
156    /// handler, otherwise the browser keeps the `AudioContext` muted.
157    pub fn start(&mut self) -> Result<()> {
158        if self.manager.is_some() {
159            return Ok(());
160        }
161        let mut manager = AudioManager::<DefaultBackend>::new(AudioManagerSettings::default())
162            .map_err(|e| anyhow!("kira AudioManager init: {e}"))?;
163        manager
164            .main_track()
165            .set_volume(self.master_volume, Tween::default());
166        self.manager = Some(manager);
167        for (data, handle) in std::mem::take(&mut self.queued) {
168            self.start_queued(data, &handle);
169        }
170        Ok(())
171    }
172
173    fn start_queued(&mut self, data: StaticSoundData, handle: &SoundHandle) {
174        let mut slot = handle.lock();
175        let Slot::Pending { volume, paused } = &*slot else {
176            return; // stopped while queued
177        };
178        let (volume, paused) = (*volume, *paused);
179        // An instant volume change replaces the start volume; a fade starts from it once
180        // the sound plays, over the duration that was asked for.
181        let (data, fade) = match volume {
182            Some((db, duration)) if duration.is_zero() => (data.volume(db), None),
183            Some((db, duration)) => (data, Some((db, duration))),
184            None => (data, None),
185        };
186        match self.play_now(data) {
187            Ok(mut kira) => {
188                if let Some((db, duration)) = fade {
189                    kira.set_volume(db, handle::tween(duration));
190                }
191                if paused {
192                    kira.pause(Tween::default());
193                }
194                *slot = Slot::Playing(kira);
195            }
196            Err(e) => {
197                log::warn!("queued sound failed to start: {e}");
198                *slot = Slot::Cancelled;
199            }
200        }
201    }
202
203    /// Whether the output device is open, i.e. [`Audio::play`] can produce sound. Always
204    /// `true` natively; on the web, `false` until the first user gesture.
205    pub fn is_started(&self) -> bool {
206        self.manager.is_some()
207    }
208
209    /// Load a sound file from disk. Not available on the web; use [`Audio::load_sound_bytes`].
210    #[cfg(not(target_arch = "wasm32"))]
211    pub fn load_sound(&mut self, path: impl AsRef<Path>) -> Result<SoundId> {
212        let data = StaticSoundData::from_file(path.as_ref())
213            .map_err(|e| anyhow!("kira load {}: {e}", path.as_ref().display()))?;
214        Ok(self.insert(data))
215    }
216
217    /// Decode an encoded sound file (wav, ogg, flac, mp3) held in memory.
218    pub fn load_sound_bytes(&mut self, bytes: impl Into<Vec<u8>>) -> Result<SoundId> {
219        let data = StaticSoundData::from_cursor(std::io::Cursor::new(bytes.into()))
220            .map_err(|e| anyhow!("kira decode: {e}"))?;
221        Ok(self.insert(data))
222    }
223
224    fn insert(&mut self, data: StaticSoundData) -> SoundId {
225        let id = SoundId(self.next_id);
226        self.next_id += 1;
227        self.sounds.insert(id, data);
228        id
229    }
230
231    /// Play a loaded sound once, at its own volume. The returned [`SoundHandle`] controls this
232    /// instance (volume, pause, stop, `is_playing`); dropping it lets the sound play out.
233    ///
234    /// Fails with [`AudioError::NotStarted`] on the web until the player's first key press,
235    /// click or touch (check [`Audio::is_started`]), so don't `?` it in `update`. For music
236    /// that should start as soon as it can, use [`Audio::play_with`] and
237    /// [`PlayOptions::deferred`].
238    pub fn play(&mut self, id: SoundId) -> Result<SoundHandle, AudioError> {
239        self.play_with(id, PlayOptions::default())
240    }
241
242    /// Play a loaded sound with [`PlayOptions`]: looping, a start volume, and whether to
243    /// queue it until audio starts.
244    pub fn play_with(
245        &mut self,
246        id: SoundId,
247        options: PlayOptions,
248    ) -> Result<SoundHandle, AudioError> {
249        let data = self
250            .sounds
251            .get(&id)
252            .ok_or(AudioError::UnknownSound(id))?
253            .clone();
254        self.play_data(data, options)
255    }
256
257    /// Play mono PCM samples (`-1.0..=1.0`) directly, e.g. a buffer rendered by
258    /// [`Synth`]. The samples are duplicated to both channels. Fails like [`Audio::play`]
259    /// before audio has started.
260    pub fn play_samples(
261        &mut self,
262        samples: &[f32],
263        sample_rate: u32,
264    ) -> Result<SoundHandle, AudioError> {
265        self.play_samples_with(samples, sample_rate, PlayOptions::default())
266    }
267
268    /// [`Audio::play_samples`] with [`PlayOptions`].
269    pub fn play_samples_with(
270        &mut self,
271        samples: &[f32],
272        sample_rate: u32,
273        options: PlayOptions,
274    ) -> Result<SoundHandle, AudioError> {
275        let frames: Arc<[Frame]> = samples
276            .iter()
277            .map(|&s| Frame { left: s, right: s })
278            .collect();
279        let data = StaticSoundData {
280            sample_rate,
281            frames,
282            settings: StaticSoundSettings::default(),
283            slice: None,
284        };
285        self.play_data(data, options)
286    }
287
288    fn play_data(
289        &mut self,
290        data: StaticSoundData,
291        options: PlayOptions,
292    ) -> Result<SoundHandle, AudioError> {
293        let mut data = data.volume(amplitude_to_db(options.volume));
294        if options.looped {
295            data = data.loop_region(..);
296        }
297        if self.manager.is_none() && options.deferred {
298            let handle = SoundHandle::pending();
299            self.queued.push((data, handle.clone()));
300            return Ok(handle);
301        }
302        self.play_now(data).map(SoundHandle::playing)
303    }
304
305    fn play_now(
306        &mut self,
307        data: StaticSoundData,
308    ) -> Result<kira::sound::static_sound::StaticSoundHandle, AudioError> {
309        self.manager
310            .as_mut()
311            .ok_or(AudioError::NotStarted)?
312            .play(data)
313            .map_err(|e| AudioError::Backend(e.to_string()))
314    }
315
316    /// Master volume on the default main track, as a linear amplitude
317    /// (`1.0` is unity gain, `0.0` is silence). Converted to decibels for kira.
318    pub fn set_master_volume(&mut self, volume: f32) {
319        self.master_volume = amplitude_to_db(volume);
320        if let Some(manager) = self.manager.as_mut() {
321            manager
322                .main_track()
323                .set_volume(self.master_volume, Tween::default());
324        }
325    }
326}
327
328/// Linear amplitude (`1.0` unity, `0.0` silence) to kira's decibels.
329pub(crate) fn amplitude_to_db(volume: f32) -> Decibels {
330    if volume <= 0.0 {
331        Decibels::SILENCE
332    } else {
333        Decibels::from(20.0 * volume.log10())
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340
341    #[test]
342    fn amplitude_to_db_maps_unity_and_silence() {
343        assert_eq!(amplitude_to_db(1.0), Decibels::IDENTITY);
344        assert_eq!(amplitude_to_db(0.0), Decibels::SILENCE);
345        assert!((amplitude_to_db(0.5).0 + 6.02).abs() < 0.01);
346    }
347
348    #[test]
349    fn play_options_builders() {
350        let o = PlayOptions::looped().volume(0.25).deferred();
351        assert!(o.looped && o.deferred);
352        assert_eq!(o.volume, 0.25);
353        assert_eq!(PlayOptions::default().volume, 1.0);
354    }
355}