use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use sayd_core::audio::AudioSink;
use crate::resample::ResamplingProducer;
const BUFFER_SECONDS: usize = 10;
struct Shared {
total_written: AtomicUsize,
discard_until: AtomicUsize,
resolved: AtomicUsize,
paused: AtomicBool,
}
pub(crate) struct RingProducer {
producer: rtrb::Producer<f32>,
shared: Arc<Shared>,
capacity: usize,
}
pub(crate) struct RingConsumer {
consumer: rtrb::Consumer<f32>,
shared: Arc<Shared>,
resolved: usize,
}
pub(crate) fn ring(capacity: usize) -> (RingProducer, RingConsumer) {
let (producer, consumer) = rtrb::RingBuffer::<f32>::new(capacity);
let shared = Arc::new(Shared {
total_written: AtomicUsize::new(0),
discard_until: AtomicUsize::new(0),
resolved: AtomicUsize::new(0),
paused: AtomicBool::new(false),
});
(
RingProducer { producer, shared: shared.clone(), capacity },
RingConsumer { consumer, shared, resolved: 0 },
)
}
impl RingProducer {
pub(crate) fn push(&mut self, samples: &[f32]) -> usize {
if samples.is_empty() {
return 0;
}
let n = samples.len().min(self.producer.slots());
if n == 0 {
return 0;
}
let Ok(mut chunk) = self.producer.write_chunk(n) else {
return 0;
};
let (a, b) = chunk.as_mut_slices();
a.copy_from_slice(&samples[..a.len()]);
b.copy_from_slice(&samples[a.len()..n]);
self.shared.total_written.fetch_add(n, Ordering::Release);
chunk.commit_all();
n
}
pub(crate) fn pending(&self) -> usize {
let resolved = self.shared.resolved.load(Ordering::Acquire);
let discard_until = self.shared.discard_until.load(Ordering::Acquire);
let effectively_resolved = resolved.max(discard_until);
let total = self.shared.total_written.load(Ordering::Acquire);
total.saturating_sub(effectively_resolved)
}
pub(crate) fn total_written(&self) -> usize {
self.shared.total_written.load(Ordering::Relaxed)
}
pub(crate) fn clear(&mut self) {
let mark = self.shared.total_written.load(Ordering::Acquire);
self.shared.discard_until.fetch_max(mark, Ordering::AcqRel);
}
pub(crate) fn set_paused(&mut self, paused: bool) {
self.shared.paused.store(paused, Ordering::Release);
}
pub(crate) fn is_paused(&self) -> bool {
self.shared.paused.load(Ordering::Acquire)
}
pub(crate) fn capacity(&self) -> usize {
self.capacity
}
}
impl RingConsumer {
#[cfg(test)]
pub(crate) fn debug_pending(&self) -> usize {
let total = self.shared.total_written.load(Ordering::Acquire);
let effectively_resolved =
self.shared.resolved.load(Ordering::Acquire).max(self.shared.discard_until.load(Ordering::Acquire));
total.saturating_sub(effectively_resolved)
}
pub(crate) fn fill(&mut self, out: &mut [f32], channels: usize) {
let channels = channels.max(1);
let frames = out.len() / channels;
let discard_until = self.shared.discard_until.load(Ordering::Acquire);
if self.resolved < discard_until {
let to_discard = (discard_until - self.resolved).min(self.consumer.slots());
if to_discard > 0 {
if let Ok(chunk) = self.consumer.read_chunk(to_discard) {
chunk.commit_all();
self.resolved += to_discard;
self.shared.resolved.store(self.resolved, Ordering::Release);
}
}
out.fill(0.0);
return;
}
if self.shared.paused.load(Ordering::Acquire) {
out.fill(0.0);
return;
}
let avail = self.consumer.slots().min(frames);
let mut taken = 0usize;
if avail > 0 {
if let Ok(chunk) = self.consumer.read_chunk(avail) {
let (a, b) = chunk.as_slices();
for (i, &s) in a.iter().chain(b.iter()).enumerate() {
let base = i * channels;
for c in 0..channels {
out[base + c] = s;
}
}
taken = a.len() + b.len();
chunk.commit_all();
self.resolved += taken;
self.shared.resolved.store(self.resolved, Ordering::Release);
}
}
for slot in out.iter_mut().skip(taken * channels) {
*slot = 0.0;
}
}
}
pub struct RingSink {
producer: ResamplingProducer,
_stream: cpal::Stream,
pub device_sample_rate: u32,
error: Arc<Mutex<Option<String>>>,
}
const _: fn() = || {
fn assert_send<T: Send>() {}
assert_send::<RingSink>();
};
impl RingSink {
pub fn new(sample_rate: u32) -> Result<Self, String> {
let host = cpal::default_host();
let device = host
.default_output_device()
.ok_or_else(|| "no default audio output device".to_string())?;
let wanted: cpal::SampleRate = sample_rate;
let mut chosen: Option<cpal::SupportedStreamConfig> = None;
if let Ok(ranges) = device.supported_output_configs() {
for r in ranges {
if r.channels() == 1
&& r.min_sample_rate() <= wanted
&& r.max_sample_rate() >= wanted
&& r.sample_format() == cpal::SampleFormat::F32
{
chosen = Some(r.with_sample_rate(wanted));
break;
}
}
}
let config = match chosen {
Some(c) => c,
None => device
.default_output_config()
.map_err(|e| format!("no usable output config: {e}"))?,
};
let device_sample_rate = config.sample_rate();
let channels = config.channels() as usize;
let capacity = device_sample_rate as usize * BUFFER_SECONDS;
let (raw_producer, mut consumer) = ring(capacity);
let producer = ResamplingProducer::new(raw_producer, sample_rate, device_sample_rate);
let error = Arc::new(Mutex::new(None));
let error_writer = error.clone();
let stream = device
.build_output_stream(
config.config(),
move |out: &mut [f32], _: &cpal::OutputCallbackInfo| {
consumer.fill(out, channels);
},
move |e| {
eprintln!("audio stream error: {e}");
*error_writer.lock().unwrap_or_else(|e| e.into_inner()) = Some(e.to_string());
},
None,
)
.map_err(|e| format!("could not build output stream: {e}"))?;
stream.play().map_err(|e| format!("could not start stream: {e}"))?;
Ok(RingSink { producer, _stream: stream, device_sample_rate, error })
}
}
impl AudioSink for RingSink {
fn push(&mut self, samples: &[f32]) -> usize {
self.producer.push(samples)
}
fn pending(&self) -> usize {
self.producer.pending()
}
fn clear(&mut self) {
self.producer.clear()
}
fn set_paused(&mut self, paused: bool) {
self.producer.set_paused(paused)
}
fn is_paused(&self) -> bool {
self.producer.is_paused()
}
fn capacity(&self) -> usize {
self.producer.capacity()
}
fn total_written(&self) -> usize {
self.producer.total_written()
}
fn take_error(&mut self) -> Option<String> {
self.error.lock().unwrap_or_else(|e| e.into_inner()).take()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn push_accepts_up_to_capacity_and_reports_short_count() {
let (mut prod, _cons) = ring(4);
assert_eq!(prod.push(&[1.0, 2.0]), 2);
assert_eq!(prod.push(&[3.0, 4.0, 5.0]), 2, "only what fits is accepted");
assert_eq!(prod.pending(), 4);
}
#[test]
fn fill_drains_fifo_and_produces_pushed_samples() {
let (mut prod, mut cons) = ring(8);
prod.push(&[1.0, 2.0, 3.0, 4.0]);
let mut out = [0.0; 4];
cons.fill(&mut out, 1);
assert_eq!(out, [1.0, 2.0, 3.0, 4.0]);
assert_eq!(prod.pending(), 0);
}
#[test]
fn underrun_yields_silence_not_stale_data() {
let (_prod, mut cons) = ring(8);
let mut out = [9.9; 4];
cons.fill(&mut out, 1);
assert_eq!(out, [0.0; 4]);
}
#[test]
fn underrun_with_some_buffered_pads_remainder_with_silence() {
let (mut prod, mut cons) = ring(8);
prod.push(&[1.0, 2.0]);
let mut out = [9.9; 4];
cons.fill(&mut out, 1);
assert_eq!(out, [1.0, 2.0, 0.0, 0.0]);
}
#[test]
fn pause_emits_silence_and_preserves_buffered_samples() {
let (mut prod, mut cons) = ring(8);
prod.push(&[1.0, 2.0]);
prod.set_paused(true);
let mut out = [9.9; 2];
cons.fill(&mut out, 1);
assert_eq!(out, [0.0, 0.0]);
assert_eq!(prod.pending(), 2, "buffered samples survive a pause");
prod.set_paused(false);
let mut out2 = [9.9; 2];
cons.fill(&mut out2, 1);
assert_eq!(out2, [1.0, 2.0], "resume plays what was buffered before the pause");
}
#[test]
fn clear_discards_buffered_samples_which_never_reach_output() {
let (mut prod, mut cons) = ring(8);
prod.push(&[1.0, 2.0, 3.0]);
prod.clear();
assert_eq!(prod.pending(), 0);
let mut out = [9.9; 3];
cons.fill(&mut out, 1);
assert_eq!(out, [0.0; 3], "cleared samples must never appear in output");
}
#[test]
fn clear_then_push_only_discards_the_pre_clear_samples() {
let (mut prod, mut cons) = ring(8);
prod.push(&[1.0, 2.0]);
prod.clear();
prod.push(&[9.0, 9.0]);
assert_eq!(prod.pending(), 2, "only the post-clear push counts as pending");
let mut out = [1.0; 2];
cons.fill(&mut out, 1);
assert_eq!(out, [0.0, 0.0]);
let mut out2 = [0.0; 2];
cons.fill(&mut out2, 1);
assert_eq!(out2, [9.0, 9.0], "fresh post-clear samples must survive the discard");
}
#[test]
fn pending_and_total_written_stay_accurate_across_push_fill_clear() {
let (mut prod, mut cons) = ring(16);
assert_eq!(prod.total_written(), 0);
prod.push(&[1.0, 2.0, 3.0]);
assert_eq!(prod.total_written(), 3);
assert_eq!(prod.pending(), 3);
let mut out = [0.0; 2];
cons.fill(&mut out, 1);
assert_eq!(prod.pending(), 1);
assert_eq!(prod.total_written(), 3, "total_written never decreases");
prod.clear();
assert_eq!(prod.pending(), 0);
assert_eq!(prod.total_written(), 3);
prod.push(&[4.0]);
assert_eq!(prod.total_written(), 4);
assert_eq!(prod.pending(), 1);
}
#[test]
fn mono_samples_fan_out_to_multiple_channels() {
let (mut prod, mut cons) = ring(8);
prod.push(&[1.0, 2.0]);
let mut out = [0.0; 6]; cons.fill(&mut out, 3);
assert_eq!(out, [1.0, 1.0, 1.0, 2.0, 2.0, 2.0]);
}
#[test]
fn capacity_reports_the_configured_size() {
let (prod, _cons) = ring(64);
assert_eq!(prod.capacity(), 64);
}
#[test]
fn concurrent_push_clear_push_keeps_pending_from_drifting() {
use std::thread;
let (mut prod, mut cons) = ring(4096);
let trials: u32 = 20_000;
let batch = 64;
let stop = Arc::new(AtomicBool::new(false));
let stop_reader = stop.clone();
let consumer = thread::spawn(move || {
let mut out = [0f32; 16];
loop {
if stop_reader.load(Ordering::Acquire) {
for _ in 0..100_000 {
if cons.debug_pending() == 0 {
break;
}
cons.fill(&mut out, 1);
}
break;
}
cons.fill(&mut out, 1);
}
});
for i in 1..=trials {
let marker = i as f32; prod.push(&vec![marker; batch]);
prod.clear();
prod.push(&vec![marker; batch]);
}
stop.store(true, Ordering::Release);
consumer.join().expect("consumer thread panicked");
assert_eq!(
prod.pending(),
0,
"pending drifted away from zero after a fully drained concurrent \
push/clear/push stress run -- clear() and fill() disagreed about \
how much was actually outstanding (total_written={})",
prod.total_written(),
);
}
#[test]
fn concurrent_bulk_push_keeps_pending_exactly_consistent_with_playback() {
use std::thread;
let (mut prod, mut cons) = ring(4096);
let total_samples: usize = 200_000;
let played = Arc::new(AtomicUsize::new(0));
let stop = Arc::new(AtomicBool::new(false));
let played_reader = played.clone();
let stop_reader = stop.clone();
let consumer = thread::spawn(move || {
let mut out = [0f32; 64];
loop {
let stopped = stop_reader.load(Ordering::Acquire);
cons.fill(&mut out, 1);
let n = out.iter().filter(|&&s| s != 0.0).count();
played_reader.fetch_add(n, Ordering::Relaxed);
if stopped && n == 0 {
break;
}
}
});
let mut state: u64 = 0x9E37_79B9_7F4A_7C15;
let mut next_chunk = |remaining: usize| -> usize {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
(1 + (state % 200) as usize).min(remaining)
};
let samples = vec![1.0f32; 256];
let mut pushed_total = 0usize;
while pushed_total < total_samples {
let chunk = next_chunk(total_samples - pushed_total).min(samples.len());
let n = prod.push(&samples[..chunk]);
pushed_total += n;
if n == 0 {
thread::yield_now();
}
}
stop.store(true, Ordering::Release);
consumer.join().expect("consumer thread panicked");
assert_eq!(prod.total_written(), total_samples);
let played_count = played.load(Ordering::Relaxed);
assert_eq!(
prod.pending(),
prod.total_written() - played_count,
"pending must equal total_written - samples actually played, with no drift",
);
assert_eq!(prod.pending(), 0, "everything pushed was eventually drained");
}
}