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 crate::registry::ServiceRegistry;
28use cranpose_core::compositionLocalOfWithPolicy;
29use cranpose_core::CompositionLocal;
30use cranpose_core::CompositionLocalProvider;
31use cranpose_macros::composable;
32use parking_lot::Mutex;
33use std::cell::RefCell;
34use std::fmt;
35use std::ops::Index;
36use std::rc::Rc;
37use std::sync::{Arc, OnceLock};
38
39/// Identifies a decoded clip held by the audio engine.
40///
41/// Handles are opaque and cheap to copy; store them in an array indexed by the
42/// app's own cue enum. [`SoundId::NONE`] never plays anything.
43#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
44pub struct SoundId(u32);
45
46impl SoundId {
47    /// A handle that refers to no clip. Playing it is a no-op.
48    pub const NONE: SoundId = SoundId(0);
49
50    /// Wraps a backend-assigned raw handle. Backends allocate from 1 upward.
51    pub fn from_raw(raw: u32) -> Self {
52        SoundId(raw)
53    }
54
55    /// The backend-assigned raw handle.
56    pub fn raw(self) -> u32 {
57        self.0
58    }
59
60    /// Whether this handle refers to a clip.
61    pub fn is_valid(self) -> bool {
62        self.0 != 0
63    }
64}
65
66/// Identifies one playing voice, so a looping cue can be stopped or retuned
67/// while it runs.
68#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
69pub struct VoiceId(u64);
70
71impl VoiceId {
72    /// A handle that refers to no voice. Stopping it is a no-op.
73    pub const NONE: VoiceId = VoiceId(0);
74
75    /// Wraps a backend-assigned raw handle. Backends allocate from 1 upward.
76    pub fn from_raw(raw: u64) -> Self {
77        VoiceId(raw)
78    }
79
80    /// The backend-assigned raw handle.
81    pub fn raw(self) -> u64 {
82        self.0
83    }
84
85    /// Whether this handle refers to a voice.
86    pub fn is_valid(self) -> bool {
87        self.0 != 0
88    }
89}
90
91/// The two mix buses every app gets, so "sound on" and "music on" are one call
92/// each rather than bookkeeping over individual voices.
93#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Default)]
94pub enum AudioBus {
95    /// Short cues: hits, pickups, UI clicks.
96    #[default]
97    Effects,
98    /// Sustained background material.
99    Music,
100}
101
102impl AudioBus {
103    /// Every bus, in index order.
104    pub const ALL: [AudioBus; 2] = [AudioBus::Effects, AudioBus::Music];
105
106    /// The bus's dense index, for backend-side arrays.
107    pub fn index(self) -> usize {
108        match self {
109            AudioBus::Effects => 0,
110            AudioBus::Music => 1,
111        }
112    }
113
114    /// The bus for a dense index produced by [`AudioBus::index`].
115    pub fn from_index(index: usize) -> Option<AudioBus> {
116        AudioBus::ALL.get(index).copied()
117    }
118}
119
120/// How one voice should sound.
121///
122/// Build with the chained setters — `PlaybackParams::new().volume(0.4).pan(-0.3)`
123/// reads like the named arguments a Compose call site would use.
124#[derive(Clone, Copy, PartialEq, Debug)]
125pub struct PlaybackParams {
126    /// Linear gain. `1.0` is the clip's recorded level.
127    pub volume: f32,
128    /// Playback rate, which shifts pitch with it. `1.0` is the recorded pitch,
129    /// `2.0` is an octave up.
130    pub rate: f32,
131    /// Stereo position from `-1.0` (hard left) through `0.0` to `1.0`.
132    pub pan: f32,
133    /// The mix bus this voice belongs to.
134    pub bus: AudioBus,
135}
136
137impl PlaybackParams {
138    /// The neutral parameters: unity gain, recorded pitch, centred, effects bus.
139    pub const DEFAULT: PlaybackParams = PlaybackParams {
140        volume: 1.0,
141        rate: 1.0,
142        pan: 0.0,
143        bus: AudioBus::Effects,
144    };
145
146    /// The slowest rate a backend honours.
147    pub const MIN_RATE: f32 = 0.05;
148    /// The fastest rate a backend honours.
149    pub const MAX_RATE: f32 = 8.0;
150    /// The loudest gain a backend honours, leaving room to amplify a quiet clip.
151    pub const MAX_VOLUME: f32 = 4.0;
152
153    /// The neutral parameters. Same as [`PlaybackParams::DEFAULT`].
154    pub const fn new() -> PlaybackParams {
155        PlaybackParams::DEFAULT
156    }
157
158    /// Sets the linear gain.
159    pub fn volume(mut self, volume: f32) -> PlaybackParams {
160        self.volume = volume;
161        self
162    }
163
164    /// Sets the playback rate (and with it the pitch).
165    pub fn rate(mut self, rate: f32) -> PlaybackParams {
166        self.rate = rate;
167        self
168    }
169
170    /// Sets the stereo position.
171    pub fn pan(mut self, pan: f32) -> PlaybackParams {
172        self.pan = pan;
173        self
174    }
175
176    /// Sets the mix bus.
177    pub fn bus(mut self, bus: AudioBus) -> PlaybackParams {
178        self.bus = bus;
179        self
180    }
181
182    /// Sets the rate from a pitch offset in equal-tempered semitones.
183    ///
184    /// A rising combo counter reads better as `pitch_semitones(combo as f32)`
185    /// than as a hand-computed ratio.
186    pub fn pitch_semitones(self, semitones: f32) -> PlaybackParams {
187        let semitones = if semitones.is_finite() {
188            semitones
189        } else {
190            0.0
191        };
192        self.rate(2.0f32.powf(semitones / 12.0))
193    }
194
195    /// Clamps every field into the range backends honour and scrubs `NaN`.
196    ///
197    /// Backends call this before handing parameters to the audio thread, which
198    /// is why a `NaN` rate from app arithmetic cannot wedge a voice.
199    pub fn sanitized(self) -> PlaybackParams {
200        fn finite(value: f32, fallback: f32) -> f32 {
201            if value.is_finite() {
202                value
203            } else {
204                fallback
205            }
206        }
207        PlaybackParams {
208            volume: finite(self.volume, 1.0).clamp(0.0, PlaybackParams::MAX_VOLUME),
209            rate: finite(self.rate, 1.0).clamp(PlaybackParams::MIN_RATE, PlaybackParams::MAX_RATE),
210            pan: finite(self.pan, 0.0).clamp(-1.0, 1.0),
211            bus: self.bus,
212        }
213    }
214
215    /// Constant-power left/right gains for these parameters, volume included.
216    ///
217    /// Computed on the caller's thread so the audio callback only multiplies.
218    pub fn gains(self) -> (f32, f32) {
219        let params = self.sanitized();
220        // pan -1..1 maps to 0..PI/2, so cos/sin sweep one full constant-power arc.
221        let angle = (params.pan + 1.0) * std::f32::consts::FRAC_PI_4;
222        (params.volume * angle.cos(), params.volume * angle.sin())
223    }
224}
225
226impl Default for PlaybackParams {
227    fn default() -> PlaybackParams {
228        PlaybackParams::DEFAULT
229    }
230}
231
232/// Decoded PCM held in memory, ready to play with no further work.
233///
234/// Samples are interleaved `f32` in `-1.0..=1.0`, one or two channels. The
235/// buffer sits behind an [`Arc`] so handing a clip to the audio thread is a
236/// refcount bump rather than a copy.
237#[derive(Clone)]
238pub struct AudioClip {
239    samples: Arc<[f32]>,
240    channels: u16,
241    sample_rate: u32,
242}
243
244impl AudioClip {
245    /// The largest clip the framework accepts, guarding a malformed header from
246    /// turning into a multi-gigabyte allocation.
247    pub const MAX_FRAMES: usize = 1 << 26;
248
249    /// Wraps already-decoded interleaved samples.
250    pub fn from_samples(
251        samples: Vec<f32>,
252        channels: u16,
253        sample_rate: u32,
254    ) -> Result<AudioClip, AudioError> {
255        if channels == 0 || channels > 2 {
256            return Err(AudioError::UnsupportedFormat(format!(
257                "clips must be mono or stereo, got {channels} channels"
258            )));
259        }
260        if sample_rate == 0 {
261            return Err(AudioError::UnsupportedFormat(
262                "clips must declare a non-zero sample rate".to_string(),
263            ));
264        }
265        if samples.is_empty() {
266            return Err(AudioError::Decode("clip holds no samples".to_string()));
267        }
268        if !samples.len().is_multiple_of(usize::from(channels)) {
269            return Err(AudioError::Decode(format!(
270                "clip holds {} samples, which is not a whole number of {channels}-channel frames",
271                samples.len()
272            )));
273        }
274        if samples.len() / usize::from(channels) > AudioClip::MAX_FRAMES {
275            return Err(AudioError::Decode(
276                "clip exceeds the maximum in-memory length".to_string(),
277            ));
278        }
279        Ok(AudioClip {
280            samples: samples.into(),
281            channels,
282            sample_rate,
283        })
284    }
285
286    /// Decodes an encoded clip. RIFF/WAVE is understood everywhere; anything
287    /// else reports [`AudioError::UnsupportedFormat`].
288    ///
289    /// Run this wherever the bytes arrive — including a worker thread — and
290    /// hand the result to [`AudioPlayer::load_clip`] when it is ready.
291    pub fn decode(bytes: &[u8]) -> Result<AudioClip, AudioError> {
292        if wav::is_wav(bytes) {
293            wav::decode(bytes)
294        } else {
295            Err(AudioError::UnsupportedFormat(
296                "only RIFF/WAVE clips are decoded by the framework".to_string(),
297            ))
298        }
299    }
300
301    /// The interleaved samples.
302    pub fn samples(&self) -> &[f32] {
303        &self.samples
304    }
305
306    /// The shared sample buffer, for a backend that keeps its own reference.
307    pub fn shared_samples(&self) -> Arc<[f32]> {
308        Arc::clone(&self.samples)
309    }
310
311    /// Channel count: 1 or 2.
312    pub fn channels(&self) -> u16 {
313        self.channels
314    }
315
316    /// The clip's recorded sample rate in Hz.
317    pub fn sample_rate(&self) -> u32 {
318        self.sample_rate
319    }
320
321    /// Frame count (samples divided by channels).
322    pub fn frames(&self) -> usize {
323        self.samples.len() / usize::from(self.channels)
324    }
325
326    /// Playing length in seconds at unity rate.
327    pub fn duration_secs(&self) -> f32 {
328        self.frames() as f32 / self.sample_rate as f32
329    }
330}
331
332impl fmt::Debug for AudioClip {
333    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
334        f.debug_struct("AudioClip")
335            .field("frames", &self.frames())
336            .field("channels", &self.channels)
337            .field("sample_rate", &self.sample_rate)
338            .finish()
339    }
340}
341
342/// Why an audio call could not be honoured.
343#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
344pub enum AudioError {
345    /// No audio backend is installed on this target.
346    #[error("no audio backend is installed")]
347    Unsupported,
348    /// The container or codec is not one the framework decodes.
349    #[error("unsupported audio format: {0}")]
350    UnsupportedFormat(String),
351    /// The bytes claimed a supported format but did not parse.
352    #[error("failed to decode audio: {0}")]
353    Decode(String),
354    /// The engine already holds its maximum number of clips.
355    #[error("the audio engine already holds its maximum of {capacity} clips")]
356    ClipTableFull {
357        /// How many clips the engine can hold at once.
358        capacity: usize,
359    },
360    /// The platform audio device refused the request.
361    #[error("audio backend failure: {0}")]
362    Backend(String),
363}
364
365/// Plays sound. Installed by the platform backend; the default is a no-op.
366///
367/// Only [`load_clip`](AudioPlayer::load_clip), [`play`](AudioPlayer::play),
368/// [`play_loop`](AudioPlayer::play_loop), [`stop`](AudioPlayer::stop),
369/// [`stop_voice`](AudioPlayer::stop_voice) and
370/// [`set_master_volume`](AudioPlayer::set_master_volume) must be implemented;
371/// everything else has a defaulted body so a partial backend still compiles.
372pub trait AudioPlayer: Send + Sync {
373    /// Hands an already-decoded clip to the engine.
374    fn load_clip(&self, clip: AudioClip) -> Result<SoundId, AudioError>;
375
376    /// Decodes `bytes` and loads the result.
377    ///
378    /// Decoding happens here, on the calling thread, so that
379    /// [`play`](AudioPlayer::play) is only a queue push. To keep even the load
380    /// off the UI thread, decode with [`AudioClip::decode`] elsewhere and call
381    /// [`load_clip`](AudioPlayer::load_clip).
382    fn load(&self, bytes: &[u8]) -> Result<SoundId, AudioError> {
383        self.load_clip(AudioClip::decode(bytes)?)
384    }
385
386    /// Releases a clip. Voices already playing it are stopped.
387    fn unload(&self, _id: SoundId) {}
388
389    /// Starts a one-shot voice. Re-triggering the same clip layers a new voice
390    /// rather than restarting the old one.
391    fn play(&self, id: SoundId, params: PlaybackParams);
392
393    /// Starts a looping voice and returns its handle.
394    fn play_loop(&self, id: SoundId, params: PlaybackParams) -> VoiceId;
395
396    /// Stops every voice playing `id`.
397    fn stop(&self, id: SoundId);
398
399    /// Stops one voice.
400    fn stop_voice(&self, voice: VoiceId);
401
402    /// Stops every voice on every bus.
403    fn stop_all(&self) {}
404
405    /// Retunes a voice while it plays — a looping engine note that follows
406    /// speed, for instance.
407    fn set_voice_params(&self, _voice: VoiceId, _params: PlaybackParams) {}
408
409    /// Sets the gain applied to every bus.
410    fn set_master_volume(&self, volume: f32);
411
412    /// The current master gain.
413    fn master_volume(&self) -> f32 {
414        1.0
415    }
416
417    /// Sets one bus's gain.
418    fn set_bus_volume(&self, _bus: AudioBus, _volume: f32) {}
419
420    /// One bus's gain.
421    fn bus_volume(&self, _bus: AudioBus) -> f32 {
422        1.0
423    }
424
425    /// Mutes or unmutes a bus. This is the "sound on" / "music on" toggle;
426    /// muting leaves voices running so unmuting resumes mid-track.
427    fn set_bus_enabled(&self, _bus: AudioBus, _enabled: bool) {}
428
429    /// Whether a bus is audible.
430    fn bus_enabled(&self, _bus: AudioBus) -> bool {
431        true
432    }
433
434    /// Releases the output device without discarding loaded clips — call it
435    /// when the app goes to the background.
436    fn suspend(&self) {}
437
438    /// Re-acquires the output device after [`suspend`](AudioPlayer::suspend).
439    fn resume(&self) {}
440
441    /// Whether a real device is behind this handle. The no-op default reports
442    /// `false`, so an app can honestly grey out its audio settings.
443    fn is_available(&self) -> bool {
444        false
445    }
446}
447
448/// Shared handle to the installed [`AudioPlayer`].
449pub type AudioPlayerRef = Arc<dyn AudioPlayer>;
450
451/// The player used when no backend is installed.
452///
453/// It still allocates real [`SoundId`]s and [`VoiceId`]s and remembers the
454/// volume and bus settings written to it, so app logic and settings screens
455/// behave identically with and without a device.
456#[derive(Default)]
457pub struct NoopAudioPlayer {
458    next_sound: Mutex<u32>,
459    next_voice: Mutex<u64>,
460    master: Mutex<f32>,
461    bus_volumes: Mutex<[f32; 2]>,
462    bus_enabled: Mutex<[bool; 2]>,
463}
464
465impl NoopAudioPlayer {
466    /// Creates a player that accepts everything and produces no sound.
467    pub fn new() -> NoopAudioPlayer {
468        NoopAudioPlayer {
469            next_sound: Mutex::new(0),
470            next_voice: Mutex::new(0),
471            master: Mutex::new(1.0),
472            bus_volumes: Mutex::new([1.0, 1.0]),
473            bus_enabled: Mutex::new([true, true]),
474        }
475    }
476}
477
478impl AudioPlayer for NoopAudioPlayer {
479    fn load_clip(&self, _clip: AudioClip) -> Result<SoundId, AudioError> {
480        let mut next = self.next_sound.lock();
481        *next = next.saturating_add(1);
482        Ok(SoundId::from_raw(*next))
483    }
484
485    fn play(&self, _id: SoundId, _params: PlaybackParams) {}
486
487    fn play_loop(&self, id: SoundId, _params: PlaybackParams) -> VoiceId {
488        if !id.is_valid() {
489            return VoiceId::NONE;
490        }
491        let mut next = self.next_voice.lock();
492        *next = next.saturating_add(1);
493        VoiceId::from_raw(*next)
494    }
495
496    fn stop(&self, _id: SoundId) {}
497
498    fn stop_voice(&self, _voice: VoiceId) {}
499
500    fn set_master_volume(&self, volume: f32) {
501        *self.master.lock() = if volume.is_finite() {
502            volume.clamp(0.0, 1.0)
503        } else {
504            1.0
505        };
506    }
507
508    fn master_volume(&self) -> f32 {
509        *self.master.lock()
510    }
511
512    fn set_bus_volume(&self, bus: AudioBus, volume: f32) {
513        let mut volumes = self.bus_volumes.lock();
514        volumes[bus.index()] = if volume.is_finite() {
515            volume.clamp(0.0, 1.0)
516        } else {
517            1.0
518        };
519    }
520
521    fn bus_volume(&self, bus: AudioBus) -> f32 {
522        self.bus_volumes.lock()[bus.index()]
523    }
524
525    fn set_bus_enabled(&self, bus: AudioBus, enabled: bool) {
526        self.bus_enabled.lock()[bus.index()] = enabled;
527    }
528
529    fn bus_enabled(&self, bus: AudioBus) -> bool {
530        self.bus_enabled.lock()[bus.index()]
531    }
532}
533
534static PLATFORM_AUDIO: ServiceRegistry<dyn AudioPlayer> = ServiceRegistry::new();
535static NOOP_AUDIO: OnceLock<AudioPlayerRef> = OnceLock::new();
536static DEFAULT_AUDIO: OnceLock<AudioPlayerRef> = OnceLock::new();
537
538struct PlatformAudioPlayer;
539
540fn registered_audio() -> AudioPlayerRef {
541    PLATFORM_AUDIO.get_or_warn("audio").unwrap_or_else(|| {
542        NOOP_AUDIO
543            .get_or_init(|| Arc::new(NoopAudioPlayer::new()))
544            .clone()
545    })
546}
547
548impl AudioPlayer for PlatformAudioPlayer {
549    fn load_clip(&self, clip: AudioClip) -> Result<SoundId, AudioError> {
550        registered_audio().load_clip(clip)
551    }
552
553    fn unload(&self, id: SoundId) {
554        registered_audio().unload(id);
555    }
556
557    fn play(&self, id: SoundId, params: PlaybackParams) {
558        registered_audio().play(id, params);
559    }
560
561    fn play_loop(&self, id: SoundId, params: PlaybackParams) -> VoiceId {
562        registered_audio().play_loop(id, params)
563    }
564
565    fn stop(&self, id: SoundId) {
566        registered_audio().stop(id);
567    }
568
569    fn stop_voice(&self, voice: VoiceId) {
570        registered_audio().stop_voice(voice);
571    }
572
573    fn stop_all(&self) {
574        registered_audio().stop_all();
575    }
576
577    fn set_voice_params(&self, voice: VoiceId, params: PlaybackParams) {
578        registered_audio().set_voice_params(voice, params);
579    }
580
581    fn set_master_volume(&self, volume: f32) {
582        registered_audio().set_master_volume(volume);
583    }
584
585    fn master_volume(&self) -> f32 {
586        registered_audio().master_volume()
587    }
588
589    fn set_bus_volume(&self, bus: AudioBus, volume: f32) {
590        registered_audio().set_bus_volume(bus, volume);
591    }
592
593    fn bus_volume(&self, bus: AudioBus) -> f32 {
594        registered_audio().bus_volume(bus)
595    }
596
597    fn set_bus_enabled(&self, bus: AudioBus, enabled: bool) {
598        registered_audio().set_bus_enabled(bus, enabled);
599    }
600
601    fn bus_enabled(&self, bus: AudioBus) -> bool {
602        registered_audio().bus_enabled(bus)
603    }
604
605    fn suspend(&self) {
606        registered_audio().suspend();
607    }
608
609    fn resume(&self) {
610        registered_audio().resume();
611    }
612
613    fn is_available(&self) -> bool {
614        registered_audio().is_available()
615    }
616}
617
618/// Installs a platform audio player, replacing any previous one.
619pub fn set_platform_audio(player: AudioPlayerRef) {
620    PLATFORM_AUDIO.set(player);
621}
622
623/// Removes any registered platform audio player (tests and teardown).
624pub fn clear_platform_audio() {
625    PLATFORM_AUDIO.clear();
626}
627
628/// The installed platform player, or a no-op one.
629pub fn default_audio() -> AudioPlayerRef {
630    DEFAULT_AUDIO
631        .get_or_init(|| Arc::new(PlatformAudioPlayer))
632        .clone()
633}
634
635/// The CompositionLocal descendants read to reach the audio player.
636pub fn local_audio() -> CompositionLocal<AudioPlayerRef> {
637    thread_local! {
638        static LOCAL_AUDIO: RefCell<Option<CompositionLocal<AudioPlayerRef>>> = const { RefCell::new(None) };
639    }
640
641    LOCAL_AUDIO.with(|cell| {
642        let mut local = cell.borrow_mut();
643        local
644            .get_or_insert_with(|| compositionLocalOfWithPolicy(default_audio, Arc::ptr_eq))
645            .clone()
646    })
647}
648
649/// Provides the installed audio player to `content`.
650#[allow(non_snake_case)]
651#[composable]
652pub fn ProvideAudio(content: impl FnOnce()) {
653    let player = cranpose_core::remember(default_audio).with(|state| state.clone());
654    let local = local_audio();
655    CompositionLocalProvider(vec![local.provides(player)], move || {
656        content();
657    });
658}
659
660/// One entry in a [`SoundBank`]: where the bytes come from and how the cue
661/// should sit in the mix before per-call parameters are applied.
662#[derive(Clone, Copy, Debug)]
663pub struct SoundSpec<'a> {
664    /// A stable name used for lookups and error reporting.
665    pub name: &'static str,
666    /// The encoded clip, typically an `include_bytes!` WAV.
667    pub bytes: &'a [u8],
668    /// The cue's own level, so a loud explosion and a quiet tick can share one
669    /// call site without per-call bookkeeping.
670    pub base_volume: f32,
671    /// The bus the cue plays on.
672    pub bus: AudioBus,
673}
674
675impl<'a> SoundSpec<'a> {
676    /// A cue at unity gain on the effects bus.
677    pub fn new(name: &'static str, bytes: &'a [u8]) -> SoundSpec<'a> {
678        SoundSpec {
679            name,
680            bytes,
681            base_volume: 1.0,
682            bus: AudioBus::Effects,
683        }
684    }
685
686    /// Sets the cue's own level.
687    pub fn volume(mut self, base_volume: f32) -> SoundSpec<'a> {
688        self.base_volume = base_volume;
689        self
690    }
691
692    /// Sets the cue's bus.
693    pub fn bus(mut self, bus: AudioBus) -> SoundSpec<'a> {
694        self.bus = bus;
695        self
696    }
697}
698
699/// A loaded cue inside a [`SoundBank`].
700#[derive(Clone, Copy, Debug)]
701pub struct SoundBankEntry {
702    /// The name from the [`SoundSpec`].
703    pub name: &'static str,
704    /// The engine handle.
705    pub id: SoundId,
706    /// The cue's own level.
707    pub base_volume: f32,
708    /// The cue's bus.
709    pub bus: AudioBus,
710}
711
712/// A cue that did not load, reported instead of silently vanishing.
713#[derive(Clone, Debug)]
714pub struct SoundBankFailure {
715    /// The name from the [`SoundSpec`].
716    pub name: &'static str,
717    /// Why it did not load.
718    pub error: AudioError,
719}
720
721struct SoundBankInner {
722    player: AudioPlayerRef,
723    entries: Vec<SoundBankEntry>,
724    failures: Vec<SoundBankFailure>,
725}
726
727impl Drop for SoundBankInner {
728    fn drop(&mut self) {
729        for entry in &self.entries {
730            self.player.unload(entry.id);
731        }
732    }
733}
734
735/// A set of cues loaded once and kept alive for as long as the composable that
736/// remembered it stays in the composition.
737///
738/// Indexing is positional, so an app's cue enum casts straight to an index;
739/// [`SoundBank::find`] covers the cases where a name is more convenient. The
740/// clips are released when the last clone drops.
741#[derive(Clone)]
742pub struct SoundBank {
743    inner: Rc<SoundBankInner>,
744}
745
746impl SoundBank {
747    /// Loads every spec through `player`, collecting the ones that fail rather
748    /// than aborting the whole bank.
749    pub fn load(player: AudioPlayerRef, specs: &[SoundSpec<'_>]) -> SoundBank {
750        let mut entries = Vec::with_capacity(specs.len());
751        let mut failures = Vec::new();
752        for spec in specs {
753            match player.load(spec.bytes) {
754                Ok(id) => entries.push(SoundBankEntry {
755                    name: spec.name,
756                    id,
757                    base_volume: spec.base_volume,
758                    bus: spec.bus,
759                }),
760                Err(error) => {
761                    // A missing cue must not take the rest of the bank with it:
762                    // the entry keeps its slot with `SoundId::NONE` so every
763                    // later index still lines up with the app's cue enum.
764                    entries.push(SoundBankEntry {
765                        name: spec.name,
766                        id: SoundId::NONE,
767                        base_volume: spec.base_volume,
768                        bus: spec.bus,
769                    });
770                    failures.push(SoundBankFailure {
771                        name: spec.name,
772                        error,
773                    });
774                }
775            }
776        }
777        SoundBank {
778            inner: Rc::new(SoundBankInner {
779                player,
780                entries,
781                failures,
782            }),
783        }
784    }
785
786    /// How many cues the bank holds, failures included.
787    pub fn len(&self) -> usize {
788        self.inner.entries.len()
789    }
790
791    /// Whether the bank holds no cues.
792    pub fn is_empty(&self) -> bool {
793        self.inner.entries.is_empty()
794    }
795
796    /// The cues, in spec order.
797    pub fn entries(&self) -> &[SoundBankEntry] {
798        &self.inner.entries
799    }
800
801    /// The cues that failed to load.
802    pub fn failures(&self) -> &[SoundBankFailure] {
803        &self.inner.failures
804    }
805
806    /// The player the bank loaded through.
807    pub fn player(&self) -> AudioPlayerRef {
808        Arc::clone(&self.inner.player)
809    }
810
811    /// The handle at `index`, or [`SoundId::NONE`] when out of range.
812    pub fn id(&self, index: usize) -> SoundId {
813        self.inner
814            .entries
815            .get(index)
816            .map(|entry| entry.id)
817            .unwrap_or(SoundId::NONE)
818    }
819
820    /// The handle for `name`, if the bank holds it.
821    pub fn find(&self, name: &str) -> Option<SoundId> {
822        self.inner
823            .entries
824            .iter()
825            .find(|entry| entry.name == name)
826            .map(|entry| entry.id)
827    }
828
829    /// Plays a cue at its own level.
830    pub fn play(&self, index: usize) {
831        self.play_with(index, PlaybackParams::DEFAULT);
832    }
833
834    /// Plays a cue, multiplying `params.volume` by the cue's own level. The
835    /// bus comes from the spec, so a cue cannot escape its bus by accident.
836    pub fn play_with(&self, index: usize, params: PlaybackParams) {
837        let Some(entry) = self.inner.entries.get(index) else {
838            return;
839        };
840        if !entry.id.is_valid() {
841            return;
842        }
843        self.inner.player.play(entry.id, entry.apply(params));
844    }
845
846    /// Plays the cue called `name`, if the bank holds it.
847    pub fn play_named(&self, name: &str, params: PlaybackParams) {
848        if let Some(index) = self.inner.entries.iter().position(|e| e.name == name) {
849            self.play_with(index, params);
850        }
851    }
852
853    /// Starts a looping voice for a cue.
854    pub fn play_loop(&self, index: usize, params: PlaybackParams) -> VoiceId {
855        let Some(entry) = self.inner.entries.get(index) else {
856            return VoiceId::NONE;
857        };
858        if !entry.id.is_valid() {
859            return VoiceId::NONE;
860        }
861        self.inner.player.play_loop(entry.id, entry.apply(params))
862    }
863
864    /// Stops every voice of a cue.
865    pub fn stop(&self, index: usize) {
866        let id = self.id(index);
867        if id.is_valid() {
868            self.inner.player.stop(id);
869        }
870    }
871}
872
873impl SoundBankEntry {
874    fn apply(&self, params: PlaybackParams) -> PlaybackParams {
875        PlaybackParams {
876            volume: params.volume * self.base_volume,
877            rate: params.rate,
878            pan: params.pan,
879            bus: self.bus,
880        }
881    }
882}
883
884impl fmt::Debug for SoundBank {
885    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
886        f.debug_struct("SoundBank")
887            .field("entries", &self.inner.entries.len())
888            .field("failures", &self.inner.failures.len())
889            .finish()
890    }
891}
892
893impl Index<usize> for SoundBank {
894    type Output = SoundId;
895
896    fn index(&self, index: usize) -> &SoundId {
897        static NONE: SoundId = SoundId::NONE;
898        self.inner
899            .entries
900            .get(index)
901            .map(|entry| &entry.id)
902            .unwrap_or(&NONE)
903    }
904}
905
906/// Loads a set of cues once and keeps them alive across recompositions.
907///
908/// The bank is rebuilt only when the spec list changes shape (its length or
909/// the set of names), which is the `remember(key)` contract applied to a
910/// resource that costs a decode.
911#[allow(non_snake_case)]
912#[composable(no_skip)]
913pub fn rememberSoundBank(specs: &[SoundSpec<'_>]) -> SoundBank {
914    let key = sound_bank_key(specs);
915    let player = local_audio().current();
916    cranpose_core::rememberKeyed((key, Arc::as_ptr(&player) as *const () as usize), |_| {
917        SoundBank::load(Arc::clone(&player), specs)
918    })
919}
920
921/// A cheap fingerprint of a spec list: its length plus an FNV-1a hash of the
922/// cue names, so a changed cue set reloads without hashing megabytes of PCM.
923fn sound_bank_key(specs: &[SoundSpec<'_>]) -> (usize, u64) {
924    let mut hash = 0xcbf2_9ce4_8422_2325u64;
925    for spec in specs {
926        for byte in spec.name.as_bytes() {
927            hash ^= u64::from(*byte);
928            hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
929        }
930        hash ^= spec.bytes.len() as u64;
931        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
932    }
933    (specs.len(), hash)
934}
935
936#[cfg(test)]
937mod tests {
938    use super::*;
939    use crate::run_test_composition;
940    use parking_lot::Mutex;
941    use std::cell::Cell;
942
943    /// A minimal single-frame mono WAVE stream.
944    fn tiny_wav() -> Vec<u8> {
945        let data = 0i16.to_le_bytes();
946        let mut out = Vec::new();
947        out.extend_from_slice(b"RIFF");
948        out.extend_from_slice(&(36u32 + data.len() as u32).to_le_bytes());
949        out.extend_from_slice(b"WAVE");
950        out.extend_from_slice(b"fmt ");
951        out.extend_from_slice(&16u32.to_le_bytes());
952        out.extend_from_slice(&1u16.to_le_bytes());
953        out.extend_from_slice(&1u16.to_le_bytes());
954        out.extend_from_slice(&8000u32.to_le_bytes());
955        out.extend_from_slice(&16000u32.to_le_bytes());
956        out.extend_from_slice(&2u16.to_le_bytes());
957        out.extend_from_slice(&16u16.to_le_bytes());
958        out.extend_from_slice(b"data");
959        out.extend_from_slice(&(data.len() as u32).to_le_bytes());
960        out.extend_from_slice(&data);
961        out
962    }
963
964    #[derive(Default)]
965    struct RecordingPlayer {
966        played: Mutex<Vec<(SoundId, PlaybackParams)>>,
967        unloaded: Mutex<Vec<SoundId>>,
968        next: Mutex<u32>,
969    }
970
971    impl AudioPlayer for RecordingPlayer {
972        fn load_clip(&self, _clip: AudioClip) -> Result<SoundId, AudioError> {
973            let mut next = self.next.lock();
974            *next += 1;
975            Ok(SoundId::from_raw(*next))
976        }
977        fn play(&self, id: SoundId, params: PlaybackParams) {
978            self.played.lock().push((id, params));
979        }
980        fn play_loop(&self, _id: SoundId, _params: PlaybackParams) -> VoiceId {
981            VoiceId::from_raw(7)
982        }
983        fn stop(&self, _id: SoundId) {}
984        fn stop_voice(&self, _voice: VoiceId) {}
985        fn set_master_volume(&self, _volume: f32) {}
986        fn unload(&self, id: SoundId) {
987            self.unloaded.lock().push(id);
988        }
989        fn is_available(&self) -> bool {
990            true
991        }
992    }
993
994    #[test]
995    fn playback_params_default_is_neutral() {
996        let params = PlaybackParams::default();
997        assert_eq!(params.volume, 1.0);
998        assert_eq!(params.rate, 1.0);
999        assert_eq!(params.pan, 0.0);
1000        assert_eq!(params.bus, AudioBus::Effects);
1001        assert_eq!(params, PlaybackParams::new());
1002        assert_eq!(params, PlaybackParams::DEFAULT);
1003    }
1004
1005    #[test]
1006    fn playback_params_sanitizes_out_of_range_and_nan() {
1007        let wild = PlaybackParams {
1008            volume: f32::NAN,
1009            rate: 1_000.0,
1010            pan: -9.0,
1011            bus: AudioBus::Music,
1012        }
1013        .sanitized();
1014        assert_eq!(wild.volume, 1.0);
1015        assert_eq!(wild.rate, PlaybackParams::MAX_RATE);
1016        assert_eq!(wild.pan, -1.0);
1017        assert_eq!(wild.bus, AudioBus::Music);
1018
1019        let slow = PlaybackParams::new().rate(0.0).sanitized();
1020        assert_eq!(slow.rate, PlaybackParams::MIN_RATE);
1021    }
1022
1023    #[test]
1024    fn pitch_semitones_maps_octaves_to_rate() {
1025        let up = PlaybackParams::new().pitch_semitones(12.0);
1026        assert!((up.rate - 2.0).abs() < 1e-5);
1027        let down = PlaybackParams::new().pitch_semitones(-12.0);
1028        assert!((down.rate - 0.5).abs() < 1e-5);
1029        let broken = PlaybackParams::new().pitch_semitones(f32::NAN);
1030        assert_eq!(broken.rate, 1.0);
1031    }
1032
1033    #[test]
1034    fn pan_gains_are_constant_power() {
1035        let (left, right) = PlaybackParams::new().gains();
1036        assert!((left - right).abs() < 1e-6);
1037        assert!((left * left + right * right - 1.0).abs() < 1e-5);
1038
1039        let (left, right) = PlaybackParams::new().pan(-1.0).gains();
1040        assert!((left - 1.0).abs() < 1e-5);
1041        assert!(right.abs() < 1e-5);
1042
1043        let (left, right) = PlaybackParams::new().pan(1.0).gains();
1044        assert!(left.abs() < 1e-5);
1045        assert!((right - 1.0).abs() < 1e-5);
1046    }
1047
1048    #[test]
1049    fn audio_bus_indices_round_trip() {
1050        for bus in AudioBus::ALL {
1051            assert_eq!(AudioBus::from_index(bus.index()), Some(bus));
1052        }
1053        assert_eq!(AudioBus::from_index(2), None);
1054        assert_eq!(AudioBus::default(), AudioBus::Effects);
1055    }
1056
1057    #[test]
1058    fn noop_player_hands_out_handles_and_keeps_settings() {
1059        let _guard = crate::registry::test_service_guard();
1060        clear_platform_audio();
1061        let player = default_audio();
1062        assert!(!player.is_available());
1063
1064        let id = player.load(&tiny_wav()).expect("no-op load succeeds");
1065        assert!(id.is_valid());
1066        let second = player.load(&tiny_wav()).expect("no-op load succeeds");
1067        assert_ne!(id, second);
1068
1069        // None of these do anything, and none of them panic.
1070        player.play(id, PlaybackParams::new());
1071        let voice = player.play_loop(id, PlaybackParams::new());
1072        assert!(voice.is_valid());
1073        player.stop_voice(voice);
1074        player.stop(id);
1075        player.stop_all();
1076        player.set_voice_params(voice, PlaybackParams::new());
1077        player.unload(id);
1078        player.suspend();
1079        player.resume();
1080
1081        player.set_master_volume(0.25);
1082        assert_eq!(player.master_volume(), 0.25);
1083        player.set_master_volume(f32::NAN);
1084        assert_eq!(player.master_volume(), 1.0);
1085        player.set_bus_enabled(AudioBus::Music, false);
1086        assert!(!player.bus_enabled(AudioBus::Music));
1087        assert!(player.bus_enabled(AudioBus::Effects));
1088        player.set_bus_volume(AudioBus::Effects, 0.5);
1089        assert_eq!(player.bus_volume(AudioBus::Effects), 0.5);
1090    }
1091
1092    #[test]
1093    fn noop_player_rejects_invalid_loop_handle() {
1094        let _guard = crate::registry::test_service_guard();
1095        let player = NoopAudioPlayer::new();
1096        assert_eq!(
1097            player.play_loop(SoundId::NONE, PlaybackParams::new()),
1098            VoiceId::NONE
1099        );
1100    }
1101
1102    #[test]
1103    fn registered_player_replaces_the_default() {
1104        let _guard = crate::registry::test_service_guard();
1105        clear_platform_audio();
1106        assert!(!default_audio().is_available());
1107        let player: AudioPlayerRef = Arc::new(RecordingPlayer::default());
1108        set_platform_audio(player);
1109        assert!(default_audio().is_available());
1110        clear_platform_audio();
1111        assert!(!default_audio().is_available());
1112    }
1113
1114    #[test]
1115    fn audio_clip_validates_shape() {
1116        assert!(matches!(
1117            AudioClip::from_samples(vec![0.0], 0, 44_100),
1118            Err(AudioError::UnsupportedFormat(_))
1119        ));
1120        assert!(matches!(
1121            AudioClip::from_samples(vec![0.0], 3, 44_100),
1122            Err(AudioError::UnsupportedFormat(_))
1123        ));
1124        assert!(matches!(
1125            AudioClip::from_samples(vec![0.0], 1, 0),
1126            Err(AudioError::UnsupportedFormat(_))
1127        ));
1128        assert!(matches!(
1129            AudioClip::from_samples(Vec::new(), 1, 44_100),
1130            Err(AudioError::Decode(_))
1131        ));
1132        assert!(matches!(
1133            AudioClip::from_samples(vec![0.0, 0.0, 0.0], 2, 44_100),
1134            Err(AudioError::Decode(_))
1135        ));
1136
1137        let clip = AudioClip::from_samples(vec![0.0, 0.5], 2, 44_100).expect("valid clip");
1138        assert_eq!(clip.frames(), 1);
1139        assert_eq!(clip.channels(), 2);
1140        assert!(clip.duration_secs() > 0.0);
1141        assert_eq!(clip.shared_samples().len(), 2);
1142        assert!(format!("{clip:?}").contains("AudioClip"));
1143    }
1144
1145    #[test]
1146    fn audio_clip_decode_rejects_unknown_container() {
1147        assert!(matches!(
1148            AudioClip::decode(b"OggS not really"),
1149            Err(AudioError::UnsupportedFormat(_))
1150        ));
1151    }
1152
1153    #[test]
1154    fn sound_bank_loads_applies_base_volume_and_unloads_on_drop() {
1155        let player = Arc::new(RecordingPlayer::default());
1156        let wav = tiny_wav();
1157        let specs = [
1158            SoundSpec::new("hit", &wav).volume(0.5),
1159            SoundSpec::new("music", &wav).bus(AudioBus::Music),
1160            SoundSpec::new("broken", b"not audio"),
1161        ];
1162        let player_ref: AudioPlayerRef = player.clone();
1163        let bank = SoundBank::load(player_ref, &specs);
1164
1165        assert_eq!(bank.len(), 3);
1166        assert!(!bank.is_empty());
1167        assert_eq!(bank.failures().len(), 1);
1168        assert_eq!(bank.failures()[0].name, "broken");
1169        assert!(!bank.id(2).is_valid());
1170        assert_eq!(bank.find("music"), Some(bank.id(1)));
1171        assert_eq!(bank.find("absent"), None);
1172        assert_eq!(bank[0], bank.id(0));
1173        assert_eq!(bank[99], SoundId::NONE);
1174        assert!(format!("{bank:?}").contains("SoundBank"));
1175
1176        bank.play(0);
1177        bank.play_with(1, PlaybackParams::new().volume(0.5));
1178        bank.play_named("hit", PlaybackParams::new().pan(1.0));
1179        bank.play_with(2, PlaybackParams::new());
1180        bank.play_named("absent", PlaybackParams::new());
1181        assert_eq!(bank.play_loop(2, PlaybackParams::new()), VoiceId::NONE);
1182        assert!(bank.play_loop(0, PlaybackParams::new()).is_valid());
1183        assert_eq!(bank.play_loop(99, PlaybackParams::new()), VoiceId::NONE);
1184        bank.stop(0);
1185        bank.stop(2);
1186
1187        let played = player.played.lock().clone();
1188        assert_eq!(played.len(), 3);
1189        assert!((played[0].1.volume - 0.5).abs() < 1e-6);
1190        assert_eq!(played[0].1.bus, AudioBus::Effects);
1191        assert!((played[1].1.volume - 0.5).abs() < 1e-6);
1192        assert_eq!(played[1].1.bus, AudioBus::Music);
1193        assert!((played[2].1.pan - 1.0).abs() < 1e-6);
1194
1195        drop(bank);
1196        assert_eq!(player.unloaded.lock().len(), 3);
1197    }
1198
1199    #[test]
1200    fn sound_bank_key_tracks_names_and_lengths() {
1201        let a = [1u8, 2, 3];
1202        let b = [1u8, 2, 3, 4];
1203        assert_eq!(
1204            sound_bank_key(&[SoundSpec::new("x", &a)]),
1205            sound_bank_key(&[SoundSpec::new("x", &a)])
1206        );
1207        assert_ne!(
1208            sound_bank_key(&[SoundSpec::new("x", &a)]),
1209            sound_bank_key(&[SoundSpec::new("y", &a)])
1210        );
1211        assert_ne!(
1212            sound_bank_key(&[SoundSpec::new("x", &a)]),
1213            sound_bank_key(&[SoundSpec::new("x", &b)])
1214        );
1215        assert_ne!(
1216            sound_bank_key(&[SoundSpec::new("x", &a)]),
1217            sound_bank_key(&[SoundSpec::new("x", &a), SoundSpec::new("x", &a)])
1218        );
1219    }
1220
1221    #[test]
1222    fn provide_audio_publishes_the_platform_player() {
1223        let _guard = crate::registry::test_service_guard();
1224        clear_platform_audio();
1225        let player: AudioPlayerRef = Arc::new(RecordingPlayer::default());
1226        set_platform_audio(player);
1227
1228        let captured = Rc::new(RefCell::new(None));
1229        {
1230            let captured = Rc::clone(&captured);
1231            run_test_composition(move || {
1232                let captured = Rc::clone(&captured);
1233                ProvideAudio(move || {
1234                    *captured.borrow_mut() = Some(local_audio().current().is_available());
1235                });
1236            });
1237        }
1238
1239        assert_eq!(*captured.borrow(), Some(true));
1240        clear_platform_audio();
1241    }
1242
1243    #[test]
1244    fn local_audio_defaults_to_the_noop_player() {
1245        let _guard = crate::registry::test_service_guard();
1246        clear_platform_audio();
1247        let captured = Rc::new(RefCell::new(None));
1248        {
1249            let captured = Rc::clone(&captured);
1250            run_test_composition(move || {
1251                let captured = Rc::clone(&captured);
1252                ProvideAudio(move || {
1253                    *captured.borrow_mut() = Some(local_audio().current().is_available());
1254                });
1255            });
1256        }
1257        assert_eq!(*captured.borrow(), Some(false));
1258    }
1259
1260    #[test]
1261    fn remember_sound_bank_loads_once_across_recompositions() {
1262        let _guard = crate::registry::test_service_guard();
1263        clear_platform_audio();
1264        let player = Arc::new(RecordingPlayer::default());
1265        let player_ref: AudioPlayerRef = player.clone();
1266        set_platform_audio(player_ref);
1267
1268        let wav = tiny_wav();
1269        let bank_len = Rc::new(Cell::new(0usize));
1270        let bank_len_build = Rc::clone(&bank_len);
1271        let mut build = move || {
1272            let specs = [SoundSpec::new("a", &wav), SoundSpec::new("b", &wav)];
1273            let bank = rememberSoundBank(&specs);
1274            bank_len_build.set(bank.len());
1275        };
1276
1277        let key = cranpose_core::location_key(file!(), line!(), column!());
1278        let mut composition = cranpose_core::Composition::new(cranpose_core::MemoryApplier::new());
1279        composition.render(key, &mut build).expect("first render");
1280        composition.render(key, &mut build).expect("second render");
1281
1282        assert_eq!(bank_len.get(), 2);
1283        assert_eq!(
1284            *player.next.lock(),
1285            2,
1286            "the bank decodes once across renders"
1287        );
1288        clear_platform_audio();
1289    }
1290}