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