use crate::loss::CaptureRejection;
use crate::{AudioLoss, Result};
use std::num::NonZeroU64;
use std::sync::{
atomic::{AtomicBool, AtomicU64, Ordering},
Arc, Mutex,
};
#[cfg(not(target_has_atomic = "64"))]
compile_error!("capture diagnostics require lock-free 64-bit atomics");
#[derive(Clone)]
pub struct CaptureDiagnostics {
inner: Arc<Pending>,
}
struct Pending {
discontinuity: AtomicBool,
startup_drop: Mutex<Option<(u64, u32)>>,
summaries: [AtomicU64; 3],
rate: u32,
channels: u16,
}
impl CaptureDiagnostics {
pub fn new(rate: u32, channels: u16) -> Self {
Self {
inner: Arc::new(Pending {
discontinuity: AtomicBool::new(false),
startup_drop: Mutex::new(None),
summaries: std::array::from_fn(|_| AtomicU64::new(0)),
rate,
channels,
}),
}
}
pub fn record_startup_drop(&self, frames: u64, native_rate: u32) {
*self
.inner
.startup_drop
.lock()
.unwrap_or_else(|error| error.into_inner()) = Some((frames, native_rate));
}
pub fn take_startup_drop(&self) -> Option<(u64, u32)> {
self.inner
.startup_drop
.lock()
.unwrap_or_else(|error| error.into_inner())
.take()
}
pub fn mark_discontinuity(&self) {
self.inner.discontinuity.store(true, Ordering::Release);
}
pub fn take_discontinuity(&self) -> bool {
self.inner.discontinuity.swap(false, Ordering::AcqRel)
}
fn record(&self, index: usize, samples: Option<NonZeroU64>) {
let summary = &self.inner.summaries[index];
let Some(samples) = samples else {
summary.store(u64::MAX, Ordering::Release);
return;
};
let mut previous = summary.load(Ordering::Acquire);
for _ in 0..8 {
if previous == u64::MAX {
return;
}
let next = previous.saturating_add(samples.get());
match summary.compare_exchange_weak(previous, next, Ordering::AcqRel, Ordering::Acquire)
{
Ok(_) => return,
Err(current) => previous = current,
}
}
summary.store(u64::MAX, Ordering::Release);
}
pub fn record_corrupt_buffer(&self, samples: Option<NonZeroU64>) {
self.record(0, samples);
}
pub fn record_malformed_buffer(&self, samples: Option<NonZeroU64>) {
self.record(1, samples);
}
pub fn record_callback_rejected(&self, samples: Option<NonZeroU64>) {
self.record(2, samples);
}
pub fn drain(&self) -> Result<Vec<AudioLoss>> {
let mut losses = Vec::new();
for (summary, reason) in self.inner.summaries.iter().zip([
CaptureRejection::CorruptBuffer,
CaptureRejection::MalformedBuffer,
CaptureRejection::CallbackRejected,
]) {
let pending = summary.swap(0, Ordering::AcqRel);
if pending != 0 {
losses.push(AudioLoss::rejected(
reason,
if pending == u64::MAX {
None
} else {
NonZeroU64::new(pending)
},
self.inner.rate,
self.inner.channels,
)?);
}
}
Ok(losses)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn known_unknown_saturated_and_final_drain_are_observable_once() {
let diagnostics = CaptureDiagnostics::new(48_000, 2);
diagnostics.record_corrupt_buffer(NonZeroU64::new(100));
diagnostics.record_corrupt_buffer(NonZeroU64::new(100));
diagnostics.record_malformed_buffer(None);
diagnostics.record_malformed_buffer(NonZeroU64::new(7));
diagnostics.record_callback_rejected(NonZeroU64::new(u64::MAX - 1));
diagnostics.record_callback_rejected(NonZeroU64::new(2));
let losses = diagnostics.drain().unwrap();
assert_eq!(losses.len(), 3);
assert_eq!(losses[0].samples().unwrap().get(), 200);
assert_eq!(losses[1].samples(), None);
assert_eq!(losses[2].samples(), None);
for loss in &losses {
assert_eq!((loss.sample_rate(), loss.channels()), (48_000, 2));
}
assert!(diagnostics.drain().unwrap().is_empty());
}
#[test]
fn concurrent_recording_preserves_every_observation() {
let diagnostics = CaptureDiagnostics::new(48_000, 2);
let handles: Vec<_> = (0..4)
.map(|_| {
let diagnostics = diagnostics.clone();
std::thread::spawn(move || {
for _ in 0..1_000 {
diagnostics.record_corrupt_buffer(NonZeroU64::new(1));
}
})
})
.collect();
for handle in handles {
handle.join().unwrap();
}
let loss = diagnostics.drain().unwrap()[0];
assert!(loss.samples().is_none_or(|samples| samples.get() == 4_000));
assert!(diagnostics.drain().unwrap().is_empty());
}
}
#[cfg(test)]
mod startup_tests {
use super::*;
#[test]
fn composite_startup_gap_is_drained_once_with_native_rate() {
let diagnostics = CaptureDiagnostics::new(48_000, 2);
assert_eq!(diagnostics.take_startup_drop(), None);
diagnostics.record_startup_drop(441, 44_100);
assert_eq!(diagnostics.clone().take_startup_drop(), Some((441, 44_100)));
assert_eq!(diagnostics.take_startup_drop(), None);
}
}
#[cfg(test)]
mod routing_tests {
use super::*;
#[test]
fn routing_discontinuity_is_sticky_and_consumed_once() {
let diagnostics = CaptureDiagnostics::new(48_000, 2);
assert!(!diagnostics.take_discontinuity());
diagnostics.mark_discontinuity();
diagnostics.mark_discontinuity();
assert!(diagnostics.clone().take_discontinuity());
assert!(!diagnostics.take_discontinuity());
}
}