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: Self = Self(0);
50
51    /// Wraps a backend-assigned raw handle. Backends allocate from 1 upward.
52    pub fn from_raw(raw: u32) -> Self {
53        Self(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: Self = Self(0);
75
76    /// Wraps a backend-assigned raw handle. Backends allocate from 1 upward.
77    pub fn from_raw(raw: u64) -> Self {
78        Self(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: [Self; 2] = [Self::Effects, Self::Music];
106
107    /// The bus's dense index, for backend-side arrays.
108    pub fn index(self) -> usize {
109        match self {
110            Self::Effects => 0,
111            Self::Music => 1,
112        }
113    }
114
115    /// The bus for a dense index produced by [`AudioBus::index`].
116    pub fn from_index(index: usize) -> Option<Self> {
117        Self::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: Self = Self {
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() -> Self {
156        Self::DEFAULT
157    }
158
159    /// Sets the linear gain.
160    pub fn volume(mut self, volume: f32) -> Self {
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) -> Self {
167        self.rate = rate;
168        self
169    }
170
171    /// Sets the stereo position.
172    pub fn pan(mut self, pan: f32) -> Self {
173        self.pan = pan;
174        self
175    }
176
177    /// Sets the mix bus.
178    pub fn bus(mut self, bus: AudioBus) -> Self {
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) -> Self {
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) -> Self {
201        fn finite(value: f32, fallback: f32) -> f32 {
202            if value.is_finite() { value } else { fallback }
203        }
204        Self {
205            volume: finite(self.volume, 1.0).clamp(0.0, Self::MAX_VOLUME),
206            rate: finite(self.rate, 1.0).clamp(Self::MIN_RATE, Self::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() -> Self {
224        Self::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<Self, 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) > Self::MAX_FRAMES {
271            return Err(AudioError::Decode(
272                "clip exceeds the maximum in-memory length".to_string(),
273            ));
274        }
275        Ok(Self {
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<Self, 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() -> Self {
464        Self {
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
538        .get_or_warn("audio")
539        .unwrap_or_else(|| Arc::clone(NOOP_AUDIO.get_or_init(|| Arc::new(NoopAudioPlayer::new()))))
540}
541
542impl AudioPlayer for PlatformAudioPlayer {
543    fn load_clip(&self, clip: AudioClip) -> Result<SoundId, AudioError> {
544        registered_audio().load_clip(clip)
545    }
546
547    fn unload(&self, id: SoundId) {
548        registered_audio().unload(id);
549    }
550
551    fn play(&self, id: SoundId, params: PlaybackParams) {
552        registered_audio().play(id, params);
553    }
554
555    fn play_loop(&self, id: SoundId, params: PlaybackParams) -> VoiceId {
556        registered_audio().play_loop(id, params)
557    }
558
559    fn stop(&self, id: SoundId) {
560        registered_audio().stop(id);
561    }
562
563    fn stop_voice(&self, voice: VoiceId) {
564        registered_audio().stop_voice(voice);
565    }
566
567    fn stop_all(&self) {
568        registered_audio().stop_all();
569    }
570
571    fn set_voice_params(&self, voice: VoiceId, params: PlaybackParams) {
572        registered_audio().set_voice_params(voice, params);
573    }
574
575    fn set_master_volume(&self, volume: f32) {
576        registered_audio().set_master_volume(volume);
577    }
578
579    fn master_volume(&self) -> f32 {
580        registered_audio().master_volume()
581    }
582
583    fn set_bus_volume(&self, bus: AudioBus, volume: f32) {
584        registered_audio().set_bus_volume(bus, volume);
585    }
586
587    fn bus_volume(&self, bus: AudioBus) -> f32 {
588        registered_audio().bus_volume(bus)
589    }
590
591    fn set_bus_enabled(&self, bus: AudioBus, enabled: bool) {
592        registered_audio().set_bus_enabled(bus, enabled);
593    }
594
595    fn bus_enabled(&self, bus: AudioBus) -> bool {
596        registered_audio().bus_enabled(bus)
597    }
598
599    fn suspend(&self) {
600        registered_audio().suspend();
601    }
602
603    fn resume(&self) {
604        registered_audio().resume();
605    }
606
607    fn is_available(&self) -> bool {
608        registered_audio().is_available()
609    }
610}
611
612/// Installs a platform audio player, replacing any previous one.
613pub fn set_platform_audio(player: AudioPlayerRef) {
614    PLATFORM_AUDIO.set(player);
615}
616
617/// Removes any registered platform audio player (tests and teardown).
618pub fn clear_platform_audio() {
619    PLATFORM_AUDIO.clear();
620}
621
622/// The installed platform player, or a no-op one.
623pub fn default_audio() -> AudioPlayerRef {
624    Arc::clone(DEFAULT_AUDIO.get_or_init(|| Arc::new(PlatformAudioPlayer)))
625}
626
627/// The CompositionLocal descendants read to reach the audio player.
628pub fn local_audio() -> CompositionLocal<AudioPlayerRef> {
629    crate::composition_locals::cached_local(
630        |locals| &locals.audio,
631        || compositionLocalOfWithPolicy(default_audio, Arc::ptr_eq),
632    )
633}
634
635/// Provides the installed audio player to `content`.
636#[composable]
637pub fn ProvideAudio(content: impl FnOnce()) {
638    let player = cranpose_core::remember(default_audio).with(|state| Arc::clone(state));
639    let local = local_audio();
640    CompositionLocalProvider([local.provides(player)], move || {
641        content();
642    });
643}
644
645/// One entry in a [`SoundBank`]: where the bytes come from and how the cue
646/// should sit in the mix before per-call parameters are applied.
647#[derive(Clone, Copy, Debug)]
648pub struct SoundSpec<'a> {
649    /// A stable name used for lookups and error reporting.
650    pub name: &'static str,
651    /// The encoded clip, typically an `include_bytes!` WAV.
652    pub bytes: &'a [u8],
653    /// The cue's own level, so a loud explosion and a quiet tick can share one
654    /// call site without per-call bookkeeping.
655    pub base_volume: f32,
656    /// The bus the cue plays on.
657    pub bus: AudioBus,
658}
659
660impl<'a> SoundSpec<'a> {
661    /// A cue at unity gain on the effects bus.
662    pub fn new(name: &'static str, bytes: &'a [u8]) -> Self {
663        SoundSpec {
664            name,
665            bytes,
666            base_volume: 1.0,
667            bus: AudioBus::Effects,
668        }
669    }
670
671    /// Sets the cue's own level.
672    pub fn volume(mut self, base_volume: f32) -> Self {
673        self.base_volume = base_volume;
674        self
675    }
676
677    /// Sets the cue's bus.
678    pub fn bus(mut self, bus: AudioBus) -> Self {
679        self.bus = bus;
680        self
681    }
682}
683
684/// A loaded cue inside a [`SoundBank`].
685#[derive(Clone, Copy, Debug)]
686pub struct SoundBankEntry {
687    /// The name from the [`SoundSpec`].
688    pub name: &'static str,
689    /// The engine handle.
690    pub id: SoundId,
691    /// The cue's own level.
692    pub base_volume: f32,
693    /// The cue's bus.
694    pub bus: AudioBus,
695}
696
697/// A cue that did not load, reported instead of silently vanishing.
698#[derive(Clone, Debug)]
699pub struct SoundBankFailure {
700    /// The name from the [`SoundSpec`].
701    pub name: &'static str,
702    /// Why it did not load.
703    pub error: AudioError,
704}
705
706struct SoundBankInner {
707    player: AudioPlayerRef,
708    entries: Vec<SoundBankEntry>,
709    failures: Vec<SoundBankFailure>,
710}
711
712impl Drop for SoundBankInner {
713    fn drop(&mut self) {
714        for entry in &self.entries {
715            self.player.unload(entry.id);
716        }
717    }
718}
719
720/// A set of cues loaded once and kept alive for as long as the composable that
721/// remembered it stays in the composition.
722///
723/// Indexing is positional, so an app's cue enum casts straight to an index;
724/// [`SoundBank::find`] covers the cases where a name is more convenient. The
725/// clips are released when the last clone drops.
726#[derive(Clone)]
727pub struct SoundBank {
728    inner: Rc<SoundBankInner>,
729}
730
731impl SoundBank {
732    /// Loads every spec through `player`, collecting the ones that fail rather
733    /// than aborting the whole bank.
734    pub fn load(player: AudioPlayerRef, specs: &[SoundSpec<'_>]) -> Self {
735        let mut entries = Vec::with_capacity(specs.len());
736        let mut failures = Vec::new();
737        for spec in specs {
738            match player.load(spec.bytes) {
739                Ok(id) => entries.push(SoundBankEntry {
740                    name: spec.name,
741                    id,
742                    base_volume: spec.base_volume,
743                    bus: spec.bus,
744                }),
745                Err(error) => {
746                    entries.push(SoundBankEntry {
747                        name: spec.name,
748                        id: SoundId::NONE,
749                        base_volume: spec.base_volume,
750                        bus: spec.bus,
751                    });
752                    failures.push(SoundBankFailure {
753                        name: spec.name,
754                        error,
755                    });
756                }
757            }
758        }
759        Self {
760            inner: Rc::new(SoundBankInner {
761                player,
762                entries,
763                failures,
764            }),
765        }
766    }
767
768    /// How many cues the bank holds, failures included.
769    pub fn len(&self) -> usize {
770        self.inner.entries.len()
771    }
772
773    /// Whether the bank holds no cues.
774    pub fn is_empty(&self) -> bool {
775        self.inner.entries.is_empty()
776    }
777
778    /// The cues, in spec order.
779    pub fn entries(&self) -> &[SoundBankEntry] {
780        &self.inner.entries
781    }
782
783    /// The cues that failed to load.
784    pub fn failures(&self) -> &[SoundBankFailure] {
785        &self.inner.failures
786    }
787
788    /// The player the bank loaded through.
789    pub fn player(&self) -> AudioPlayerRef {
790        Arc::clone(&self.inner.player)
791    }
792
793    /// The handle at `index`, or [`SoundId::NONE`] when out of range.
794    pub fn id(&self, index: usize) -> SoundId {
795        self.inner
796            .entries
797            .get(index)
798            .map_or(SoundId::NONE, |entry| entry.id)
799    }
800
801    /// The handle for `name`, if the bank holds it.
802    pub fn find(&self, name: &str) -> Option<SoundId> {
803        self.inner
804            .entries
805            .iter()
806            .find(|entry| entry.name == name)
807            .map(|entry| entry.id)
808    }
809
810    /// Plays a cue at its own level.
811    pub fn play(&self, index: usize) {
812        self.play_with(index, PlaybackParams::DEFAULT);
813    }
814
815    /// Plays a cue, multiplying `params.volume` by the cue's own level. The
816    /// bus comes from the spec, so a cue cannot escape its bus by accident.
817    pub fn play_with(&self, index: usize, params: PlaybackParams) {
818        let Some(entry) = self.inner.entries.get(index) else {
819            return;
820        };
821        if !entry.id.is_valid() {
822            return;
823        }
824        self.inner.player.play(entry.id, entry.apply(params));
825    }
826
827    /// Plays the cue called `name`, if the bank holds it.
828    pub fn play_named(&self, name: &str, params: PlaybackParams) {
829        if let Some(index) = self.inner.entries.iter().position(|e| e.name == name) {
830            self.play_with(index, params);
831        }
832    }
833
834    /// Starts a looping voice for a cue.
835    pub fn play_loop(&self, index: usize, params: PlaybackParams) -> VoiceId {
836        let Some(entry) = self.inner.entries.get(index) else {
837            return VoiceId::NONE;
838        };
839        if !entry.id.is_valid() {
840            return VoiceId::NONE;
841        }
842        self.inner.player.play_loop(entry.id, entry.apply(params))
843    }
844
845    /// Stops every voice of a cue.
846    pub fn stop(&self, index: usize) {
847        let id = self.id(index);
848        if id.is_valid() {
849            self.inner.player.stop(id);
850        }
851    }
852}
853
854impl SoundBankEntry {
855    fn apply(&self, params: PlaybackParams) -> PlaybackParams {
856        PlaybackParams {
857            volume: params.volume * self.base_volume,
858            rate: params.rate,
859            pan: params.pan,
860            bus: self.bus,
861        }
862    }
863}
864
865impl fmt::Debug for SoundBank {
866    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
867        f.debug_struct("SoundBank")
868            .field("entries", &self.inner.entries.len())
869            .field("failures", &self.inner.failures.len())
870            .finish()
871    }
872}
873
874impl Index<usize> for SoundBank {
875    type Output = SoundId;
876
877    fn index(&self, index: usize) -> &SoundId {
878        static NONE: SoundId = SoundId::NONE;
879        self.inner
880            .entries
881            .get(index)
882            .map_or(&NONE, |entry| &entry.id)
883    }
884}
885
886/// Loads a set of cues once and keeps them alive across recompositions.
887///
888/// The bank is rebuilt only when the spec list changes shape (its length or
889/// the set of names), which is the `remember(key)` contract applied to a
890/// resource that costs a decode.
891#[composable(no_skip)]
892#[track_caller]
893pub fn rememberSoundBank(specs: &[SoundSpec<'_>]) -> SoundBank {
894    let key = sound_bank_key(specs);
895    let player = local_audio().current();
896    cranpose_core::rememberKeyed((key, Arc::as_ptr(&player) as *const () as usize), |_| {
897        SoundBank::load(Arc::clone(&player), specs)
898    })
899}
900
901fn sound_bank_key(specs: &[SoundSpec<'_>]) -> (usize, u64) {
902    let mut hash = 0xcbf2_9ce4_8422_2325u64;
903    for spec in specs {
904        for byte in spec.name.as_bytes() {
905            hash ^= u64::from(*byte);
906            hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
907        }
908        hash ^= spec.bytes.len() as u64;
909        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
910    }
911    (specs.len(), hash)
912}
913
914#[cfg(test)]
915#[path = "tests/audio_tests.rs"]
916mod tests;