web-audio-api 1.3.0

A pure Rust implementation of the Web Audio API, for use in non-browser contexts
Documentation
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;

#[derive(Clone, Debug)]
pub(crate) struct AudioStats {
    inner: Arc<AudioStatsInner>,
}

#[derive(Debug)]
struct AudioStatsInner {
    callback_count: AtomicU64,
    render_duration_ns_total: AtomicU64,
    callback_budget_ns_total: AtomicU64,
    underrun_count: AtomicU64,
    peak_load_ppm: AtomicU64,
    underrun_duration_ns_total: AtomicU64,
    underrun_events_total: AtomicU64,
    previous_underrun: AtomicBool,
    latest_latency_ns: AtomicU64,
    latency_sum_ns: AtomicU64,
    latency_count: AtomicU64,
    latency_min_ns: AtomicU64,
    latency_max_ns: AtomicU64,
}

#[derive(Copy, Clone, Debug)]
pub(crate) struct AudioStatsSnapshot {
    pub callback_count: u64,
    pub render_duration_ns_total: u64,
    pub callback_budget_ns_total: u64,
    pub underrun_count: u64,
    pub underrun_duration_ns_total: u64,
    pub underrun_events_total: u64,
    pub latency_sum_ns: u64,
    pub latency_count: u64,
    pub latency_min_ns: u64,
    pub latency_max_ns: u64,
}

impl Default for AudioStats {
    fn default() -> Self {
        Self::new()
    }
}

impl AudioStats {
    pub(crate) fn new() -> Self {
        Self {
            inner: Arc::new(AudioStatsInner {
                callback_count: AtomicU64::new(0),
                render_duration_ns_total: AtomicU64::new(0),
                callback_budget_ns_total: AtomicU64::new(0),
                underrun_count: AtomicU64::new(0),
                peak_load_ppm: AtomicU64::new(0),
                underrun_duration_ns_total: AtomicU64::new(0),
                underrun_events_total: AtomicU64::new(0),
                previous_underrun: AtomicBool::new(false),
                latest_latency_ns: AtomicU64::new(0),
                latency_sum_ns: AtomicU64::new(0),
                latency_count: AtomicU64::new(0),
                latency_min_ns: AtomicU64::new(u64::MAX),
                latency_max_ns: AtomicU64::new(0),
            }),
        }
    }

    pub(crate) fn record_render_callback(&self, render_duration_ns: u64, callback_budget_ns: u64) {
        self.inner.callback_count.fetch_add(1, Ordering::Relaxed);
        self.inner
            .render_duration_ns_total
            .fetch_add(render_duration_ns, Ordering::Relaxed);
        self.inner
            .callback_budget_ns_total
            .fetch_add(callback_budget_ns, Ordering::Relaxed);
        let load_ppm = render_duration_ns
            .saturating_mul(1_000_000)
            .checked_div(callback_budget_ns)
            .unwrap_or(0);
        self.inner
            .peak_load_ppm
            .fetch_max(load_ppm, Ordering::Relaxed);

        let underrun_duration_ns = render_duration_ns.saturating_sub(callback_budget_ns);
        let underrun = underrun_duration_ns > 0;
        if underrun {
            self.inner.underrun_count.fetch_add(1, Ordering::Relaxed);
            self.inner
                .underrun_duration_ns_total
                .fetch_add(underrun_duration_ns, Ordering::Relaxed);
            if !self.inner.previous_underrun.swap(true, Ordering::Relaxed) {
                self.inner
                    .underrun_events_total
                    .fetch_add(1, Ordering::Relaxed);
            }
        } else {
            self.inner.previous_underrun.store(false, Ordering::Relaxed);
        }
    }

    pub(crate) fn record_latency_seconds(&self, latency: f64) {
        if !latency.is_finite() || latency < 0. {
            return;
        }

        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
        let latency_ns = (latency * 1_000_000_000.).round() as u64;
        self.record_latency_ns(latency_ns);
    }

    pub(crate) fn record_latency_ns(&self, latency_ns: u64) {
        self.inner
            .latest_latency_ns
            .store(latency_ns, Ordering::Relaxed);
        self.inner
            .latency_sum_ns
            .fetch_add(latency_ns, Ordering::Relaxed);
        self.inner.latency_count.fetch_add(1, Ordering::Relaxed);
        self.inner
            .latency_min_ns
            .fetch_min(latency_ns, Ordering::Relaxed);
        self.inner
            .latency_max_ns
            .fetch_max(latency_ns, Ordering::Relaxed);
    }

    pub(crate) fn reset_latency(&self) {
        let current = self.inner.latest_latency_ns.load(Ordering::Relaxed);
        self.inner.latency_sum_ns.store(current, Ordering::Relaxed);
        self.inner.latency_count.store(1, Ordering::Relaxed);
        self.inner.latency_min_ns.store(current, Ordering::Relaxed);
        self.inner.latency_max_ns.store(current, Ordering::Relaxed);
    }

    pub(crate) fn snapshot(&self) -> AudioStatsSnapshot {
        let latency_min_ns = self.inner.latency_min_ns.load(Ordering::Relaxed);
        AudioStatsSnapshot {
            callback_count: self.inner.callback_count.load(Ordering::Relaxed),
            render_duration_ns_total: self.inner.render_duration_ns_total.load(Ordering::Relaxed),
            callback_budget_ns_total: self.inner.callback_budget_ns_total.load(Ordering::Relaxed),
            underrun_count: self.inner.underrun_count.load(Ordering::Relaxed),
            underrun_duration_ns_total: self
                .inner
                .underrun_duration_ns_total
                .load(Ordering::Relaxed),
            underrun_events_total: self.inner.underrun_events_total.load(Ordering::Relaxed),
            latency_sum_ns: self.inner.latency_sum_ns.load(Ordering::Relaxed),
            latency_count: self.inner.latency_count.load(Ordering::Relaxed),
            latency_min_ns: if latency_min_ns == u64::MAX {
                0
            } else {
                latency_min_ns
            },
            latency_max_ns: self.inner.latency_max_ns.load(Ordering::Relaxed),
        }
    }

    pub(crate) fn take_peak_load(&self) -> f64 {
        self.inner.peak_load_ppm.swap(0, Ordering::Relaxed) as f64 / 1_000_000.
    }
}

impl AudioStatsSnapshot {
    pub(crate) fn underrun_duration_seconds(&self) -> f64 {
        ns_to_seconds(self.underrun_duration_ns_total)
    }

    pub(crate) fn average_latency_seconds(&self) -> f64 {
        self.latency_sum_ns
            .checked_div(self.latency_count)
            .map_or(0., ns_to_seconds)
    }

    pub(crate) fn minimum_latency_seconds(&self) -> f64 {
        ns_to_seconds(self.latency_min_ns)
    }

    pub(crate) fn maximum_latency_seconds(&self) -> f64 {
        ns_to_seconds(self.latency_max_ns)
    }
}

fn ns_to_seconds(ns: u64) -> f64 {
    ns as f64 / 1_000_000_000.
}

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

    #[test]
    fn underrun_duration_tracks_missed_deadline_time() {
        let stats = AudioStats::new();

        stats.record_render_callback(3_000_000, 2_000_000);
        stats.record_render_callback(2_500_000, 2_000_000);

        let snapshot = stats.snapshot();
        assert_eq!(snapshot.underrun_count, 2);
        assert_eq!(snapshot.underrun_events_total, 1);
        assert_eq!(snapshot.underrun_duration_ns_total, 1_500_000);
        assert_eq!(snapshot.underrun_duration_seconds(), 0.0015);
    }

    #[test]
    fn underrun_events_count_continuous_sequences() {
        let stats = AudioStats::new();

        stats.record_render_callback(3_000_000, 2_000_000);
        stats.record_render_callback(1_000_000, 2_000_000);
        stats.record_render_callback(3_000_000, 2_000_000);

        let snapshot = stats.snapshot();
        assert_eq!(snapshot.underrun_count, 2);
        assert_eq!(snapshot.underrun_events_total, 2);
        assert_eq!(snapshot.underrun_duration_ns_total, 2_000_000);
    }
}