ponko 0.0.1

A fast 2D game engine for simulating millions of things: ECS on all cores, hot reload, an in-game inspector, AI-agent control, and a C++ API.
Documentation
//! Positional playback without custom DSP: Kira applies the gain, pan and rate.
use super::{AudioBus, AudioEffects, Ctx, Playing, Sound, SoundParams};
use crate::{Entity, Quat, Transform3D, Vec3};

/// Distance gain curve. Keyed curves use normalized distance and linear gain;
/// keys must be sorted by distance. Values outside the keys use the nearest key.
/// ```
/// use ponko::AudioRolloff;
/// let curve = AudioRolloff::Keyed(&[(0.0, 1.0), (0.5, 0.3), (1.0, 0.0)]);
/// ```
#[derive(Clone, Copy, Debug, Default)]
pub enum AudioRolloff {
    /// Inverse distance inside the range, silent at the maximum distance.
    #[default]
    Logarithmic,
    /// Linear fade between the minimum and maximum distances.
    Linear,
    /// Sorted (normalized distance, gain) keys; an empty curve is silent.
    Keyed(&'static [(f32, f32)]),
}

/// Settings for a positional voice. Distances are in world units (metres for
/// doppler); pitch is controlled by `sound.speed`. Invalid ranges are clamped.
/// ```
/// use ponko::*;
/// let settings = AudioSettings3D { max_distance: 80.0, doppler: 0.0, ..Default::default() };
/// ```
#[derive(Clone, Copy, Debug)]
pub struct AudioSettings3D {
    /// Volume, pitch/playback speed and looping.
    pub sound: SoundParams,
    /// Routing bus, selected when playback starts. Defaults to `sfx`.
    /// ```
    /// # use ponko::*;
    /// let settings = AudioSettings3D { bus: AudioBus::default(), ..Default::default() };
    /// ```
    pub bus: AudioBus,
    /// Initial per-voice low-pass and shared reverb send. Update live voices
    /// with `Ctx::set_sound_effects` (including positional voices).
    /// ```
    /// # use ponko::*;
    /// let settings = AudioSettings3D { effects: AudioEffects::default(), ..Default::default() };
    /// ```
    pub effects: AudioEffects,
    /// Full-volume radius, clamped to at least 0.001.
    pub min_distance: f32,
    /// Silent beyond this radius; clamped above the minimum radius.
    pub max_distance: f32,
    /// Distance gain curve.
    pub rolloff: AudioRolloff,
    /// Velocity multiplier; zero disables doppler. Default 1, sound speed 343 m/s.
    pub doppler: f32,
}
impl Default for AudioSettings3D {
    fn default() -> Self {
        Self {
            sound: SoundParams::default(),
            bus: AudioBus::default(),
            effects: AudioEffects::default(),
            min_distance: 1.0,
            max_distance: 100.0,
            rolloff: AudioRolloff::default(),
            doppler: 1.0,
        }
    }
}

/// Where the ears are. By default they follow `ctx.camera3d`; set your own with
/// [`Ctx::set_audio_listener`]. Velocity (for doppler) is estimated from frame
/// to frame.
/// ```
/// use ponko::*;
/// let listener = AudioListener3D { position: Vec3::ZERO, rotation: Quat::IDENTITY };
/// ```
#[derive(Clone, Copy, Debug, Default)]
pub struct AudioListener3D {
    /// Ear position in world space.
    pub position: Vec3,
    /// Orientation: +X is right and -Z is forward.
    pub rotation: Quat,
}

/// A sound that plays from an entity, paired with `Transform3D`. It starts by
/// itself and follows the entity every frame. Removing the component or the
/// entity stops it. One-shots don't restart after they finish.
/// ```
/// use ponko::*;
/// # fn setup(ctx: &mut Ctx, sound: Sound) {
/// ctx.world.spawn((Transform3D::default(), AudioSource3D::new(sound, AudioSettings3D::default())));
/// # }
/// ```
#[derive(Debug)]
pub struct AudioSource3D {
    /// Clip to play. Changes apply when the component is replaced.
    pub sound: Sound,
    /// Live volume, pitch, attenuation, effects and looping settings (looping applies at start).
    pub settings: AudioSettings3D,
    playing: Option<Playing>,
}
impl AudioSource3D {
    /// Creates an unstarted source. Looping follows `settings.sound.looping`.
    /// ```
    /// use ponko::*;
    /// # fn f(sound: Sound) {
    /// let source = AudioSource3D::new(sound, AudioSettings3D::default());
    /// # }
    /// ```
    pub fn new(sound: Sound, settings: AudioSettings3D) -> Self {
        Self {
            sound,
            settings,
            playing: None,
        }
    }
}

struct Voice {
    id: Playing,
    entity: Option<Entity>,
    position: Vec3,
    settings: AudioSettings3D,
    fresh: bool,
    remaining: f32,
}
#[derive(Default)]
pub(super) struct SpatialState {
    /// The listener of the last update, for its velocity.
    listener: Option<AudioListener3D>,
    /// The game's own listener, instead of the camera.
    custom: Option<AudioListener3D>,
    voices: Vec<Voice>,
    next_silent: u64,
}

impl SpatialState {
    pub(super) fn remove(&mut self, id: Playing) {
        self.voices.retain(|voice| voice.id != id);
    }
}

fn gain(distance: f32, settings: AudioSettings3D) -> f32 {
    let min = settings.min_distance.max(0.001);
    let max = settings.max_distance.max(min + 0.001);
    if distance >= max {
        return 0.0;
    }
    if distance <= min {
        return 1.0;
    }
    let t = (distance - min) / (max - min);
    match settings.rolloff {
        AudioRolloff::Linear => 1.0 - t,
        AudioRolloff::Logarithmic => min / distance,
        AudioRolloff::Keyed(keys) => {
            let Some(&(mut x, mut y)) = keys.first() else {
                return 0.0;
            };
            for &(next_x, next_y) in &keys[1..] {
                if t <= next_x {
                    return (y + (next_y - y) * ((t - x) / (next_x - x).max(f32::EPSILON)).clamp(0.0, 1.0)).max(0.0);
                }
                (x, y) = (next_x, next_y);
            }
            y.max(0.0)
        }
    }
}
fn pan(offset: Vec3, rotation: Quat) -> f32 {
    offset.normalize_or_zero().dot(rotation * Vec3::X).clamp(-1.0, 1.0)
}
fn doppler(direction: Vec3, emitter: Vec3, listener: Vec3, factor: f32) -> f32 {
    let factor = factor.max(0.0);
    let radial = |velocity: Vec3| (velocity.dot(direction) * factor).clamp(-300.0, 300.0);
    ((343.0 + radial(listener)) / (343.0 + radial(emitter))).clamp(0.25, 4.0)
}

impl Ctx {
    /// Plays a sound from a point in the world. Volume, panning and doppler
    /// follow the listener every frame. A looping sound (`settings.sound.looping`)
    /// plays until `stop_sound`.
    /// ```
    /// use ponko::*;
    /// # fn f(ctx: &mut Ctx, sound: Sound) {
    /// ctx.play_sound_at(&sound, vec3(3.0, 0.0, -5.0), AudioSettings3D::default());
    /// # }
    /// ```
    pub fn play_sound_at(&mut self, sound: &Sound, position: Vec3, settings: AudioSettings3D) -> Playing {
        let listener = self.audio_listener();
        let offset = position - listener.position;
        let mut params = settings.sound;
        params.volume *= gain(offset.length(), settings);
        let id = if self.headless {
            self.audio.spatial.next_silent += 1;
            Playing(self.audio.spatial.next_silent)
        } else {
            self.play_sound_on(sound, params, settings.bus, settings.effects)
        };
        self.audio.spatialize(
            id.0,
            params.volume,
            pan(offset, listener.rotation),
            params.speed.max(0.01),
        );
        self.audio.spatial.voices.push(Voice {
            id,
            entity: None,
            position,
            settings,
            fresh: true,
            remaining: sound.seconds(),
        });
        id
    }
}

impl Ctx {
    /// Puts the ears somewhere other than the camera, such as on the player in
    /// a third-person game. `None` goes back to following `ctx.camera3d`.
    /// ```
    /// use ponko::*;
    /// # fn f(ctx: &mut Ctx, player: Vec3) {
    /// ctx.set_audio_listener(Some(AudioListener3D { position: player, rotation: Quat::IDENTITY }));
    /// # }
    /// ```
    pub fn set_audio_listener(&mut self, listener: Option<AudioListener3D>) {
        self.audio.spatial.custom = listener;
    }

    /// Where the ears are: the listener set with [`Ctx::set_audio_listener`],
    /// or else the camera.
    /// ```
    /// use ponko::*;
    /// # fn f(ctx: &Ctx) {
    /// let ears = ctx.audio_listener().position;
    /// # }
    /// ```
    pub fn audio_listener(&self) -> AudioListener3D {
        self.audio.spatial.custom.unwrap_or(AudioListener3D {
            position: self.camera3d.position,
            rotation: self.camera3d.rotation,
        })
    }
}

/// Moves every positional sound to this frame's listener and emitters. The
/// game runs it after the frame's systems; no allocations unless a voice starts.
pub(crate) fn update_audio3d(ctx: &mut Ctx) {
    let listener = ctx.audio_listener();
    let dt = ctx.dt();
    let inverse_dt = if dt > 0.0 { dt.recip() } else { 0.0 };
    let velocity = ctx
        .audio
        .spatial
        .listener
        .map_or(Vec3::ZERO, |old| (listener.position - old.position) * inverse_dt);
    ctx.audio.spatial.listener = Some(listener);
    // Split fields rather than gathering the query into an allocated buffer.
    for (entity, transform, source) in ctx.world.query::<(Entity, &Transform3D, &mut AudioSource3D)>().iter() {
        if source.playing.is_none() {
            let offset = transform.position - listener.position;
            let mut params = source.settings.sound;
            params.volume *= gain(offset.length(), source.settings);
            let id = Playing(if ctx.headless {
                ctx.audio.spatial.next_silent += 1;
                ctx.audio.spatial.next_silent
            } else {
                ctx.audio
                    .play_on(source.sound.id, params, source.settings.bus, source.settings.effects)
            });
            source.playing = Some(id);
            ctx.audio.spatial.voices.push(Voice {
                id,
                entity: Some(entity),
                position: transform.position,
                settings: source.settings,
                fresh: true,
                remaining: source.sound.seconds(),
            });
        }
    }
    let mut i = 0;
    while i < ctx.audio.spatial.voices.len() {
        let voice = &mut ctx.audio.spatial.voices[i];
        let mut position = voice.position;
        let mut changed_effects = None;
        if let Some(entity) = voice.entity {
            match (
                ctx.world.get::<Transform3D>(entity),
                ctx.world.get::<AudioSource3D>(entity),
            ) {
                (Some(transform), Some(source)) if source.playing == Some(voice.id) => {
                    position = transform.position;
                    let looping = voice.settings.sound.looping;
                    if source.settings.effects != voice.settings.effects {
                        changed_effects = Some(source.settings.effects);
                    }
                    voice.settings = source.settings;
                    voice.settings.sound.looping = looping;
                }
                _ => {
                    let id = voice.id;
                    ctx.audio.stop(id.0);
                    ctx.audio.spatial.voices.swap_remove(i);
                    continue;
                }
            }
        }
        let emitter_velocity = if voice.fresh {
            Vec3::ZERO
        } else {
            (position - voice.position) * inverse_dt
        };
        let offset = position - listener.position;
        let settings = voice.settings;
        let speed = settings.sound.speed.max(0.01)
            * doppler(offset.normalize_or_zero(), emitter_velocity, velocity, settings.doppler);
        voice.remaining -= dt.max(0.0) * speed;
        voice.position = position;
        voice.fresh = false;
        let (id, volume, panning) = (
            voice.id,
            settings.sound.volume * gain(offset.length(), settings),
            pan(offset, listener.rotation),
        );
        let finished = !settings.sound.looping && voice.remaining <= 0.0;
        if let Some(effects) = changed_effects {
            // Kira smooths the change, so a filter can follow the game (a
            // door closing, the camera moving inside).
            ctx.audio.effects(id.0, effects);
        }
        ctx.audio.spatialize(id.0, volume, panning, speed);
        if finished {
            ctx.audio.spatial.voices.swap_remove(i);
        } else {
            i += 1;
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn audio_distance_curves_and_boundaries() {
        let mut settings = AudioSettings3D {
            min_distance: 1.0,
            max_distance: 11.0,
            rolloff: AudioRolloff::Linear,
            ..Default::default()
        };
        assert_eq!(gain(0.0, settings), 1.0);
        assert_eq!(gain(6.0, settings), 0.5);
        assert_eq!(gain(11.0, settings), 0.0);
        settings.rolloff = AudioRolloff::Logarithmic;
        assert_eq!(gain(2.0, settings), 0.5);
        settings.rolloff = AudioRolloff::Keyed(&[(0.0, 1.0), (1.0, 0.2)]);
        assert!((gain(6.0, settings) - 0.6).abs() < 1e-6);
    }
    #[test]
    fn audio_pan_and_doppler_follow_listener_orientation_and_velocity() {
        assert_eq!(pan(Vec3::X, Quat::IDENTITY), 1.0);
        assert_eq!(pan(-Vec3::X, Quat::IDENTITY), -1.0);
        assert_eq!(pan(Vec3::ZERO, Quat::IDENTITY), 0.0);
        assert!(doppler(Vec3::X, -Vec3::X * 20.0, Vec3::ZERO, 1.0) > 1.0);
        assert!(doppler(Vec3::X, Vec3::X * 20.0, Vec3::ZERO, 1.0) < 1.0);
        assert_eq!(doppler(Vec3::X, Vec3::X, Vec3::ZERO, 0.0), 1.0);
    }
    #[test]
    fn audio_headless_sources_start_update_and_cleanup_without_device() {
        crate::Game::new("audio test", 1, 1)
            .system(|ctx| {
                update_audio3d(ctx);
                assert!(ctx.world.query::<&AudioSource3D>().iter().all(|s| s.playing.is_some()));
            })
            .run_headless(2, |ctx| {
                let sound = ctx.create_sound(100, &[0.0; 100]);
                let entity = ctx.world.spawn((
                    Transform3D::default(),
                    AudioSource3D::new(sound, AudioSettings3D::default()),
                ));
                update_audio3d(ctx);
                assert_eq!(ctx.audio.spatial.voices.len(), 1);
                ctx.world.get_mut::<Transform3D>(entity).unwrap().position = Vec3::X * 5.0;
                update_audio3d(ctx);
                assert_eq!(ctx.audio.spatial.voices[0].position, Vec3::X * 5.0);
                ctx.world.despawn(entity);
                update_audio3d(ctx);
                assert!(ctx.audio.spatial.voices.is_empty());
            });
    }
}