Skip to main content

rlx_ring/
lib.rs

1//! A lock-free single-producer/single-consumer ring of interleaved `f32`
2//! samples — the one piece of pure-Rust `unsafe` in the project.
3//!
4//! The producer side lives on an audio thread (WASAPI capture, foobar's
5//! `visualisation_stream`, ...) and must stay real-time safe: `push_samples`
6//! performs no allocation, no locks, no logging, no I/O (NFR section 5).
7//!
8//! Samples are interleaved `f32` frames. If the ring is full the producer drops
9//! the excess (never blocks); the consumer is expected to drain every render
10//! frame so reads stay near the write head (NFR section 3).
11//!
12//! This crate is deliberately dependency-free (no wgpu, no audio-source, no
13//! platform types) so `cargo +nightly miri test -p rlx-ring` compiles and runs
14//! the SPSC unit tests under Miri in seconds — the fast UB gate the extraction
15//! exists for (Plan 0005). Format validation and the audio-facing `intake`
16//! wrapper live in `rlx-core`'s `audio` module, which re-exports these types.
17
18// Hot-path panic-denial pragma (Plan 0002 Phase 2). The audio callback and
19// ring must never panic in production; violations fail the build.
20#![deny(
21    clippy::unwrap_used,
22    clippy::expect_used,
23    clippy::indexing_slicing,
24    clippy::panic,
25    clippy::unreachable
26)]
27
28use std::cell::UnsafeCell;
29use std::sync::Arc;
30use std::sync::atomic::{AtomicUsize, Ordering};
31
32/// Create an SPSC pair over a ring sized to hold at least `capacity_samples`
33/// interleaved samples (rounded up to a power of two so the index mask is
34/// valid). `channels` is the interleaving width the producer rounds pushes to
35/// so a partial write never splits a frame; the caller (rlx-core's `intake`)
36/// has already validated it.
37pub fn spsc(capacity_samples: usize, channels: u16) -> (SampleProducer, SampleConsumer) {
38    let capacity = capacity_samples.max(1).next_power_of_two();
39    let shared = Arc::new(RingShared {
40        buf: (0..capacity).map(|_| UnsafeCell::new(0.0)).collect(),
41        mask: capacity - 1,
42        head: PaddedAtomicUsize::new(0),
43        tail: PaddedAtomicUsize::new(0),
44    });
45    (
46        SampleProducer {
47            shared: Arc::clone(&shared),
48            channels,
49        },
50        SampleConsumer { shared },
51    )
52}
53
54/// Keep head and tail on separate cache lines so the two threads do not
55/// false-share.
56#[repr(align(64))]
57struct PaddedAtomicUsize(AtomicUsize);
58
59impl PaddedAtomicUsize {
60    fn new(v: usize) -> Self {
61        Self(AtomicUsize::new(v))
62    }
63}
64
65struct RingShared {
66    buf: Box<[UnsafeCell<f32>]>,
67    mask: usize,
68    /// Total samples ever written (monotonic); producer-owned.
69    head: PaddedAtomicUsize,
70    /// Total samples ever read (monotonic); consumer-owned.
71    tail: PaddedAtomicUsize,
72}
73
74// Safety: head/tail are atomics; each buffer slot is written only by the
75// single producer while unpublished (before the head store) and read only by
76// the single consumer after the Release/Acquire handoff publishes it.
77unsafe impl Send for RingShared {}
78unsafe impl Sync for RingShared {}
79
80/// Audio-thread half. Real-time safe: `push_samples` never allocates,
81/// locks, or blocks.
82pub struct SampleProducer {
83    shared: Arc<RingShared>,
84    channels: u16,
85}
86
87impl SampleProducer {
88    /// Push interleaved samples; `samples.len()` must be a multiple of the
89    /// channel count (whole frames — the capture API's contract, checked in
90    /// debug builds only to keep the hot path free).
91    /// Returns how many samples were written; the rest are dropped if the
92    /// ring is full (dropping is the real-time-safe overflow policy).
93    #[allow(
94        clippy::indexing_slicing,
95        reason = "ring indices are masked (& mask) and `samples[..n]` is bounded by n = min(len, free); see the Safety notes"
96    )]
97    pub fn push_samples(&mut self, samples: &[f32]) -> usize {
98        debug_assert_eq!(samples.len() % self.channels as usize, 0);
99        let head = self.shared.head.0.load(Ordering::Relaxed);
100        let tail = self.shared.tail.0.load(Ordering::Acquire);
101        let free = self.shared.buf.len() - (head - tail);
102        // Round down to whole frames so a partial push never splits a frame
103        // and desynchronizes channel interleaving for the consumer.
104        let n = samples.len().min(free) / self.channels as usize * self.channels as usize;
105        for (i, &s) in samples[..n].iter().enumerate() {
106            let idx = (head + i) & self.shared.mask;
107            // Safety: slots in [head, head + free) are unpublished — only the
108            // producer touches them until the Release store below.
109            unsafe { *self.shared.buf[idx].get() = s };
110        }
111        self.shared.head.0.store(head + n, Ordering::Release);
112        n
113    }
114}
115
116/// Render/DSP-thread half.
117pub struct SampleConsumer {
118    shared: Arc<RingShared>,
119}
120
121impl SampleConsumer {
122    /// Interleaved samples currently readable.
123    pub fn available(&self) -> usize {
124        let head = self.shared.head.0.load(Ordering::Acquire);
125        let tail = self.shared.tail.0.load(Ordering::Relaxed);
126        head - tail
127    }
128
129    /// Pop up to `out.len()` interleaved samples; returns how many were read.
130    #[allow(
131        clippy::indexing_slicing,
132        reason = "ring indices are masked (& mask) and `out[..n]` is bounded by n = min(len, available); see the Safety notes"
133    )]
134    pub fn pop_samples(&mut self, out: &mut [f32]) -> usize {
135        let head = self.shared.head.0.load(Ordering::Acquire);
136        let tail = self.shared.tail.0.load(Ordering::Relaxed);
137        let n = out.len().min(head - tail);
138        for (i, slot) in out[..n].iter_mut().enumerate() {
139            let idx = (tail + i) & self.shared.mask;
140            // Safety: slots in [tail, head) were published by the producer's
141            // Release store and are not rewritten until we advance tail.
142            *slot = unsafe { *self.shared.buf[idx].get() };
143        }
144        self.shared.tail.0.store(tail + n, Ordering::Release);
145        n
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    // Tests index fixed-size arrays and known-length buffers freely; the
152    // hot-path pragma above cascades here, so re-allow indexing for tests.
153    #![allow(clippy::indexing_slicing)]
154
155    use super::*;
156
157    #[test]
158    fn roundtrip_preserves_order() {
159        let (mut tx, mut rx) = spsc(8, 1);
160        assert_eq!(tx.push_samples(&[1.0, 2.0, 3.0]), 3);
161        let mut out = [0.0; 3];
162        assert_eq!(rx.pop_samples(&mut out), 3);
163        assert_eq!(out, [1.0, 2.0, 3.0]);
164    }
165
166    #[test]
167    fn wraparound_keeps_data_intact() {
168        let (mut tx, mut rx) = spsc(4, 1);
169        let mut out = [0.0; 4];
170        for round in 0..10 {
171            let base = round as f32 * 4.0;
172            assert_eq!(
173                tx.push_samples(&[base, base + 1.0, base + 2.0, base + 3.0]),
174                4
175            );
176            assert_eq!(rx.pop_samples(&mut out), 4);
177            assert_eq!(out, [base, base + 1.0, base + 2.0, base + 3.0]);
178        }
179    }
180
181    #[test]
182    fn full_ring_drops_excess_instead_of_blocking() {
183        let (mut tx, mut rx) = spsc(4, 1);
184        assert_eq!(tx.push_samples(&[1.0, 2.0, 3.0, 4.0]), 4);
185        assert_eq!(tx.push_samples(&[5.0]), 0);
186        let mut out = [0.0; 4];
187        assert_eq!(rx.pop_samples(&mut out), 4);
188        assert_eq!(out, [1.0, 2.0, 3.0, 4.0]);
189        assert_eq!(tx.push_samples(&[5.0]), 1);
190        assert_eq!(rx.pop_samples(&mut out[..1]), 1);
191        assert_eq!(out[0], 5.0);
192    }
193
194    #[test]
195    fn cross_thread_stream_arrives_in_order() {
196        let (mut tx, mut rx) = spsc(2048, 2);
197        let total: usize = 100_000;
198        let writer = std::thread::spawn(move || {
199            let mut sent = 0usize;
200            let mut chunk = [0.0f32; 64];
201            while sent < total {
202                let n = chunk.len().min(total - sent);
203                for (i, s) in chunk[..n].iter_mut().enumerate() {
204                    *s = (sent + i) as f32;
205                }
206                sent += tx.push_samples(&chunk[..n]);
207            }
208        });
209        let mut expected = 0usize;
210        let mut buf = [0.0f32; 256];
211        while expected < total {
212            let n = rx.pop_samples(&mut buf);
213            for &s in &buf[..n] {
214                assert_eq!(s, expected as f32);
215                expected += 1;
216            }
217        }
218        writer.join().unwrap();
219    }
220}