Skip to main content

cranpose_services/
audio.rs

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