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