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}