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