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#[allow(non_snake_case)]
648#[composable]
649pub fn ProvideAudio(content: impl FnOnce()) {
650    let player = cranpose_core::remember(default_audio).with(|state| state.clone());
651    let local = local_audio();
652    CompositionLocalProvider(vec![local.provides(player)], move || {
653        content();
654    });
655}
656
657/// One entry in a [`SoundBank`]: where the bytes come from and how the cue
658/// should sit in the mix before per-call parameters are applied.
659#[derive(Clone, Copy, Debug)]
660pub struct SoundSpec<'a> {
661    /// A stable name used for lookups and error reporting.
662    pub name: &'static str,
663    /// The encoded clip, typically an `include_bytes!` WAV.
664    pub bytes: &'a [u8],
665    /// The cue's own level, so a loud explosion and a quiet tick can share one
666    /// call site without per-call bookkeeping.
667    pub base_volume: f32,
668    /// The bus the cue plays on.
669    pub bus: AudioBus,
670}
671
672impl<'a> SoundSpec<'a> {
673    /// A cue at unity gain on the effects bus.
674    pub fn new(name: &'static str, bytes: &'a [u8]) -> SoundSpec<'a> {
675        SoundSpec {
676            name,
677            bytes,
678            base_volume: 1.0,
679            bus: AudioBus::Effects,
680        }
681    }
682
683    /// Sets the cue's own level.
684    pub fn volume(mut self, base_volume: f32) -> SoundSpec<'a> {
685        self.base_volume = base_volume;
686        self
687    }
688
689    /// Sets the cue's bus.
690    pub fn bus(mut self, bus: AudioBus) -> SoundSpec<'a> {
691        self.bus = bus;
692        self
693    }
694}
695
696/// A loaded cue inside a [`SoundBank`].
697#[derive(Clone, Copy, Debug)]
698pub struct SoundBankEntry {
699    /// The name from the [`SoundSpec`].
700    pub name: &'static str,
701    /// The engine handle.
702    pub id: SoundId,
703    /// The cue's own level.
704    pub base_volume: f32,
705    /// The cue's bus.
706    pub bus: AudioBus,
707}
708
709/// A cue that did not load, reported instead of silently vanishing.
710#[derive(Clone, Debug)]
711pub struct SoundBankFailure {
712    /// The name from the [`SoundSpec`].
713    pub name: &'static str,
714    /// Why it did not load.
715    pub error: AudioError,
716}
717
718struct SoundBankInner {
719    player: AudioPlayerRef,
720    entries: Vec<SoundBankEntry>,
721    failures: Vec<SoundBankFailure>,
722}
723
724impl Drop for SoundBankInner {
725    fn drop(&mut self) {
726        for entry in &self.entries {
727            self.player.unload(entry.id);
728        }
729    }
730}
731
732/// A set of cues loaded once and kept alive for as long as the composable that
733/// remembered it stays in the composition.
734///
735/// Indexing is positional, so an app's cue enum casts straight to an index;
736/// [`SoundBank::find`] covers the cases where a name is more convenient. The
737/// clips are released when the last clone drops.
738#[derive(Clone)]
739pub struct SoundBank {
740    inner: Rc<SoundBankInner>,
741}
742
743impl SoundBank {
744    /// Loads every spec through `player`, collecting the ones that fail rather
745    /// than aborting the whole bank.
746    pub fn load(player: AudioPlayerRef, specs: &[SoundSpec<'_>]) -> SoundBank {
747        let mut entries = Vec::with_capacity(specs.len());
748        let mut failures = Vec::new();
749        for spec in specs {
750            match player.load(spec.bytes) {
751                Ok(id) => entries.push(SoundBankEntry {
752                    name: spec.name,
753                    id,
754                    base_volume: spec.base_volume,
755                    bus: spec.bus,
756                }),
757                Err(error) => {
758                    entries.push(SoundBankEntry {
759                        name: spec.name,
760                        id: SoundId::NONE,
761                        base_volume: spec.base_volume,
762                        bus: spec.bus,
763                    });
764                    failures.push(SoundBankFailure {
765                        name: spec.name,
766                        error,
767                    });
768                }
769            }
770        }
771        SoundBank {
772            inner: Rc::new(SoundBankInner {
773                player,
774                entries,
775                failures,
776            }),
777        }
778    }
779
780    /// How many cues the bank holds, failures included.
781    pub fn len(&self) -> usize {
782        self.inner.entries.len()
783    }
784
785    /// Whether the bank holds no cues.
786    pub fn is_empty(&self) -> bool {
787        self.inner.entries.is_empty()
788    }
789
790    /// The cues, in spec order.
791    pub fn entries(&self) -> &[SoundBankEntry] {
792        &self.inner.entries
793    }
794
795    /// The cues that failed to load.
796    pub fn failures(&self) -> &[SoundBankFailure] {
797        &self.inner.failures
798    }
799
800    /// The player the bank loaded through.
801    pub fn player(&self) -> AudioPlayerRef {
802        Arc::clone(&self.inner.player)
803    }
804
805    /// The handle at `index`, or [`SoundId::NONE`] when out of range.
806    pub fn id(&self, index: usize) -> SoundId {
807        self.inner
808            .entries
809            .get(index)
810            .map(|entry| entry.id)
811            .unwrap_or(SoundId::NONE)
812    }
813
814    /// The handle for `name`, if the bank holds it.
815    pub fn find(&self, name: &str) -> Option<SoundId> {
816        self.inner
817            .entries
818            .iter()
819            .find(|entry| entry.name == name)
820            .map(|entry| entry.id)
821    }
822
823    /// Plays a cue at its own level.
824    pub fn play(&self, index: usize) {
825        self.play_with(index, PlaybackParams::DEFAULT);
826    }
827
828    /// Plays a cue, multiplying `params.volume` by the cue's own level. The
829    /// bus comes from the spec, so a cue cannot escape its bus by accident.
830    pub fn play_with(&self, index: usize, params: PlaybackParams) {
831        let Some(entry) = self.inner.entries.get(index) else {
832            return;
833        };
834        if !entry.id.is_valid() {
835            return;
836        }
837        self.inner.player.play(entry.id, entry.apply(params));
838    }
839
840    /// Plays the cue called `name`, if the bank holds it.
841    pub fn play_named(&self, name: &str, params: PlaybackParams) {
842        if let Some(index) = self.inner.entries.iter().position(|e| e.name == name) {
843            self.play_with(index, params);
844        }
845    }
846
847    /// Starts a looping voice for a cue.
848    pub fn play_loop(&self, index: usize, params: PlaybackParams) -> VoiceId {
849        let Some(entry) = self.inner.entries.get(index) else {
850            return VoiceId::NONE;
851        };
852        if !entry.id.is_valid() {
853            return VoiceId::NONE;
854        }
855        self.inner.player.play_loop(entry.id, entry.apply(params))
856    }
857
858    /// Stops every voice of a cue.
859    pub fn stop(&self, index: usize) {
860        let id = self.id(index);
861        if id.is_valid() {
862            self.inner.player.stop(id);
863        }
864    }
865}
866
867impl SoundBankEntry {
868    fn apply(&self, params: PlaybackParams) -> PlaybackParams {
869        PlaybackParams {
870            volume: params.volume * self.base_volume,
871            rate: params.rate,
872            pan: params.pan,
873            bus: self.bus,
874        }
875    }
876}
877
878impl fmt::Debug for SoundBank {
879    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
880        f.debug_struct("SoundBank")
881            .field("entries", &self.inner.entries.len())
882            .field("failures", &self.inner.failures.len())
883            .finish()
884    }
885}
886
887impl Index<usize> for SoundBank {
888    type Output = SoundId;
889
890    fn index(&self, index: usize) -> &SoundId {
891        static NONE: SoundId = SoundId::NONE;
892        self.inner
893            .entries
894            .get(index)
895            .map(|entry| &entry.id)
896            .unwrap_or(&NONE)
897    }
898}
899
900/// Loads a set of cues once and keeps them alive across recompositions.
901///
902/// The bank is rebuilt only when the spec list changes shape (its length or
903/// the set of names), which is the `remember(key)` contract applied to a
904/// resource that costs a decode.
905#[allow(non_snake_case)]
906#[composable(no_skip)]
907#[track_caller]
908pub fn rememberSoundBank(specs: &[SoundSpec<'_>]) -> SoundBank {
909    let key = sound_bank_key(specs);
910    let player = local_audio().current();
911    cranpose_core::rememberKeyed((key, Arc::as_ptr(&player) as *const () as usize), |_| {
912        SoundBank::load(Arc::clone(&player), specs)
913    })
914}
915
916fn sound_bank_key(specs: &[SoundSpec<'_>]) -> (usize, u64) {
917    let mut hash = 0xcbf2_9ce4_8422_2325u64;
918    for spec in specs {
919        for byte in spec.name.as_bytes() {
920            hash ^= u64::from(*byte);
921            hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
922        }
923        hash ^= spec.bytes.len() as u64;
924        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
925    }
926    (specs.len(), hash)
927}
928
929#[cfg(test)]
930mod tests {
931    use std::cell::Cell;
932
933    use parking_lot::Mutex;
934
935    use super::*;
936    use crate::run_test_composition;
937
938    fn tiny_wav() -> Vec<u8> {
939        let data = 0i16.to_le_bytes();
940        let mut out = Vec::new();
941        out.extend_from_slice(b"RIFF");
942        out.extend_from_slice(&(36u32 + data.len() as u32).to_le_bytes());
943        out.extend_from_slice(b"WAVE");
944        out.extend_from_slice(b"fmt ");
945        out.extend_from_slice(&16u32.to_le_bytes());
946        out.extend_from_slice(&1u16.to_le_bytes());
947        out.extend_from_slice(&1u16.to_le_bytes());
948        out.extend_from_slice(&8000u32.to_le_bytes());
949        out.extend_from_slice(&16000u32.to_le_bytes());
950        out.extend_from_slice(&2u16.to_le_bytes());
951        out.extend_from_slice(&16u16.to_le_bytes());
952        out.extend_from_slice(b"data");
953        out.extend_from_slice(&(data.len() as u32).to_le_bytes());
954        out.extend_from_slice(&data);
955        out
956    }
957
958    #[derive(Default)]
959    struct RecordingPlayer {
960        played: Mutex<Vec<(SoundId, PlaybackParams)>>,
961        unloaded: Mutex<Vec<SoundId>>,
962        next: Mutex<u32>,
963    }
964
965    impl AudioPlayer for RecordingPlayer {
966        fn load_clip(&self, _clip: AudioClip) -> Result<SoundId, AudioError> {
967            let mut next = self.next.lock();
968            *next += 1;
969            Ok(SoundId::from_raw(*next))
970        }
971        fn play(&self, id: SoundId, params: PlaybackParams) {
972            self.played.lock().push((id, params));
973        }
974        fn play_loop(&self, _id: SoundId, _params: PlaybackParams) -> VoiceId {
975            VoiceId::from_raw(7)
976        }
977        fn stop(&self, _id: SoundId) {}
978        fn stop_voice(&self, _voice: VoiceId) {}
979        fn set_master_volume(&self, _volume: f32) {}
980        fn unload(&self, id: SoundId) {
981            self.unloaded.lock().push(id);
982        }
983        fn is_available(&self) -> bool {
984            true
985        }
986    }
987
988    #[test]
989    fn playback_params_default_is_neutral() {
990        let params = PlaybackParams::default();
991        assert_eq!(params.volume, 1.0);
992        assert_eq!(params.rate, 1.0);
993        assert_eq!(params.pan, 0.0);
994        assert_eq!(params.bus, AudioBus::Effects);
995        assert_eq!(params, PlaybackParams::new());
996        assert_eq!(params, PlaybackParams::DEFAULT);
997    }
998
999    #[test]
1000    fn playback_params_sanitizes_out_of_range_and_nan() {
1001        let wild = PlaybackParams {
1002            volume: f32::NAN,
1003            rate: 1_000.0,
1004            pan: -9.0,
1005            bus: AudioBus::Music,
1006        }
1007        .sanitized();
1008        assert_eq!(wild.volume, 1.0);
1009        assert_eq!(wild.rate, PlaybackParams::MAX_RATE);
1010        assert_eq!(wild.pan, -1.0);
1011        assert_eq!(wild.bus, AudioBus::Music);
1012
1013        let slow = PlaybackParams::new().rate(0.0).sanitized();
1014        assert_eq!(slow.rate, PlaybackParams::MIN_RATE);
1015    }
1016
1017    #[test]
1018    fn pitch_semitones_maps_octaves_to_rate() {
1019        let up = PlaybackParams::new().pitch_semitones(12.0);
1020        assert!((up.rate - 2.0).abs() < 1e-5);
1021        let down = PlaybackParams::new().pitch_semitones(-12.0);
1022        assert!((down.rate - 0.5).abs() < 1e-5);
1023        let broken = PlaybackParams::new().pitch_semitones(f32::NAN);
1024        assert_eq!(broken.rate, 1.0);
1025    }
1026
1027    #[test]
1028    fn pan_gains_are_constant_power() {
1029        let (left, right) = PlaybackParams::new().gains();
1030        assert!((left - right).abs() < 1e-6);
1031        assert!((left * left + right * right - 1.0).abs() < 1e-5);
1032
1033        let (left, right) = PlaybackParams::new().pan(-1.0).gains();
1034        assert!((left - 1.0).abs() < 1e-5);
1035        assert!(right.abs() < 1e-5);
1036
1037        let (left, right) = PlaybackParams::new().pan(1.0).gains();
1038        assert!(left.abs() < 1e-5);
1039        assert!((right - 1.0).abs() < 1e-5);
1040    }
1041
1042    #[test]
1043    fn audio_bus_indices_round_trip() {
1044        for bus in AudioBus::ALL {
1045            assert_eq!(AudioBus::from_index(bus.index()), Some(bus));
1046        }
1047        assert_eq!(AudioBus::from_index(2), None);
1048        assert_eq!(AudioBus::default(), AudioBus::Effects);
1049    }
1050
1051    #[test]
1052    fn noop_player_hands_out_handles_and_keeps_settings() {
1053        let _guard = crate::registry::test_service_guard();
1054        clear_platform_audio();
1055        let player = default_audio();
1056        assert!(!player.is_available());
1057
1058        let id = player.load(&tiny_wav()).expect("no-op load succeeds");
1059        assert!(id.is_valid());
1060        let second = player.load(&tiny_wav()).expect("no-op load succeeds");
1061        assert_ne!(id, second);
1062
1063        player.play(id, PlaybackParams::new());
1064        let voice = player.play_loop(id, PlaybackParams::new());
1065        assert!(voice.is_valid());
1066        player.stop_voice(voice);
1067        player.stop(id);
1068        player.stop_all();
1069        player.set_voice_params(voice, PlaybackParams::new());
1070        player.unload(id);
1071        player.suspend();
1072        player.resume();
1073
1074        player.set_master_volume(0.25);
1075        assert_eq!(player.master_volume(), 0.25);
1076        player.set_master_volume(f32::NAN);
1077        assert_eq!(player.master_volume(), 1.0);
1078        player.set_bus_enabled(AudioBus::Music, false);
1079        assert!(!player.bus_enabled(AudioBus::Music));
1080        assert!(player.bus_enabled(AudioBus::Effects));
1081        player.set_bus_volume(AudioBus::Effects, 0.5);
1082        assert_eq!(player.bus_volume(AudioBus::Effects), 0.5);
1083    }
1084
1085    #[test]
1086    fn noop_player_rejects_invalid_loop_handle() {
1087        let _guard = crate::registry::test_service_guard();
1088        let player = NoopAudioPlayer::new();
1089        assert_eq!(
1090            player.play_loop(SoundId::NONE, PlaybackParams::new()),
1091            VoiceId::NONE
1092        );
1093    }
1094
1095    #[test]
1096    fn registered_player_replaces_the_default() {
1097        let _guard = crate::registry::test_service_guard();
1098        clear_platform_audio();
1099        assert!(!default_audio().is_available());
1100        let player: AudioPlayerRef = Arc::new(RecordingPlayer::default());
1101        set_platform_audio(player);
1102        assert!(default_audio().is_available());
1103        clear_platform_audio();
1104        assert!(!default_audio().is_available());
1105    }
1106
1107    #[test]
1108    fn audio_clip_validates_shape() {
1109        assert!(matches!(
1110            AudioClip::from_samples(vec![0.0], 0, 44_100),
1111            Err(AudioError::UnsupportedFormat(_))
1112        ));
1113        assert!(matches!(
1114            AudioClip::from_samples(vec![0.0], 3, 44_100),
1115            Err(AudioError::UnsupportedFormat(_))
1116        ));
1117        assert!(matches!(
1118            AudioClip::from_samples(vec![0.0], 1, 0),
1119            Err(AudioError::UnsupportedFormat(_))
1120        ));
1121        assert!(matches!(
1122            AudioClip::from_samples(Vec::new(), 1, 44_100),
1123            Err(AudioError::Decode(_))
1124        ));
1125        assert!(matches!(
1126            AudioClip::from_samples(vec![0.0, 0.0, 0.0], 2, 44_100),
1127            Err(AudioError::Decode(_))
1128        ));
1129
1130        let clip = AudioClip::from_samples(vec![0.0, 0.5], 2, 44_100).expect("valid clip");
1131        assert_eq!(clip.frames(), 1);
1132        assert_eq!(clip.channels(), 2);
1133        assert!(clip.duration_secs() > 0.0);
1134        assert_eq!(clip.shared_samples().len(), 2);
1135        assert!(format!("{clip:?}").contains("AudioClip"));
1136    }
1137
1138    #[test]
1139    fn audio_clip_decode_rejects_unknown_container() {
1140        assert!(matches!(
1141            AudioClip::decode(b"OggS not really"),
1142            Err(AudioError::UnsupportedFormat(_))
1143        ));
1144    }
1145
1146    #[test]
1147    fn sound_bank_loads_applies_base_volume_and_unloads_on_drop() {
1148        let player = Arc::new(RecordingPlayer::default());
1149        let wav = tiny_wav();
1150        let specs = [
1151            SoundSpec::new("hit", &wav).volume(0.5),
1152            SoundSpec::new("music", &wav).bus(AudioBus::Music),
1153            SoundSpec::new("broken", b"not audio"),
1154        ];
1155        let player_ref: AudioPlayerRef = player.clone();
1156        let bank = SoundBank::load(player_ref, &specs);
1157
1158        assert_eq!(bank.len(), 3);
1159        assert!(!bank.is_empty());
1160        assert_eq!(bank.failures().len(), 1);
1161        assert_eq!(bank.failures()[0].name, "broken");
1162        assert!(!bank.id(2).is_valid());
1163        assert_eq!(bank.find("music"), Some(bank.id(1)));
1164        assert_eq!(bank.find("absent"), None);
1165        assert_eq!(bank[0], bank.id(0));
1166        assert_eq!(bank[99], SoundId::NONE);
1167        assert!(format!("{bank:?}").contains("SoundBank"));
1168
1169        bank.play(0);
1170        bank.play_with(1, PlaybackParams::new().volume(0.5));
1171        bank.play_named("hit", PlaybackParams::new().pan(1.0));
1172        bank.play_with(2, PlaybackParams::new());
1173        bank.play_named("absent", PlaybackParams::new());
1174        assert_eq!(bank.play_loop(2, PlaybackParams::new()), VoiceId::NONE);
1175        assert!(bank.play_loop(0, PlaybackParams::new()).is_valid());
1176        assert_eq!(bank.play_loop(99, PlaybackParams::new()), VoiceId::NONE);
1177        bank.stop(0);
1178        bank.stop(2);
1179
1180        let played = player.played.lock().clone();
1181        assert_eq!(played.len(), 3);
1182        assert!((played[0].1.volume - 0.5).abs() < 1e-6);
1183        assert_eq!(played[0].1.bus, AudioBus::Effects);
1184        assert!((played[1].1.volume - 0.5).abs() < 1e-6);
1185        assert_eq!(played[1].1.bus, AudioBus::Music);
1186        assert!((played[2].1.pan - 1.0).abs() < 1e-6);
1187
1188        drop(bank);
1189        assert_eq!(player.unloaded.lock().len(), 3);
1190    }
1191
1192    #[test]
1193    fn sound_bank_key_tracks_names_and_lengths() {
1194        let a = [1u8, 2, 3];
1195        let b = [1u8, 2, 3, 4];
1196        assert_eq!(
1197            sound_bank_key(&[SoundSpec::new("x", &a)]),
1198            sound_bank_key(&[SoundSpec::new("x", &a)])
1199        );
1200        assert_ne!(
1201            sound_bank_key(&[SoundSpec::new("x", &a)]),
1202            sound_bank_key(&[SoundSpec::new("y", &a)])
1203        );
1204        assert_ne!(
1205            sound_bank_key(&[SoundSpec::new("x", &a)]),
1206            sound_bank_key(&[SoundSpec::new("x", &b)])
1207        );
1208        assert_ne!(
1209            sound_bank_key(&[SoundSpec::new("x", &a)]),
1210            sound_bank_key(&[SoundSpec::new("x", &a), SoundSpec::new("x", &a)])
1211        );
1212    }
1213
1214    #[test]
1215    fn provide_audio_publishes_the_platform_player() {
1216        let _guard = crate::registry::test_service_guard();
1217        clear_platform_audio();
1218        let player: AudioPlayerRef = Arc::new(RecordingPlayer::default());
1219        set_platform_audio(player);
1220
1221        let captured = Rc::new(RefCell::new(None));
1222        {
1223            let captured = Rc::clone(&captured);
1224            run_test_composition(move || {
1225                let captured = Rc::clone(&captured);
1226                ProvideAudio(move || {
1227                    *captured.borrow_mut() = Some(local_audio().current().is_available());
1228                });
1229            });
1230        }
1231
1232        assert_eq!(*captured.borrow(), Some(true));
1233        clear_platform_audio();
1234    }
1235
1236    #[test]
1237    fn local_audio_defaults_to_the_noop_player() {
1238        let _guard = crate::registry::test_service_guard();
1239        clear_platform_audio();
1240        let captured = Rc::new(RefCell::new(None));
1241        {
1242            let captured = Rc::clone(&captured);
1243            run_test_composition(move || {
1244                let captured = Rc::clone(&captured);
1245                ProvideAudio(move || {
1246                    *captured.borrow_mut() = Some(local_audio().current().is_available());
1247                });
1248            });
1249        }
1250        assert_eq!(*captured.borrow(), Some(false));
1251    }
1252
1253    #[test]
1254    fn remember_sound_bank_loads_once_across_recompositions() {
1255        let _guard = crate::registry::test_service_guard();
1256        clear_platform_audio();
1257        let player = Arc::new(RecordingPlayer::default());
1258        let player_ref: AudioPlayerRef = player.clone();
1259        set_platform_audio(player_ref);
1260
1261        let wav = tiny_wav();
1262        let bank_len = Rc::new(Cell::new(0usize));
1263        let bank_len_build = Rc::clone(&bank_len);
1264        let mut build = move || {
1265            let specs = [SoundSpec::new("a", &wav), SoundSpec::new("b", &wav)];
1266            let bank = rememberSoundBank(&specs);
1267            bank_len_build.set(bank.len());
1268        };
1269
1270        let key = cranpose_core::location_key(file!(), line!(), column!());
1271        let mut composition = cranpose_core::Composition::new(cranpose_core::MemoryApplier::new());
1272        composition.render(key, &mut build).expect("first render");
1273        composition.render(key, &mut build).expect("second render");
1274
1275        assert_eq!(bank_len.get(), 2);
1276        assert_eq!(
1277            *player.next.lock(),
1278            2,
1279            "the bank decodes once across renders"
1280        );
1281        clear_platform_audio();
1282    }
1283}