Skip to main content

rlx_core/
audio.rs

1//! Source-agnostic sample intake: format validation plus a lock-free SPSC ring
2//! buffer.
3//!
4//! Format (sample rate, channel count) is validated once when the intake is
5//! created — the boundary — and the hot path trusts it from then on. The ring
6//! itself lives in the dependency-free [`rlx_ring`] crate (so Miri can check
7//! its `unsafe` without compiling the wgpu graph, Plan 0005); its
8//! [`SampleProducer`]/[`SampleConsumer`] handles are re-exported here so this
9//! module stays the single audio-intake surface for the standalone and FFI.
10//!
11//! Samples are interleaved f32 frames. The producer side lives on an audio
12//! thread and stays real-time safe (no allocation, locks, logging, or I/O in
13//! `push_samples`, NFR section 5); if the ring is full the producer drops the
14//! excess (never blocks), and the consumer drains every render frame (NFR
15//! section 3).
16
17// Hot-path panic-denial pragma (Plan 0002 Phase 2). The audio callback and
18// ring must never panic in production; violations fail the build.
19#![deny(
20    clippy::unwrap_used,
21    clippy::expect_used,
22    clippy::indexing_slicing,
23    clippy::panic,
24    clippy::unreachable
25)]
26
27// The SPSC ring internals moved to `rlx-ring` (Plan 0005); re-export the
28// handles so the public `audio` API and every call site stay unchanged.
29pub use rlx_ring::{SampleConsumer, SampleProducer};
30
31/// Lowest sample rate the intake accepts (Hz).
32pub const MIN_SAMPLE_RATE: u32 = 8_000;
33/// Highest sample rate the intake accepts (Hz).
34pub const MAX_SAMPLE_RATE: u32 = 384_000;
35/// Most interleaved channels the intake accepts.
36pub const MAX_CHANNELS: u16 = 8;
37
38/// PCM stream format, checked once at the intake boundary.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub struct AudioFormat {
41    /// Frames per second, in `MIN_SAMPLE_RATE..=MAX_SAMPLE_RATE`.
42    pub sample_rate: u32,
43    /// Interleaved channel count, in `1..=MAX_CHANNELS`.
44    pub channels: u16,
45}
46
47/// Why an [`AudioFormat`] was rejected at the boundary.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum FormatError {
50    /// Sample rate fell outside `MIN_SAMPLE_RATE..=MAX_SAMPLE_RATE`.
51    SampleRateOutOfRange(u32),
52    /// Channel count fell outside `1..=MAX_CHANNELS`.
53    ChannelsOutOfRange(u16),
54}
55
56impl std::fmt::Display for FormatError {
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        match self {
59            FormatError::SampleRateOutOfRange(sr) => {
60                write!(
61                    f,
62                    "sample rate {sr} outside {MIN_SAMPLE_RATE}..={MAX_SAMPLE_RATE}"
63                )
64            }
65            FormatError::ChannelsOutOfRange(ch) => {
66                write!(f, "channel count {ch} outside 1..={MAX_CHANNELS}")
67            }
68        }
69    }
70}
71
72impl std::error::Error for FormatError {}
73
74impl AudioFormat {
75    /// Check the rate and channel bounds; the hot path trusts the result.
76    pub fn validate(self) -> Result<Self, FormatError> {
77        if !(MIN_SAMPLE_RATE..=MAX_SAMPLE_RATE).contains(&self.sample_rate) {
78            return Err(FormatError::SampleRateOutOfRange(self.sample_rate));
79        }
80        if self.channels == 0 || self.channels > MAX_CHANNELS {
81            return Err(FormatError::ChannelsOutOfRange(self.channels));
82        }
83        Ok(self)
84    }
85}
86
87/// Create a validated intake: an SPSC pair sized for at least
88/// `capacity_frames` frames of headroom (rounded up to a power of two).
89pub fn intake(
90    format: AudioFormat,
91    capacity_frames: usize,
92) -> Result<(SampleProducer, SampleConsumer), FormatError> {
93    let format = format.validate()?;
94    let capacity_samples = capacity_frames.max(1) * format.channels as usize;
95    Ok(rlx_ring::spsc(capacity_samples, format.channels))
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    fn fmt(sample_rate: u32, channels: u16) -> AudioFormat {
103        AudioFormat {
104            sample_rate,
105            channels,
106        }
107    }
108
109    #[test]
110    fn format_validation_rejects_out_of_range() {
111        assert!(fmt(48_000, 2).validate().is_ok());
112        assert!(matches!(
113            fmt(4_000, 2).validate(),
114            Err(FormatError::SampleRateOutOfRange(4_000))
115        ));
116        assert!(matches!(
117            fmt(48_000, 0).validate(),
118            Err(FormatError::ChannelsOutOfRange(0))
119        ));
120        assert!(matches!(
121            fmt(48_000, 9).validate(),
122            Err(FormatError::ChannelsOutOfRange(9))
123        ));
124    }
125}