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`]. With the `synth` feature, [`Audio::play_synth`] plays chiptune
21//! music written in tracker notation live on the built-in synth, and [`Synth`] renders effects
22//! and jingles to PCM (see [`synth`]). Mixer tracks, spatial audio and file streaming are not wrapped;
23//! [`kira`] is re-exported for anything beyond this API, and [`SoundHandle::with_kira`]
24//! reaches the underlying handle.
25//!
26//! On the web, browsers keep an `AudioContext` suspended until the first user gesture, and kira
27//! offers no way to resume it. [`Audio`] therefore opens the backend lazily there: sounds can be
28//! loaded right away, but playback only starts once [`Audio::start`] has run (the toolkit's core
29//! calls it on the first key press, click or touch, or sooner if the page already had a gesture
30//! and the browser lets audio start). Until then plays fail with
31//! [`AudioError::NotStarted`], unless they were [`PlayOptions::deferred`], which queues them.
32//!
33//! [`Audio::suspend`] pauses everything at once. On the web, the toolkit's core also
34//! suspends the `AudioContext` while the game's canvas is detached from the page (see
35//! `DetachHandle`), since the game gets no frames then to do it itself.
36
37#![forbid(unsafe_code)]
38
39use std::collections::HashMap;
40#[cfg(not(target_arch = "wasm32"))]
41use std::path::Path;
42use std::sync::Arc;
43
44use anyhow::{Result, anyhow};
45use kira::sound::static_sound::{StaticSoundData, StaticSoundSettings};
46use kira::track::{TrackBuilder, TrackHandle};
47use kira::{AudioManager, AudioManagerSettings, Decibels, Frame, Tween};
48
49mod handle;
50use handle::Slot;
51pub use handle::SoundHandle;
52mod stream;
53pub use kira;
54use stream::StreamData;
55pub use stream::{AudioStream, StreamHandle};
56
57#[cfg(target_arch = "wasm32")]
58mod web;
59#[cfg(target_arch = "wasm32")]
60pub use web::DetachHandle;
61
62#[cfg(target_arch = "wasm32")]
63type Backend = web::WebBackend;
64#[cfg(not(target_arch = "wasm32"))]
65type Backend = kira::DefaultBackend;
66
67#[cfg(feature = "synth")]
68mod music;
69#[cfg(feature = "synth")]
70use music::PreparedSynth;
71#[cfg(feature = "synth")]
72pub use music::SynthHandle;
73#[cfg(feature = "synth")]
74pub mod synth;
75#[cfg(feature = "synth")]
76pub use synth::Synth;
77#[cfg(feature = "synth")]
78pub use synthie;
79
80#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
81pub struct SoundId(pub u32);
82
83/// Why a sound didn't play.
84#[derive(Debug)]
85#[non_exhaustive]
86pub enum AudioError {
87    /// Audio hasn't started yet: on the web, the player hasn't pressed a key, clicked or
88    /// touched the page. Not a real failure; try again later, check [`Audio::is_started`]
89    /// first, or play with [`PlayOptions::deferred`] to queue the sound.
90    NotStarted,
91    /// The [`SoundId`] wasn't returned by this [`Audio`].
92    UnknownSound(SoundId),
93    /// kira refused to play the sound (e.g. its sound limit was reached).
94    Backend(String),
95}
96
97impl std::fmt::Display for AudioError {
98    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99        match self {
100            Self::NotStarted => f.write_str("audio not started yet (waiting for a user gesture)"),
101            Self::UnknownSound(id) => write!(f, "unknown SoundId {id:?}"),
102            Self::Backend(e) => write!(f, "kira play: {e}"),
103        }
104    }
105}
106
107impl std::error::Error for AudioError {}
108
109/// How to play a sound: see [`Audio::play_with`]. The default plays once at the sound's own
110/// volume, and fails with [`AudioError::NotStarted`] before audio is available.
111#[derive(Copy, Clone, Debug, PartialEq)]
112pub struct PlayOptions {
113    /// Loop the whole sound until stopped.
114    pub looped: bool,
115    /// Linear amplitude (`1.0` is the sound's own level, `0.0` silence).
116    pub volume: f32,
117    /// If audio hasn't started yet (web, before the first user gesture), queue the sound and
118    /// start it once audio starts, instead of failing with [`AudioError::NotStarted`]. Meant
119    /// for music and ambience; a one-shot effect would play late, out of context.
120    pub deferred: bool,
121}
122
123impl Default for PlayOptions {
124    fn default() -> Self {
125        Self {
126            looped: false,
127            volume: 1.0,
128            deferred: false,
129        }
130    }
131}
132
133impl PlayOptions {
134    /// Options for a sound that loops until stopped.
135    pub fn looped() -> Self {
136        Self {
137            looped: true,
138            ..Self::default()
139        }
140    }
141
142    /// Set [`PlayOptions::volume`].
143    pub fn volume(self, volume: f32) -> Self {
144        Self { volume, ..self }
145    }
146
147    /// Set [`PlayOptions::deferred`].
148    pub fn deferred(self) -> Self {
149        Self {
150            deferred: true,
151            ..self
152        }
153    }
154}
155
156/// The open output: kira's manager and the bus every sound plays on.
157struct Manager {
158    kira: AudioManager<Backend>,
159    /// A sub track between the sounds and the main track, so [`Audio::suspend`] can pause
160    /// them all at once (kira's main track can't pause).
161    bus: TrackHandle,
162}
163
164pub struct Audio {
165    manager: Option<Manager>,
166    /// Set by [`Audio::suspend`]; applied to the bus once audio starts.
167    suspended: bool,
168    #[cfg(target_arch = "wasm32")]
169    detach: DetachHandle,
170    sounds: HashMap<SoundId, StaticSoundData>,
171    next_id: u32,
172    /// Main-track gain, remembered so a volume set before [`Audio::start`] still applies.
173    master_volume: Decibels,
174    /// [`PlayOptions::deferred`] sounds waiting for [`Audio::start`].
175    queued: Vec<(StaticSoundData, SoundHandle)>,
176    /// [`PlayOptions::deferred`] streams waiting for [`Audio::start`].
177    queued_streams: Vec<StreamData>,
178    /// [`PlayOptions::deferred`] synths waiting for [`Audio::start`].
179    #[cfg(feature = "synth")]
180    queued_synths: Vec<PreparedSynth>,
181}
182
183impl Audio {
184    /// Create the audio subsystem. On native targets this opens the output device immediately;
185    /// on the web it defers that to [`Audio::start`].
186    pub fn new() -> Result<Self> {
187        let mut audio = Self {
188            manager: None,
189            suspended: false,
190            #[cfg(target_arch = "wasm32")]
191            detach: DetachHandle::default(),
192            sounds: HashMap::new(),
193            next_id: 1,
194            master_volume: Decibels::IDENTITY,
195            queued: Vec::new(),
196            queued_streams: Vec::new(),
197            #[cfg(feature = "synth")]
198            queued_synths: Vec::new(),
199        };
200        if !cfg!(target_arch = "wasm32") {
201            audio.start()?;
202        }
203        Ok(audio)
204    }
205
206    /// Open the output device if it isn't open yet, and start any [`PlayOptions::deferred`]
207    /// sounds queued until then. Idempotent. On the web this must run inside a user-gesture
208    /// handler, otherwise the browser keeps the `AudioContext` muted.
209    pub fn start(&mut self) -> Result<()> {
210        if self.manager.is_some() {
211            return Ok(());
212        }
213        #[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut))]
214        let mut settings = AudioManagerSettings::<Backend>::default();
215        #[cfg(target_arch = "wasm32")]
216        {
217            settings.backend_settings = self.detach.clone();
218        }
219        let mut kira = AudioManager::<Backend>::new(settings)
220            .map_err(|e| anyhow!("kira AudioManager init: {e}"))?;
221        kira.main_track()
222            .set_volume(self.master_volume, Tween::default());
223        let mut bus = kira
224            .add_sub_track(TrackBuilder::new())
225            .map_err(|e| anyhow!("kira bus track: {e}"))?;
226        if self.suspended {
227            bus.pause(Tween::default());
228        }
229        self.manager = Some(Manager { kira, bus });
230        for (data, handle) in std::mem::take(&mut self.queued) {
231            self.start_queued(data, &handle);
232        }
233        for data in std::mem::take(&mut self.queued_streams) {
234            if !data.handle().is_finished() {
235                self.start_stream(data);
236            }
237        }
238        #[cfg(feature = "synth")]
239        for prepared in std::mem::take(&mut self.queued_synths) {
240            if !prepared.handle().is_finished() {
241                self.start_synth(prepared);
242            }
243        }
244        Ok(())
245    }
246
247    fn start_queued(&mut self, data: StaticSoundData, handle: &SoundHandle) {
248        let mut slot = handle.lock();
249        let Slot::Pending { volume, paused } = &*slot else {
250            return; // stopped while queued
251        };
252        let (volume, paused) = (*volume, *paused);
253        // An instant volume change replaces the start volume; a fade starts from it once
254        // the sound plays, over the duration that was asked for.
255        let (data, fade) = match volume {
256            Some((db, duration)) if duration.is_zero() => (data.volume(db), None),
257            Some((db, duration)) => (data, Some((db, duration))),
258            None => (data, None),
259        };
260        match self.play_now(data) {
261            Ok(mut kira) => {
262                if let Some((db, duration)) = fade {
263                    kira.set_volume(db, handle::tween(duration));
264                }
265                if paused {
266                    kira.pause(Tween::default());
267                }
268                *slot = Slot::Playing(kira);
269            }
270            Err(e) => {
271                log::warn!("queued sound failed to start: {e}");
272                *slot = Slot::Cancelled;
273            }
274        }
275    }
276
277    /// Whether the output device is open, i.e. [`Audio::play`] can produce sound. Always
278    /// `true` natively; on the web, `false` until the first user gesture.
279    pub fn is_started(&self) -> bool {
280        self.manager.is_some()
281    }
282
283    /// Load a sound file from disk. Not available on the web; use [`Audio::load_sound_bytes`].
284    #[cfg(not(target_arch = "wasm32"))]
285    pub fn load_sound(&mut self, path: impl AsRef<Path>) -> Result<SoundId> {
286        let data = StaticSoundData::from_file(path.as_ref())
287            .map_err(|e| anyhow!("kira load {}: {e}", path.as_ref().display()))?;
288        Ok(self.insert(data))
289    }
290
291    /// Decode an encoded sound file (wav, ogg, flac, mp3) held in memory.
292    pub fn load_sound_bytes(&mut self, bytes: impl Into<Vec<u8>>) -> Result<SoundId> {
293        let data = StaticSoundData::from_cursor(std::io::Cursor::new(bytes.into()))
294            .map_err(|e| anyhow!("kira decode: {e}"))?;
295        Ok(self.insert(data))
296    }
297
298    fn insert(&mut self, data: StaticSoundData) -> SoundId {
299        let id = SoundId(self.next_id);
300        self.next_id += 1;
301        self.sounds.insert(id, data);
302        id
303    }
304
305    /// Play a loaded sound once, at its own volume. The returned [`SoundHandle`] controls this
306    /// instance (volume, pause, stop, `is_playing`); dropping it lets the sound play out.
307    ///
308    /// Fails with [`AudioError::NotStarted`] on the web until the player's first key press,
309    /// click or touch (check [`Audio::is_started`]), so don't `?` it in `update`. For music
310    /// that should start as soon as it can, use [`Audio::play_with`] and
311    /// [`PlayOptions::deferred`].
312    pub fn play(&mut self, id: SoundId) -> Result<SoundHandle, AudioError> {
313        self.play_with(id, PlayOptions::default())
314    }
315
316    /// Play a loaded sound with [`PlayOptions`]: looping, a start volume, and whether to
317    /// queue it until audio starts.
318    pub fn play_with(
319        &mut self,
320        id: SoundId,
321        options: PlayOptions,
322    ) -> Result<SoundHandle, AudioError> {
323        let data = self
324            .sounds
325            .get(&id)
326            .ok_or(AudioError::UnknownSound(id))?
327            .clone();
328        self.play_data(data, options)
329    }
330
331    /// Play mono PCM samples (`-1.0..=1.0`) directly, e.g. a buffer rendered by
332    /// [`Synth`]. The samples are duplicated to both channels. Fails like [`Audio::play`]
333    /// before audio has started.
334    pub fn play_samples(
335        &mut self,
336        samples: &[f32],
337        sample_rate: u32,
338    ) -> Result<SoundHandle, AudioError> {
339        self.play_samples_with(samples, sample_rate, PlayOptions::default())
340    }
341
342    /// [`Audio::play_samples`] with [`PlayOptions`].
343    pub fn play_samples_with(
344        &mut self,
345        samples: &[f32],
346        sample_rate: u32,
347        options: PlayOptions,
348    ) -> Result<SoundHandle, AudioError> {
349        let frames: Arc<[Frame]> = samples
350            .iter()
351            .map(|&s| Frame { left: s, right: s })
352            .collect();
353        let data = StaticSoundData {
354            sample_rate,
355            frames,
356            settings: StaticSoundSettings::default(),
357            slice: None,
358        };
359        self.play_data(data, options)
360    }
361
362    fn play_data(
363        &mut self,
364        data: StaticSoundData,
365        options: PlayOptions,
366    ) -> Result<SoundHandle, AudioError> {
367        let mut data = data.volume(amplitude_to_db(options.volume));
368        if options.looped {
369            data = data.loop_region(..);
370        }
371        if self.manager.is_none() && options.deferred {
372            let handle = SoundHandle::pending();
373            self.queued.push((data, handle.clone()));
374            return Ok(handle);
375        }
376        self.play_now(data).map(SoundHandle::playing)
377    }
378
379    fn play_now(
380        &mut self,
381        data: StaticSoundData,
382    ) -> Result<kira::sound::static_sound::StaticSoundHandle, AudioError> {
383        self.manager
384            .as_mut()
385            .ok_or(AudioError::NotStarted)?
386            .bus
387            .play(data)
388            .map_err(|e| AudioError::Backend(e.to_string()))
389    }
390
391    /// Play audio generated while it plays, such as a synthesizer or an emulated sound
392    /// chip: `stream` is asked for mono samples at `sample_rate` as the mixer needs them,
393    /// on the audio thread, and resampled to the output rate. It plays until stopped
394    /// through the returned handle. Fails like [`Audio::play`] before audio has started.
395    pub fn play_stream(
396        &mut self,
397        stream: impl AudioStream,
398        sample_rate: u32,
399    ) -> Result<StreamHandle, AudioError> {
400        self.play_stream_with(stream, sample_rate, PlayOptions::default())
401    }
402
403    /// [`Audio::play_stream`] with [`PlayOptions`]. `volume` and `deferred` apply;
404    /// `looped` has no meaning for a stream, which runs until stopped.
405    pub fn play_stream_with(
406        &mut self,
407        stream: impl AudioStream,
408        sample_rate: u32,
409        options: PlayOptions,
410    ) -> Result<StreamHandle, AudioError> {
411        let pending = self.manager.is_none();
412        if pending && !options.deferred {
413            return Err(AudioError::NotStarted);
414        }
415        let (data, handle) =
416            StreamData::new(Box::new(stream), sample_rate, options.volume, pending);
417        if pending {
418            self.queued_streams.push(data);
419        } else {
420            self.start_stream(data);
421        }
422        Ok(handle)
423    }
424
425    fn start_stream(&mut self, data: StreamData) {
426        let handle = data.handle().clone();
427        let Some(manager) = self.manager.as_mut() else {
428            return;
429        };
430        match manager.bus.play(data) {
431            Ok(()) => handle.mark_started(),
432            Err(e) => {
433                log::warn!("stream failed to start: {e}");
434                handle.stop();
435            }
436        }
437    }
438
439    /// Play a [`synth::Song`] live on the synth, for music: the song is sequenced
440    /// sample-accurately on the audio thread, so tempo and drums stay tight whatever the
441    /// frame rate. The returned [`SynthHandle`] controls volume (ducking), pause and stop, and
442    /// sends live [`synth::AudioEvent`]s.
443    ///
444    /// [`PlayOptions`] apply as for other sounds: [`PlayOptions::looped`] loops the song,
445    /// otherwise it plays once (whatever the song's own [`synth::Song::looped`] says), and
446    /// `volume` and `deferred` work as usual. A song that plays once ends by itself
447    /// after its release and effect tails ([`SynthHandle::is_finished`] turns `true`); a
448    /// looping one plays until stopped. Dropping the last handle stops the synth either way.
449    /// Fails like [`Audio::play`] before audio has started.
450    ///
451    /// ```no_run
452    /// # use game_toolkit_audio::{Audio, PlayOptions};
453    /// use game_toolkit_audio::synth::{ChannelNo, DrumHit, Preset, Song, Tempo};
454    /// # fn demo(audio: &mut Audio) -> anyhow::Result<()> {
455    /// let song = Song::new(Tempo::bpm(140.0, 4))
456    ///     .patch(Preset::C64Bass.params())
457    ///     .lane(ChannelNo(0), "E2@0 . E3 . E2 . E3 . G2 . G3 . A2 . A3 -")?
458    ///     .lane(ChannelNo(3), "k h s h k h s h k h s h k k s o")?;
459    /// let music = audio.play_synth(song, PlayOptions::looped().volume(0.4).deferred())?;
460    /// let _ = music.drum(DrumHit::Crash); // a live hit on a game event
461    /// music.fade_volume(0.1, std::time::Duration::from_millis(300)); // duck
462    /// # Ok(()) }
463    /// ```
464    #[cfg(feature = "synth")]
465    pub fn play_synth(
466        &mut self,
467        mut song: synth::Song,
468        options: PlayOptions,
469    ) -> Result<SynthHandle, AudioError> {
470        song.looped = options.looped;
471        let data =
472            synthie::kira::SynthSoundData::new().with_sequencer(synth::SongPlayer::new(song));
473        self.play_synth_sound(data, options)
474    }
475
476    /// [`Audio::play_synth`] for a synth set up by hand: start patches per channel, a custom
477    /// [`synthie::prelude::Sequencer`] instead of a song, another channel count, or none at
478    /// all to play it only from [`SynthHandle`] events. `options.looped` has no meaning here;
479    /// the sequencer decides.
480    #[cfg(feature = "synth")]
481    pub fn play_synth_sound<S, const N: usize>(
482        &mut self,
483        data: synthie::kira::SynthSoundData<S, N>,
484        options: PlayOptions,
485    ) -> Result<SynthHandle, AudioError>
486    where
487        S: synthie::prelude::Sequencer + Send + 'static,
488    {
489        let pending = self.manager.is_none();
490        if pending && !options.deferred {
491            return Err(AudioError::NotStarted);
492        }
493        let data = data.with_volume(amplitude_to_db(options.volume));
494        let prepared = PreparedSynth::new(data, pending);
495        let handle = prepared.handle().clone();
496        if pending {
497            self.queued_synths.push(prepared);
498        } else {
499            self.start_synth(prepared);
500        }
501        Ok(handle)
502    }
503
504    #[cfg(feature = "synth")]
505    fn start_synth(&mut self, prepared: PreparedSynth) {
506        let handle = prepared.handle().clone();
507        let Some(manager) = self.manager.as_mut() else {
508            return;
509        };
510        match manager.bus.play(prepared) {
511            Ok(()) => handle.mark_started(),
512            Err(e) => {
513                log::warn!("synth failed to start: {e}");
514                handle.stop();
515            }
516        }
517    }
518
519    /// Master volume on the default main track, as a linear amplitude
520    /// (`1.0` is unity gain, `0.0` is silence). Converted to decibels for kira.
521    pub fn set_master_volume(&mut self, volume: f32) {
522        self.master_volume = amplitude_to_db(volume);
523        if let Some(manager) = self.manager.as_mut() {
524            manager
525                .kira
526                .main_track()
527                .set_volume(self.master_volume, Tween::default());
528        }
529    }
530
531    /// Pause everything that's playing (sounds, streams, synths) until [`Audio::resume`],
532    /// e.g. for a pause menu or when the host page hides the game. Sounds started while
533    /// suspended wait too. Works before audio has started: it then starts suspended.
534    ///
535    /// Independent of the per-sound [`SoundHandle::pause`], and of the toolkit suspending the
536    /// web output while the canvas is detached from the page (`DetachHandle`, web only):
537    /// reattaching the canvas doesn't undo a suspend.
538    pub fn suspend(&mut self) {
539        self.set_suspended(true);
540    }
541
542    /// Undo [`Audio::suspend`]: everything it paused continues where it left off.
543    pub fn resume(&mut self) {
544        self.set_suspended(false);
545    }
546
547    /// Whether [`Audio::suspend`] is in effect.
548    pub fn is_suspended(&self) -> bool {
549        self.suspended
550    }
551
552    fn set_suspended(&mut self, suspended: bool) {
553        if self.suspended == suspended {
554            return;
555        }
556        self.suspended = suspended;
557        if let Some(manager) = self.manager.as_mut() {
558            if suspended {
559                manager.bus.pause(Tween::default());
560            } else {
561                manager.bus.resume(Tween::default());
562            }
563        }
564    }
565
566    /// Web only: the handle that suspends the output while the game's canvas is detached
567    /// from the page. The toolkit's core drives it; see [`DetachHandle`].
568    #[cfg(target_arch = "wasm32")]
569    pub fn detach_handle(&self) -> DetachHandle {
570        self.detach.clone()
571    }
572}
573
574/// Linear amplitude (`1.0` unity, `0.0` silence) to kira's decibels.
575pub(crate) fn amplitude_to_db(volume: f32) -> Decibels {
576    if volume <= 0.0 {
577        Decibels::SILENCE
578    } else {
579        Decibels::from(20.0 * volume.log10())
580    }
581}
582
583#[cfg(test)]
584mod tests {
585    use super::*;
586
587    #[test]
588    fn amplitude_to_db_maps_unity_and_silence() {
589        assert_eq!(amplitude_to_db(1.0), Decibels::IDENTITY);
590        assert_eq!(amplitude_to_db(0.0), Decibels::SILENCE);
591        assert!((amplitude_to_db(0.5).0 + 6.02).abs() < 0.01);
592    }
593
594    #[test]
595    fn play_options_builders() {
596        let o = PlayOptions::looped().volume(0.25).deferred();
597        assert!(o.looped && o.deferred);
598        assert_eq!(o.volume, 0.25);
599        assert_eq!(PlayOptions::default().volume, 1.0);
600    }
601
602    #[test]
603    fn suspend_before_start_is_remembered() {
604        let mut audio = Audio {
605            manager: None,
606            suspended: false,
607            #[cfg(target_arch = "wasm32")]
608            detach: DetachHandle::default(),
609            sounds: HashMap::new(),
610            next_id: 1,
611            master_volume: Decibels::IDENTITY,
612            queued: Vec::new(),
613            queued_streams: Vec::new(),
614            #[cfg(feature = "synth")]
615            queued_synths: Vec::new(),
616        };
617        assert!(!audio.is_suspended());
618        audio.suspend();
619        audio.suspend();
620        assert!(audio.is_suspended());
621        audio.resume();
622        assert!(!audio.is_suspended());
623    }
624}