Skip to main content

cranpose_services/
audio.rs

1//! Sound effects and music — the audio analogue of [`haptics`](crate::haptics).
2//!
3//! ```rust,ignore
4//! # use cranpose_services::*;
5//! const CUES: &[SoundSpec] = &[
6//!     SoundSpec::new("hit", include_bytes!("hit.wav")),
7//!     SoundSpec::new("music", include_bytes!("theme.wav")),
8//! ];
9//!
10//! #[composable]
11//! fn Game() {
12//!     let bank = rememberSoundBank(CUES);
13//!     let combo = rememberMutableStateOf(|| 0u32);
14//!     // A combo counter raises the pitch of the same cue.
15//!     bank.play_with(0, PlaybackParams::new().pitch_semitones(combo.value() as f32));
16//! }
17//! ```
18//!
19//! # Threading
20//!
21//! A real backend owns a real-time audio thread; the handle only enqueues
22//! commands for it. Decoding happens in [`AudioPlayer::load`], never during
23//! playback, so a cue that fires every few frames costs one queue push.
24
25mod wav;
26
27use std::{
28    fmt,
29    ops::Index,
30    rc::Rc,
31    sync::{Arc, OnceLock},
32};
33
34use cranpose_core::{CompositionLocal, CompositionLocalProvider, compositionLocalOfWithPolicy};
35use cranpose_macros::composable;
36use parking_lot::Mutex;
37
38use crate::registry::ServiceRegistry;
39
40/// Identifies a decoded clip held by the audio engine.
41///
42/// Handles are opaque and cheap to copy; store them in an array indexed by the
43/// app's own cue enum. [`SoundId::NONE`] never plays anything.
44#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
45pub struct SoundId(u32);
46
47impl SoundId {
48    /// A handle that refers to no clip. Playing it is a no-op.
49    pub const NONE: SoundId = SoundId(0);
50
51    /// Wraps a backend-assigned raw handle. Backends allocate from 1 upward.
52    pub fn from_raw(raw: u32) -> Self {
53        SoundId(raw)
54    }
55
56    /// The backend-assigned raw handle.
57    pub fn raw(self) -> u32 {
58        self.0
59    }
60
61    /// Whether this handle refers to a clip.
62    pub fn is_valid(self) -> bool {
63        self.0 != 0
64    }
65}
66
67/// Identifies one playing voice, so a looping cue can be stopped or retuned
68/// while it runs.
69#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
70pub struct VoiceId(u64);
71
72impl VoiceId {
73    /// A handle that refers to no voice. Stopping it is a no-op.
74    pub const NONE: VoiceId = VoiceId(0);
75
76    /// Wraps a backend-assigned raw handle. Backends allocate from 1 upward.
77    pub fn from_raw(raw: u64) -> Self {
78        VoiceId(raw)
79    }
80
81    /// The backend-assigned raw handle.
82    pub fn raw(self) -> u64 {
83        self.0
84    }
85
86    /// Whether this handle refers to a voice.
87    pub fn is_valid(self) -> bool {
88        self.0 != 0
89    }
90}
91
92/// The two mix buses every app gets, so "sound on" and "music on" are one call
93/// each rather than bookkeeping over individual voices.
94#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Default)]
95pub enum AudioBus {
96    /// Short cues: hits, pickups, UI clicks.
97    #[default]
98    Effects,
99    /// Sustained background material.
100    Music,
101}
102
103impl AudioBus {
104    /// Every bus, in index order.
105    pub const ALL: [AudioBus; 2] = [AudioBus::Effects, AudioBus::Music];
106
107    /// The bus's dense index, for backend-side arrays.
108    pub fn index(self) -> usize {
109        match self {
110            AudioBus::Effects => 0,
111            AudioBus::Music => 1,
112        }
113    }
114
115    /// The bus for a dense index produced by [`AudioBus::index`].
116    pub fn from_index(index: usize) -> Option<AudioBus> {
117        AudioBus::ALL.get(index).copied()
118    }
119}
120
121/// How one voice should sound.
122///
123/// Build with the chained setters — `PlaybackParams::new().volume(0.4).pan(-0.3)`
124/// reads like the named arguments a Compose call site would use.
125#[derive(Clone, Copy, PartialEq, Debug)]
126pub struct PlaybackParams {
127    /// Linear gain. `1.0` is the clip's recorded level.
128    pub volume: f32,
129    /// Playback rate, which shifts pitch with it. `1.0` is the recorded pitch,
130    /// `2.0` is an octave up.
131    pub rate: f32,
132    /// Stereo position from `-1.0` (hard left) through `0.0` to `1.0`.
133    pub pan: f32,
134    /// The mix bus this voice belongs to.
135    pub bus: AudioBus,
136}
137
138impl PlaybackParams {
139    /// The neutral parameters: unity gain, recorded pitch, centred, effects bus.
140    pub const DEFAULT: PlaybackParams = PlaybackParams {
141        volume: 1.0,
142        rate: 1.0,
143        pan: 0.0,
144        bus: AudioBus::Effects,
145    };
146
147    /// The slowest rate a backend honours.
148    pub const MIN_RATE: f32 = 0.05;
149    /// The fastest rate a backend honours.
150    pub const MAX_RATE: f32 = 8.0;
151    /// The loudest gain a backend honours, leaving room to amplify a quiet clip.
152    pub const MAX_VOLUME: f32 = 4.0;
153
154    /// The neutral parameters. Same as [`PlaybackParams::DEFAULT`].
155    pub const fn new() -> PlaybackParams {
156        PlaybackParams::DEFAULT
157    }
158
159    /// Sets the linear gain.
160    pub fn volume(mut self, volume: f32) -> PlaybackParams {
161        self.volume = volume;
162        self
163    }
164
165    /// Sets the playback rate (and with it the pitch).
166    pub fn rate(mut self, rate: f32) -> PlaybackParams {
167        self.rate = rate;
168        self
169    }
170
171    /// Sets the stereo position.
172    pub fn pan(mut self, pan: f32) -> PlaybackParams {
173        self.pan = pan;
174        self
175    }
176
177    /// Sets the mix bus.
178    pub fn bus(mut self, bus: AudioBus) -> PlaybackParams {
179        self.bus = bus;
180        self
181    }
182
183    /// Sets the rate from a pitch offset in equal-tempered semitones.
184    ///
185    /// A rising combo counter reads better as `pitch_semitones(combo as f32)`
186    /// than as a hand-computed ratio.
187    pub fn pitch_semitones(self, semitones: f32) -> PlaybackParams {
188        let semitones = if semitones.is_finite() {
189            semitones
190        } else {
191            0.0
192        };
193        self.rate(2.0f32.powf(semitones / 12.0))
194    }
195
196    /// Clamps every field into the range backends honour and scrubs `NaN`.
197    ///
198    /// Backends call this before handing parameters to the audio thread, which
199    /// is why a `NaN` rate from app arithmetic cannot wedge a voice.
200    pub fn sanitized(self) -> PlaybackParams {
201        fn finite(value: f32, fallback: f32) -> f32 {
202            if value.is_finite() { value } else { fallback }
203        }
204        PlaybackParams {
205            volume: finite(self.volume, 1.0).clamp(0.0, PlaybackParams::MAX_VOLUME),
206            rate: finite(self.rate, 1.0).clamp(PlaybackParams::MIN_RATE, PlaybackParams::MAX_RATE),
207            pan: finite(self.pan, 0.0).clamp(-1.0, 1.0),
208            bus: self.bus,
209        }
210    }
211
212    /// Constant-power left/right gains for these parameters, volume included.
213    ///
214    /// Computed on the caller's thread so the audio callback only multiplies.
215    pub fn gains(self) -> (f32, f32) {
216        let params = self.sanitized();
217        let angle = (params.pan + 1.0) * std::f32::consts::FRAC_PI_4;
218        (params.volume * angle.cos(), params.volume * angle.sin())
219    }
220}
221
222impl Default for PlaybackParams {
223    fn default() -> PlaybackParams {
224        PlaybackParams::DEFAULT
225    }
226}
227
228/// Decoded PCM held in memory, ready to play with no further work.
229///
230/// Samples are interleaved `f32` in `-1.0..=1.0`, one or two channels. The
231/// buffer sits behind an [`Arc`] so handing a clip to the audio thread is a
232/// refcount bump rather than a copy.
233#[derive(Clone)]
234pub struct AudioClip {
235    samples: Arc<[f32]>,
236    channels: u16,
237    sample_rate: u32,
238}
239
240impl AudioClip {
241    /// The largest clip the framework accepts, guarding a malformed header from
242    /// turning into a multi-gigabyte allocation.
243    pub const MAX_FRAMES: usize = 1 << 26;
244
245    /// Wraps already-decoded interleaved samples.
246    pub fn from_samples(
247        samples: Vec<f32>,
248        channels: u16,
249        sample_rate: u32,
250    ) -> Result<AudioClip, AudioError> {
251        if channels == 0 || channels > 2 {
252            return Err(AudioError::UnsupportedFormat(format!(
253                "clips must be mono or stereo, got {channels} channels"
254            )));
255        }
256        if sample_rate == 0 {
257            return Err(AudioError::UnsupportedFormat(
258                "clips must declare a non-zero sample rate".to_string(),
259            ));
260        }
261        if samples.is_empty() {
262            return Err(AudioError::Decode("clip holds no samples".to_string()));
263        }
264        if !samples.len().is_multiple_of(usize::from(channels)) {
265            return Err(AudioError::Decode(format!(
266                "clip holds {} samples, which is not a whole number of {channels}-channel frames",
267                samples.len()
268            )));
269        }
270        if samples.len() / usize::from(channels) > AudioClip::MAX_FRAMES {
271            return Err(AudioError::Decode(
272                "clip exceeds the maximum in-memory length".to_string(),
273            ));
274        }
275        Ok(AudioClip {
276            samples: samples.into(),
277            channels,
278            sample_rate,
279        })
280    }
281
282    /// Decodes an encoded clip. RIFF/WAVE is understood everywhere; anything
283    /// else reports [`AudioError::UnsupportedFormat`].
284    ///
285    /// Run this wherever the bytes arrive — including a worker thread — and
286    /// hand the result to [`AudioPlayer::load_clip`] when it is ready.
287    pub fn decode(bytes: &[u8]) -> Result<AudioClip, AudioError> {
288        if wav::is_wav(bytes) {
289            wav::decode(bytes)
290        } else {
291            Err(AudioError::UnsupportedFormat(
292                "only RIFF/WAVE clips are decoded by the framework".to_string(),
293            ))
294        }
295    }
296
297    /// The interleaved samples.
298    pub fn samples(&self) -> &[f32] {
299        &self.samples
300    }
301
302    /// The shared sample buffer, for a backend that keeps its own reference.
303    pub fn shared_samples(&self) -> Arc<[f32]> {
304        Arc::clone(&self.samples)
305    }
306
307    /// Channel count: 1 or 2.
308    pub fn channels(&self) -> u16 {
309        self.channels
310    }
311
312    /// The clip's recorded sample rate in Hz.
313    pub fn sample_rate(&self) -> u32 {
314        self.sample_rate
315    }
316
317    /// Frame count (samples divided by channels).
318    pub fn frames(&self) -> usize {
319        self.samples.len() / usize::from(self.channels)
320    }
321
322    /// Playing length in seconds at unity rate.
323    pub fn duration_secs(&self) -> f32 {
324        self.frames() as f32 / self.sample_rate as f32
325    }
326}
327
328impl fmt::Debug for AudioClip {
329    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
330        f.debug_struct("AudioClip")
331            .field("frames", &self.frames())
332            .field("channels", &self.channels)
333            .field("sample_rate", &self.sample_rate)
334            .finish()
335    }
336}
337
338/// Why an audio call could not be honoured.
339#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
340pub enum AudioError {
341    /// No audio backend is installed on this target.
342    #[error("no audio backend is installed")]
343    Unsupported,
344    /// The container or codec is not one the framework decodes.
345    #[error("unsupported audio format: {0}")]
346    UnsupportedFormat(String),
347    /// The bytes claimed a supported format but did not parse.
348    #[error("failed to decode audio: {0}")]
349    Decode(String),
350    /// The engine already holds its maximum number of clips.
351    #[error("the audio engine already holds its maximum of {capacity} clips")]
352    ClipTableFull {
353        /// How many clips the engine can hold at once.
354        capacity: usize,
355    },
356    /// The platform audio device refused the request.
357    #[error("audio backend failure: {0}")]
358    Backend(String),
359}
360
361/// Plays sound. Installed by the platform backend; the default is a no-op.
362///
363/// Only [`load_clip`](AudioPlayer::load_clip), [`play`](AudioPlayer::play),
364/// [`play_loop`](AudioPlayer::play_loop), [`stop`](AudioPlayer::stop),
365/// [`stop_voice`](AudioPlayer::stop_voice) and
366/// [`set_master_volume`](AudioPlayer::set_master_volume) must be implemented;
367/// everything else has a defaulted body so a partial backend still compiles.
368pub trait AudioPlayer: Send + Sync {
369    /// Hands an already-decoded clip to the engine.
370    fn load_clip(&self, clip: AudioClip) -> Result<SoundId, AudioError>;
371
372    /// Decodes `bytes` and loads the result.
373    ///
374    /// Decoding happens here, on the calling thread, so that
375    /// [`play`](AudioPlayer::play) is only a queue push. To keep even the load
376    /// off the UI thread, decode with [`AudioClip::decode`] elsewhere and call
377    /// [`load_clip`](AudioPlayer::load_clip).
378    fn load(&self, bytes: &[u8]) -> Result<SoundId, AudioError> {
379        self.load_clip(AudioClip::decode(bytes)?)
380    }
381
382    /// Releases a clip. Voices already playing it are stopped.
383    fn unload(&self, _id: SoundId) {}
384
385    /// Starts a one-shot voice. Re-triggering the same clip layers a new voice
386    /// rather than restarting the old one.
387    fn play(&self, id: SoundId, params: PlaybackParams);
388
389    /// Starts a looping voice and returns its handle.
390    fn play_loop(&self, id: SoundId, params: PlaybackParams) -> VoiceId;
391
392    /// Stops every voice playing `id`.
393    fn stop(&self, id: SoundId);
394
395    /// Stops one voice.
396    fn stop_voice(&self, voice: VoiceId);
397
398    /// Stops every voice on every bus.
399    fn stop_all(&self) {}
400
401    /// Retunes a voice while it plays — a looping engine note that follows
402    /// speed, for instance.
403    fn set_voice_params(&self, _voice: VoiceId, _params: PlaybackParams) {}
404
405    /// Sets the gain applied to every bus.
406    fn set_master_volume(&self, volume: f32);
407
408    /// The current master gain.
409    fn master_volume(&self) -> f32 {
410        1.0
411    }
412
413    /// Sets one bus's gain.
414    fn set_bus_volume(&self, _bus: AudioBus, _volume: f32) {}
415
416    /// One bus's gain.
417    fn bus_volume(&self, _bus: AudioBus) -> f32 {
418        1.0
419    }
420
421    /// Mutes or unmutes a bus. This is the "sound on" / "music on" toggle;
422    /// muting leaves voices running so unmuting resumes mid-track.
423    fn set_bus_enabled(&self, _bus: AudioBus, _enabled: bool) {}
424
425    /// Whether a bus is audible.
426    fn bus_enabled(&self, _bus: AudioBus) -> bool {
427        true
428    }
429
430    /// Releases the output device without discarding loaded clips — call it
431    /// when the app goes to the background.
432    fn suspend(&self) {}
433
434    /// Re-acquires the output device after [`suspend`](AudioPlayer::suspend).
435    fn resume(&self) {}
436
437    /// Whether a real device is behind this handle. The no-op default reports
438    /// `false`, so an app can honestly grey out its audio settings.
439    fn is_available(&self) -> bool {
440        false
441    }
442}
443
444/// Shared handle to the installed [`AudioPlayer`].
445pub type AudioPlayerRef = Arc<dyn AudioPlayer>;
446
447/// The player used when no backend is installed.
448///
449/// It still allocates real [`SoundId`]s and [`VoiceId`]s and remembers the
450/// volume and bus settings written to it, so app logic and settings screens
451/// behave identically with and without a device.
452#[derive(Default)]
453pub struct NoopAudioPlayer {
454    next_sound: Mutex<u32>,
455    next_voice: Mutex<u64>,
456    master: Mutex<f32>,
457    bus_volumes: Mutex<[f32; 2]>,
458    bus_enabled: Mutex<[bool; 2]>,
459}
460
461impl NoopAudioPlayer {
462    /// Creates a player that accepts everything and produces no sound.
463    pub fn new() -> NoopAudioPlayer {
464        NoopAudioPlayer {
465            next_sound: Mutex::new(0),
466            next_voice: Mutex::new(0),
467            master: Mutex::new(1.0),
468            bus_volumes: Mutex::new([1.0, 1.0]),
469            bus_enabled: Mutex::new([true, true]),
470        }
471    }
472}
473
474impl AudioPlayer for NoopAudioPlayer {
475    fn load_clip(&self, _clip: AudioClip) -> Result<SoundId, AudioError> {
476        let mut next = self.next_sound.lock();
477        *next = next.saturating_add(1);
478        Ok(SoundId::from_raw(*next))
479    }
480
481    fn play(&self, _id: SoundId, _params: PlaybackParams) {}
482
483    fn play_loop(&self, id: SoundId, _params: PlaybackParams) -> VoiceId {
484        if !id.is_valid() {
485            return VoiceId::NONE;
486        }
487        let mut next = self.next_voice.lock();
488        *next = next.saturating_add(1);
489        VoiceId::from_raw(*next)
490    }
491
492    fn stop(&self, _id: SoundId) {}
493
494    fn stop_voice(&self, _voice: VoiceId) {}
495
496    fn set_master_volume(&self, volume: f32) {
497        *self.master.lock() = if volume.is_finite() {
498            volume.clamp(0.0, 1.0)
499        } else {
500            1.0
501        };
502    }
503
504    fn master_volume(&self) -> f32 {
505        *self.master.lock()
506    }
507
508    fn set_bus_volume(&self, bus: AudioBus, volume: f32) {
509        let mut volumes = self.bus_volumes.lock();
510        volumes[bus.index()] = if volume.is_finite() {
511            volume.clamp(0.0, 1.0)
512        } else {
513            1.0
514        };
515    }
516
517    fn bus_volume(&self, bus: AudioBus) -> f32 {
518        self.bus_volumes.lock()[bus.index()]
519    }
520
521    fn set_bus_enabled(&self, bus: AudioBus, enabled: bool) {
522        self.bus_enabled.lock()[bus.index()] = enabled;
523    }
524
525    fn bus_enabled(&self, bus: AudioBus) -> bool {
526        self.bus_enabled.lock()[bus.index()]
527    }
528}
529
530static PLATFORM_AUDIO: ServiceRegistry<dyn AudioPlayer> = ServiceRegistry::new();
531static NOOP_AUDIO: OnceLock<AudioPlayerRef> = OnceLock::new();
532static DEFAULT_AUDIO: OnceLock<AudioPlayerRef> = OnceLock::new();
533
534struct PlatformAudioPlayer;
535
536fn registered_audio() -> AudioPlayerRef {
537    PLATFORM_AUDIO.get_or_warn("audio").unwrap_or_else(|| {
538        NOOP_AUDIO
539            .get_or_init(|| Arc::new(NoopAudioPlayer::new()))
540            .clone()
541    })
542}
543
544impl AudioPlayer for PlatformAudioPlayer {
545    fn load_clip(&self, clip: AudioClip) -> Result<SoundId, AudioError> {
546        registered_audio().load_clip(clip)
547    }
548
549    fn unload(&self, id: SoundId) {
550        registered_audio().unload(id);
551    }
552
553    fn play(&self, id: SoundId, params: PlaybackParams) {
554        registered_audio().play(id, params);
555    }
556
557    fn play_loop(&self, id: SoundId, params: PlaybackParams) -> VoiceId {
558        registered_audio().play_loop(id, params)
559    }
560
561    fn stop(&self, id: SoundId) {
562        registered_audio().stop(id);
563    }
564
565    fn stop_voice(&self, voice: VoiceId) {
566        registered_audio().stop_voice(voice);
567    }
568
569    fn stop_all(&self) {
570        registered_audio().stop_all();
571    }
572
573    fn set_voice_params(&self, voice: VoiceId, params: PlaybackParams) {
574        registered_audio().set_voice_params(voice, params);
575    }
576
577    fn set_master_volume(&self, volume: f32) {
578        registered_audio().set_master_volume(volume);
579    }
580
581    fn master_volume(&self) -> f32 {
582        registered_audio().master_volume()
583    }
584
585    fn set_bus_volume(&self, bus: AudioBus, volume: f32) {
586        registered_audio().set_bus_volume(bus, volume);
587    }
588
589    fn bus_volume(&self, bus: AudioBus) -> f32 {
590        registered_audio().bus_volume(bus)
591    }
592
593    fn set_bus_enabled(&self, bus: AudioBus, enabled: bool) {
594        registered_audio().set_bus_enabled(bus, enabled);
595    }
596
597    fn bus_enabled(&self, bus: AudioBus) -> bool {
598        registered_audio().bus_enabled(bus)
599    }
600
601    fn suspend(&self) {
602        registered_audio().suspend();
603    }
604
605    fn resume(&self) {
606        registered_audio().resume();
607    }
608
609    fn is_available(&self) -> bool {
610        registered_audio().is_available()
611    }
612}
613
614/// Installs a platform audio player, replacing any previous one.
615pub fn set_platform_audio(player: AudioPlayerRef) {
616    PLATFORM_AUDIO.set(player);
617}
618
619/// Removes any registered platform audio player (tests and teardown).
620pub fn clear_platform_audio() {
621    PLATFORM_AUDIO.clear();
622}
623
624/// The installed platform player, or a no-op one.
625pub fn default_audio() -> AudioPlayerRef {
626    DEFAULT_AUDIO
627        .get_or_init(|| Arc::new(PlatformAudioPlayer))
628        .clone()
629}
630
631/// The CompositionLocal descendants read to reach the audio player.
632pub fn local_audio() -> CompositionLocal<AudioPlayerRef> {
633    crate::composition_locals::cached_local(
634        |locals| &locals.audio,
635        || compositionLocalOfWithPolicy(default_audio, Arc::ptr_eq),
636    )
637}
638
639/// Provides the installed audio player to `content`.
640#[composable]
641pub fn ProvideAudio(content: impl FnOnce()) {
642    let player = cranpose_core::remember(default_audio).with(|state| state.clone());
643    let local = local_audio();
644    CompositionLocalProvider([local.provides(player)], move || {
645        content();
646    });
647}
648
649/// One entry in a [`SoundBank`]: where the bytes come from and how the cue
650/// should sit in the mix before per-call parameters are applied.
651#[derive(Clone, Copy, Debug)]
652pub struct SoundSpec<'a> {
653    /// A stable name used for lookups and error reporting.
654    pub name: &'static str,
655    /// The encoded clip, typically an `include_bytes!` WAV.
656    pub bytes: &'a [u8],
657    /// The cue's own level, so a loud explosion and a quiet tick can share one
658    /// call site without per-call bookkeeping.
659    pub base_volume: f32,
660    /// The bus the cue plays on.
661    pub bus: AudioBus,
662}
663
664impl<'a> SoundSpec<'a> {
665    /// A cue at unity gain on the effects bus.
666    pub fn new(name: &'static str, bytes: &'a [u8]) -> SoundSpec<'a> {
667        SoundSpec {
668            name,
669            bytes,
670            base_volume: 1.0,
671            bus: AudioBus::Effects,
672        }
673    }
674
675    /// Sets the cue's own level.
676    pub fn volume(mut self, base_volume: f32) -> SoundSpec<'a> {
677        self.base_volume = base_volume;
678        self
679    }
680
681    /// Sets the cue's bus.
682    pub fn bus(mut self, bus: AudioBus) -> SoundSpec<'a> {
683        self.bus = bus;
684        self
685    }
686}
687
688/// A loaded cue inside a [`SoundBank`].
689#[derive(Clone, Copy, Debug)]
690pub struct SoundBankEntry {
691    /// The name from the [`SoundSpec`].
692    pub name: &'static str,
693    /// The engine handle.
694    pub id: SoundId,
695    /// The cue's own level.
696    pub base_volume: f32,
697    /// The cue's bus.
698    pub bus: AudioBus,
699}
700
701/// A cue that did not load, reported instead of silently vanishing.
702#[derive(Clone, Debug)]
703pub struct SoundBankFailure {
704    /// The name from the [`SoundSpec`].
705    pub name: &'static str,
706    /// Why it did not load.
707    pub error: AudioError,
708}
709
710struct SoundBankInner {
711    player: AudioPlayerRef,
712    entries: Vec<SoundBankEntry>,
713    failures: Vec<SoundBankFailure>,
714}
715
716impl Drop for SoundBankInner {
717    fn drop(&mut self) {
718        for entry in &self.entries {
719            self.player.unload(entry.id);
720        }
721    }
722}
723
724/// A set of cues loaded once and kept alive for as long as the composable that
725/// remembered it stays in the composition.
726///
727/// Indexing is positional, so an app's cue enum casts straight to an index;
728/// [`SoundBank::find`] covers the cases where a name is more convenient. The
729/// clips are released when the last clone drops.
730#[derive(Clone)]
731pub struct SoundBank {
732    inner: Rc<SoundBankInner>,
733}
734
735impl SoundBank {
736    /// Loads every spec through `player`, collecting the ones that fail rather
737    /// than aborting the whole bank.
738    pub fn load(player: AudioPlayerRef, specs: &[SoundSpec<'_>]) -> SoundBank {
739        let mut entries = Vec::with_capacity(specs.len());
740        let mut failures = Vec::new();
741        for spec in specs {
742            match player.load(spec.bytes) {
743                Ok(id) => entries.push(SoundBankEntry {
744                    name: spec.name,
745                    id,
746                    base_volume: spec.base_volume,
747                    bus: spec.bus,
748                }),
749                Err(error) => {
750                    entries.push(SoundBankEntry {
751                        name: spec.name,
752                        id: SoundId::NONE,
753                        base_volume: spec.base_volume,
754                        bus: spec.bus,
755                    });
756                    failures.push(SoundBankFailure {
757                        name: spec.name,
758                        error,
759                    });
760                }
761            }
762        }
763        SoundBank {
764            inner: Rc::new(SoundBankInner {
765                player,
766                entries,
767                failures,
768            }),
769        }
770    }
771
772    /// How many cues the bank holds, failures included.
773    pub fn len(&self) -> usize {
774        self.inner.entries.len()
775    }
776
777    /// Whether the bank holds no cues.
778    pub fn is_empty(&self) -> bool {
779        self.inner.entries.is_empty()
780    }
781
782    /// The cues, in spec order.
783    pub fn entries(&self) -> &[SoundBankEntry] {
784        &self.inner.entries
785    }
786
787    /// The cues that failed to load.
788    pub fn failures(&self) -> &[SoundBankFailure] {
789        &self.inner.failures
790    }
791
792    /// The player the bank loaded through.
793    pub fn player(&self) -> AudioPlayerRef {
794        Arc::clone(&self.inner.player)
795    }
796
797    /// The handle at `index`, or [`SoundId::NONE`] when out of range.
798    pub fn id(&self, index: usize) -> SoundId {
799        self.inner
800            .entries
801            .get(index)
802            .map_or(SoundId::NONE, |entry| entry.id)
803    }
804
805    /// The handle for `name`, if the bank holds it.
806    pub fn find(&self, name: &str) -> Option<SoundId> {
807        self.inner
808            .entries
809            .iter()
810            .find(|entry| entry.name == name)
811            .map(|entry| entry.id)
812    }
813
814    /// Plays a cue at its own level.
815    pub fn play(&self, index: usize) {
816        self.play_with(index, PlaybackParams::DEFAULT);
817    }
818
819    /// Plays a cue, multiplying `params.volume` by the cue's own level. The
820    /// bus comes from the spec, so a cue cannot escape its bus by accident.
821    pub fn play_with(&self, index: usize, params: PlaybackParams) {
822        let Some(entry) = self.inner.entries.get(index) else {
823            return;
824        };
825        if !entry.id.is_valid() {
826            return;
827        }
828        self.inner.player.play(entry.id, entry.apply(params));
829    }
830
831    /// Plays the cue called `name`, if the bank holds it.
832    pub fn play_named(&self, name: &str, params: PlaybackParams) {
833        if let Some(index) = self.inner.entries.iter().position(|e| e.name == name) {
834            self.play_with(index, params);
835        }
836    }
837
838    /// Starts a looping voice for a cue.
839    pub fn play_loop(&self, index: usize, params: PlaybackParams) -> VoiceId {
840        let Some(entry) = self.inner.entries.get(index) else {
841            return VoiceId::NONE;
842        };
843        if !entry.id.is_valid() {
844            return VoiceId::NONE;
845        }
846        self.inner.player.play_loop(entry.id, entry.apply(params))
847    }
848
849    /// Stops every voice of a cue.
850    pub fn stop(&self, index: usize) {
851        let id = self.id(index);
852        if id.is_valid() {
853            self.inner.player.stop(id);
854        }
855    }
856}
857
858impl SoundBankEntry {
859    fn apply(&self, params: PlaybackParams) -> PlaybackParams {
860        PlaybackParams {
861            volume: params.volume * self.base_volume,
862            rate: params.rate,
863            pan: params.pan,
864            bus: self.bus,
865        }
866    }
867}
868
869impl fmt::Debug for SoundBank {
870    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
871        f.debug_struct("SoundBank")
872            .field("entries", &self.inner.entries.len())
873            .field("failures", &self.inner.failures.len())
874            .finish()
875    }
876}
877
878impl Index<usize> for SoundBank {
879    type Output = SoundId;
880
881    fn index(&self, index: usize) -> &SoundId {
882        static NONE: SoundId = SoundId::NONE;
883        self.inner
884            .entries
885            .get(index)
886            .map_or(&NONE, |entry| &entry.id)
887    }
888}
889
890/// Loads a set of cues once and keeps them alive across recompositions.
891///
892/// The bank is rebuilt only when the spec list changes shape (its length or
893/// the set of names), which is the `remember(key)` contract applied to a
894/// resource that costs a decode.
895#[composable(no_skip)]
896#[track_caller]
897pub fn rememberSoundBank(specs: &[SoundSpec<'_>]) -> SoundBank {
898    let key = sound_bank_key(specs);
899    let player = local_audio().current();
900    cranpose_core::rememberKeyed((key, Arc::as_ptr(&player) as *const () as usize), |_| {
901        SoundBank::load(Arc::clone(&player), specs)
902    })
903}
904
905fn sound_bank_key(specs: &[SoundSpec<'_>]) -> (usize, u64) {
906    let mut hash = 0xcbf2_9ce4_8422_2325u64;
907    for spec in specs {
908        for byte in spec.name.as_bytes() {
909            hash ^= u64::from(*byte);
910            hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
911        }
912        hash ^= spec.bytes.len() as u64;
913        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
914    }
915    (specs.len(), hash)
916}
917
918#[cfg(test)]
919#[path = "tests/audio_tests.rs"]
920mod tests;