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()`, which also marks the first delivery after an eviction DISCONTINUITY.
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, ChunkFlags};
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 {
38            rb,
39            dropped,
40            observed_drops: 0,
41        },
42    )
43}
44
45/// Handle for the processing thread. Pushes chunks using the DROP_OLDEST policy.
46pub struct ChunkProducer {
47    rb: Shared,
48    dropped: Arc<AtomicU64>,
49}
50
51impl ChunkProducer {
52    /// Push a chunk. If full, discard the oldest chunk (DROP_OLDEST) and count it.
53    ///
54    /// Set `dropped_before` to the cumulative number of chunks discarded before this chunk
55    /// was inserted, including the one evicted to make room for this chunk. Consumers can
56    /// find the number dropped since the previous chunk from the difference between
57    /// consecutive `dropped_before` values, and the total from the absolute value.
58    ///
59    /// Return `Some(total_dropped)` if this push dropped a chunk (for deciding whether to
60    /// fire [`crate::types::Event::ChunkDropped`]); otherwise return `None`.
61    pub fn push(&mut self, chunk: AudioChunk) -> Option<u64> {
62        self.push_with_evicted(chunk).map(|(total, _)| total)
63    }
64
65    /// Publish through the same DROP_OLDEST path, retaining the evicted PCM for exact loss
66    /// accounting. The returned count is cumulative; the evicted chunk is one interval loss.
67    /// This runs on the processing thread, never the realtime callback.
68    pub fn push_with_evicted(&mut self, mut chunk: AudioChunk) -> Option<(u64, AudioChunk)> {
69        // Poisoning (a panic while another thread held the lock) does not corrupt the ring,
70        // so recover the inner value and continue instead of causing a second panic.
71        let mut rb = self.rb.lock().unwrap_or_else(|e| e.into_inner());
72
73        // Check first whether this push will evict the oldest item (the ring is full).
74        let will_evict = rb.is_full();
75
76        if will_evict {
77            self.dropped.fetch_add(1, Ordering::Relaxed);
78        }
79        // Record the cumulative drop count, including any eviction caused by this push.
80        let total = self.dropped.load(Ordering::Relaxed);
81        chunk.dropped_before = u32::try_from(total).unwrap_or(u32::MAX);
82
83        let evicted = rb.push_overwrite(chunk);
84        drop(rb);
85
86        debug_assert_eq!(evicted.is_some(), will_evict);
87
88        evicted.map(|chunk| (total, chunk))
89    }
90
91    /// Cumulative number of chunks discarded by DROP_OLDEST.
92    pub fn dropped_count(&self) -> u64 {
93        self.dropped.load(Ordering::Relaxed)
94    }
95}
96
97/// Handle for the poll thread. Consume chunks with `try_pop`.
98pub struct ChunkConsumer {
99    rb: Shared,
100    dropped: Arc<AtomicU64>,
101    observed_drops: u64,
102}
103
104impl ChunkConsumer {
105    /// Take the oldest chunk, or return `None` without blocking if empty.
106    /// Mark the first delivery after newly observed evictions DISCONTINUITY, including
107    /// a surviving chunk that was inserted before those evictions occurred.
108    pub fn try_pop(&mut self) -> Option<AudioChunk> {
109        // Poisoning does not corrupt the ring, so recover and continue.
110        let mut rb = self.rb.lock().unwrap_or_else(|e| e.into_inner());
111        let mut chunk = rb.try_pop()?;
112        let dropped = self.dropped.load(Ordering::Relaxed);
113        if dropped != self.observed_drops {
114            // The oldest survivor can predate the overflow. Mark the next delivery,
115            // rather than only the newly pushed chunk, while holding the ring lock.
116            chunk.flags |= ChunkFlags::DISCONTINUITY;
117            self.observed_drops = dropped;
118        }
119        Some(chunk)
120    }
121
122    /// Number of chunks currently buffered in the ring.
123    pub fn len(&self) -> usize {
124        // Poisoning does not corrupt the ring, so recover and continue.
125        self.rb
126            .lock()
127            .unwrap_or_else(|e| e.into_inner())
128            .occupied_len()
129    }
130
131    /// Whether the ring is empty.
132    pub fn is_empty(&self) -> bool {
133        self.len() == 0
134    }
135
136    /// Cumulative number of chunks discarded by DROP_OLDEST.
137    pub fn dropped_count(&self) -> u64 {
138        self.dropped.load(Ordering::Relaxed)
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use crate::types::ChunkFlags;
146
147    fn chunk(seq: u64) -> AudioChunk {
148        AudioChunk {
149            frame_index: seq * 960,
150            data: vec![0.0; 1920],
151            frames: 960,
152            pts_ns: seq as i64 * 20_000_000,
153            seq,
154            flags: ChunkFlags::empty(),
155            dropped_before: 0,
156            peak: 0.0,
157            rms: 0.0,
158        }
159    }
160
161    #[test]
162    fn fifo_order_when_not_full() {
163        let (mut p, mut c) = chunk_ring(4);
164        assert!(c.is_empty());
165        for s in 0..3 {
166            assert_eq!(p.push(chunk(s)), None);
167        }
168        assert_eq!(c.len(), 3);
169        assert_eq!(c.try_pop().unwrap().seq, 0);
170        assert_eq!(c.try_pop().unwrap().seq, 1);
171        assert_eq!(c.try_pop().unwrap().seq, 2);
172        assert!(c.try_pop().is_none());
173    }
174
175    #[test]
176    fn drop_oldest_when_full_and_counts() {
177        let (mut p, mut c) = chunk_ring(2);
178        // Fill the ring to capacity 2.
179        assert_eq!(p.push(chunk(0)), None);
180        assert_eq!(p.push(chunk(1)), None);
181        assert_eq!(p.dropped_count(), 0);
182
183        // Full: discard the oldest (seq0) and insert seq2.
184        let dropped_total = p.push(chunk(2));
185        assert_eq!(dropped_total, Some(1));
186        assert_eq!(p.dropped_count(), 1);
187
188        // The next push drops one more item.
189        let dropped_total = p.push(chunk(3));
190        assert_eq!(dropped_total, Some(2));
191        assert_eq!(p.dropped_count(), 2);
192
193        // The two newest items remain: seq2 and seq3.
194        let first = c.try_pop().unwrap();
195        assert_eq!(first.seq, 2);
196        // Cumulative drops before seq2 was inserted = 1 (seq0 was discarded).
197        assert_eq!(first.dropped_before, 1);
198
199        let second = c.try_pop().unwrap();
200        assert_eq!(second.seq, 3);
201        // Cumulative drops before seq3 was inserted = 2 (seq0 and seq1 were discarded).
202        assert_eq!(second.dropped_before, 2);
203
204        assert!(c.try_pop().is_none());
205    }
206
207    #[test]
208    fn dropped_before_is_cumulative() {
209        let (mut p, mut c) = chunk_ring(1);
210        p.push(chunk(0)); // Inserted (dropped_before=0)
211                          // Capacity 1 is full, so each push drops an item.
212        p.push(chunk(1)); // Discard seq0; cumulative drops=1
213        c.try_pop(); // Take seq1, leaving the ring empty
214        let r = p.push(chunk(2)); // Inserted into the empty ring; no drops
215        assert_eq!(r, None);
216        let got = c.try_pop().unwrap();
217        assert_eq!(got.seq, 2);
218        // No new drops occurred, but the cumulative count remains 1.
219        assert_eq!(got.dropped_before, 1);
220        assert_eq!(p.dropped_count(), 1);
221    }
222
223    /// Capacity 0 is clamped to `max(1)` and works as a capacity-1 ring without panicking.
224    #[test]
225    fn zero_capacity_is_clamped_to_one() {
226        let (mut p, mut c) = chunk_ring(0);
227        assert!(c.is_empty());
228        assert_eq!(p.push(chunk(0)), None); // One item is inserted.
229        assert_eq!(c.len(), 1);
230        // Full: the next push discards the oldest item.
231        assert_eq!(p.push(chunk(1)), Some(1));
232        let got = c.try_pop().unwrap();
233        assert_eq!(got.seq, 1);
234    }
235
236    /// `len` / `is_empty` track push/pop and never exceed capacity (guards against off-by-one errors).
237    #[test]
238    fn len_tracks_occupancy_and_is_capped() {
239        let (mut p, mut c) = chunk_ring(3);
240        assert!(c.is_empty());
241        for s in 0..3 {
242            p.push(chunk(s));
243        }
244        assert_eq!(c.len(), 3);
245        assert!(!c.is_empty());
246        // Push two more items after filling: capacity stays at 3 as the oldest items are replaced.
247        p.push(chunk(3));
248        p.push(chunk(4));
249        assert_eq!(c.len(), 3, "occupancy never exceeds capacity 3");
250        // The three newest items remain: seq2, seq3, and seq4.
251        assert_eq!(c.try_pop().unwrap().seq, 2);
252        assert_eq!(c.try_pop().unwrap().seq, 3);
253        assert_eq!(c.try_pop().unwrap().seq, 4);
254        assert!(c.try_pop().is_none());
255        assert!(c.is_empty());
256    }
257
258    /// `try_pop` on an empty ring returns `None` without blocking. Producer and consumer
259    /// share the dropped counter.
260    #[test]
261    fn empty_pop_is_none_and_dropped_is_shared() {
262        let (mut p, mut c) = chunk_ring(2);
263        assert!(c.try_pop().is_none());
264        // With capacity 2, cause one drop.
265        p.push(chunk(0));
266        p.push(chunk(1));
267        p.push(chunk(2)); // Discard seq0.
268        assert_eq!(p.dropped_count(), 1);
269        assert_eq!(c.dropped_count(), 1, "consumer sees the same dropped count");
270    }
271    #[test]
272    fn dropped_chunk_preserves_its_exact_frame_gap() {
273        let (mut producer, mut consumer) = chunk_ring(1);
274        producer.push(chunk(0));
275        let first = consumer.try_pop().unwrap();
276        producer.push(chunk(1));
277        producer.push(chunk(2));
278        let next = consumer.try_pop().unwrap();
279        assert_eq!(
280            next.frame_index - first.frame_index - first.frames as u64,
281            960
282        );
283        assert_eq!(next.dropped_before, 1);
284    }
285
286    #[test]
287    fn eviction_returns_actual_pcm_and_marks_oldest_survivor_once() {
288        let (mut producer, mut consumer) = chunk_ring(3);
289        let mut tail = chunk(0);
290        tail.frames = 13;
291        tail.data.truncate(26);
292        producer.push(tail.clone());
293        producer.push(chunk(1));
294        producer.push(chunk(2));
295        let (total, evicted) = producer.push_with_evicted(chunk(3)).unwrap();
296        assert_eq!(total, 1);
297        assert_eq!(evicted, tail);
298        let next = consumer.try_pop().unwrap();
299        assert_eq!(next.seq, 1);
300        assert_eq!(
301            next.dropped_before, 0,
302            "survivor was queued before eviction"
303        );
304        assert!(next.flags.contains(ChunkFlags::DISCONTINUITY));
305        assert!(!consumer
306            .try_pop()
307            .unwrap()
308            .flags
309            .contains(ChunkFlags::DISCONTINUITY));
310        assert!(!consumer
311            .try_pop()
312            .unwrap()
313            .flags
314            .contains(ChunkFlags::DISCONTINUITY));
315    }
316}