push-pqqsxyupswry #21
@@ -40,85 +40,20 @@ impl BroadcastPacer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Check timing and apply pacing
|
||||
/// Check timing and apply pacing - NO-OP VERSION
|
||||
///
|
||||
/// This function:
|
||||
/// 1. Detects TopZeroSync (audio_timestamp < 0.1 after >1s) and marks pending reset
|
||||
/// 2. On next chunk, resets timer with elapsed=0 guarantee
|
||||
/// 3. Drops frames that are late (audio_ts < elapsed)
|
||||
/// 4. Sleeps if too far ahead (lead_time > max_lead_time)
|
||||
/// Pacing is now handled entirely by the expiration-based system in
|
||||
/// TimedBroadcast. This method is kept for backward compatibility
|
||||
/// but always returns Ok(()).
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// - `Ok(())` if frame is on time or successfully paced
|
||||
/// - `Err(SkipFrame)` if frame is too late and should be dropped
|
||||
/// - Always returns `Ok(())`
|
||||
pub async fn check_and_pace(&mut self, audio_timestamp: f64) -> Result<(), SkipFrame> {
|
||||
// ╔═══════════════════════════════════════════════════════════════╗
|
||||
// ║ 1. DÉTECTION timestamp proche de 0 → marquer reset ║
|
||||
// ║ Quand timestamp < 0.1s, c'est un nouveau morceau ║
|
||||
// ╚═══════════════════════════════════════════════════════════════╝
|
||||
if audio_timestamp < 0.1 && !self.pending_reset {
|
||||
trace!(
|
||||
"{} broadcaster: Timestamp near zero detected, will reset timer on next chunk",
|
||||
self.label
|
||||
);
|
||||
self.pending_reset = true;
|
||||
}
|
||||
|
||||
// ╔═══════════════════════════════════════════════════════════════╗
|
||||
// ║ 2. RESET TIMER si pending ║
|
||||
// ║ Le reset se fait AVANT le calcul d'elapsed pour garantir ║
|
||||
// ║ elapsed=0 pour le premier chunk du nouveau morceau ║
|
||||
// ╚═══════════════════════════════════════════════════════════════╝
|
||||
let elapsed = if self.pending_reset {
|
||||
self.start_time = Instant::now();
|
||||
self.pending_reset = false;
|
||||
trace!(
|
||||
"{} broadcaster: Timer reset at audio_ts={:.3}s",
|
||||
self.label, audio_timestamp
|
||||
);
|
||||
0.0 // Garantit elapsed=0 pour ce chunk
|
||||
} else {
|
||||
self.start_time.elapsed().as_secs_f64()
|
||||
};
|
||||
|
||||
// ╔═══════════════════════════════════════════════════════════════╗
|
||||
// ║ 3. CALCUL DU LEAD TIME ║
|
||||
// ║ lead_time > 0 : en avance (OK) ║
|
||||
// ║ lead_time < 0 : en retard (SKIP) ║
|
||||
// ╚═══════════════════════════════════════════════════════════════╝
|
||||
let lead_time = audio_timestamp - elapsed;
|
||||
|
||||
// ╔═══════════════════════════════════════════════════════════════╗
|
||||
// ║ 4. DROP FRAMES EN RETARD ║
|
||||
// ╚═══════════════════════════════════════════════════════════════╝
|
||||
if lead_time < 0.0 {
|
||||
warn!(
|
||||
"{}: Dropping late frame: audio_ts={:.3}s, elapsed={:.3}s, lag={:.3}s",
|
||||
self.label, audio_timestamp, elapsed, -lead_time
|
||||
);
|
||||
return Err(SkipFrame);
|
||||
}
|
||||
|
||||
// ╔═══════════════════════════════════════════════════════════════╗
|
||||
// ║ 5. BACKPRESSURE NATURELLE - Pas de sleep ! ║
|
||||
// ║ ║
|
||||
// ║ Le pacing vient de : ║
|
||||
// ║ - TimerBufferNode en amont (envoi régulier à 50ms/chunk) ║
|
||||
// ║ - Capacité limitée du broadcast channel ║
|
||||
// ║ - Client HTTP qui lit à vitesse réelle ║
|
||||
// ║ ║
|
||||
// ║ Pas besoin de sleep explicite qui causerait des bursts ║
|
||||
// ╚═══════════════════════════════════════════════════════════════╝
|
||||
|
||||
// Log pour info si on est très en avance, mais on ne dort PAS
|
||||
if self.max_lead_time > 0.0 && lead_time > self.max_lead_time {
|
||||
trace!(
|
||||
"{} broadcaster: lead_time={:.3}s > max={:.3}s (audio_ts={:.3}s, elapsed={:.3}s) - relying on natural backpressure",
|
||||
self.label, lead_time, self.max_lead_time, audio_timestamp, elapsed
|
||||
);
|
||||
}
|
||||
|
||||
trace!(
|
||||
"{} broadcaster: check_and_pace called with audio_ts={:.3}s (no-op - pacing handled by TimedBroadcast)",
|
||||
self.label, audio_timestamp
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +116,8 @@ struct PcmChunk {
|
||||
bytes: Vec<u8>,
|
||||
/// Timestamp in seconds (from AudioSegment)
|
||||
timestamp_sec: f64,
|
||||
/// Duration in seconds of this PCM chunk (samples / sample_rate)
|
||||
duration_sec: f64,
|
||||
}
|
||||
|
||||
/// Snapshot of track metadata at a point in time.
|
||||
@@ -259,6 +261,12 @@ enum FlacStreamState {
|
||||
}
|
||||
|
||||
/// Pure FLAC client stream (implements AsyncRead).
|
||||
///
|
||||
/// Each read pulls bytes out of a [`timed_broadcast`] receiver.
|
||||
/// If the receiver reports [`TryRecvError::Lagged`] it means the underlying
|
||||
/// queue expired packets before the client consumed them; we log the skip
|
||||
/// and immediately keep draining so that a late client can resynchronise
|
||||
/// with the latest epoch instead of stalling forever.
|
||||
pub struct FlacClientStream {
|
||||
rx: timed_broadcast::Receiver<Bytes>,
|
||||
buffer: VecDeque<u8>,
|
||||
@@ -369,6 +377,9 @@ impl Drop for FlacClientStream {
|
||||
///
|
||||
/// This stream injects ICY metadata blocks at regular intervals,
|
||||
/// allowing clients to display "Now Playing" information.
|
||||
/// As with [`FlacClientStream`], hitting [`TryRecvError::Lagged`]
|
||||
/// simply indicates that the timed broadcast discarded a stale chunk;
|
||||
/// the client resumes with fresh data to avoid wedging the HTTP response.
|
||||
pub struct IcyClientStream {
|
||||
rx: timed_broadcast::Receiver<Bytes>,
|
||||
metadata: Arc<RwLock<MetadataSnapshot>>,
|
||||
@@ -614,11 +625,13 @@ impl StreamingFlacSinkLogic {
|
||||
.take()
|
||||
.ok_or_else(|| AudioError::ProcessingError("PCM receiver already consumed".into()))?;
|
||||
|
||||
// Create shared timestamp for pacing
|
||||
// Create shared timestamp and duration for pacing
|
||||
let current_timestamp = Arc::new(RwLock::new(0.0f64));
|
||||
let current_duration = Arc::new(RwLock::new(0.0f64));
|
||||
|
||||
// Create ByteStreamReader for the encoder
|
||||
let pcm_reader = ByteStreamReader::new(pcm_rx, current_timestamp.clone());
|
||||
let pcm_reader =
|
||||
ByteStreamReader::new(pcm_rx, current_timestamp.clone(), current_duration.clone());
|
||||
|
||||
// Create PCM format
|
||||
let pcm_format = PcmFormat {
|
||||
@@ -636,7 +649,7 @@ impl StreamingFlacSinkLogic {
|
||||
|
||||
debug!("FLAC encoder initialized successfully");
|
||||
|
||||
// Spawn broadcaster task with timestamp for pacing
|
||||
// Spawn broadcaster task with timestamp and duration for pacing
|
||||
let flac_broadcast = self.flac_broadcast.clone();
|
||||
let flac_header = self.flac_header.clone();
|
||||
let max_lead = self.broadcast_max_lead_time;
|
||||
@@ -646,7 +659,9 @@ impl StreamingFlacSinkLogic {
|
||||
flac_broadcast,
|
||||
flac_header,
|
||||
current_timestamp,
|
||||
current_duration,
|
||||
max_lead,
|
||||
sample_rate,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -746,17 +761,25 @@ impl NodeLogic for StreamingFlacSinkLogic {
|
||||
// Convert chunk to PCM bytes
|
||||
let pcm_bytes = chunk_to_pcm_bytes(&chunk, self.bits_per_sample)?;
|
||||
|
||||
// Calculate exact duration from samples and sample rate
|
||||
let sample_rate = self
|
||||
.sample_rate
|
||||
.expect("sample_rate should be initialized");
|
||||
let duration_sec = chunk.len() as f64 / sample_rate as f64;
|
||||
|
||||
trace!(
|
||||
"Sending PCM chunk: {} bytes, {} samples @ {:.2}s",
|
||||
"Sending PCM chunk: {} bytes, {} samples @ {:.2}s (duration={:.3}s)",
|
||||
pcm_bytes.len(),
|
||||
chunk.len(),
|
||||
seg.timestamp_sec
|
||||
seg.timestamp_sec,
|
||||
duration_sec
|
||||
);
|
||||
|
||||
// Send to FLAC encoder with timestamp
|
||||
// Send to FLAC encoder with timestamp and duration
|
||||
let pcm_chunk = PcmChunk {
|
||||
bytes: pcm_bytes,
|
||||
timestamp_sec: seg.timestamp_sec,
|
||||
duration_sec,
|
||||
};
|
||||
let send_start = std::time::Instant::now();
|
||||
if let Err(e) = self.pcm_tx.send(pcm_chunk).await {
|
||||
@@ -786,11 +809,6 @@ impl NodeLogic for StreamingFlacSinkLogic {
|
||||
break;
|
||||
}
|
||||
|
||||
SyncMarker::TopZeroSync => {
|
||||
self.flac_broadcast.mark_top_zero();
|
||||
trace!("TopZeroSync propagated to FLAC broadcast");
|
||||
}
|
||||
|
||||
_ => {
|
||||
trace!("Received other sync marker");
|
||||
}
|
||||
@@ -826,7 +844,9 @@ async fn broadcast_flac_stream(
|
||||
broadcast_tx: timed_broadcast::Sender<Bytes>,
|
||||
header_cache: Arc<RwLock<Option<Bytes>>>,
|
||||
current_timestamp: Arc<RwLock<f64>>,
|
||||
current_duration: Arc<RwLock<f64>>,
|
||||
broadcast_max_lead_time: f64,
|
||||
sample_rate: u32,
|
||||
) -> Result<(), AudioError> {
|
||||
trace!(
|
||||
"Broadcaster task started with FLAC frame boundary detection (max_lead={:.3}s)",
|
||||
@@ -847,6 +867,8 @@ async fn broadcast_flac_stream(
|
||||
let mut broadcast_count = 0u64;
|
||||
let mut total_read_time = 0.0f64;
|
||||
let mut read_count = 0u64;
|
||||
let mut encoded_samples = 0u64;
|
||||
let sample_rate_f64 = sample_rate as f64;
|
||||
|
||||
loop {
|
||||
let read_start = std::time::Instant::now();
|
||||
@@ -856,7 +878,12 @@ async fn broadcast_flac_stream(
|
||||
if !accumulator.is_empty() {
|
||||
let bytes = Bytes::from(std::mem::take(&mut accumulator));
|
||||
let audio_ts = *current_timestamp.read().await;
|
||||
if broadcast_tx.send(bytes.clone(), audio_ts).await.is_err() {
|
||||
let segment_dur = *current_duration.read().await;
|
||||
if broadcast_tx
|
||||
.send(bytes.clone(), audio_ts, segment_dur)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
trace!("Broadcast closed before sending final FLAC data");
|
||||
break;
|
||||
}
|
||||
@@ -897,19 +924,20 @@ async fn broadcast_flac_stream(
|
||||
n
|
||||
);
|
||||
|
||||
// Find where to split: position of last sync code (start of last incomplete frame)
|
||||
// Everything before this position contains only complete frames
|
||||
let boundary = flac_frame_utils::find_complete_frames_boundary(&accumulator);
|
||||
// Locate complete frames and total samples they represent
|
||||
let (boundary, total_samples) =
|
||||
flac_frame_utils::find_complete_frames_with_samples(&accumulator);
|
||||
|
||||
trace!(
|
||||
"Buffer state: accumulator={} bytes, boundary={} bytes, will_send={}",
|
||||
"Buffer state: accumulator={} bytes, boundary={} bytes, total_samples={}, will_send={}",
|
||||
accumulator.len(),
|
||||
boundary,
|
||||
boundary >= 1024
|
||||
total_samples,
|
||||
boundary >= 1024 && total_samples > 0
|
||||
);
|
||||
|
||||
// Only broadcast if we have at least one complete frame (1KB minimum to avoid excessive small sends)
|
||||
if boundary >= 1024 {
|
||||
// Only broadcast if we have at least one complete frame (keep 1KB minimum to avoid tiny sends)
|
||||
if boundary >= 1024 && total_samples > 0 {
|
||||
// ╔═══════════════════════════════════════════════════════════════╗
|
||||
// ║ BACKPRESSURE INTELLIGENTE BASÉE SUR LE TIMING ║
|
||||
// ║ ║
|
||||
@@ -921,13 +949,17 @@ async fn broadcast_flac_stream(
|
||||
// ║ Cela crée la backpressure vers TimerBufferNode tout en ║
|
||||
// ║ permettant de dropper les chunks vraiment périmés. ║
|
||||
// ╚═══════════════════════════════════════════════════════════════╝
|
||||
let audio_timestamp = *current_timestamp.read().await;
|
||||
let frame_start_samples = encoded_samples;
|
||||
encoded_samples = encoded_samples.saturating_add(total_samples);
|
||||
let audio_timestamp = frame_start_samples as f64 / sample_rate_f64;
|
||||
let segment_duration = total_samples as f64 / sample_rate_f64;
|
||||
|
||||
if stats_last_log.elapsed() >= Duration::from_secs(1) {
|
||||
trace!(
|
||||
"Broadcaster pacing snapshot: audio_ts={:.3}s buffer_bytes={}",
|
||||
"Broadcaster pacing snapshot: audio_ts={:.3}s buffer_bytes={} samples={} ",
|
||||
audio_timestamp,
|
||||
accumulator.len()
|
||||
accumulator.len(),
|
||||
total_samples
|
||||
);
|
||||
stats_last_log = std::time::Instant::now();
|
||||
}
|
||||
@@ -939,6 +971,13 @@ async fn broadcast_flac_stream(
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok(mut ts) = current_timestamp.try_write() {
|
||||
*ts = audio_timestamp;
|
||||
}
|
||||
if let Ok(mut dur) = current_duration.try_write() {
|
||||
*dur = segment_duration;
|
||||
}
|
||||
|
||||
// Split at boundary to avoid copying - extract prefix, keep suffix
|
||||
let remaining = accumulator.split_off(boundary);
|
||||
let to_send = std::mem::replace(&mut accumulator, remaining);
|
||||
@@ -973,17 +1012,24 @@ async fn broadcast_flac_stream(
|
||||
if !header_captured && bytes.len() >= 4 && &bytes[0..4] == b"fLaC" {
|
||||
*header_cache.write().await = Some(bytes.clone());
|
||||
header_captured = true;
|
||||
trace!("FLAC header captured ({} bytes)", bytes.len());
|
||||
trace!("FLAC header captured ({} bytes), not broadcasting", bytes.len());
|
||||
// Skip broadcasting the header: clients prepend it locally on subscribe
|
||||
continue;
|
||||
}
|
||||
|
||||
let num_receivers = broadcast_tx.receiver_count();
|
||||
match broadcast_tx.send(bytes.clone(), audio_timestamp).await {
|
||||
match broadcast_tx
|
||||
.send(bytes.clone(), audio_timestamp, segment_duration)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
if num_receivers > 0 {
|
||||
trace!(
|
||||
"Broadcasted {} bytes to {} receivers",
|
||||
"Broadcasted {} bytes to {} receivers (ts={:.3}s, dur={:.3}s)",
|
||||
bytes.len(),
|
||||
num_receivers
|
||||
num_receivers,
|
||||
audio_timestamp,
|
||||
segment_duration
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1237,15 +1283,22 @@ struct ByteStreamReader {
|
||||
finished: bool,
|
||||
/// Shared timestamp for broadcaster pacing
|
||||
current_timestamp: Arc<RwLock<f64>>,
|
||||
/// Shared duration for broadcaster pacing
|
||||
current_duration: Arc<RwLock<f64>>,
|
||||
}
|
||||
|
||||
impl ByteStreamReader {
|
||||
fn new(rx: mpsc::Receiver<PcmChunk>, current_timestamp: Arc<RwLock<f64>>) -> Self {
|
||||
fn new(
|
||||
rx: mpsc::Receiver<PcmChunk>,
|
||||
current_timestamp: Arc<RwLock<f64>>,
|
||||
current_duration: Arc<RwLock<f64>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
rx,
|
||||
buffer: VecDeque::new(),
|
||||
finished: false,
|
||||
current_timestamp,
|
||||
current_duration,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1278,10 +1331,13 @@ impl AsyncRead for ByteStreamReader {
|
||||
if chunk.bytes.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Update shared timestamp for broadcaster pacing
|
||||
// Update shared timestamp and duration for broadcaster pacing
|
||||
if let Ok(mut ts) = self.current_timestamp.try_write() {
|
||||
*ts = chunk.timestamp_sec;
|
||||
}
|
||||
if let Ok(mut dur) = self.current_duration.try_write() {
|
||||
*dur = chunk.duration_sec;
|
||||
}
|
||||
self.buffer.extend(chunk.bytes);
|
||||
}
|
||||
Poll::Ready(None) => {
|
||||
|
||||
@@ -92,6 +92,8 @@ struct PcmChunk {
|
||||
bytes: Vec<u8>,
|
||||
/// Timestamp in seconds (from AudioSegment)
|
||||
timestamp_sec: f64,
|
||||
/// Duration in seconds of this PCM chunk (samples / sample_rate)
|
||||
duration_sec: f64,
|
||||
}
|
||||
|
||||
/// Snapshot of track metadata (reuse from streaming_flac_sink)
|
||||
@@ -301,11 +303,13 @@ impl StreamingOggFlacSinkLogic {
|
||||
.take()
|
||||
.ok_or_else(|| AudioError::ProcessingError("PCM receiver already consumed".into()))?;
|
||||
|
||||
// Create shared timestamp for pacing
|
||||
// Create shared timestamp and duration for pacing
|
||||
let current_timestamp = Arc::new(RwLock::new(0.0f64));
|
||||
let current_duration = Arc::new(RwLock::new(0.0f64));
|
||||
|
||||
// Create ByteStreamReader for the encoder
|
||||
let pcm_reader = ByteStreamReader::new(pcm_rx, current_timestamp.clone());
|
||||
let pcm_reader =
|
||||
ByteStreamReader::new(pcm_rx, current_timestamp.clone(), current_duration.clone());
|
||||
|
||||
// Create PCM format
|
||||
let pcm_format = PcmFormat {
|
||||
@@ -323,7 +327,7 @@ impl StreamingOggFlacSinkLogic {
|
||||
|
||||
debug!("OGG-FLAC encoder initialized successfully");
|
||||
|
||||
// Spawn OGG wrapper + broadcaster task with timestamp for pacing
|
||||
// Spawn OGG wrapper + broadcaster task with timestamp and duration for pacing
|
||||
let ogg_broadcast = self.ogg_broadcast.clone();
|
||||
let ogg_header = self.ogg_header.clone();
|
||||
let max_lead = self.broadcast_max_lead_time;
|
||||
@@ -333,6 +337,7 @@ impl StreamingOggFlacSinkLogic {
|
||||
ogg_broadcast,
|
||||
ogg_header,
|
||||
current_timestamp,
|
||||
current_duration,
|
||||
max_lead,
|
||||
)
|
||||
.await
|
||||
@@ -432,17 +437,23 @@ impl NodeLogic for StreamingOggFlacSinkLogic {
|
||||
// Convert chunk to PCM bytes
|
||||
let pcm_bytes = chunk_to_pcm_bytes(&chunk, self.bits_per_sample)?;
|
||||
|
||||
// Calculate exact duration from samples and sample rate
|
||||
let sample_rate = self.sample_rate.expect("sample_rate should be initialized");
|
||||
let duration_sec = chunk.len() as f64 / sample_rate as f64;
|
||||
|
||||
trace!(
|
||||
"Sending PCM chunk: {} bytes, {} samples @ {:.2}s",
|
||||
"Sending PCM chunk: {} bytes, {} samples @ {:.2}s (duration={:.3}s)",
|
||||
pcm_bytes.len(),
|
||||
chunk.len(),
|
||||
seg.timestamp_sec
|
||||
seg.timestamp_sec,
|
||||
duration_sec
|
||||
);
|
||||
|
||||
// Send to FLAC encoder with timestamp
|
||||
// Send to FLAC encoder with timestamp and duration
|
||||
let pcm_chunk = PcmChunk {
|
||||
bytes: pcm_bytes,
|
||||
timestamp_sec: seg.timestamp_sec,
|
||||
duration_sec,
|
||||
};
|
||||
if let Err(e) = self.pcm_tx.send(pcm_chunk).await {
|
||||
warn!("Failed to send PCM data to encoder: {}", e);
|
||||
@@ -626,15 +637,22 @@ struct ByteStreamReader {
|
||||
finished: bool,
|
||||
/// Shared timestamp for broadcaster pacing
|
||||
current_timestamp: Arc<RwLock<f64>>,
|
||||
/// Shared duration for broadcaster pacing
|
||||
current_duration: Arc<RwLock<f64>>,
|
||||
}
|
||||
|
||||
impl ByteStreamReader {
|
||||
fn new(rx: mpsc::Receiver<PcmChunk>, current_timestamp: Arc<RwLock<f64>>) -> Self {
|
||||
fn new(
|
||||
rx: mpsc::Receiver<PcmChunk>,
|
||||
current_timestamp: Arc<RwLock<f64>>,
|
||||
current_duration: Arc<RwLock<f64>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
rx,
|
||||
buffer: VecDeque::new(),
|
||||
finished: false,
|
||||
current_timestamp,
|
||||
current_duration,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -667,10 +685,13 @@ impl AsyncRead for ByteStreamReader {
|
||||
if chunk.bytes.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Update shared timestamp for broadcaster pacing
|
||||
// Update shared timestamp and duration for broadcaster pacing
|
||||
if let Ok(mut ts) = self.current_timestamp.try_write() {
|
||||
*ts = chunk.timestamp_sec;
|
||||
}
|
||||
if let Ok(mut dur) = self.current_duration.try_write() {
|
||||
*dur = chunk.duration_sec;
|
||||
}
|
||||
self.buffer.extend(chunk.bytes);
|
||||
}
|
||||
Poll::Ready(None) => {
|
||||
@@ -784,6 +805,7 @@ async fn broadcast_ogg_flac_stream(
|
||||
broadcast_tx: timed_broadcast::Sender<Bytes>,
|
||||
header_cache: Arc<RwLock<Option<Bytes>>>,
|
||||
current_timestamp: Arc<RwLock<f64>>,
|
||||
current_duration: Arc<RwLock<f64>>,
|
||||
broadcast_max_lead_time: f64,
|
||||
) -> Result<(), AudioError> {
|
||||
trace!(
|
||||
@@ -840,13 +862,21 @@ async fn broadcast_ogg_flac_stream(
|
||||
bos_bytes.len() + comment_bytes.len()
|
||||
);
|
||||
|
||||
// Broadcast header
|
||||
if broadcast_tx.send(bos_bytes.clone(), 0.0).await.is_err() {
|
||||
// Broadcast header (BOS and comment are metadata, not audio, so duration=0.0)
|
||||
if broadcast_tx
|
||||
.send(bos_bytes.clone(), 0.0, 0.0)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
trace!("No receivers for BOS page, terminating broadcast");
|
||||
return Ok(());
|
||||
}
|
||||
total_ogg_bytes += comment_bytes.len() as u64;
|
||||
if broadcast_tx.send(comment_bytes.clone(), 0.0).await.is_err() {
|
||||
if broadcast_tx
|
||||
.send(comment_bytes.clone(), 0.0, 0.0)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
trace!("No receivers for comment page, terminating broadcast");
|
||||
return Ok(());
|
||||
}
|
||||
@@ -867,7 +897,12 @@ async fn broadcast_ogg_flac_stream(
|
||||
let eos_bytes = Bytes::from(eos_page);
|
||||
total_ogg_bytes += eos_bytes.len() as u64;
|
||||
let eos_ts = *current_timestamp.read().await;
|
||||
if broadcast_tx.send(eos_bytes.clone(), eos_ts).await.is_err() {
|
||||
let eos_dur = *current_duration.read().await;
|
||||
if broadcast_tx
|
||||
.send(eos_bytes.clone(), eos_ts, eos_dur)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
trace!("Broadcast closed before sending final EOS page");
|
||||
break;
|
||||
}
|
||||
@@ -876,12 +911,16 @@ async fn broadcast_ogg_flac_stream(
|
||||
flac_accumulator.len()
|
||||
);
|
||||
} else {
|
||||
// Send empty EOS page
|
||||
// Send empty EOS page (metadata page, duration=0.0)
|
||||
let eos_page = ogg_writer.create_page(&[], false, true, false);
|
||||
let eos_bytes = Bytes::from(eos_page);
|
||||
total_ogg_bytes += eos_bytes.len() as u64;
|
||||
let eos_ts = *current_timestamp.read().await;
|
||||
if broadcast_tx.send(eos_bytes.clone(), eos_ts).await.is_err() {
|
||||
if broadcast_tx
|
||||
.send(eos_bytes.clone(), eos_ts, 0.0)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
trace!("Broadcast closed before sending empty EOS page");
|
||||
break;
|
||||
}
|
||||
@@ -1023,9 +1062,13 @@ async fn broadcast_ogg_flac_stream(
|
||||
}
|
||||
|
||||
// Envoyer au broadcast
|
||||
match broadcast_tx.send(ogg_bytes.clone(), audio_timestamp).await {
|
||||
let segment_duration = *current_duration.read().await;
|
||||
match broadcast_tx
|
||||
.send(ogg_bytes.clone(), audio_timestamp, segment_duration)
|
||||
.await
|
||||
{
|
||||
Ok(n) => {
|
||||
trace!("Broadcasted OGG page with 1 FLAC frame ({} bytes), {} samples ({} bytes total with OGG overhead) to {} receivers", first_frame.len(), first_frame_samples, ogg_bytes.len(), n);
|
||||
trace!("Broadcasted OGG page with 1 FLAC frame ({} bytes), {} samples ({} bytes total with OGG overhead) to {} receivers (ts={:.3}s, dur={:.3}s)", first_frame.len(), first_frame_samples, ogg_bytes.len(), n, audio_timestamp, segment_duration);
|
||||
}
|
||||
Err(_) => {
|
||||
trace!("No active receivers for OGG-FLAC broadcast, terminating loop");
|
||||
|
||||
@@ -15,7 +15,7 @@ use std::{
|
||||
};
|
||||
|
||||
use tokio::sync::Notify;
|
||||
use tracing::{trace, warn};
|
||||
use tracing::{trace, info, warn};
|
||||
|
||||
/// Paquet diffusé contenant la charge utile + méta timing.
|
||||
#[derive(Clone)]
|
||||
@@ -40,8 +40,17 @@ impl<T> fmt::Debug for TimedPacket<T> {
|
||||
/// Erreur remontée par `Receiver::try_recv`.
|
||||
#[derive(Debug)]
|
||||
pub enum TryRecvError {
|
||||
/// Aucun paquet n'est disponible pour le moment.
|
||||
Empty,
|
||||
/// 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),
|
||||
/// Le channel est fermé et plus aucun paquet n'est disponible.
|
||||
Closed,
|
||||
}
|
||||
|
||||
@@ -71,7 +80,10 @@ struct State<T> {
|
||||
closed: bool,
|
||||
epoch: u64,
|
||||
epoch_start: Instant,
|
||||
last_segment_end: Option<Instant>,
|
||||
cursors: Vec<Weak<ReceiverCursor>>,
|
||||
initialized: bool,
|
||||
saw_positive_timestamp: bool,
|
||||
}
|
||||
|
||||
impl<T> State<T> {
|
||||
@@ -83,14 +95,19 @@ impl<T> State<T> {
|
||||
closed: false,
|
||||
epoch: 0,
|
||||
epoch_start,
|
||||
last_segment_end: None,
|
||||
cursors: Vec::new(),
|
||||
initialized: false,
|
||||
saw_positive_timestamp: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn purge_expired(&mut self, now: Instant) -> bool {
|
||||
fn purge_expired(&mut self) -> bool {
|
||||
let mut purged = 0u64;
|
||||
while let Some(entry) = self.buffer.front() {
|
||||
if entry.expires_at <= now {
|
||||
if entry.expires_at <= Instant::now() {
|
||||
let delta=Instant::now()-entry.expires_at;
|
||||
info!("TimedBroadcast: purging expired packet (epoch={},delta={})",entry.epoch,delta.as_millis());
|
||||
self.buffer.pop_front();
|
||||
self.head_seq += 1;
|
||||
purged += 1;
|
||||
@@ -220,7 +237,11 @@ impl<T> Clone for Sender<T> {
|
||||
|
||||
impl<T> Sender<T> {
|
||||
/// Diffuse un paquet. Bloque si la capacité est atteinte avec des paquets non périmés.
|
||||
pub async fn send(&self, payload: T, audio_timestamp: f64) -> Result<usize, SendError<T>>
|
||||
///
|
||||
/// Le TTL de chaque paquet est calculé à partir du `epoch_start` courant et du
|
||||
/// `audio_timestamp` fournis, ce qui signifie qu’un receiver en retard finira
|
||||
/// par recevoir un [`TryRecvError::Lagged`] lorsque `expires_at` est dépassé.
|
||||
pub async fn send(&self, payload: T, audio_timestamp: f64, segment_duration: f64) -> Result<usize, SendError<T>>
|
||||
where
|
||||
T: Clone,
|
||||
{
|
||||
@@ -228,7 +249,6 @@ impl<T> Sender<T> {
|
||||
loop {
|
||||
let mut wait_deadline = None;
|
||||
{
|
||||
let now = Instant::now();
|
||||
let mut state = self
|
||||
.inner
|
||||
.state
|
||||
@@ -239,17 +259,64 @@ impl<T> Sender<T> {
|
||||
return Err(SendError(payload.expect("payload already consumed")));
|
||||
}
|
||||
|
||||
if state.purge_expired(now) {
|
||||
self.inner.space_notify.notify_waiters();
|
||||
// Détecter si c'est un TopZero
|
||||
let is_top_zero = audio_timestamp == 0.0;
|
||||
|
||||
// Gérer l'initialisation ET le TopZero ensemble
|
||||
if !state.initialized {
|
||||
let now = Instant::now();
|
||||
if is_top_zero {
|
||||
// Premier paquet = TopZero → epoch commence à 0
|
||||
state.epoch_start = now;
|
||||
state.epoch = 0;
|
||||
info!("TimedBroadcast: initialized with TopZero (epoch=0)");
|
||||
} else {
|
||||
// Premier paquet avec ts > 0 → calculer epoch_start rétroactif
|
||||
let offset = Duration::from_secs_f64(audio_timestamp);
|
||||
state.epoch_start = now.checked_sub(offset).unwrap_or(now);
|
||||
state.epoch = 0;
|
||||
info!(
|
||||
"TimedBroadcast: initialized with ts={:.3}s (epoch=0)",
|
||||
audio_timestamp
|
||||
);
|
||||
}
|
||||
state.initialized = true;
|
||||
state.saw_positive_timestamp = !is_top_zero;
|
||||
} else if is_top_zero {
|
||||
// TopZero sur un channel déjà initialisé
|
||||
let allow_reset = state.saw_positive_timestamp;
|
||||
if allow_reset {
|
||||
let now = Instant::now();
|
||||
state.epoch_start = state
|
||||
.last_segment_end
|
||||
.map(|end| end.max(now))
|
||||
.unwrap_or(now);
|
||||
state.epoch = state.epoch.wrapping_add(1);
|
||||
state.saw_positive_timestamp = false; // Reset pour le prochain cycle
|
||||
info!(
|
||||
"TimedBroadcast: TopZero detected, new epoch={} (had_last_segment={})",
|
||||
state.epoch,
|
||||
state.last_segment_end.is_some()
|
||||
);
|
||||
} else {
|
||||
warn!(
|
||||
"TimedBroadcast: Ignoring duplicate TopZero (epoch={})",
|
||||
state.epoch
|
||||
);
|
||||
}
|
||||
} else if audio_timestamp > 0.0 {
|
||||
state.saw_positive_timestamp = true;
|
||||
}
|
||||
|
||||
if state.prune_consumed() {
|
||||
let consumed = state.prune_consumed();
|
||||
let expired = state.purge_expired();
|
||||
if consumed || expired {
|
||||
self.inner.space_notify.notify_waiters();
|
||||
}
|
||||
|
||||
if state.buffer.len() < self.inner.capacity {
|
||||
let audio_offset = Duration::from_secs_f64(audio_timestamp.max(0.0));
|
||||
let expires_at = state.epoch_start + audio_offset;
|
||||
// Le paquet expire à la fin de son segment audio
|
||||
let expires_at = state.epoch_start + Duration::from_secs_f64(audio_timestamp + segment_duration);
|
||||
let entry = Entry {
|
||||
seq: state.next_seq,
|
||||
expires_at,
|
||||
@@ -259,6 +326,10 @@ impl<T> Sender<T> {
|
||||
};
|
||||
state.next_seq += 1;
|
||||
state.buffer.push_back(entry);
|
||||
|
||||
// Stocker la fin de ce segment pour la continuité temporelle
|
||||
state.last_segment_end = Some(expires_at);
|
||||
|
||||
let receivers = self.inner.receiver_count.load(Ordering::SeqCst);
|
||||
drop(state);
|
||||
self.inner.data_notify.notify_waiters();
|
||||
@@ -304,21 +375,15 @@ impl<T> Sender<T> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Marque un TopZero : incrémente l'epoch pour les paquets suivants.
|
||||
/// Marque un TopZero : DEPRECATED - no-op pour compatibilité.
|
||||
///
|
||||
/// Reset le timer epoch_start sans effacer le buffer. Les paquets
|
||||
/// du morceau précédent continueront à être distribués naturellement.
|
||||
/// Cela évite de perdre les dernières frames FLAC à la transition entre morceaux.
|
||||
/// Le vrai TopZero est maintenant détecté automatiquement dans send()
|
||||
/// quand audio_timestamp == 0.0. Cette méthode est conservée pour
|
||||
/// compatibilité avec le code existant mais ne fait rien.
|
||||
pub fn mark_top_zero(&self) {
|
||||
let mut state = self
|
||||
.inner
|
||||
.state
|
||||
.lock()
|
||||
.expect("timed broadcast mutex poisoned");
|
||||
state.epoch = state.epoch.wrapping_add(1);
|
||||
state.epoch_start = Instant::now();
|
||||
// Ne PAS effacer le buffer - laisser les paquets du morceau précédent
|
||||
// se vider naturellement pour éviter de perdre les dernières frames
|
||||
trace!("TimedBroadcast: mark_top_zero() called but ignored (auto-detection active)");
|
||||
// No-op - TopZero est maintenant détecté automatiquement dans send()
|
||||
// quand audio_timestamp == 0.0
|
||||
}
|
||||
|
||||
/// Nombre actuel de receivers abonnés.
|
||||
@@ -341,6 +406,10 @@ impl<T> Drop for Sender<T> {
|
||||
}
|
||||
|
||||
/// Receiver côté consommateur.
|
||||
///
|
||||
/// Chaque receiver garde son propre curseur `next_seq`. Si le producteur
|
||||
/// recycle un paquet via `purge_expired()` avant que ce curseur ne l’ait lu,
|
||||
/// la prochaine tentative de lecture retournera [`TryRecvError::Lagged`].
|
||||
pub struct Receiver<T> {
|
||||
inner: Arc<Inner<T>>,
|
||||
next_seq: u64,
|
||||
@@ -366,8 +435,7 @@ where
|
||||
return Err(TryRecvError::Closed);
|
||||
}
|
||||
|
||||
let now = Instant::now();
|
||||
if state.purge_expired(now) {
|
||||
if state.purge_expired() {
|
||||
self.inner.space_notify.notify_waiters();
|
||||
}
|
||||
|
||||
@@ -401,6 +469,12 @@ where
|
||||
}
|
||||
|
||||
/// Version synchrone utilisée dans `poll_read`.
|
||||
///
|
||||
/// # 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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user