Skip to main content

mirage_engine/sound/
build.rs

1use core::hash::Hash;
2use core::time::Duration;
3
4use crate::math::Vec3;
5use crate::sound::SoundData;
6use crate::sound::mixer::GLIDE;
7use crate::{Assets, Catalog};
8
9/// The slowest a sound may play; less becomes this rate.
10const SLOWEST_PITCH: f32 = 0.01;
11
12/// Turns a game's value into a sound.
13///
14/// Values are cache keys: equal values must build the same sound.
15/// [`Catalog`] proves every named asset loads before the first frame.
16pub trait Sounds: Catalog + Hash + Eq + Clone {
17    /// Builds this value's samples, the first time playing it needs them.
18    ///
19    /// Must not read a file or the network; loaded data is in `assets`.
20    fn build(&self, assets: &Assets) -> SoundData;
21
22    /// This sound at `gain`; see [`SoundCue::gain`].
23    fn gain(self, gain: f32) -> SoundCue<Self> {
24        SoundCue::from(self).gain(gain)
25    }
26
27    /// This sound at `pitch`; see [`SoundCue::pitch`].
28    fn pitch(self, pitch: f32) -> SoundCue<Self> {
29        SoundCue::from(self).pitch(pitch)
30    }
31
32    /// This sound at `position`; see [`SoundCue::at`].
33    fn at(self, position: Vec3) -> SoundCue<Self> {
34        SoundCue::from(self).at(position)
35    }
36
37    /// This sound heard `range` meters out; see [`SoundCue::range`].
38    fn range(self, range: f32) -> SoundCue<Self> {
39        SoundCue::from(self).range(range)
40    }
41
42    /// This sound at full level `reference` meters out; see
43    /// [`SoundCue::reference`].
44    fn reference(self, reference: f32) -> SoundCue<Self> {
45        SoundCue::from(self).reference(reference)
46    }
47
48    /// This sound over a `fade`; see [`SoundCue::fade`].
49    fn fade(self, fade: Duration) -> SoundCue<Self> {
50        SoundCue::from(self).fade(fade)
51    }
52
53    /// This sound sliding its changes over `glide`; see
54    /// [`SoundCue::glide`].
55    fn glide(self, glide: Duration) -> SoundCue<Self> {
56        SoundCue::from(self).glide(glide)
57    }
58
59    /// The part of this sound between `start` and `end`; see
60    /// [`SoundCue::trim_to`].
61    fn trim_to(self, start: Duration, end: Duration) -> SoundCue<Self> {
62        SoundCue::from(self).trim_to(start, end)
63    }
64
65    /// This sound looping from `at`; see [`SoundCue::loop_from`].
66    fn loop_from(self, at: Duration) -> SoundCue<Self> {
67        SoundCue::from(self).loop_from(at)
68    }
69
70    /// This sound as the `nth` place it sounds at; see
71    /// [`SoundCue::instance`].
72    fn instance(self, nth: u32) -> SoundCue<Self> {
73        SoundCue::from(self).instance(nth)
74    }
75}
76
77/// A sound and how to play it.
78///
79/// Every knob has a default, so a bare vocabulary value plays as it was
80/// loaded.
81#[must_use = "a sound is only played once play or sustain takes it"]
82#[derive(Debug)]
83pub struct SoundCue<S: Sounds> {
84    sound: S,
85    knobs: Knobs,
86}
87
88impl<S: Sounds> SoundCue<S> {
89    /// Range a placed sound is heard at when no call sets it: `50` meters.
90    pub const DEFAULT_RANGE: f32 = Falloff::DEFAULT.range;
91
92    /// Distance a placed sound holds its full level within when no call sets
93    /// it: `1` meter.
94    pub const DEFAULT_REFERENCE: f32 = Falloff::DEFAULT.reference;
95
96    /// Fade a voice takes to come up and go down when no call sets it: as
97    /// short as it can be without a click.
98    pub const DEFAULT_FADE: Duration = Duration::from_millis(5);
99
100    /// Span a change of gain or position slides over when no call sets it:
101    /// short enough to follow a moving source, long enough not to click.
102    pub const DEFAULT_GLIDE: Duration = GLIDE;
103
104    /// Plays at `gain`, a fraction of the level the sound was loaded at;
105    /// `1.0` as loaded, never less than nothing.
106    pub fn gain(mut self, gain: f32) -> Self {
107        self.knobs.gain = gain.max(0.0);
108        self
109    }
110
111    /// Plays at `pitch`, a fraction of the rate the sound was loaded at,
112    /// which moves pitch and speed together; clamped to never go below
113    /// `0.01` of that rate.
114    pub fn pitch(mut self, pitch: f32) -> Self {
115        self.knobs.pitch = pitch.max(SLOWEST_PITCH);
116        self
117    }
118
119    /// Places the sound at `position`, which pans it and fades it with
120    /// distance from the listener.
121    ///
122    /// A sound with no position is heard the same in both ears, however the
123    /// listener moves.
124    pub fn at(mut self, position: Vec3) -> Self {
125        self.knobs.position = Some(position);
126        self
127    }
128
129    /// Fades a placed sound to nothing `range` meters from the listener;
130    /// [`SoundCue::DEFAULT_RANGE`] until set.
131    ///
132    /// The reference never lies past the range, so a smaller range holds it
133    /// there.
134    pub fn range(mut self, range: f32) -> Self {
135        self.knobs.falloff = self.knobs.falloff.with_range(range);
136        self
137    }
138
139    /// Holds a placed sound at full level within `reference` meters of the
140    /// listener, past which it falls by the inverse of the distance to
141    /// nothing at the range; [`SoundCue::DEFAULT_REFERENCE`] until set.
142    ///
143    /// A reference past the range is held at the range. One at or under zero
144    /// is held just above zero.
145    pub fn reference(mut self, reference: f32) -> Self {
146        self.knobs.falloff = self.knobs.falloff.with_reference(reference);
147        self
148    }
149
150    /// Takes `fade` to come up at the start and to go down when a sustain
151    /// ends; [`SoundCue::DEFAULT_FADE`] until set.
152    ///
153    /// A loop wrap is never faded.
154    pub fn fade(mut self, fade: Duration) -> Self {
155        self.knobs.fade = fade;
156        self
157    }
158
159    /// Slides over `glide` every later change of gain or position;
160    /// [`SoundCue::DEFAULT_GLIDE`] until set.
161    ///
162    /// A crossfade over seconds states its span here: the gain a frame
163    /// declares is the level the voice slides to, never a step.
164    pub fn glide(mut self, glide: Duration) -> Self {
165        self.knobs.glide = glide;
166        self
167    }
168
169    /// Plays only what lies between `start` and `end` of the sound's own
170    /// timeline.
171    ///
172    /// Both clamp to the sound; a window with nothing in it plays nothing,
173    /// with a debug log.
174    pub fn trim_to(mut self, start: Duration, end: Duration) -> Self {
175        self.knobs.trim = Some((start, end));
176        self
177    }
178
179    /// Comes back to `at` every time a sustain plays to the end of its
180    /// window, so that what lies before it is heard once.
181    ///
182    /// In the sound's own timeline, clamped into the window with a debug
183    /// log. A one-shot never loops, so [`play`](crate::FrameContext::play)
184    /// ignores this with a debug log.
185    pub fn loop_from(mut self, at: Duration) -> Self {
186        self.knobs.loop_from = Some(at);
187        self
188    }
189
190    /// Sustains the sound as the `nth` of the places it sounds at, so one
191    /// value is heard at more than one place at once; `0` until set.
192    ///
193    /// A sustain is kept alive by its value and its instance together, and
194    /// each instance is a voice with knobs of its own. A one-shot is a voice
195    /// of its own already, so [`play`](crate::FrameContext::play) ignores this
196    /// with a debug log.
197    pub fn instance(mut self, nth: u32) -> Self {
198        self.knobs.instance = nth;
199        self
200    }
201
202    /// The sound and its knobs, for the engine to resolve.
203    pub(crate) fn split(self) -> (S, Knobs) {
204        (self.sound, self.knobs)
205    }
206}
207
208impl<S: Sounds> From<S> for SoundCue<S> {
209    fn from(sound: S) -> Self {
210        Self {
211            sound,
212            knobs: Knobs {
213                gain: 1.0,
214                pitch: 1.0,
215                position: None,
216                falloff: Falloff::DEFAULT,
217                fade: Self::DEFAULT_FADE,
218                glide: Self::DEFAULT_GLIDE,
219                trim: None,
220                loop_from: None,
221                instance: 0,
222            },
223        }
224    }
225}
226
227impl<S: Sounds> Clone for SoundCue<S> {
228    fn clone(&self) -> Self {
229        Self {
230            sound: self.sound.clone(),
231            knobs: self.knobs,
232        }
233    }
234}
235
236/// Everything a submission sets on one voice, apart from which sound it
237/// is.
238#[derive(Clone, Copy, Debug)]
239pub(crate) struct Knobs {
240    pub(crate) gain: f32,
241    pub(crate) pitch: f32,
242    pub(crate) position: Option<Vec3>,
243    pub(crate) falloff: Falloff,
244    pub(crate) fade: Duration,
245    pub(crate) glide: Duration,
246    pub(crate) trim: Option<(Duration, Duration)>,
247    pub(crate) loop_from: Option<Duration>,
248    pub(crate) instance: u32,
249}
250
251/// How a placed sound's level falls with distance from the listener.
252///
253/// The reference lies above zero and never past the range, so the level is a
254/// fraction between nothing and one at every distance.
255#[derive(Clone, Copy, Debug)]
256pub(crate) struct Falloff {
257    reference: f32,
258    range: f32,
259}
260
261impl Falloff {
262    /// What a cue takes until a call sets either distance: full level within
263    /// `1` meter, nothing past `50`.
264    pub(crate) const DEFAULT: Self = Self {
265        reference: 1.0,
266        range: 50.0,
267    };
268
269    /// The smallest either distance may be, since the level divides by both.
270    const SMALLEST: f32 = f32::EPSILON;
271
272    /// Holds the level within `reference` meters, at most as far out as the
273    /// range.
274    pub(crate) fn with_reference(self, reference: f32) -> Self {
275        Self {
276            reference: reference.clamp(Self::SMALLEST, self.range),
277            ..self
278        }
279    }
280
281    /// Falls to nothing `range` meters out, holding the reference within it.
282    pub(crate) fn with_range(self, range: f32) -> Self {
283        let range = range.max(Self::SMALLEST);
284        Self {
285            reference: self.reference.min(range),
286            range,
287        }
288    }
289
290    /// The fraction of its own level a placed sound is heard at, `distance`
291    /// meters from the listener.
292    pub(crate) fn level(self, distance: f32) -> f32 {
293        if distance <= self.reference {
294            return 1.0;
295        }
296
297        let shift = self.reference / self.range;
298        ((self.reference / distance - shift) / (1.0 - shift)).clamp(0.0, 1.0)
299    }
300}
301
302/// The vocabulary of a game with no sounds of its own.
303///
304/// No value of it exists, so a silent game has nothing to play.
305#[derive(Clone, Debug, Eq, Hash, PartialEq)]
306pub enum NoSounds {}
307
308impl Catalog for NoSounds {
309    fn catalog() -> Vec<Self> {
310        Vec::new()
311    }
312}
313
314impl Sounds for NoSounds {
315    fn build(&self, _assets: &Assets) -> SoundData {
316        match *self {}
317    }
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323
324    /// Distances a [`Falloff`] is read at: the listener's own place, the way
325    /// out past any range under test, and steps between them.
326    const DISTANCES: [f32; 7] = [0.0, 0.5, 1.0, 2.0, 10.0, 50.0, 1e9];
327
328    #[test]
329    fn a_reference_past_the_range_or_at_or_under_zero_leaves_a_level_that_never_rises() {
330        let past = Falloff::DEFAULT.with_range(2.0).with_reference(50.0);
331        let none = Falloff::DEFAULT.with_reference(0.0);
332        let negative = Falloff::DEFAULT.with_reference(-5.0).with_range(-1.0);
333
334        for falloff in [past, none, negative] {
335            let mut nearer = 1.0;
336            for distance in DISTANCES {
337                let level = falloff.level(distance);
338                assert!(
339                    (0.0..=nearer).contains(&level),
340                    "{falloff:?} is heard at {level} from {distance} meters"
341                );
342                nearer = level;
343            }
344        }
345    }
346
347    #[test]
348    fn a_reference_and_a_range_are_heard_the_same_whichever_is_set_first() {
349        let reference_first = Falloff::DEFAULT.with_reference(50.0).with_range(2.0);
350        let range_first = Falloff::DEFAULT.with_range(2.0).with_reference(50.0);
351
352        for distance in DISTANCES {
353            assert_eq!(
354                reference_first.level(distance),
355                range_first.level(distance),
356                "from {distance} meters"
357            );
358        }
359    }
360}