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}