mirage-engine 0.1.1

Mirage, an immediate-mode 3D engine for simple games on desktop and the browser
Documentation
//! The arithmetic behind what the page is passed: where its buffers are
//! cut for a new pitch, and the levels its own panning takes.

use core::f32::consts::FRAC_PI_4;
use core::time::Duration;

use crate::sound::data::ClipFrame;
use crate::sound::mixer::Levels;

/// At most how far past the frame now playing a streamed voice takes a new
/// pitch: the frames scheduled beyond the cut are stopped and scheduled
/// again.
pub(crate) const REPITCH: Duration = Duration::from_millis(100);

/// The buffers a streamed voice has passed to the page and not played out.
///
/// Times are seconds on the page's own clock; `produced` counts frames of
/// playback, as [`Window::at`](crate::sound::mixer::Window::at) takes them.
pub(crate) struct Scheduled<'a> {
    /// When each buffer stops playing.
    pub(crate) ends: &'a [f64],
    /// When the last of them stops.
    pub(crate) until: f64,
    /// Frames of playback the voice has taken by then.
    pub(crate) produced: ClipFrame,
    /// Clip frames one second of playback takes, at the pitch the buffers
    /// were scheduled with.
    pub(crate) rate: f64,
}

impl Scheduled<'_> {
    /// Where the buffers are cut off for a new pitch: the end of the first
    /// buffer to finish within [`REPITCH`] of `now`, or [`REPITCH`] past
    /// `now` when none does, so the new pitch is heard within [`REPITCH`]
    /// either way. A voice with nothing left scheduled is cut at `now`.
    pub(crate) fn cut(&self, now: f64) -> Cut {
        let ends = self.ends.iter().copied().filter(|end| *end > now);
        let at = ends
            .fold(f64::INFINITY, f64::min)
            .min(now + REPITCH.as_secs_f64())
            .min(self.until)
            .max(now);

        Cut {
            at,
            produced: self
                .produced
                .back(((self.until - at) * self.rate).round() as u64),
        }
    }
}

/// Where a streamed voice stops playing what it has scheduled, so that the
/// frames after it can be scheduled again at the new pitch.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct Cut {
    /// Time on the page's clock, in seconds, where the buffers stop.
    pub(crate) at: f64,
    /// Frames of playback the voice has taken by then.
    pub(crate) produced: ClipFrame,
}

/// The same levels as one gain and one place between the ears, which is
/// what the browser's own panning takes;
/// [`levels`](crate::sound::mixer::levels) turned around.
pub(crate) fn split(levels: &Levels) -> (f32, f32) {
    let gain = levels.left.hypot(levels.right);
    let pan = levels.right.atan2(levels.left) / FRAC_PI_4 - 1.0;

    (gain, pan.clamp(-1.0, 1.0))
}

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

    #[test]
    fn a_streamed_repitch_cuts_the_buffers_scheduled_ahead_within_its_bound() {
        let now = 10.0;
        let cut = |ends: &[f64], until: f64, produced: u64| {
            Scheduled {
                ends,
                until,
                produced: ClipFrame::new(produced),
                rate: 100.0,
            }
            .cut(now)
        };

        assert_eq!(
            cut(&[10.05, 10.55], 10.55, 100),
            Cut {
                at: 10.05,
                produced: ClipFrame::new(50)
            },
            "the buffer playing now ends inside the bound, so the cut is there"
        );

        let bounded = cut(&[10.55], 10.55, 100);
        assert!(
            bounded.at <= now + REPITCH.as_secs_f64(),
            "and with none ending inside it, the cut is no further off than the bound"
        );
        assert_eq!(
            bounded.produced,
            ClipFrame::new(55),
            "leaving the frames up to the cut to play"
        );

        assert_eq!(
            cut(&[], 9.9, 100),
            Cut {
                at: now,
                produced: ClipFrame::new(100)
            },
            "and a voice with nothing scheduled is cut where it stands"
        );
    }

    #[test]
    fn the_page_hears_the_levels_the_mixer_would_make() {
        for gain in [0.25f32, 1.0, 1.5] {
            for pan in [-1.0f32, -0.5, 0.0, 0.7, 1.0] {
                let angle = (pan + 1.0) * FRAC_PI_4;
                let made = Levels {
                    left: gain * angle.cos(),
                    right: gain * angle.sin(),
                };

                let (heard, panned) = split(&made);
                let back = (panned + 1.0) * FRAC_PI_4;
                assert!(
                    (heard * back.cos() - made.left).abs() < 1e-5
                        && (heard * back.sin() - made.right).abs() < 1e-5,
                    "gain {gain} pan {pan} came back as gain {heard} pan {panned}"
                );
            }
        }
    }
}