Files
pmomusic/pmoaudio-ext/src/sinks/timed_broadcast.rs

608 lines
20 KiB
Rust
Raw Normal View History

//! Broadcast channel avec TTL et propagation de TopZero.
//! Inspiré de `tokio::sync::broadcast` mais ajoute :
//! - Capacité bornée avec blocage des producteurs quand aucun slot nest libre.
//! - Expiration automatique des messages (TTL) pour libérer les slots.
//! - Propagation dun compteur `epoch` incrémenté sur chaque TopZeroSync.
use std::{
collections::VecDeque,
2025-11-22 04:59:29 +01:00
fmt,
sync::{
atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering},
Arc, Mutex, Weak,
},
time::{Duration, Instant},
};
use tokio::sync::Notify;
2025-11-22 04:59:29 +01:00
use tracing::{info, trace, warn};
2025-11-17 03:03:06 +01:00
/// Tolérance pour détecter un timestamp à zéro (TopZero).
const TOP_ZERO_EPSILON: f64 = 1e-9;
2025-11-20 07:29:31 +01:00
pub const DEFAULT_BROADCAST_MAX_LEAD_TIME: f64 = 0.5;
/// Paquet diffusé contenant la charge utile + méta timing.
#[derive(Clone)]
pub struct TimedPacket<T> {
/// Charge utile diffusée aux clients.
pub payload: T,
/// Timestamp audio relatif (en secondes) pour pacing côté client.
pub audio_timestamp: f64,
/// Compteur incrémenté lorsqu'un TopZeroSync est reçu.
pub epoch: u64,
}
impl<T> fmt::Debug for TimedPacket<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("TimedPacket")
.field("audio_timestamp", &self.audio_timestamp)
.field("epoch", &self.epoch)
.finish_non_exhaustive()
}
}
/// Erreur remontée par `Receiver::try_recv`.
#[derive(Debug)]
pub enum TryRecvError {
2025-11-16 22:02:00 +01:00
/// Aucun paquet n'est disponible pour le moment.
Empty,
2025-11-16 22:02:00 +01:00
/// Le receiver est en retard : le champ contient combien de paquets ont expiré
/// ou ont déjà été consommés par les autres abonnés.
///
/// Ce cas survient lorsque `purge_expired()` avance `head_seq` et que ce
/// `Receiver` réclamait encore l'un des numéros supprimés. Le client doit
/// donc ignorer les données perdues et se resynchroniser sur les paquets
/// courants.
Lagged(u64),
2025-11-16 22:02:00 +01:00
/// Le channel est fermé et plus aucun paquet n'est disponible.
Closed,
}
/// Erreur remontée par `Receiver::recv`.
#[derive(Debug)]
pub enum RecvError {
Lagged(u64),
Closed,
}
/// Erreur remontée par `Sender::send`.
#[derive(Debug)]
2025-11-17 22:31:27 +01:00
/// Erreur de diffusion détaillant la raison pour laquelle un paquet n'a pas été accepté.
pub enum SendError<T> {
Closed(T),
Expired(T),
}
struct Entry<T> {
seq: u64,
expires_at: Instant,
payload: T,
audio_timestamp: f64,
epoch: u64,
}
struct State<T> {
2025-11-20 21:28:37 +01:00
name: String,
buffer: VecDeque<Entry<T>>,
head_seq: u64,
next_seq: u64,
closed: bool,
epoch: u64,
epoch_start: Instant,
2025-11-16 22:02:00 +01:00
last_segment_end: Option<Instant>,
cursors: Vec<Weak<ReceiverCursor>>,
2025-11-16 22:02:00 +01:00
initialized: bool,
2025-11-17 02:56:19 +01:00
last_purge: Instant,
}
impl<T> State<T> {
fn new(name: &str, capacity: usize, epoch_start: Instant) -> Self {
Self {
name: name.to_string(),
buffer: VecDeque::with_capacity(capacity),
head_seq: 0,
next_seq: 0,
closed: false,
epoch: 0,
epoch_start,
2025-11-16 22:02:00 +01:00
last_segment_end: None,
cursors: Vec::new(),
2025-11-16 22:02:00 +01:00
initialized: false,
2025-11-17 02:56:19 +01:00
last_purge: epoch_start,
}
}
2025-11-17 03:03:06 +01:00
fn purge_expired(&mut self, now: Instant) -> bool {
2025-11-20 21:28:37 +01:00
// Throttling : purger au maximum toutes les 20ms
if now.duration_since(self.last_purge) < Duration::from_millis(20) {
2025-11-17 02:56:19 +01:00
return false;
}
self.last_purge = now;
let mut purged = 0u64;
while let Some(entry) = self.buffer.front() {
2025-11-17 02:56:19 +01:00
if entry.expires_at <= now {
let delta = now - entry.expires_at;
trace!(
"TimedBroadcast[{}]: purging expired packet (@{} epoch={},delta={})",
self.name,
entry.seq,
entry.epoch,
delta.as_millis()
);
self.buffer.pop_front();
self.head_seq += 1;
purged += 1;
} else {
break;
}
}
if purged > 0 {
2025-11-15 15:43:42 +01:00
trace!(
"TimedBroadcast[{}]: purged {} expired packet(s) (head_seq={})",
self.name,
purged,
self.head_seq
);
return true;
}
false
}
fn prune_consumed(&mut self) -> bool {
let mut min_next = self.next_seq;
let mut has_cursor = false;
self.cursors.retain(|weak| {
if let Some(cursor) = weak.upgrade() {
let pos = cursor.next_seq.load(Ordering::SeqCst);
if pos < min_next {
min_next = pos;
}
has_cursor = true;
true
} else {
false
}
});
if !has_cursor {
return false;
}
let removable = min_next.saturating_sub(self.head_seq) as usize;
if removable == 0 {
return false;
}
for _ in 0..removable {
2025-11-20 21:28:37 +01:00
let oentry = self.buffer.pop_front();
if oentry.is_some() {
let entry = oentry.unwrap();
trace!(
"TimedBroadcast[{}]: pruning played packet (@{} epoch={})",
self.name, entry.seq, entry.epoch
);
2025-11-20 21:28:37 +01:00
self.head_seq += 1;
}
}
true
}
}
struct Inner<T> {
state: Mutex<State<T>>,
data_notify: Notify,
space_notify: Notify,
capacity: usize,
sender_count: AtomicUsize,
receiver_count: AtomicUsize,
is_closed: AtomicBool,
}
impl<T> Inner<T> {
fn new(name: &str, capacity: usize) -> Self {
Self {
state: Mutex::new(State::new(name, capacity, Instant::now())),
data_notify: Notify::new(),
space_notify: Notify::new(),
capacity,
sender_count: AtomicUsize::new(1),
receiver_count: AtomicUsize::new(0),
is_closed: AtomicBool::new(false),
}
}
fn close(&self) {
if !self.is_closed.swap(true, Ordering::SeqCst) {
if let Ok(mut state) = self.state.lock() {
state.closed = true;
}
self.data_notify.notify_waiters();
self.space_notify.notify_waiters();
}
}
}
/// Créé un channel broadcast temporisé.
2025-11-20 21:28:37 +01:00
pub fn channel<T>(name: &str, capacity: usize) -> (Sender<T>, Receiver<T>) {
assert!(capacity > 0, "capacity must be > 0");
let inner = Arc::new(Inner::new(name, capacity));
let next_seq = {
let state = inner.state.lock().expect("timed broadcast mutex poisoned");
state.next_seq
};
let sender = Sender {
inner: inner.clone(),
};
let cursor = Arc::new(ReceiverCursor {
next_seq: AtomicU64::new(next_seq),
});
{
let mut state = inner.state.lock().expect("timed broadcast mutex poisoned");
state.cursors.push(Arc::downgrade(&cursor));
}
inner.receiver_count.store(1, Ordering::SeqCst);
let receiver = Receiver {
inner,
next_seq,
cursor,
};
(sender, receiver)
}
/// Sender côté producteur.
pub struct Sender<T> {
inner: Arc<Inner<T>>,
}
impl<T> Clone for Sender<T> {
fn clone(&self) -> Self {
self.inner.sender_count.fetch_add(1, Ordering::SeqCst);
Self {
inner: self.inner.clone(),
}
}
}
impl<T> Sender<T> {
/// Diffuse un paquet. Bloque si la capacité est atteinte avec des paquets non périmés.
2025-11-16 22:02:00 +01:00
///
/// Le TTL de chaque paquet est calculé à partir du `epoch_start` courant et du
/// `audio_timestamp` fournis, ce qui signifie quun receiver en retard finira
/// par recevoir un [`TryRecvError::Lagged`] lorsque `expires_at` est dépassé.
2025-11-17 22:31:27 +01:00
pub async fn send(
&self,
payload: T,
audio_timestamp: f64,
segment_duration: f64,
) -> Result<usize, SendError<T>>
where
T: Clone,
{
let mut payload = Some(payload);
loop {
let mut wait_deadline = None;
{
let mut state = self
.inner
.state
.lock()
.expect("timed broadcast mutex poisoned");
if state.closed {
2025-11-17 22:31:27 +01:00
return Err(SendError::Closed(
payload.expect("payload already consumed"),
));
}
2025-11-17 03:03:06 +01:00
// Capturer le temps UNE SEULE FOIS pour cohérence temporelle
let now = Instant::now();
// 1. Purger d'abord les paquets expirés et consommés pour libérer l'espace
// (skip pour le tout premier paquet)
if state.buffer.len() > 0 {
let consumed = state.prune_consumed();
let expired = state.purge_expired(now);
if consumed || expired {
self.inner.space_notify.notify_waiters();
}
}
2025-11-19 15:26:30 +01:00
// 2. Vérifier si un slot est disponible et insérer
let is_top_zero =
audio_timestamp.abs() < TOP_ZERO_EPSILON
&& segment_duration >= TOP_ZERO_EPSILON;
let is_zero_header =
audio_timestamp.abs() < TOP_ZERO_EPSILON
&& segment_duration < TOP_ZERO_EPSILON;
2025-11-19 15:26:30 +01:00
if state.buffer.len() < self.inner.capacity {
if !state.initialized {
if !is_top_zero && segment_duration >= TOP_ZERO_EPSILON {
2025-11-19 15:26:30 +01:00
warn!(
"TimedBroadcast[{}]: First packet has non-zero timestamp {:.1}ms - Duration={:.1}ms, treating as epoch start anyway",
state.name,
audio_timestamp*1000.0,
segment_duration*1000.0
2025-11-19 15:26:30 +01:00
);
}
state.epoch_start = now;
state.epoch = 0;
state.initialized = true;
info!(
"TimedBroadcast[{}]: initialized (epoch=0, ts={:.1}ms - Duration={:.1}ms)",
state.name,
audio_timestamp*1000.0,
segment_duration*1000.0
2025-11-19 15:26:30 +01:00
);
} else if is_top_zero || is_zero_header {
2025-11-20 21:28:37 +01:00
// Restart epoch on TopZero relative to current wall-clock time to avoid
// expired packets when there's a long gap between tracks. Also trigger
// on zero-duration headers (OGG BOS/comment) so the epoch is reset
// before testing expiration.
state.epoch_start = state
.last_segment_end
.map(|end| end.max(now))
.unwrap_or(now);
2025-11-20 21:28:37 +01:00
// state.epoch_start = now;
2025-11-19 15:26:30 +01:00
state.epoch = state.epoch.wrapping_add(1);
info!(
"TimedBroadcast[{}]: new epoch={} (continuous={} - Duration={}ms)",
state.name,
2025-11-17 03:03:06 +01:00
state.epoch,
state.last_segment_end.is_some(),
segment_duration*1000.0
2025-11-17 03:03:06 +01:00
);
}
let expires_at = state.epoch_start
+ Duration::from_secs_f64(audio_timestamp
+ segment_duration);
2025-11-19 15:26:30 +01:00
let is_first_packet = state.next_seq == 0;
if !is_first_packet && !is_top_zero && !is_zero_header && expires_at <= now {
2025-11-19 15:26:30 +01:00
let grace_period = Duration::from_millis(50);
if now > expires_at + grace_period {
warn!(
"TimedBroadcast[{}]: rejecting already expired packet (ts={:.3}s, epoch={}, delta={}ms)",
state.name,
2025-11-19 15:26:30 +01:00
audio_timestamp,
state.epoch,
now.duration_since(expires_at).as_millis()
);
return Err(SendError::Expired(
payload.expect("payload already consumed"),
));
}
}
let entry = Entry {
seq: state.next_seq,
expires_at,
payload: payload.take().expect("payload already consumed"),
audio_timestamp,
epoch: state.epoch,
};
state.next_seq += 1;
state.buffer.push_back(entry);
2025-11-16 22:02:00 +01:00
// 5. Only advance segment end for real audio (skip 0-duration metadata)
if segment_duration >= TOP_ZERO_EPSILON {
let new_end = expires_at;
state.last_segment_end = Some(match state.last_segment_end.take() {
Some(prev) => prev.max(new_end),
None => new_end,
});
2025-11-17 02:56:19 +01:00
}
2025-11-16 22:02:00 +01:00
let receivers = self.inner.receiver_count.load(Ordering::SeqCst);
drop(state);
self.inner.data_notify.notify_waiters();
return Ok(receivers);
}
wait_deadline = state.buffer.front().map(|entry| entry.expires_at);
}
if let Some(deadline) = wait_deadline {
let deadline = tokio::time::Instant::from_std(deadline);
tokio::select! {
_ = self.inner.space_notify.notified() => {},
_ = tokio::time::sleep_until(deadline) => {},
}
} else {
self.inner.space_notify.notified().await;
}
}
}
/// Crée un nouveau receiver abonné au flux.
pub fn subscribe(&self) -> Receiver<T> {
let mut state = self
.inner
.state
.lock()
.expect("timed broadcast mutex poisoned");
let next_seq = state.next_seq;
let cursor = Arc::new(ReceiverCursor {
next_seq: AtomicU64::new(next_seq),
});
state.cursors.push(Arc::downgrade(&cursor));
state.prune_consumed();
drop(state);
self.inner.receiver_count.fetch_add(1, Ordering::SeqCst);
Receiver {
inner: self.inner.clone(),
next_seq,
cursor,
}
}
/// Nombre actuel de receivers abonnés.
pub fn receiver_count(&self) -> usize {
self.inner.receiver_count.load(Ordering::SeqCst)
}
/// Ferme explicitement le channel.
pub fn close(&self) {
self.inner.close();
}
}
impl<T> Drop for Sender<T> {
fn drop(&mut self) {
if self.inner.sender_count.fetch_sub(1, Ordering::SeqCst) == 1 {
self.inner.close();
}
}
}
/// Receiver côté consommateur.
2025-11-16 22:02:00 +01:00
///
/// Chaque receiver garde son propre curseur `next_seq`. Si le producteur
/// recycle un paquet via `purge_expired()` avant que ce curseur ne lait lu,
/// la prochaine tentative de lecture retournera [`TryRecvError::Lagged`].
pub struct Receiver<T> {
inner: Arc<Inner<T>>,
next_seq: u64,
cursor: Arc<ReceiverCursor>,
}
struct ReceiverCursor {
next_seq: AtomicU64,
}
impl<T> Receiver<T>
where
T: Clone,
{
fn poll_entry(&mut self) -> Result<TimedPacket<T>, TryRecvError> {
let mut state = self
.inner
.state
.lock()
.expect("timed broadcast mutex poisoned");
if state.closed && state.buffer.is_empty() {
return Err(TryRecvError::Closed);
}
2025-11-17 03:03:06 +01:00
let now = Instant::now();
if state.purge_expired(now) {
self.inner.space_notify.notify_waiters();
}
if self.next_seq < state.head_seq {
let skipped = state.head_seq - self.next_seq;
self.next_seq = state.head_seq;
return Err(TryRecvError::Lagged(skipped));
}
let offset = (self.next_seq - state.head_seq) as usize;
if offset < state.buffer.len() {
let entry = state.buffer.get(offset).expect("invalid buffer offset");
let packet = TimedPacket {
payload: entry.payload.clone(),
audio_timestamp: entry.audio_timestamp,
epoch: entry.epoch,
};
self.next_seq += 1;
self.cursor.next_seq.store(self.next_seq, Ordering::SeqCst);
if state.prune_consumed() {
self.inner.space_notify.notify_waiters();
}
return Ok(packet);
}
if state.closed {
Err(TryRecvError::Closed)
} else {
Err(TryRecvError::Empty)
}
}
/// Version synchrone utilisée dans `poll_read`.
2025-11-16 22:02:00 +01:00
///
/// # Erreurs
///
/// * [`TryRecvError::Lagged`] — des paquets ont expiré avant d'être consommés.
/// * [`TryRecvError::Empty`] — la file est vide pour l'instant.
/// * [`TryRecvError::Closed`] — plus aucun paquet n'arrivera.
pub fn try_recv(&mut self) -> Result<TimedPacket<T>, TryRecvError> {
self.poll_entry()
}
/// Attends qu'un paquet soit disponible.
pub async fn recv(&mut self) -> Result<TimedPacket<T>, RecvError> {
loop {
match self.try_recv() {
Ok(packet) => return Ok(packet),
Err(TryRecvError::Empty) => {
self.inner.data_notify.notified().await;
}
Err(TryRecvError::Lagged(skipped)) => return Err(RecvError::Lagged(skipped)),
Err(TryRecvError::Closed) => return Err(RecvError::Closed),
}
}
}
}
impl<T> Clone for Receiver<T> {
fn clone(&self) -> Self {
self.inner.receiver_count.fetch_add(1, Ordering::SeqCst);
let cursor = Arc::new(ReceiverCursor {
next_seq: AtomicU64::new(self.next_seq),
});
{
let mut state = self
.inner
.state
.lock()
.expect("timed broadcast mutex poisoned");
state.cursors.push(Arc::downgrade(&cursor));
}
Self {
inner: self.inner.clone(),
next_seq: self.next_seq,
cursor,
}
}
}
impl<T> Drop for Receiver<T> {
fn drop(&mut self) {
self.cursor.next_seq.store(self.next_seq, Ordering::SeqCst);
if let Ok(mut state) = self.inner.state.lock() {
if state.prune_consumed() {
self.inner.space_notify.notify_waiters();
}
}
if self.inner.receiver_count.fetch_sub(1, Ordering::SeqCst) == 1 {
self.inner.space_notify.notify_waiters();
}
}
}
2025-11-20 07:29:31 +01:00
/// Calculate broadcast channel capacity based on max_lead_time.
///
/// Estimates the number of items needed to buffer max_lead_time seconds of audio.
/// Assumes ~20 items per second (50ms per chunk).
///
/// # Arguments
///
/// * `max_lead_time` - Maximum lead time in seconds
///
/// # Returns
///
/// Broadcast channel capacity (minimum 100 items)
pub(crate) fn calculate_broadcast_capacity(max_lead_time: f64) -> usize {
// Estimation: ~20 items/second (chunks de 50ms en moyenne)
// Pour 10s: 200 items
let estimated_items_per_second = 20.0;
let capacity = (max_lead_time * estimated_items_per_second) as usize;
capacity.max(100) // Minimum 100 items
}