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