mirage-engine 0.2.0

Mirage, an immediate-mode 3D engine for simple games on desktop and the browser
Documentation
use core::hash::Hash;
use core::time::Duration;

use crate::math::Vec3;
use crate::sound::SoundData;
use crate::sound::mixer::GLIDE;
use crate::{Assets, Catalog};

/// The slowest a sound may play; less becomes this rate.
const SLOWEST_PITCH: f32 = 0.01;

/// Turns a game's value into a sound.
///
/// Values are cache keys: equal values must build the same sound.
/// [`Catalog`] proves every named asset loads before the first frame.
pub trait Sounds: Catalog + Hash + Eq + Clone {
    /// Builds this value's samples, the first time playing it needs them.
    ///
    /// Must not read a file or the network; loaded data is in `assets`.
    fn build(&self, assets: &Assets) -> SoundData;

    /// This sound at `gain`; see [`SoundCue::gain`].
    fn gain(self, gain: f32) -> SoundCue<Self> {
        SoundCue::from(self).gain(gain)
    }

    /// This sound at `pitch`; see [`SoundCue::pitch`].
    fn pitch(self, pitch: f32) -> SoundCue<Self> {
        SoundCue::from(self).pitch(pitch)
    }

    /// This sound at `position`; see [`SoundCue::at`].
    fn at(self, position: Vec3) -> SoundCue<Self> {
        SoundCue::from(self).at(position)
    }

    /// This sound heard `range` meters out; see [`SoundCue::range`].
    fn range(self, range: f32) -> SoundCue<Self> {
        SoundCue::from(self).range(range)
    }

    /// This sound at full level `reference` meters out; see
    /// [`SoundCue::reference`].
    fn reference(self, reference: f32) -> SoundCue<Self> {
        SoundCue::from(self).reference(reference)
    }

    /// This sound over a `fade`; see [`SoundCue::fade`].
    fn fade(self, fade: Duration) -> SoundCue<Self> {
        SoundCue::from(self).fade(fade)
    }

    /// This sound sliding its changes over `glide`; see
    /// [`SoundCue::glide`].
    fn glide(self, glide: Duration) -> SoundCue<Self> {
        SoundCue::from(self).glide(glide)
    }

    /// The part of this sound between `start` and `end`; see
    /// [`SoundCue::trim_to`].
    fn trim_to(self, start: Duration, end: Duration) -> SoundCue<Self> {
        SoundCue::from(self).trim_to(start, end)
    }

    /// This sound looping from `at`; see [`SoundCue::loop_from`].
    fn loop_from(self, at: Duration) -> SoundCue<Self> {
        SoundCue::from(self).loop_from(at)
    }

    /// This sound as the `nth` place it sounds at; see
    /// [`SoundCue::instance`].
    fn instance(self, nth: u32) -> SoundCue<Self> {
        SoundCue::from(self).instance(nth)
    }
}

/// A sound and how to play it.
///
/// Every knob has a default, so a bare vocabulary value plays as it was
/// loaded.
#[must_use = "a sound is only played once play or sustain takes it"]
#[derive(Debug)]
pub struct SoundCue<S: Sounds> {
    sound: S,
    knobs: Knobs,
}

impl<S: Sounds> SoundCue<S> {
    /// Range a placed sound is heard at when no call sets it: `50` meters.
    pub const DEFAULT_RANGE: f32 = Falloff::DEFAULT.range;

    /// Distance a placed sound holds its full level within when no call sets
    /// it: `1` meter.
    pub const DEFAULT_REFERENCE: f32 = Falloff::DEFAULT.reference;

    /// Fade a voice takes to come up and go down when no call sets it: as
    /// short as it can be without a click.
    pub const DEFAULT_FADE: Duration = Duration::from_millis(5);

    /// Span a change of gain or position slides over when no call sets it:
    /// short enough to follow a moving source, long enough not to click.
    pub const DEFAULT_GLIDE: Duration = GLIDE;

    /// Plays at `gain`, a fraction of the level the sound was loaded at;
    /// `1.0` as loaded, never less than nothing.
    pub fn gain(mut self, gain: f32) -> Self {
        self.knobs.gain = gain.max(0.0);
        self
    }

    /// Plays at `pitch`, a fraction of the rate the sound was loaded at,
    /// which moves pitch and speed together; clamped to never go below
    /// `0.01` of that rate.
    pub fn pitch(mut self, pitch: f32) -> Self {
        self.knobs.pitch = pitch.max(SLOWEST_PITCH);
        self
    }

    /// Places the sound at `position`, which pans it and fades it with
    /// distance from the listener.
    ///
    /// A sound with no position is heard the same in both ears, however the
    /// listener moves.
    pub fn at(mut self, position: Vec3) -> Self {
        self.knobs.position = Some(position);
        self
    }

    /// Fades a placed sound to nothing `range` meters from the listener;
    /// [`SoundCue::DEFAULT_RANGE`] until set.
    ///
    /// The reference never lies past the range, so a smaller range holds it
    /// there.
    pub fn range(mut self, range: f32) -> Self {
        self.knobs.falloff = self.knobs.falloff.with_range(range);
        self
    }

    /// Holds a placed sound at full level within `reference` meters of the
    /// listener, past which it falls by the inverse of the distance to
    /// nothing at the range; [`SoundCue::DEFAULT_REFERENCE`] until set.
    ///
    /// A reference past the range is held at the range. One at or under zero
    /// is held just above zero.
    pub fn reference(mut self, reference: f32) -> Self {
        self.knobs.falloff = self.knobs.falloff.with_reference(reference);
        self
    }

    /// Takes `fade` to come up at the start and to go down when a sustain
    /// ends; [`SoundCue::DEFAULT_FADE`] until set.
    ///
    /// A loop wrap is never faded.
    pub fn fade(mut self, fade: Duration) -> Self {
        self.knobs.fade = fade;
        self
    }

    /// Slides over `glide` every later change of gain or position;
    /// [`SoundCue::DEFAULT_GLIDE`] until set.
    ///
    /// A crossfade over seconds states its span here: the gain a frame
    /// declares is the level the voice slides to, never a step.
    pub fn glide(mut self, glide: Duration) -> Self {
        self.knobs.glide = glide;
        self
    }

    /// Plays only what lies between `start` and `end` of the sound's own
    /// timeline.
    ///
    /// Both clamp to the sound; a window with nothing in it plays nothing,
    /// with a debug log.
    pub fn trim_to(mut self, start: Duration, end: Duration) -> Self {
        self.knobs.trim = Some((start, end));
        self
    }

    /// Comes back to `at` every time a sustain plays to the end of its
    /// window, so that what lies before it is heard once.
    ///
    /// In the sound's own timeline, clamped into the window with a debug
    /// log. A one-shot never loops, so [`play`](crate::FrameContext::play)
    /// ignores this with a debug log.
    pub fn loop_from(mut self, at: Duration) -> Self {
        self.knobs.loop_from = Some(at);
        self
    }

    /// Sustains the sound as the `nth` of the places it sounds at, so one
    /// value is heard at more than one place at once; `0` until set.
    ///
    /// A sustain is kept alive by its value and its instance together, and
    /// each instance is a voice with knobs of its own. A one-shot is a voice
    /// of its own already, so [`play`](crate::FrameContext::play) ignores this
    /// with a debug log.
    pub fn instance(mut self, nth: u32) -> Self {
        self.knobs.instance = nth;
        self
    }

    /// The sound and its knobs, for the engine to resolve.
    pub(crate) fn split(self) -> (S, Knobs) {
        (self.sound, self.knobs)
    }
}

impl<S: Sounds> From<S> for SoundCue<S> {
    fn from(sound: S) -> Self {
        Self {
            sound,
            knobs: Knobs {
                gain: 1.0,
                pitch: 1.0,
                position: None,
                falloff: Falloff::DEFAULT,
                fade: Self::DEFAULT_FADE,
                glide: Self::DEFAULT_GLIDE,
                trim: None,
                loop_from: None,
                instance: 0,
            },
        }
    }
}

impl<S: Sounds> Clone for SoundCue<S> {
    fn clone(&self) -> Self {
        Self {
            sound: self.sound.clone(),
            knobs: self.knobs,
        }
    }
}

/// Everything a submission sets on one voice, apart from which sound it
/// is.
#[derive(Clone, Copy, Debug)]
pub(crate) struct Knobs {
    pub(crate) gain: f32,
    pub(crate) pitch: f32,
    pub(crate) position: Option<Vec3>,
    pub(crate) falloff: Falloff,
    pub(crate) fade: Duration,
    pub(crate) glide: Duration,
    pub(crate) trim: Option<(Duration, Duration)>,
    pub(crate) loop_from: Option<Duration>,
    pub(crate) instance: u32,
}

/// How a placed sound's level falls with distance from the listener.
///
/// The reference lies above zero and never past the range, so the level is a
/// fraction between nothing and one at every distance.
#[derive(Clone, Copy, Debug)]
pub(crate) struct Falloff {
    reference: f32,
    range: f32,
}

impl Falloff {
    /// What a cue takes until a call sets either distance: full level within
    /// `1` meter, nothing past `50`.
    pub(crate) const DEFAULT: Self = Self {
        reference: 1.0,
        range: 50.0,
    };

    /// The smallest either distance may be, since the level divides by both.
    const SMALLEST: f32 = f32::EPSILON;

    /// Holds the level within `reference` meters, at most as far out as the
    /// range.
    pub(crate) fn with_reference(self, reference: f32) -> Self {
        Self {
            reference: reference.clamp(Self::SMALLEST, self.range),
            ..self
        }
    }

    /// Falls to nothing `range` meters out, holding the reference within it.
    pub(crate) fn with_range(self, range: f32) -> Self {
        let range = range.max(Self::SMALLEST);
        Self {
            reference: self.reference.min(range),
            range,
        }
    }

    /// The fraction of its own level a placed sound is heard at, `distance`
    /// meters from the listener.
    pub(crate) fn level(self, distance: f32) -> f32 {
        if distance <= self.reference {
            return 1.0;
        }

        let shift = self.reference / self.range;
        ((self.reference / distance - shift) / (1.0 - shift)).clamp(0.0, 1.0)
    }
}

/// The vocabulary of a game with no sounds of its own.
///
/// No value of it exists, so a silent game has nothing to play.
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub enum NoSounds {}

impl Catalog for NoSounds {
    fn catalog() -> Vec<Self> {
        Vec::new()
    }
}

impl Sounds for NoSounds {
    fn build(&self, _assets: &Assets) -> SoundData {
        match *self {}
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Distances a [`Falloff`] is read at: the listener's own place, the way
    /// out past any range under test, and steps between them.
    const DISTANCES: [f32; 7] = [0.0, 0.5, 1.0, 2.0, 10.0, 50.0, 1e9];

    #[test]
    fn a_reference_past_the_range_or_at_or_under_zero_leaves_a_level_that_never_rises() {
        let past = Falloff::DEFAULT.with_range(2.0).with_reference(50.0);
        let none = Falloff::DEFAULT.with_reference(0.0);
        let negative = Falloff::DEFAULT.with_reference(-5.0).with_range(-1.0);

        for falloff in [past, none, negative] {
            let mut nearer = 1.0;
            for distance in DISTANCES {
                let level = falloff.level(distance);
                assert!(
                    (0.0..=nearer).contains(&level),
                    "{falloff:?} is heard at {level} from {distance} meters"
                );
                nearer = level;
            }
        }
    }

    #[test]
    fn a_reference_and_a_range_are_heard_the_same_whichever_is_set_first() {
        let reference_first = Falloff::DEFAULT.with_reference(50.0).with_range(2.0);
        let range_first = Falloff::DEFAULT.with_range(2.0).with_reference(50.0);

        for distance in DISTANCES {
            assert_eq!(
                reference_first.level(distance),
                range_first.level(distance),
                "from {distance} meters"
            );
        }
    }
}