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}