use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::thread::{self, JoinHandle, Thread};
use std::time::Duration;
use crossbeam_queue::ArrayQueue;
const WORKER_PARK_TIMEOUT: Duration = Duration::from_millis(100);
const DEFAULT_TAP_FRAMES: usize = 32 * 1024;
const DEFAULT_TAP_CHANNELS: usize = 2;
pub struct AudioTap<S> {
ring: ArrayQueue<S>,
channels: usize,
scratch: Mutex<Vec<S>>,
waker: OnceLock<Thread>,
}
impl<S: Copy + Send + 'static> Default for AudioTap<S> {
fn default() -> Self {
Self::new(DEFAULT_TAP_FRAMES, DEFAULT_TAP_CHANNELS)
}
}
impl<S: Copy + Send + 'static> AudioTap<S> {
#[must_use]
pub fn new(frame_capacity: usize, channels: usize) -> Self {
assert!(channels > 0, "AudioTap needs at least one channel");
Self {
ring: ArrayQueue::new(frame_capacity * channels),
channels,
scratch: Mutex::new(Vec::new()),
waker: OnceLock::new(),
}
}
#[must_use]
pub fn channels(&self) -> usize {
self.channels
}
pub fn push_frames(&self, interleaved: &[S]) {
for frame in interleaved.chunks_exact(self.channels) {
if self.ring.capacity() - self.ring.len() < self.channels {
break;
}
for &sample in frame {
let _ = self.ring.push(sample);
}
}
if let Some(worker) = self.waker.get() {
worker.unpark();
}
}
pub fn clear(&self) {
let Ok(mut guard) = self.scratch.try_lock() else {
return;
};
while self.ring.pop().is_some() {}
guard.clear();
}
pub fn drain_with(&self, mut f: impl FnMut(&[S])) {
let Ok(mut scratch) = self.scratch.try_lock() else {
return;
};
while let Some(sample) = self.ring.pop() {
scratch.push(sample);
}
let whole = scratch.len() - scratch.len() % self.channels;
if whole > 0 {
f(&scratch[..whole]);
scratch.drain(..whole);
}
}
#[must_use]
pub fn spawn_worker(
self: Arc<Self>,
name: &str,
mut on_drain: impl FnMut(&[S]) + Send + 'static,
) -> StreamWorker {
assert!(
self.waker.get().is_none(),
"AudioTap already has a StreamWorker; attach at most one worker per tap",
);
let tap = Arc::clone(&self);
let shutdown = Arc::new(AtomicBool::new(false));
let shutdown_thread = Arc::clone(&shutdown);
let handle = thread::Builder::new()
.name(name.to_owned())
.spawn(move || {
while !shutdown_thread.load(Ordering::Acquire) {
tap.drain_with(&mut on_drain);
thread::park_timeout(WORKER_PARK_TIMEOUT);
}
})
.expect("spawn truce stream worker");
let _ = self.waker.set(handle.thread().clone());
StreamWorker {
shutdown,
thread: handle.thread().clone(),
handle: Some(handle),
}
}
}
pub struct StreamWorker {
shutdown: Arc<AtomicBool>,
thread: Thread,
handle: Option<JoinHandle<()>>,
}
impl Drop for StreamWorker {
fn drop(&mut self) {
self.shutdown.store(true, Ordering::Release);
self.thread.unpark();
if let Some(handle) = self.handle.take() {
let _ = handle.join();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trips_frames() {
let tap = AudioTap::<f32>::new(16, 2);
tap.push_frames(&[1.0, 2.0, 3.0, 4.0]); let mut got = Vec::new();
tap.drain_with(|chunk| got.extend_from_slice(chunk));
assert_eq!(got, vec![1.0, 2.0, 3.0, 4.0]);
}
#[test]
fn drop_on_full_stays_frame_aligned() {
let tap = AudioTap::<i32>::new(2, 2);
tap.push_frames(&[1, 1, 2, 2, 3, 3, 4, 4]);
let mut got = Vec::new();
tap.drain_with(|chunk| got.extend_from_slice(chunk));
assert_eq!(got.len() % 2, 0);
assert_eq!(got, vec![1, 1, 2, 2]);
}
#[test]
fn drain_of_empty_tap_does_not_call_f() {
let tap = AudioTap::<f32>::new(4, 1);
let mut called = false;
tap.drain_with(|_| called = true);
assert!(!called, "no callback when nothing is buffered");
}
#[test]
fn stream_worker_drains_in_order() {
use std::sync::mpsc;
let tap = Arc::new(AudioTap::<i32>::new(64, 2));
let (tx, rx) = mpsc::channel();
let worker = tap
.clone()
.spawn_worker("test-stream-worker", move |chunk| {
for &sample in chunk {
let _ = tx.send(sample);
}
});
tap.push_frames(&[1, 2, 3, 4]);
let mut got = Vec::new();
for _ in 0..4 {
got.push(
rx.recv_timeout(Duration::from_secs(5))
.expect("worker drained the pushed frames"),
);
}
assert_eq!(got, vec![1, 2, 3, 4]);
drop(worker);
}
#[test]
fn default_is_a_usable_stereo_tap() {
let tap = AudioTap::<f32>::default();
assert_eq!(tap.channels(), DEFAULT_TAP_CHANNELS);
tap.push_frames(&[1.0, 2.0]);
let mut got = Vec::new();
tap.drain_with(|chunk| got.extend_from_slice(chunk));
assert_eq!(got, vec![1.0, 2.0]);
}
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
#[cfg_attr(
miri,
ignore = "concurrency soak - too slow under Miri, no timing repro"
)]
#[test]
fn concurrent_drain_hands_out_whole_frames() {
use std::time::Instant;
let frames = 100_000usize;
for _round in 0..4 {
let tap = Arc::new(AudioTap::<i32>::new(frames, 2));
let producer = {
let tap = Arc::clone(&tap);
thread::spawn(move || {
for i in 0..frames as i32 {
tap.push_frames(&[2 * i, 2 * i + 1]);
}
})
};
let mut got: Vec<i32> = Vec::with_capacity(frames * 2);
let start = Instant::now();
while got.len() < frames * 2 {
tap.drain_with(|chunk| {
assert_eq!(
chunk.len() % 2,
0,
"drain handed a partial frame (len {})",
chunk.len()
);
got.extend_from_slice(chunk);
});
assert!(start.elapsed() < Duration::from_secs(30), "drain stalled");
}
producer.join().unwrap();
assert_eq!(got.len(), frames * 2);
for (j, &v) in got.iter().enumerate() {
assert_eq!(v, j as i32, "sample {j} out of place - frame misaligned");
}
}
}
#[test]
#[should_panic(expected = "at most one worker per tap")]
fn second_worker_panics() {
let tap = Arc::new(AudioTap::<i32>::new(16, 2));
let _first = tap.clone().spawn_worker("first", |_| {});
let _second = tap.clone().spawn_worker("second", |_| {});
}
}