Skip to main content

flexaudio_core/
chunk_ring.rs

1//! SPSC ring for completed [`AudioChunk`] values, backed by ringbuf.
2//!
3//! When full, pop the oldest item and push the new one (DROP_OLDEST). Count dropped items
4//! with [`AtomicU64`] and record the count in the next chunk's `dropped_before`. Consumers
5//! use `try_pop()`.
6//!
7//! ringbuf's overwrite operation (`push_overwrite`) requires the producer to pop the oldest
8//! item, which also touches the consumer index and breaks the lock-free SPSC assumption
9//! (concurrent overwrite requires a lock). This ring's producer runs on the normal-priority
10//! ingest/processing thread, not the RT thread, so a short [`Mutex`] protects the ring itself.
11//! The RT path ([`mod@crate::raw_ring`]) never takes this lock.
12
13use std::sync::atomic::{AtomicU64, Ordering};
14use std::sync::{Arc, Mutex};
15
16use ringbuf::traits::{Consumer, Observer, RingBuffer};
17use ringbuf::HeapRb;
18
19use crate::types::AudioChunk;
20
21type Shared = Arc<Mutex<HeapRb<AudioChunk>>>;
22
23/// Create a chunk ring with capacity `capacity_chunks`.
24///
25/// Give the producer to the processing thread and the consumer to the poll thread. The
26/// `dropped` counter tracks chunks discarded by DROP_OLDEST and is recorded in the next
27/// chunk pushed as `dropped_before`.
28pub fn chunk_ring(capacity_chunks: usize) -> (ChunkProducer, ChunkConsumer) {
29    let cap = capacity_chunks.max(1);
30    let rb: Shared = Arc::new(Mutex::new(HeapRb::<AudioChunk>::new(cap)));
31    let dropped = Arc::new(AtomicU64::new(0));
32    (
33        ChunkProducer {
34            rb: rb.clone(),
35            dropped: dropped.clone(),
36        },
37        ChunkConsumer { rb, dropped },
38    )
39}
40
41/// Handle for the processing thread. Pushes chunks using the DROP_OLDEST policy.
42pub struct ChunkProducer {
43    rb: Shared,
44    dropped: Arc<AtomicU64>,
45}
46
47impl ChunkProducer {
48    /// Push a chunk. If full, discard the oldest chunk (DROP_OLDEST) and count it.
49    ///
50    /// Set `dropped_before` to the cumulative number of chunks discarded before this chunk
51    /// was inserted, including the one evicted to make room for this chunk. Consumers can
52    /// find the number dropped since the previous chunk from the difference between
53    /// consecutive `dropped_before` values, and the total from the absolute value.
54    ///
55    /// Return `Some(total_dropped)` if this push dropped a chunk (for deciding whether to
56    /// fire [`crate::types::Event::ChunkDropped`]); otherwise return `None`.
57    pub fn push(&mut self, mut chunk: AudioChunk) -> Option<u64> {
58        // Poisoning (a panic while another thread held the lock) does not corrupt the ring,
59        // so recover the inner value and continue instead of causing a second panic.
60        let mut rb = self.rb.lock().unwrap_or_else(|e| e.into_inner());
61
62        // Check first whether this push will evict the oldest item (the ring is full).
63        let will_evict = rb.is_full();
64
65        if will_evict {
66            self.dropped.fetch_add(1, Ordering::Relaxed);
67        }
68        // Record the cumulative drop count, including any eviction caused by this push.
69        let total = self.dropped.load(Ordering::Relaxed);
70        chunk.dropped_before = u32::try_from(total).unwrap_or(u32::MAX);
71
72        let evicted = rb.push_overwrite(chunk);
73        drop(rb);
74
75        debug_assert_eq!(evicted.is_some(), will_evict);
76
77        if will_evict {
78            Some(total)
79        } else {
80            None
81        }
82    }
83
84    /// Cumulative number of chunks discarded by DROP_OLDEST.
85    pub fn dropped_count(&self) -> u64 {
86        self.dropped.load(Ordering::Relaxed)
87    }
88}
89
90/// Handle for the poll thread. Consume chunks with `try_pop`.
91pub struct ChunkConsumer {
92    rb: Shared,
93    dropped: Arc<AtomicU64>,
94}
95
96impl ChunkConsumer {
97    /// Take the oldest chunk, or return `None` without blocking if empty.
98    pub fn try_pop(&mut self) -> Option<AudioChunk> {
99        // Poisoning does not corrupt the ring, so recover and continue.
100        let mut rb = self.rb.lock().unwrap_or_else(|e| e.into_inner());
101        rb.try_pop()
102    }
103
104    /// Number of chunks currently buffered in the ring.
105    pub fn len(&self) -> usize {
106        // Poisoning does not corrupt the ring, so recover and continue.
107        self.rb
108            .lock()
109            .unwrap_or_else(|e| e.into_inner())
110            .occupied_len()
111    }
112
113    /// Whether the ring is empty.
114    pub fn is_empty(&self) -> bool {
115        self.len() == 0
116    }
117
118    /// Cumulative number of chunks discarded by DROP_OLDEST.
119    pub fn dropped_count(&self) -> u64 {
120        self.dropped.load(Ordering::Relaxed)
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use crate::types::ChunkFlags;
128
129    fn chunk(seq: u64) -> AudioChunk {
130        AudioChunk {
131            data: vec![0.0; 1920],
132            frames: 960,
133            pts_ns: seq as i64 * 20_000_000,
134            seq,
135            flags: ChunkFlags::empty(),
136            dropped_before: 0,
137            peak: 0.0,
138            rms: 0.0,
139        }
140    }
141
142    #[test]
143    fn fifo_order_when_not_full() {
144        let (mut p, mut c) = chunk_ring(4);
145        assert!(c.is_empty());
146        for s in 0..3 {
147            assert_eq!(p.push(chunk(s)), None);
148        }
149        assert_eq!(c.len(), 3);
150        assert_eq!(c.try_pop().unwrap().seq, 0);
151        assert_eq!(c.try_pop().unwrap().seq, 1);
152        assert_eq!(c.try_pop().unwrap().seq, 2);
153        assert!(c.try_pop().is_none());
154    }
155
156    #[test]
157    fn drop_oldest_when_full_and_counts() {
158        let (mut p, mut c) = chunk_ring(2);
159        // Fill the ring to capacity 2.
160        assert_eq!(p.push(chunk(0)), None);
161        assert_eq!(p.push(chunk(1)), None);
162        assert_eq!(p.dropped_count(), 0);
163
164        // Full: discard the oldest (seq0) and insert seq2.
165        let dropped_total = p.push(chunk(2));
166        assert_eq!(dropped_total, Some(1));
167        assert_eq!(p.dropped_count(), 1);
168
169        // The next push drops one more item.
170        let dropped_total = p.push(chunk(3));
171        assert_eq!(dropped_total, Some(2));
172        assert_eq!(p.dropped_count(), 2);
173
174        // The two newest items remain: seq2 and seq3.
175        let first = c.try_pop().unwrap();
176        assert_eq!(first.seq, 2);
177        // Cumulative drops before seq2 was inserted = 1 (seq0 was discarded).
178        assert_eq!(first.dropped_before, 1);
179
180        let second = c.try_pop().unwrap();
181        assert_eq!(second.seq, 3);
182        // Cumulative drops before seq3 was inserted = 2 (seq0 and seq1 were discarded).
183        assert_eq!(second.dropped_before, 2);
184
185        assert!(c.try_pop().is_none());
186    }
187
188    #[test]
189    fn dropped_before_is_cumulative() {
190        let (mut p, mut c) = chunk_ring(1);
191        p.push(chunk(0)); // Inserted (dropped_before=0)
192                          // Capacity 1 is full, so each push drops an item.
193        p.push(chunk(1)); // Discard seq0; cumulative drops=1
194        c.try_pop(); // Take seq1, leaving the ring empty
195        let r = p.push(chunk(2)); // Inserted into the empty ring; no drops
196        assert_eq!(r, None);
197        let got = c.try_pop().unwrap();
198        assert_eq!(got.seq, 2);
199        // No new drops occurred, but the cumulative count remains 1.
200        assert_eq!(got.dropped_before, 1);
201        assert_eq!(p.dropped_count(), 1);
202    }
203
204    /// Capacity 0 is clamped to `max(1)` and works as a capacity-1 ring without panicking.
205    #[test]
206    fn zero_capacity_is_clamped_to_one() {
207        let (mut p, mut c) = chunk_ring(0);
208        assert!(c.is_empty());
209        assert_eq!(p.push(chunk(0)), None); // One item is inserted.
210        assert_eq!(c.len(), 1);
211        // Full: the next push discards the oldest item.
212        assert_eq!(p.push(chunk(1)), Some(1));
213        let got = c.try_pop().unwrap();
214        assert_eq!(got.seq, 1);
215    }
216
217    /// `len` / `is_empty` track push/pop and never exceed capacity (guards against off-by-one errors).
218    #[test]
219    fn len_tracks_occupancy_and_is_capped() {
220        let (mut p, mut c) = chunk_ring(3);
221        assert!(c.is_empty());
222        for s in 0..3 {
223            p.push(chunk(s));
224        }
225        assert_eq!(c.len(), 3);
226        assert!(!c.is_empty());
227        // Push two more items after filling: capacity stays at 3 as the oldest items are replaced.
228        p.push(chunk(3));
229        p.push(chunk(4));
230        assert_eq!(c.len(), 3, "occupancy never exceeds capacity 3");
231        // The three newest items remain: seq2, seq3, and seq4.
232        assert_eq!(c.try_pop().unwrap().seq, 2);
233        assert_eq!(c.try_pop().unwrap().seq, 3);
234        assert_eq!(c.try_pop().unwrap().seq, 4);
235        assert!(c.try_pop().is_none());
236        assert!(c.is_empty());
237    }
238
239    /// `try_pop` on an empty ring returns `None` without blocking. Producer and consumer
240    /// share the dropped counter.
241    #[test]
242    fn empty_pop_is_none_and_dropped_is_shared() {
243        let (mut p, mut c) = chunk_ring(2);
244        assert!(c.try_pop().is_none());
245        // With capacity 2, cause one drop.
246        p.push(chunk(0));
247        p.push(chunk(1));
248        p.push(chunk(2)); // Discard seq0.
249        assert_eq!(p.dropped_count(), 1);
250        assert_eq!(c.dropped_count(), 1, "consumer sees the same dropped count");
251    }
252}