Skip to main content

ponko/audio/
spatial.rs

1//! Positional playback without custom DSP: Kira applies the gain, pan and rate.
2use super::{AudioBus, AudioEffects, Ctx, Playing, Sound, SoundParams};
3use crate::{Entity, Quat, Transform3D, Vec3};
4
5/// Distance gain curve. Keyed curves use normalized distance and linear gain;
6/// keys must be sorted by distance. Values outside the keys use the nearest key.
7/// ```
8/// use ponko::AudioRolloff;
9/// let curve = AudioRolloff::Keyed(&[(0.0, 1.0), (0.5, 0.3), (1.0, 0.0)]);
10/// ```
11#[derive(Clone, Copy, Debug, Default)]
12pub enum AudioRolloff {
13    /// Inverse distance inside the range, silent at the maximum distance.
14    #[default]
15    Logarithmic,
16    /// Linear fade between the minimum and maximum distances.
17    Linear,
18    /// Sorted (normalized distance, gain) keys; an empty curve is silent.
19    Keyed(&'static [(f32, f32)]),
20}
21
22/// Settings for a positional voice. Distances are in world units (metres for
23/// doppler); pitch is controlled by `sound.speed`. Invalid ranges are clamped.
24/// ```
25/// use ponko::*;
26/// let settings = AudioSettings3D { max_distance: 80.0, doppler: 0.0, ..Default::default() };
27/// ```
28#[derive(Clone, Copy, Debug)]
29pub struct AudioSettings3D {
30    /// Volume, pitch/playback speed and looping.
31    pub sound: SoundParams,
32    /// Routing bus, selected when playback starts. Defaults to `sfx`.
33    /// ```
34    /// # use ponko::*;
35    /// let settings = AudioSettings3D { bus: AudioBus::default(), ..Default::default() };
36    /// ```
37    pub bus: AudioBus,
38    /// Initial per-voice low-pass and shared reverb send. Update live voices
39    /// with `Ctx::set_sound_effects` (including positional voices).
40    /// ```
41    /// # use ponko::*;
42    /// let settings = AudioSettings3D { effects: AudioEffects::default(), ..Default::default() };
43    /// ```
44    pub effects: AudioEffects,
45    /// Full-volume radius, clamped to at least 0.001.
46    pub min_distance: f32,
47    /// Silent beyond this radius; clamped above the minimum radius.
48    pub max_distance: f32,
49    /// Distance gain curve.
50    pub rolloff: AudioRolloff,
51    /// Velocity multiplier; zero disables doppler. Default 1, sound speed 343 m/s.
52    pub doppler: f32,
53}
54impl Default for AudioSettings3D {
55    fn default() -> Self {
56        Self {
57            sound: SoundParams::default(),
58            bus: AudioBus::default(),
59            effects: AudioEffects::default(),
60            min_distance: 1.0,
61            max_distance: 100.0,
62            rolloff: AudioRolloff::default(),
63            doppler: 1.0,
64        }
65    }
66}
67
68/// Where the ears are. By default they follow `ctx.camera3d`; set your own with
69/// [`Ctx::set_audio_listener`]. Velocity (for doppler) is estimated from frame
70/// to frame.
71/// ```
72/// use ponko::*;
73/// let listener = AudioListener3D { position: Vec3::ZERO, rotation: Quat::IDENTITY };
74/// ```
75#[derive(Clone, Copy, Debug, Default)]
76pub struct AudioListener3D {
77    /// Ear position in world space.
78    pub position: Vec3,
79    /// Orientation: +X is right and -Z is forward.
80    pub rotation: Quat,
81}
82
83/// A sound that plays from an entity, paired with `Transform3D`. It starts by
84/// itself and follows the entity every frame. Removing the component or the
85/// entity stops it. One-shots don't restart after they finish.
86/// ```
87/// use ponko::*;
88/// # fn setup(ctx: &mut Ctx, sound: Sound) {
89/// ctx.world.spawn((Transform3D::default(), AudioSource3D::new(sound, AudioSettings3D::default())));
90/// # }
91/// ```
92#[derive(Debug)]
93pub struct AudioSource3D {
94    /// Clip to play. Changes apply when the component is replaced.
95    pub sound: Sound,
96    /// Live volume, pitch, attenuation, effects and looping settings (looping applies at start).
97    pub settings: AudioSettings3D,
98    playing: Option<Playing>,
99}
100impl AudioSource3D {
101    /// Creates an unstarted source. Looping follows `settings.sound.looping`.
102    /// ```
103    /// use ponko::*;
104    /// # fn f(sound: Sound) {
105    /// let source = AudioSource3D::new(sound, AudioSettings3D::default());
106    /// # }
107    /// ```
108    pub fn new(sound: Sound, settings: AudioSettings3D) -> Self {
109        Self {
110            sound,
111            settings,
112            playing: None,
113        }
114    }
115}
116
117struct Voice {
118    id: Playing,
119    entity: Option<Entity>,
120    position: Vec3,
121    settings: AudioSettings3D,
122    fresh: bool,
123    remaining: f32,
124}
125#[derive(Default)]
126pub(super) struct SpatialState {
127    /// The listener of the last update, for its velocity.
128    listener: Option<AudioListener3D>,
129    /// The game's own listener, instead of the camera.
130    custom: Option<AudioListener3D>,
131    voices: Vec<Voice>,
132    next_silent: u64,
133}
134
135impl SpatialState {
136    pub(super) fn remove(&mut self, id: Playing) {
137        self.voices.retain(|voice| voice.id != id);
138    }
139}
140
141fn gain(distance: f32, settings: AudioSettings3D) -> f32 {
142    let min = settings.min_distance.max(0.001);
143    let max = settings.max_distance.max(min + 0.001);
144    if distance >= max {
145        return 0.0;
146    }
147    if distance <= min {
148        return 1.0;
149    }
150    let t = (distance - min) / (max - min);
151    match settings.rolloff {
152        AudioRolloff::Linear => 1.0 - t,
153        AudioRolloff::Logarithmic => min / distance,
154        AudioRolloff::Keyed(keys) => {
155            let Some(&(mut x, mut y)) = keys.first() else {
156                return 0.0;
157            };
158            for &(next_x, next_y) in &keys[1..] {
159                if t <= next_x {
160                    return (y + (next_y - y) * ((t - x) / (next_x - x).max(f32::EPSILON)).clamp(0.0, 1.0)).max(0.0);
161                }
162                (x, y) = (next_x, next_y);
163            }
164            y.max(0.0)
165        }
166    }
167}
168fn pan(offset: Vec3, rotation: Quat) -> f32 {
169    offset.normalize_or_zero().dot(rotation * Vec3::X).clamp(-1.0, 1.0)
170}
171fn doppler(direction: Vec3, emitter: Vec3, listener: Vec3, factor: f32) -> f32 {
172    let factor = factor.max(0.0);
173    let radial = |velocity: Vec3| (velocity.dot(direction) * factor).clamp(-300.0, 300.0);
174    ((343.0 + radial(listener)) / (343.0 + radial(emitter))).clamp(0.25, 4.0)
175}
176
177impl Ctx {
178    /// Plays a sound from a point in the world. Volume, panning and doppler
179    /// follow the listener every frame. A looping sound (`settings.sound.looping`)
180    /// plays until `stop_sound`.
181    /// ```
182    /// use ponko::*;
183    /// # fn f(ctx: &mut Ctx, sound: Sound) {
184    /// ctx.play_sound_at(&sound, vec3(3.0, 0.0, -5.0), AudioSettings3D::default());
185    /// # }
186    /// ```
187    pub fn play_sound_at(&mut self, sound: &Sound, position: Vec3, settings: AudioSettings3D) -> Playing {
188        let listener = self.audio_listener();
189        let offset = position - listener.position;
190        let mut params = settings.sound;
191        params.volume *= gain(offset.length(), settings);
192        let id = if self.headless {
193            self.audio.spatial.next_silent += 1;
194            Playing(self.audio.spatial.next_silent)
195        } else {
196            self.play_sound_on(sound, params, settings.bus, settings.effects)
197        };
198        self.audio.spatialize(
199            id.0,
200            params.volume,
201            pan(offset, listener.rotation),
202            params.speed.max(0.01),
203        );
204        self.audio.spatial.voices.push(Voice {
205            id,
206            entity: None,
207            position,
208            settings,
209            fresh: true,
210            remaining: sound.seconds(),
211        });
212        id
213    }
214}
215
216impl Ctx {
217    /// Puts the ears somewhere other than the camera, such as on the player in
218    /// a third-person game. `None` goes back to following `ctx.camera3d`.
219    /// ```
220    /// use ponko::*;
221    /// # fn f(ctx: &mut Ctx, player: Vec3) {
222    /// ctx.set_audio_listener(Some(AudioListener3D { position: player, rotation: Quat::IDENTITY }));
223    /// # }
224    /// ```
225    pub fn set_audio_listener(&mut self, listener: Option<AudioListener3D>) {
226        self.audio.spatial.custom = listener;
227    }
228
229    /// Where the ears are: the listener set with [`Ctx::set_audio_listener`],
230    /// or else the camera.
231    /// ```
232    /// use ponko::*;
233    /// # fn f(ctx: &Ctx) {
234    /// let ears = ctx.audio_listener().position;
235    /// # }
236    /// ```
237    pub fn audio_listener(&self) -> AudioListener3D {
238        self.audio.spatial.custom.unwrap_or(AudioListener3D {
239            position: self.camera3d.position,
240            rotation: self.camera3d.rotation,
241        })
242    }
243}
244
245/// Moves every positional sound to this frame's listener and emitters. The
246/// game runs it after the frame's systems; no allocations unless a voice starts.
247pub(crate) fn update_audio3d(ctx: &mut Ctx) {
248    let listener = ctx.audio_listener();
249    let dt = ctx.dt();
250    let inverse_dt = if dt > 0.0 { dt.recip() } else { 0.0 };
251    let velocity = ctx
252        .audio
253        .spatial
254        .listener
255        .map_or(Vec3::ZERO, |old| (listener.position - old.position) * inverse_dt);
256    ctx.audio.spatial.listener = Some(listener);
257    // Split fields rather than gathering the query into an allocated buffer.
258    for (entity, transform, source) in ctx.world.query::<(Entity, &Transform3D, &mut AudioSource3D)>().iter() {
259        if source.playing.is_none() {
260            let offset = transform.position - listener.position;
261            let mut params = source.settings.sound;
262            params.volume *= gain(offset.length(), source.settings);
263            let id = Playing(if ctx.headless {
264                ctx.audio.spatial.next_silent += 1;
265                ctx.audio.spatial.next_silent
266            } else {
267                ctx.audio
268                    .play_on(source.sound.id, params, source.settings.bus, source.settings.effects)
269            });
270            source.playing = Some(id);
271            ctx.audio.spatial.voices.push(Voice {
272                id,
273                entity: Some(entity),
274                position: transform.position,
275                settings: source.settings,
276                fresh: true,
277                remaining: source.sound.seconds(),
278            });
279        }
280    }
281    let mut i = 0;
282    while i < ctx.audio.spatial.voices.len() {
283        let voice = &mut ctx.audio.spatial.voices[i];
284        let mut position = voice.position;
285        let mut changed_effects = None;
286        if let Some(entity) = voice.entity {
287            match (
288                ctx.world.get::<Transform3D>(entity),
289                ctx.world.get::<AudioSource3D>(entity),
290            ) {
291                (Some(transform), Some(source)) if source.playing == Some(voice.id) => {
292                    position = transform.position;
293                    let looping = voice.settings.sound.looping;
294                    if source.settings.effects != voice.settings.effects {
295                        changed_effects = Some(source.settings.effects);
296                    }
297                    voice.settings = source.settings;
298                    voice.settings.sound.looping = looping;
299                }
300                _ => {
301                    let id = voice.id;
302                    ctx.audio.stop(id.0);
303                    ctx.audio.spatial.voices.swap_remove(i);
304                    continue;
305                }
306            }
307        }
308        let emitter_velocity = if voice.fresh {
309            Vec3::ZERO
310        } else {
311            (position - voice.position) * inverse_dt
312        };
313        let offset = position - listener.position;
314        let settings = voice.settings;
315        let speed = settings.sound.speed.max(0.01)
316            * doppler(offset.normalize_or_zero(), emitter_velocity, velocity, settings.doppler);
317        voice.remaining -= dt.max(0.0) * speed;
318        voice.position = position;
319        voice.fresh = false;
320        let (id, volume, panning) = (
321            voice.id,
322            settings.sound.volume * gain(offset.length(), settings),
323            pan(offset, listener.rotation),
324        );
325        let finished = !settings.sound.looping && voice.remaining <= 0.0;
326        if let Some(effects) = changed_effects {
327            // Kira smooths the change, so a filter can follow the game (a
328            // door closing, the camera moving inside).
329            ctx.audio.effects(id.0, effects);
330        }
331        ctx.audio.spatialize(id.0, volume, panning, speed);
332        if finished {
333            ctx.audio.spatial.voices.swap_remove(i);
334        } else {
335            i += 1;
336        }
337    }
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343    #[test]
344    fn audio_distance_curves_and_boundaries() {
345        let mut settings = AudioSettings3D {
346            min_distance: 1.0,
347            max_distance: 11.0,
348            rolloff: AudioRolloff::Linear,
349            ..Default::default()
350        };
351        assert_eq!(gain(0.0, settings), 1.0);
352        assert_eq!(gain(6.0, settings), 0.5);
353        assert_eq!(gain(11.0, settings), 0.0);
354        settings.rolloff = AudioRolloff::Logarithmic;
355        assert_eq!(gain(2.0, settings), 0.5);
356        settings.rolloff = AudioRolloff::Keyed(&[(0.0, 1.0), (1.0, 0.2)]);
357        assert!((gain(6.0, settings) - 0.6).abs() < 1e-6);
358    }
359    #[test]
360    fn audio_pan_and_doppler_follow_listener_orientation_and_velocity() {
361        assert_eq!(pan(Vec3::X, Quat::IDENTITY), 1.0);
362        assert_eq!(pan(-Vec3::X, Quat::IDENTITY), -1.0);
363        assert_eq!(pan(Vec3::ZERO, Quat::IDENTITY), 0.0);
364        assert!(doppler(Vec3::X, -Vec3::X * 20.0, Vec3::ZERO, 1.0) > 1.0);
365        assert!(doppler(Vec3::X, Vec3::X * 20.0, Vec3::ZERO, 1.0) < 1.0);
366        assert_eq!(doppler(Vec3::X, Vec3::X, Vec3::ZERO, 0.0), 1.0);
367    }
368    #[test]
369    fn audio_headless_sources_start_update_and_cleanup_without_device() {
370        crate::Game::new("audio test", 1, 1)
371            .system(|ctx| {
372                update_audio3d(ctx);
373                assert!(ctx.world.query::<&AudioSource3D>().iter().all(|s| s.playing.is_some()));
374            })
375            .run_headless(2, |ctx| {
376                let sound = ctx.create_sound(100, &[0.0; 100]);
377                let entity = ctx.world.spawn((
378                    Transform3D::default(),
379                    AudioSource3D::new(sound, AudioSettings3D::default()),
380                ));
381                update_audio3d(ctx);
382                assert_eq!(ctx.audio.spatial.voices.len(), 1);
383                ctx.world.get_mut::<Transform3D>(entity).unwrap().position = Vec3::X * 5.0;
384                update_audio3d(ctx);
385                assert_eq!(ctx.audio.spatial.voices[0].position, Vec3::X * 5.0);
386                ctx.world.despawn(entity);
387                update_audio3d(ctx);
388                assert!(ctx.audio.spatial.voices.is_empty());
389            });
390    }
391}