Skip to main content

kvas_engine/
audio.rs

1//! Sound: the [`AudioSource`] component and the [`AudioBus`] that scripts talk to.
2//!
3//! The engine itself plays nothing. Scripts and components only queue requests on the
4//! scene's bus; a shell (the editor or the player) hands them to the `kvas_audio` backend
5//! once per frame. That keeps the simulation deterministic and testable without a sound card.
6//!
7//! ```
8//! use kvas_engine::audio::AudioBus;
9//! let mut bus = AudioBus::default();
10//! let id = bus.play("sounds/click.wav", 0.8);
11//! bus.stop(id);
12//! assert_eq!(bus.drain().len(), 2);
13//! ```
14
15use crate::components::Component;
16use crate::reflect::{AssetKind, Field};
17use glam::Vec3;
18use serde::{Deserialize, Serialize};
19
20/// Handle of a sound started through the bus; use it to stop or adjust that sound later.
21#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
22pub struct SoundId(pub u64);
23
24/// One queued request for the audio backend.
25#[derive(Clone, Debug)]
26pub enum AudioCmd {
27    /// Start a one-shot or looping sound.
28    Play {
29        /// Handle assigned by the bus.
30        id: SoundId,
31        /// Asset path of the clip (wav, ogg, mp3 or flac).
32        clip: String,
33        /// Linear gain, 1 = unchanged.
34        volume: f32,
35        /// Playback speed / pitch multiplier, 1 = unchanged.
36        pitch: f32,
37        /// Stereo position for non-spatial sounds: -1 left, 0 centre, +1 right.
38        pan: f32,
39        /// Repeat until stopped.
40        looped: bool,
41        /// World position for a 3D sound; `None` plays it in 2D.
42        position: Option<Vec3>,
43        /// Distance below which a 3D sound is at full volume.
44        min_distance: f32,
45        /// Distance above which a 3D sound is silent.
46        max_distance: f32,
47    },
48    /// Stop one sound.
49    Stop(SoundId),
50    /// Stop every sound started through the bus.
51    StopAll,
52    /// Change the gain of a running sound.
53    SetVolume(SoundId, f32),
54}
55
56/// Pending audio requests and the master volume. Lives in the scene, is never saved.
57#[derive(Clone, Debug)]
58pub struct AudioBus {
59    /// Gain applied to everything, 0..1.
60    pub master_volume: f32,
61    /// Silences every sound without losing their state.
62    pub muted: bool,
63    /// Room echo added to sounds started from now on, 0 (dry) .. 1 (cathedral).
64    pub reverb: f32,
65    /// Size of the room for the echo: delay of the first reflection in milliseconds (20 small
66    /// room .. 120 hall).
67    pub reverb_room_ms: f32,
68    /// Separate gain of music (2D looping clips started with `play_music`).
69    pub music_volume: f32,
70    /// Separate gain of every other sound.
71    pub effects_volume: f32,
72    next: u64,
73    commands: Vec<AudioCmd>,
74}
75
76impl Default for AudioBus {
77    fn default() -> Self {
78        Self { master_volume: 1.0, muted: false, reverb: 0.0, reverb_room_ms: 45.0, music_volume: 1.0, effects_volume: 1.0, next: 1, commands: Vec::new() }
79    }
80}
81
82impl AudioBus {
83    fn push_play(&mut self, clip: &str, volume: f32, pitch: f32, pan: f32, looped: bool, position: Option<Vec3>) -> SoundId {
84        let id = SoundId(self.next);
85        self.next += 1;
86        self.commands.push(AudioCmd::Play {
87            id,
88            clip: clip.to_string(),
89            volume: volume.max(0.0),
90            pitch: if pitch.is_finite() && pitch > 0.0 { pitch } else { 1.0 },
91            pan: pan.clamp(-1.0, 1.0),
92            looped,
93            position,
94            min_distance: 2.0,
95            max_distance: 40.0,
96        });
97        id
98    }
99
100    /// Plays a clip with every option at once (used by the editor's sound panel).
101    pub fn play_with(&mut self, clip: &str, volume: f32, pitch: f32, pan: f32, looped: bool) -> SoundId {
102        self.push_play(clip, volume, pitch, pan, looped, None)
103    }
104
105    /// Plays a clip in 2D at the given volume.
106    pub fn play(&mut self, clip: &str, volume: f32) -> SoundId {
107        self.push_play(clip, volume, 1.0, 0.0, false, None)
108    }
109
110    /// Plays a looping clip in 2D (music, ambience). Stop it with [`Self::stop`].
111    pub fn play_loop(&mut self, clip: &str, volume: f32) -> SoundId {
112        self.push_play(clip, volume, 1.0, 0.0, true, None)
113    }
114
115    /// Plays a clip with a stereo position: `pan` -1 is fully left, +1 fully right.
116    pub fn play_panned(&mut self, clip: &str, volume: f32, pan: f32) -> SoundId {
117        self.push_play(clip, volume, 1.0, pan, false, None)
118    }
119
120    /// Plays a clip with a speed / pitch change (`pitch` 2.0 is an octave higher and twice as fast).
121    pub fn play_pitched(&mut self, clip: &str, volume: f32, pitch: f32) -> SoundId {
122        self.push_play(clip, volume, pitch, 0.0, false, None)
123    }
124
125    /// Plays a clip at a world position: it gets quieter with distance and pans left/right.
126    pub fn play_at(&mut self, clip: &str, volume: f32, position: Vec3) -> SoundId {
127        self.push_play(clip, volume, 1.0, 0.0, false, Some(position))
128    }
129
130    /// Stops one sound.
131    pub fn stop(&mut self, id: SoundId) {
132        self.commands.push(AudioCmd::Stop(id));
133    }
134
135    /// Stops all sounds.
136    pub fn stop_all(&mut self) {
137        self.commands.push(AudioCmd::StopAll);
138    }
139
140    /// Changes the gain of a running sound (fade-outs, ducking).
141    pub fn set_volume(&mut self, id: SoundId, volume: f32) {
142        self.commands.push(AudioCmd::SetVolume(id, volume.max(0.0)));
143    }
144
145    /// Takes the queued requests; the backend calls this once per frame.
146    pub fn drain(&mut self) -> Vec<AudioCmd> {
147        std::mem::take(&mut self.commands)
148    }
149}
150
151/// A sound attached to an object: ambience, a radio, a machine hum.
152///
153/// With `spatial` on, volume follows the distance to the listener (the scene camera) and the
154/// sound is panned left/right by where the object is; off, it plays flat with `pan`.
155#[derive(Clone, Debug, Serialize, Deserialize)]
156#[serde(default)]
157pub struct AudioSource {
158    /// Clip asset path.
159    pub clip: String,
160    /// Linear gain.
161    pub volume: f32,
162    /// Speed / pitch multiplier.
163    pub pitch: f32,
164    /// Repeat the clip.
165    pub looping: bool,
166    /// Start when the game starts.
167    pub play_on_start: bool,
168    /// Treat the object position as the sound position.
169    pub spatial: bool,
170    /// Full-volume radius in metres (spatial only).
171    pub min_distance: f32,
172    /// Silent beyond this distance in metres (spatial only).
173    pub max_distance: f32,
174    /// Stereo position when not spatial, -1 left .. +1 right.
175    pub pan: f32,
176    /// Set to true from a script to (re)start the clip; the backend clears it.
177    pub trigger: bool,
178    /// Opening angle in degrees of the sound beam along the object's forward (-Z) axis.
179    /// 360 = the same in every direction; a narrow cone is a loudspeaker that is quieter behind and to the sides.
180    pub cone_angle: f32,
181}
182
183impl Default for AudioSource {
184    fn default() -> Self {
185        Self {
186            clip: String::new(),
187            volume: 1.0,
188            pitch: 1.0,
189            looping: true,
190            play_on_start: true,
191            spatial: true,
192            min_distance: 2.0,
193            max_distance: 40.0,
194            pan: 0.0,
195            trigger: false,
196            cone_angle: 360.0,
197        }
198    }
199}
200
201impl Component for AudioSource {
202    fn fields(&mut self) -> Vec<Field<'_>> {
203        vec![
204            Field::asset("clip", &mut self.clip, AssetKind::Audio),
205            Field::f32("volume", &mut self.volume),
206            Field::f32("pitch", &mut self.pitch),
207            Field::bool("looping", &mut self.looping),
208            Field::bool("play_on_start", &mut self.play_on_start),
209            Field::bool("spatial", &mut self.spatial),
210            Field::f32("min_distance", &mut self.min_distance),
211            Field::f32("max_distance", &mut self.max_distance),
212            Field::f32("pan", &mut self.pan),
213            Field::f32("cone_angle", &mut self.cone_angle),
214            Field::bool("trigger", &mut self.trigger),
215        ]
216    }
217}
218
219/// Gain for a 3D sound: 1 inside `min`, falling linearly to 0 at `max`.
220pub fn distance_gain(distance: f32, min: f32, max: f32) -> f32 {
221    if distance <= min {
222        1.0
223    } else if distance >= max || max <= min {
224        0.0
225    } else {
226        let t = (distance - min) / (max - min);
227        (1.0 - t) * (1.0 - t)
228    }
229}
230
231/// Gain of a directional source: full inside the inner 60 % of the cone, fading to 10 % at its edge and beyond.
232/// `forward` is where the source points, `to_listener` the direction from the source to the listener.
233pub fn cone_gain(forward: Vec3, to_listener: Vec3, cone_degrees: f32) -> f32 {
234    if cone_degrees >= 359.0 {
235        return 1.0;
236    }
237    let (f, t) = (forward.normalize_or_zero(), to_listener.normalize_or_zero());
238    if f == Vec3::ZERO || t == Vec3::ZERO {
239        return 1.0;
240    }
241    let angle = f.dot(t).clamp(-1.0, 1.0).acos().to_degrees();
242    let half = (cone_degrees * 0.5).max(1.0);
243    let inner = half * 0.6;
244    if angle <= inner {
245        1.0
246    } else if angle >= half {
247        0.1
248    } else {
249        let k = (angle - inner) / (half - inner);
250        1.0 - 0.9 * k * k * (3.0 - 2.0 * k)
251    }
252}
253
254/// Stereo position (-1 left .. 1 right) of a source seen from a listener.
255pub fn stereo_pan(listener: Vec3, listener_right: Vec3, source: Vec3) -> f32 {
256    let to = source - listener;
257    let d = to.length();
258    if d < 1e-3 {
259        return 0.0;
260    }
261    (to.dot(listener_right) / d).clamp(-1.0, 1.0)
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    #[test]
269    fn falloff_is_one_inside_and_zero_outside() {
270        assert_eq!(distance_gain(1.0, 2.0, 10.0), 1.0);
271        assert_eq!(distance_gain(10.0, 2.0, 10.0), 0.0);
272        assert!(distance_gain(6.0, 2.0, 10.0) < 0.5);
273    }
274
275    #[test]
276    fn cone_is_loud_in_front_and_quiet_behind() {
277        assert_eq!(cone_gain(Vec3::NEG_Z, Vec3::NEG_Z, 90.0), 1.0);
278        assert_eq!(cone_gain(Vec3::NEG_Z, Vec3::Z, 90.0), 0.1);
279        assert_eq!(cone_gain(Vec3::NEG_Z, Vec3::Z, 360.0), 1.0);
280        let mid = cone_gain(Vec3::NEG_Z, Vec3::new(0.6, 0.0, -0.8), 90.0);
281        assert!(mid < 1.0 && mid > 0.1);
282    }
283
284    #[test]
285    fn pan_follows_side() {
286        assert!(stereo_pan(Vec3::ZERO, Vec3::X, Vec3::new(5.0, 0.0, 0.0)) > 0.99);
287        assert!(stereo_pan(Vec3::ZERO, Vec3::X, Vec3::new(-5.0, 0.0, 0.0)) < -0.99);
288    }
289}