debuggage des stream
This commit is contained in:
108
pmoaudio-ext/src/sinks/broadcast_pacing.rs
Normal file
108
pmoaudio-ext/src/sinks/broadcast_pacing.rs
Normal file
@@ -0,0 +1,108 @@
|
||||
//! Shared broadcast pacing logic for streaming sinks.
|
||||
//!
|
||||
//! Provides intelligent backpressure based on audio timing:
|
||||
//! - Detects TopZeroSync (when audio timestamp resets to 0)
|
||||
//! - Drops frames that are late (audio_ts < elapsed)
|
||||
//! - Paces broadcast to match audio playback rate
|
||||
|
||||
use std::time::Instant;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
/// Error returned when a frame should be skipped (too late)
|
||||
#[derive(Debug)]
|
||||
pub struct SkipFrame;
|
||||
|
||||
/// Manages broadcast pacing with TopZeroSync detection
|
||||
pub struct BroadcastPacer {
|
||||
/// Start time (reset on TopZeroSync)
|
||||
start_time: Instant,
|
||||
/// Maximum allowed lead time before sleeping (0 = no pacing)
|
||||
max_lead_time: f64,
|
||||
/// Label for logging (e.g., "FLAC" or "OGG")
|
||||
label: String,
|
||||
}
|
||||
|
||||
impl BroadcastPacer {
|
||||
/// Create a new broadcast pacer
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `max_lead_time` - Maximum lead time in seconds (0 = no pacing)
|
||||
/// * `label` - Label for logging
|
||||
pub fn new(max_lead_time: f64, label: impl Into<String>) -> Self {
|
||||
Self {
|
||||
start_time: Instant::now(),
|
||||
max_lead_time: max_lead_time.max(0.0),
|
||||
label: label.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check timing and apply pacing
|
||||
///
|
||||
/// This function:
|
||||
/// 1. Detects TopZeroSync (audio_timestamp < 0.1 after >1s) and resets timer
|
||||
/// 2. Drops frames that are late (audio_ts < elapsed)
|
||||
/// 3. Sleeps if too far ahead (lead_time > max_lead_time)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// - `Ok(())` if frame is on time or successfully paced
|
||||
/// - `Err(SkipFrame)` if frame is too late and should be dropped
|
||||
pub async fn check_and_pace(&mut self, audio_timestamp: f64) -> Result<(), SkipFrame> {
|
||||
// ╔═══════════════════════════════════════════════════════════════╗
|
||||
// ║ 1. DÉTECTION TopZeroSync ║
|
||||
// ║ Si le timestamp revient proche de 0, reset l'horloge ║
|
||||
// ╚═══════════════════════════════════════════════════════════════╝
|
||||
let elapsed_since_start = self.start_time.elapsed().as_secs_f64();
|
||||
if audio_timestamp < 0.1 && elapsed_since_start > 1.0 {
|
||||
self.start_time = Instant::now();
|
||||
info!(
|
||||
"{} broadcaster: TopZeroSync detected, resetting timer",
|
||||
self.label
|
||||
);
|
||||
}
|
||||
|
||||
// ╔═══════════════════════════════════════════════════════════════╗
|
||||
// ║ 2. CALCUL DU LEAD TIME ║
|
||||
// ║ lead_time > 0 : en avance (OK) ║
|
||||
// ║ lead_time < 0 : en retard (SKIP) ║
|
||||
// ╚═══════════════════════════════════════════════════════════════╝
|
||||
let elapsed = self.start_time.elapsed().as_secs_f64();
|
||||
let lead_time = audio_timestamp - elapsed;
|
||||
|
||||
// ╔═══════════════════════════════════════════════════════════════╗
|
||||
// ║ 3. DROP FRAMES EN RETARD (tolérance zéro) ║
|
||||
// ╚═══════════════════════════════════════════════════════════════╝
|
||||
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);
|
||||
}
|
||||
|
||||
// ╔═══════════════════════════════════════════════════════════════╗
|
||||
// ║ 4. 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 {
|
||||
debug!(
|
||||
"{} 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
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -93,10 +93,16 @@ impl NodeLogic for FlacCacheSinkLogic {
|
||||
|
||||
loop {
|
||||
// Attendre le premier chunk audio pour cette track
|
||||
tracing::debug!("FlacCacheSink: Waiting for first audio chunk (track_number={})", track_number);
|
||||
let (first_segment, track_metadata) = if let Some(metadata) = next_track_metadata.take() {
|
||||
tracing::debug!(
|
||||
"FlacCacheSink: Waiting for first audio chunk (track_number={})",
|
||||
track_number
|
||||
);
|
||||
let (first_segment, track_metadata) = if let Some(metadata) = next_track_metadata.take()
|
||||
{
|
||||
// On a déjà reçu le TrackBoundary en Phase 3 de la track précédente
|
||||
tracing::debug!("FlacCacheSink: Using TrackBoundary metadata from previous track's Phase 3");
|
||||
tracing::debug!(
|
||||
"FlacCacheSink: Using TrackBoundary metadata from previous track's Phase 3"
|
||||
);
|
||||
// Attendre juste le premier chunk
|
||||
match wait_for_first_audio_chunk(&mut rx, &stop_token).await {
|
||||
Ok(chunk) => {
|
||||
@@ -296,18 +302,30 @@ impl NodeLogic for FlacCacheSinkLogic {
|
||||
url
|
||||
}
|
||||
Err(e) if e.is_transient() => {
|
||||
tracing::debug!("FlacCacheSink: Transient error getting cover URL for pk {}: {}", pk, e);
|
||||
tracing::debug!(
|
||||
"FlacCacheSink: Transient error getting cover URL for pk {}: {}",
|
||||
pk,
|
||||
e
|
||||
);
|
||||
None
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("FlacCacheSink: Cannot obtain cover URL for audio asset {}: {}", pk, e);
|
||||
tracing::warn!(
|
||||
"FlacCacheSink: Cannot obtain cover URL for audio asset {}: {}",
|
||||
pk,
|
||||
e
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(cover_url) = url {
|
||||
tracing::debug!("FlacCacheSink: Attempting to cache cover from URL: {}", cover_url);
|
||||
match self.covers
|
||||
tracing::debug!(
|
||||
"FlacCacheSink: Attempting to cache cover from URL: {}",
|
||||
cover_url
|
||||
);
|
||||
match self
|
||||
.covers
|
||||
.add_from_url(&cover_url, self.collection.as_deref())
|
||||
.await
|
||||
{
|
||||
@@ -323,7 +341,11 @@ impl NodeLogic for FlacCacheSinkLogic {
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("FlacCacheSink: Failed to cache cover for audio asset {}: {}", pk, e);
|
||||
tracing::warn!(
|
||||
"FlacCacheSink: Failed to cache cover for audio asset {}: {}",
|
||||
pk,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -339,20 +361,27 @@ impl NodeLogic for FlacCacheSinkLogic {
|
||||
playlist_handle.push(pk.clone()).await.map_err(|e| {
|
||||
AudioError::ProcessingError(format!("Failed to add to playlist: {}", e))
|
||||
})?;
|
||||
tracing::info!("FlacCacheSink: Successfully pushed to playlist in {:?}", push_start.elapsed());
|
||||
tracing::info!(
|
||||
"FlacCacheSink: Successfully pushed to playlist in {:?}",
|
||||
push_start.elapsed()
|
||||
);
|
||||
}
|
||||
|
||||
// Si EndOfStream a été reçu pendant le prebuffer, on a déjà tout traité
|
||||
// Il faut juste attendre que le pump se termine et retourner
|
||||
if end_of_stream_received {
|
||||
tracing::debug!("FlacCacheSink: EndOfStream was received during prebuffer, track complete");
|
||||
tracing::debug!(
|
||||
"FlacCacheSink: EndOfStream was received during prebuffer, track complete"
|
||||
);
|
||||
drop(pump_handle);
|
||||
track_number += 1;
|
||||
continue; // Passer à la track suivante (qui n'arrivera pas car EndOfStream)
|
||||
}
|
||||
|
||||
// Phase 3: Continuer à dispatcher jusqu'au TrackBoundary
|
||||
tracing::debug!("FlacCacheSink: Continuing dispatch until TrackBoundary (pump runs in background)");
|
||||
tracing::debug!(
|
||||
"FlacCacheSink: Continuing dispatch until TrackBoundary (pump runs in background)"
|
||||
);
|
||||
let mut track_tx = track_tx_opt; // track_tx_opt contient Some(track_tx) car end_of_stream_received est false
|
||||
let mut pump_handle = Some(pump_handle);
|
||||
let mut pump_closed = false;
|
||||
@@ -384,7 +413,9 @@ impl NodeLogic for FlacCacheSinkLogic {
|
||||
if tx.send(segment).await.is_err() {
|
||||
// Le pump a fermé son channel - cela peut arriver si le fichier
|
||||
// était déjà en cache (add_from_reader retourne immédiatement)
|
||||
tracing::debug!("FlacCacheSink: pump closed track_tx, checking pump status");
|
||||
tracing::debug!(
|
||||
"FlacCacheSink: pump closed track_tx, checking pump status"
|
||||
);
|
||||
drop(track_tx.take());
|
||||
|
||||
// Attendre que le pump se termine et vérifier le résultat
|
||||
@@ -397,13 +428,21 @@ impl NodeLogic for FlacCacheSinkLogic {
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
// Le pump a rencontré une erreur
|
||||
tracing::error!("FlacCacheSink: pump died with error: {}", e);
|
||||
tracing::error!(
|
||||
"FlacCacheSink: pump died with error: {}",
|
||||
e
|
||||
);
|
||||
return Err(e);
|
||||
}
|
||||
Err(e) => {
|
||||
// Le pump task a paniqué
|
||||
tracing::error!("FlacCacheSink: pump task panicked: {}", e);
|
||||
return Err(AudioError::ProcessingError("Pump task panicked".to_string()));
|
||||
tracing::error!(
|
||||
"FlacCacheSink: pump task panicked: {}",
|
||||
e
|
||||
);
|
||||
return Err(AudioError::ProcessingError(
|
||||
"Pump task panicked".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -534,7 +573,9 @@ async fn wait_for_first_audio_chunk(
|
||||
_AudioSegment::Sync(marker) => match &**marker {
|
||||
SyncMarker::TrackBoundary { .. } => {
|
||||
// On ne devrait pas recevoir de TrackBoundary ici car on l'a déjà
|
||||
tracing::warn!("FlacCacheSink: Unexpected TrackBoundary while waiting for first chunk");
|
||||
tracing::warn!(
|
||||
"FlacCacheSink: Unexpected TrackBoundary while waiting for first chunk"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
SyncMarker::EndOfStream => {
|
||||
|
||||
@@ -374,8 +374,8 @@ mod tests {
|
||||
// Real-world example: first frame at 0, false positive at 7
|
||||
let data = vec![
|
||||
0xFF, 0xF8, 0xC9, 0xA8, // Valid frame header at position 0
|
||||
0x00, 0x8D, 0x4C,
|
||||
0xFF, 0xFE, 0x00, 0x00, // False positive at position 7 (0xFE has reserved bit set)
|
||||
0x00, 0x8D, 0x4C, 0xFF, 0xFE, 0x00,
|
||||
0x00, // False positive at position 7 (0xFE has reserved bit set)
|
||||
];
|
||||
|
||||
// Position 0 should be valid
|
||||
|
||||
@@ -10,6 +10,9 @@ mod flac_cache_sink;
|
||||
#[cfg(feature = "cache-sink")]
|
||||
pub use flac_cache_sink::{FlacCacheSink, FlacCacheSinkStats, TrackStats};
|
||||
|
||||
#[cfg(feature = "http-stream")]
|
||||
mod broadcast_pacing;
|
||||
|
||||
#[cfg(feature = "http-stream")]
|
||||
mod flac_frame_utils;
|
||||
|
||||
@@ -17,10 +20,12 @@ mod flac_frame_utils;
|
||||
mod streaming_flac_sink;
|
||||
|
||||
#[cfg(feature = "http-stream")]
|
||||
pub use streaming_flac_sink::{StreamingFlacSink, StreamHandle, MetadataSnapshot, FlacClientStream, IcyClientStream};
|
||||
pub use streaming_flac_sink::{
|
||||
FlacClientStream, IcyClientStream, MetadataSnapshot, StreamHandle, StreamingFlacSink,
|
||||
};
|
||||
|
||||
#[cfg(feature = "http-stream")]
|
||||
mod streaming_ogg_flac_sink;
|
||||
|
||||
#[cfg(feature = "http-stream")]
|
||||
pub use streaming_ogg_flac_sink::{StreamingOggFlacSink, OggFlacStreamHandle, OggFlacClientStream};
|
||||
pub use streaming_ogg_flac_sink::{OggFlacClientStream, OggFlacStreamHandle, StreamingOggFlacSink};
|
||||
|
||||
@@ -62,7 +62,7 @@ use std::sync::Arc;
|
||||
use std::task::{Context, Poll};
|
||||
use std::time::Duration;
|
||||
|
||||
use super::flac_frame_utils;
|
||||
use super::{broadcast_pacing::BroadcastPacer, flac_frame_utils};
|
||||
use async_trait::async_trait;
|
||||
use bytes::Bytes;
|
||||
use pmoaudio::{
|
||||
@@ -81,16 +81,29 @@ use tracing::{debug, error, info, trace, warn};
|
||||
/// Standard value used by most streaming servers.
|
||||
const DEFAULT_ICY_METAINT: usize = 16000;
|
||||
|
||||
/// Broadcast channel capacity for FLAC bytes.
|
||||
/// Set to 128 to provide ~10 seconds of buffer for network jitter.
|
||||
/// With TimerNode pacing the stream to real-time, this is sufficient
|
||||
/// while keeping metadata synchronized (larger buffers cause metadata drift).
|
||||
const BROADCAST_CAPACITY: usize = 128;
|
||||
|
||||
/// Maximum lead time for HTTP broadcast pacing (in seconds).
|
||||
/// Default maximum lead time for HTTP broadcast pacing (in seconds).
|
||||
/// The broadcaster will sleep if it's ahead of real-time by more than this amount.
|
||||
/// This is much smaller than the pipeline TimerNode's 3.0s to provide tighter control.
|
||||
const BROADCAST_MAX_LEAD_TIME: f64 = 0.5;
|
||||
const DEFAULT_BROADCAST_MAX_LEAD_TIME: f64 = 0.5;
|
||||
|
||||
/// 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)
|
||||
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
|
||||
}
|
||||
|
||||
/// PCM chunk with audio data and timestamp for precise pacing.
|
||||
#[derive(Debug)]
|
||||
@@ -190,7 +203,11 @@ impl StreamHandle {
|
||||
/// Subscribe to the FLAC stream with custom ICY metadata interval.
|
||||
pub fn subscribe_icy_with_interval(&self, metaint: usize) -> IcyClientStream {
|
||||
let count = self.active_clients.fetch_add(1, Ordering::SeqCst);
|
||||
debug!("New ICY client subscribed (total: {}, metaint: {})", count + 1, metaint);
|
||||
debug!(
|
||||
"New ICY client subscribed (total: {}, metaint: {})",
|
||||
count + 1,
|
||||
metaint
|
||||
);
|
||||
|
||||
IcyClientStream {
|
||||
rx: self.flac_broadcast.subscribe(),
|
||||
@@ -254,7 +271,10 @@ impl AsyncRead for FlacClientStream {
|
||||
|
||||
if let Some(header) = header_opt {
|
||||
self.buffer.extend(header.iter());
|
||||
info!("Sending cached FLAC header to new client ({} bytes)", header.len());
|
||||
info!(
|
||||
"Sending cached FLAC header to new client ({} bytes)",
|
||||
header.len()
|
||||
);
|
||||
self.state = FlacStreamState::Streaming;
|
||||
continue; // Now copy header to output buffer
|
||||
} else {
|
||||
@@ -413,7 +433,10 @@ impl AsyncRead for IcyClientStream {
|
||||
|
||||
if let Some(header) = header_opt {
|
||||
self.buffer.extend(header.iter());
|
||||
info!("Sending cached FLAC header to new ICY client ({} bytes)", header.len());
|
||||
info!(
|
||||
"Sending cached FLAC header to new ICY client ({} bytes)",
|
||||
header.len()
|
||||
);
|
||||
self.state = FlacStreamState::Streaming;
|
||||
continue; // Now copy header to output buffer
|
||||
} else {
|
||||
@@ -533,6 +556,7 @@ struct StreamingFlacSinkLogic {
|
||||
flac_header: Arc<RwLock<Option<Bytes>>>,
|
||||
encoder_state: Option<EncoderState>,
|
||||
sample_rate: Option<u32>,
|
||||
broadcast_max_lead_time: f64,
|
||||
}
|
||||
|
||||
impl StreamingFlacSinkLogic {
|
||||
@@ -542,12 +566,16 @@ impl StreamingFlacSinkLogic {
|
||||
return Ok(()); // Already initialized
|
||||
}
|
||||
|
||||
info!("Initializing FLAC encoder with sample rate: {} Hz", sample_rate);
|
||||
info!(
|
||||
"Initializing FLAC encoder with sample rate: {} Hz",
|
||||
sample_rate
|
||||
);
|
||||
|
||||
// Take the PCM receiver (we only initialize once)
|
||||
let pcm_rx = self.pcm_rx.take().ok_or_else(|| {
|
||||
AudioError::ProcessingError("PCM receiver already consumed".into())
|
||||
})?;
|
||||
let pcm_rx = self
|
||||
.pcm_rx
|
||||
.take()
|
||||
.ok_or_else(|| AudioError::ProcessingError("PCM receiver already consumed".into()))?;
|
||||
|
||||
// Create shared timestamp for pacing
|
||||
let current_timestamp = Arc::new(RwLock::new(0.0f64));
|
||||
@@ -565,15 +593,26 @@ impl StreamingFlacSinkLogic {
|
||||
// Start the FLAC encoder
|
||||
let flac_stream = encode_flac_stream(pcm_reader, pcm_format, self.encoder_options.clone())
|
||||
.await
|
||||
.map_err(|e| AudioError::ProcessingError(format!("Failed to start FLAC encoder: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
AudioError::ProcessingError(format!("Failed to start FLAC encoder: {}", e))
|
||||
})?;
|
||||
|
||||
info!("FLAC encoder initialized successfully");
|
||||
|
||||
// Spawn broadcaster task with timestamp for pacing
|
||||
let flac_broadcast = self.flac_broadcast.clone();
|
||||
let flac_header = self.flac_header.clone();
|
||||
let max_lead = self.broadcast_max_lead_time;
|
||||
let broadcaster_task = tokio::spawn(async move {
|
||||
if let Err(e) = broadcast_flac_stream(flac_stream, flac_broadcast, flac_header, current_timestamp).await {
|
||||
if let Err(e) = broadcast_flac_stream(
|
||||
flac_stream,
|
||||
flac_broadcast,
|
||||
flac_header,
|
||||
current_timestamp,
|
||||
max_lead,
|
||||
)
|
||||
.await
|
||||
{
|
||||
error!("Broadcaster task error: {}", e);
|
||||
}
|
||||
});
|
||||
@@ -682,10 +721,19 @@ impl NodeLogic for StreamingFlacSinkLogic {
|
||||
bytes: pcm_bytes,
|
||||
timestamp_sec: seg.timestamp_sec,
|
||||
};
|
||||
let send_start = std::time::Instant::now();
|
||||
if let Err(e) = self.pcm_tx.send(pcm_chunk).await {
|
||||
warn!("Failed to send PCM data to encoder: {}", e);
|
||||
break;
|
||||
}
|
||||
let send_duration = send_start.elapsed();
|
||||
if send_duration.as_millis() >= 50 {
|
||||
debug!(
|
||||
"StreamingFlacSink: pcm_tx send blocked for {:.3}s (ts={:.3}s)",
|
||||
send_duration.as_secs_f64(),
|
||||
seg.timestamp_sec
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_AudioSegment::Sync(marker) => {
|
||||
@@ -728,7 +776,6 @@ impl NodeLogic for StreamingFlacSinkLogic {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Broadcaster task: reads FLAC bytes from encoder and broadcasts to all clients.
|
||||
/// Implements precise real-time pacing based on audio timestamps.
|
||||
/// Ensures data is sent at FLAC frame boundaries to prevent sync errors in strict decoders like FFPlay.
|
||||
@@ -737,8 +784,12 @@ async fn broadcast_flac_stream(
|
||||
broadcast_tx: broadcast::Sender<Bytes>,
|
||||
header_cache: Arc<RwLock<Option<Bytes>>>,
|
||||
current_timestamp: Arc<RwLock<f64>>,
|
||||
broadcast_max_lead_time: f64,
|
||||
) -> Result<(), AudioError> {
|
||||
info!("Broadcaster task started with FLAC frame boundary detection");
|
||||
info!(
|
||||
"Broadcaster task started with FLAC frame boundary detection (max_lead={:.3}s)",
|
||||
broadcast_max_lead_time
|
||||
);
|
||||
|
||||
// Use larger read buffer (16KB) to reduce syscalls and accumulator for frame boundary detection
|
||||
// The accumulator is necessary to ensure we only send complete FLAC frames
|
||||
@@ -746,9 +797,17 @@ async fn broadcast_flac_stream(
|
||||
let mut accumulator = Vec::with_capacity(32768); // Pre-allocate to reduce reallocations
|
||||
let mut total_bytes = 0u64;
|
||||
let mut header_captured = false;
|
||||
let start_time = std::time::Instant::now();
|
||||
let mut pacer = BroadcastPacer::new(broadcast_max_lead_time, "FLAC");
|
||||
let mut stats_last_log = std::time::Instant::now();
|
||||
|
||||
// Timing instrumentation for burst detection
|
||||
let mut last_broadcast_time = std::time::Instant::now();
|
||||
let mut broadcast_count = 0u64;
|
||||
let mut total_read_time = 0.0f64;
|
||||
let mut read_count = 0u64;
|
||||
|
||||
loop {
|
||||
let read_start = std::time::Instant::now();
|
||||
match flac_stream.read(&mut read_buffer).await {
|
||||
Ok(0) => {
|
||||
// EOF - send any remaining data
|
||||
@@ -760,14 +819,38 @@ async fn broadcast_flac_stream(
|
||||
break;
|
||||
}
|
||||
Ok(n) => {
|
||||
let read_duration = read_start.elapsed().as_secs_f64();
|
||||
read_count += 1;
|
||||
total_read_time += read_duration;
|
||||
|
||||
if read_duration > 0.01 {
|
||||
debug!(
|
||||
"FLAC: flac_stream.read() took {:.3}s for {} bytes (avg: {:.3}s over {} reads)",
|
||||
read_duration,
|
||||
n,
|
||||
total_read_time / read_count as f64,
|
||||
read_count
|
||||
);
|
||||
}
|
||||
|
||||
total_bytes += n as u64;
|
||||
if total_bytes % 100000 == 0 || total_bytes < 10000 {
|
||||
trace!("Read {} bytes from FLAC encoder (total: {})", n, total_bytes);
|
||||
trace!(
|
||||
"Read {} bytes from FLAC encoder (total: {})",
|
||||
n,
|
||||
total_bytes
|
||||
);
|
||||
}
|
||||
|
||||
// Append to accumulator
|
||||
accumulator.extend_from_slice(&read_buffer[..n]);
|
||||
|
||||
trace!(
|
||||
"FLAC: accumulator now {} bytes after reading {} bytes",
|
||||
accumulator.len(),
|
||||
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);
|
||||
@@ -781,18 +864,33 @@ async fn broadcast_flac_stream(
|
||||
|
||||
// Only broadcast if we have at least one complete frame (1KB minimum to avoid excessive small sends)
|
||||
if boundary >= 1024 {
|
||||
// Precise pacing based on audio timestamp
|
||||
// ╔═══════════════════════════════════════════════════════════════╗
|
||||
// ║ BACKPRESSURE INTELLIGENTE BASÉE SUR LE TIMING ║
|
||||
// ║ ║
|
||||
// ║ BroadcastPacer gère : ║
|
||||
// ║ 1. Détection TopZeroSync (audio_ts < 0.1) ║
|
||||
// ║ 2. Drop des chunks en retard (audio_ts < elapsed) ║
|
||||
// ║ 3. Pacing pour contrôler le débit (max_lead_time) ║
|
||||
// ║ ║
|
||||
// ║ 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 elapsed = start_time.elapsed().as_secs_f64();
|
||||
let lead_time = audio_timestamp - elapsed;
|
||||
|
||||
if lead_time > BROADCAST_MAX_LEAD_TIME {
|
||||
let sleep_duration = lead_time - BROADCAST_MAX_LEAD_TIME;
|
||||
if stats_last_log.elapsed() >= Duration::from_secs(1) {
|
||||
debug!(
|
||||
"Broadcaster pacing: sleeping {:.3}s (audio_ts={:.3}s, elapsed={:.3}s, lead={:.3}s)",
|
||||
sleep_duration, audio_timestamp, elapsed, lead_time
|
||||
"Broadcaster pacing snapshot: audio_ts={:.3}s buffer_bytes={}",
|
||||
audio_timestamp,
|
||||
accumulator.len()
|
||||
);
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs_f64(sleep_duration)).await;
|
||||
stats_last_log = std::time::Instant::now();
|
||||
}
|
||||
|
||||
// Check timing et apply pacing (skip si en retard)
|
||||
if pacer.check_and_pace(audio_timestamp).await.is_err() {
|
||||
// Chunk en retard : vider l'accumulator et continuer
|
||||
accumulator.clear();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Split at boundary to avoid copying - extract prefix, keep suffix
|
||||
@@ -800,6 +898,31 @@ async fn broadcast_flac_stream(
|
||||
let to_send = std::mem::replace(&mut accumulator, remaining);
|
||||
let bytes = Bytes::from(to_send);
|
||||
|
||||
// Measure broadcast interval for burst detection
|
||||
let broadcast_interval = last_broadcast_time.elapsed().as_secs_f64();
|
||||
last_broadcast_time = std::time::Instant::now();
|
||||
broadcast_count += 1;
|
||||
|
||||
// Log if interval is unusual (too short = burst, too long = stall)
|
||||
if broadcast_interval < 0.01 || broadcast_interval > 0.1 {
|
||||
debug!(
|
||||
"FLAC: broadcast interval {:.3}s ({}ms) - size={} bytes (count={})",
|
||||
broadcast_interval,
|
||||
(broadcast_interval * 1000.0) as u32,
|
||||
bytes.len(),
|
||||
broadcast_count
|
||||
);
|
||||
}
|
||||
|
||||
// Periodic stats
|
||||
if broadcast_count % 100 == 0 {
|
||||
debug!(
|
||||
"FLAC: {} broadcasts sent, accumulator={} bytes remaining",
|
||||
broadcast_count,
|
||||
accumulator.len()
|
||||
);
|
||||
}
|
||||
|
||||
// Capture first chunk as header if it contains "fLaC"
|
||||
if !header_captured && bytes.len() >= 4 && &bytes[0..4] == b"fLaC" {
|
||||
*header_cache.write().await = Some(bytes.clone());
|
||||
@@ -812,7 +935,11 @@ async fn broadcast_flac_stream(
|
||||
// No receivers, but that's okay - clients may not be connected yet
|
||||
trace!("No active receivers for FLAC broadcast: {}", e);
|
||||
} else if num_receivers > 0 {
|
||||
trace!("Broadcasted {} bytes to {} receivers", bytes.len(), num_receivers);
|
||||
trace!(
|
||||
"Broadcasted {} bytes to {} receivers",
|
||||
bytes.len(),
|
||||
num_receivers
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -857,9 +984,19 @@ impl StreamingFlacSink {
|
||||
/// A tuple of `(sink, handle)` where:
|
||||
/// - `sink` is added to the audio pipeline
|
||||
/// - `handle` is used by HTTP handlers to serve streams
|
||||
pub fn new(
|
||||
pub fn new(encoder_options: EncoderOptions, bits_per_sample: u8) -> (Self, StreamHandle) {
|
||||
Self::with_max_broadcast_lead(
|
||||
encoder_options,
|
||||
bits_per_sample,
|
||||
DEFAULT_BROADCAST_MAX_LEAD_TIME,
|
||||
)
|
||||
}
|
||||
|
||||
/// Create a sink with a custom broadcast pacing limit.
|
||||
pub fn with_max_broadcast_lead(
|
||||
encoder_options: EncoderOptions,
|
||||
bits_per_sample: u8,
|
||||
broadcast_max_lead_time: f64,
|
||||
) -> (Self, StreamHandle) {
|
||||
// Validate bit depth
|
||||
if ![16, 24, 32].contains(&bits_per_sample) {
|
||||
@@ -872,8 +1009,15 @@ impl StreamingFlacSink {
|
||||
// Shared metadata
|
||||
let metadata = Arc::new(RwLock::new(MetadataSnapshot::default()));
|
||||
|
||||
// Calculate broadcast capacity based on max_lead_time
|
||||
let broadcast_capacity = calculate_broadcast_capacity(broadcast_max_lead_time);
|
||||
info!(
|
||||
"StreamingFlacSink: using broadcast capacity of {} items (max_lead_time={:.1}s)",
|
||||
broadcast_capacity, broadcast_max_lead_time
|
||||
);
|
||||
|
||||
// Broadcast channel for FLAC bytes
|
||||
let (flac_broadcast, _) = broadcast::channel(BROADCAST_CAPACITY);
|
||||
let (flac_broadcast, _) = broadcast::channel(broadcast_capacity);
|
||||
|
||||
// FLAC header cache
|
||||
let flac_header = Arc::new(RwLock::new(None));
|
||||
@@ -900,6 +1044,7 @@ impl StreamingFlacSink {
|
||||
flac_header,
|
||||
encoder_state: None,
|
||||
sample_rate: None,
|
||||
broadcast_max_lead_time: broadcast_max_lead_time.max(0.0),
|
||||
};
|
||||
|
||||
let sink = Self {
|
||||
|
||||
@@ -53,7 +53,7 @@ use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
use super::flac_frame_utils;
|
||||
use super::{broadcast_pacing::BroadcastPacer, flac_frame_utils};
|
||||
use async_trait::async_trait;
|
||||
use bytes::Bytes;
|
||||
use pmoaudio::{
|
||||
@@ -68,13 +68,18 @@ use tokio::sync::{broadcast, mpsc, RwLock};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{debug, error, info, trace, warn};
|
||||
|
||||
/// Broadcast channel capacity for OGG-FLAC bytes.
|
||||
/// Same as StreamingFlacSink for consistency.
|
||||
const BROADCAST_CAPACITY: usize = 128;
|
||||
/// Default maximum lead time for HTTP broadcast pacing (in seconds).
|
||||
const DEFAULT_BROADCAST_MAX_LEAD_TIME: f64 = 0.0;
|
||||
|
||||
/// Maximum lead time for HTTP broadcast pacing (in seconds).
|
||||
/// The broadcaster will sleep if it's ahead of real-time by more than this amount.
|
||||
const BROADCAST_MAX_LEAD_TIME: f64 = 0.5;
|
||||
/// Calculate broadcast channel capacity based on max lead time.
|
||||
///
|
||||
/// Estimate: ~20 OGG pages per second (assuming 50ms chunks).
|
||||
/// Minimum capacity: 100 items for buffering even with 0 lead time.
|
||||
fn calculate_broadcast_capacity(max_lead_time: f64) -> usize {
|
||||
let estimated_items_per_second = 20.0;
|
||||
let capacity = (max_lead_time * estimated_items_per_second) as usize;
|
||||
capacity.max(100) // Minimum 100 items
|
||||
}
|
||||
|
||||
/// PCM chunk with audio data and timestamp for precise pacing.
|
||||
#[derive(Debug)]
|
||||
@@ -167,7 +172,10 @@ impl AsyncRead for OggFlacClientStream {
|
||||
|
||||
if let Some(header) = header_opt {
|
||||
self.buffer.extend(header.iter());
|
||||
info!("Sending cached OGG-FLAC header to new client ({} bytes)", header.len());
|
||||
info!(
|
||||
"Sending cached OGG-FLAC header to new client ({} bytes)",
|
||||
header.len()
|
||||
);
|
||||
self.state = OggFlacStreamState::Streaming;
|
||||
continue; // Now copy header to output buffer
|
||||
} else {
|
||||
@@ -203,7 +211,7 @@ impl AsyncRead for OggFlacClientStream {
|
||||
// Schedule a wakeup after a small delay to avoid busy-loop polling.
|
||||
let waker = cx.waker().clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
|
||||
tokio::time::sleep(tokio::time::Duration::from_micros(100)).await;
|
||||
waker.wake();
|
||||
});
|
||||
return Poll::Pending;
|
||||
@@ -248,6 +256,7 @@ struct StreamingOggFlacSinkLogic {
|
||||
ogg_header: Arc<RwLock<Option<Bytes>>>,
|
||||
encoder_state: Option<EncoderState>,
|
||||
sample_rate: Option<u32>,
|
||||
broadcast_max_lead_time: f64,
|
||||
}
|
||||
|
||||
impl StreamingOggFlacSinkLogic {
|
||||
@@ -257,12 +266,16 @@ impl StreamingOggFlacSinkLogic {
|
||||
return Ok(()); // Already initialized
|
||||
}
|
||||
|
||||
info!("Initializing OGG-FLAC encoder with sample rate: {} Hz", sample_rate);
|
||||
info!(
|
||||
"Initializing OGG-FLAC encoder with sample rate: {} Hz",
|
||||
sample_rate
|
||||
);
|
||||
|
||||
// Take the PCM receiver (we only initialize once)
|
||||
let pcm_rx = self.pcm_rx.take().ok_or_else(|| {
|
||||
AudioError::ProcessingError("PCM receiver already consumed".into())
|
||||
})?;
|
||||
let pcm_rx = self
|
||||
.pcm_rx
|
||||
.take()
|
||||
.ok_or_else(|| AudioError::ProcessingError("PCM receiver already consumed".into()))?;
|
||||
|
||||
// Create shared timestamp for pacing
|
||||
let current_timestamp = Arc::new(RwLock::new(0.0f64));
|
||||
@@ -280,15 +293,26 @@ impl StreamingOggFlacSinkLogic {
|
||||
// Start the FLAC encoder
|
||||
let flac_stream = encode_flac_stream(pcm_reader, pcm_format, self.encoder_options.clone())
|
||||
.await
|
||||
.map_err(|e| AudioError::ProcessingError(format!("Failed to start FLAC encoder: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
AudioError::ProcessingError(format!("Failed to start FLAC encoder: {}", e))
|
||||
})?;
|
||||
|
||||
info!("OGG-FLAC encoder initialized successfully");
|
||||
|
||||
// Spawn OGG wrapper + broadcaster task with timestamp for pacing
|
||||
let ogg_broadcast = self.ogg_broadcast.clone();
|
||||
let ogg_header = self.ogg_header.clone();
|
||||
let max_lead = self.broadcast_max_lead_time;
|
||||
let broadcaster_task = tokio::spawn(async move {
|
||||
if let Err(e) = broadcast_ogg_flac_stream(flac_stream, ogg_broadcast, ogg_header, current_timestamp).await {
|
||||
if let Err(e) = broadcast_ogg_flac_stream(
|
||||
flac_stream,
|
||||
ogg_broadcast,
|
||||
ogg_header,
|
||||
current_timestamp,
|
||||
max_lead,
|
||||
)
|
||||
.await
|
||||
{
|
||||
error!("OGG broadcaster task error: {}", e);
|
||||
}
|
||||
});
|
||||
@@ -464,6 +488,19 @@ impl StreamingOggFlacSink {
|
||||
pub fn new(
|
||||
encoder_options: EncoderOptions,
|
||||
bits_per_sample: u8,
|
||||
) -> (Self, OggFlacStreamHandle) {
|
||||
Self::with_max_broadcast_lead(
|
||||
encoder_options,
|
||||
bits_per_sample,
|
||||
DEFAULT_BROADCAST_MAX_LEAD_TIME,
|
||||
)
|
||||
}
|
||||
|
||||
/// Create a sink with a custom broadcast pacing limit.
|
||||
pub fn with_max_broadcast_lead(
|
||||
encoder_options: EncoderOptions,
|
||||
bits_per_sample: u8,
|
||||
broadcast_max_lead_time: f64,
|
||||
) -> (Self, OggFlacStreamHandle) {
|
||||
// Validate bit depth
|
||||
if ![16, 24, 32].contains(&bits_per_sample) {
|
||||
@@ -477,7 +514,14 @@ impl StreamingOggFlacSink {
|
||||
let metadata = Arc::new(RwLock::new(MetadataSnapshot::default()));
|
||||
|
||||
// Broadcast channel for OGG-FLAC bytes
|
||||
let (ogg_broadcast, _) = broadcast::channel(BROADCAST_CAPACITY);
|
||||
// Capacity calculated from max_lead_time to ensure enough buffering
|
||||
let broadcast_capacity = calculate_broadcast_capacity(broadcast_max_lead_time);
|
||||
tracing::debug!(
|
||||
"OGG-FLAC broadcast capacity: {} items (for {:.1}s max lead time)",
|
||||
broadcast_capacity,
|
||||
broadcast_max_lead_time
|
||||
);
|
||||
let (ogg_broadcast, _) = broadcast::channel(broadcast_capacity);
|
||||
|
||||
// OGG-FLAC header cache
|
||||
let ogg_header = Arc::new(RwLock::new(None));
|
||||
@@ -504,6 +548,7 @@ impl StreamingOggFlacSink {
|
||||
ogg_header,
|
||||
encoder_state: None,
|
||||
sample_rate: None,
|
||||
broadcast_max_lead_time: broadcast_max_lead_time.max(0.0),
|
||||
};
|
||||
|
||||
let sink = Self {
|
||||
@@ -709,17 +754,27 @@ async fn broadcast_ogg_flac_stream(
|
||||
broadcast_tx: broadcast::Sender<Bytes>,
|
||||
header_cache: Arc<RwLock<Option<Bytes>>>,
|
||||
current_timestamp: Arc<RwLock<f64>>,
|
||||
broadcast_max_lead_time: f64,
|
||||
) -> Result<(), AudioError> {
|
||||
info!("OGG-FLAC broadcaster task started with FLAC frame boundary detection");
|
||||
info!(
|
||||
"OGG-FLAC broadcaster task started with FLAC frame boundary detection (max_lead={:.3}s)",
|
||||
broadcast_max_lead_time
|
||||
);
|
||||
|
||||
let stream_serial = rand::random::<u32>();
|
||||
let mut ogg_writer = OggPageWriter::new(stream_serial);
|
||||
|
||||
let mut total_ogg_bytes = 0u64;
|
||||
let mut header_captured = false;
|
||||
let start_time = std::time::Instant::now();
|
||||
let mut pacer = BroadcastPacer::new(broadcast_max_lead_time, "OGG");
|
||||
let mut last_granule_update_time = 0.0f64;
|
||||
|
||||
// Timing instrumentation for burst detection
|
||||
let mut last_broadcast_time = std::time::Instant::now();
|
||||
let mut broadcast_count = 0u64;
|
||||
let mut total_read_time = 0.0f64;
|
||||
let mut read_count = 0u64;
|
||||
|
||||
// Step 1: Read FLAC header (fLaC + metadata blocks)
|
||||
let flac_header = read_flac_header(&mut flac_stream).await?;
|
||||
info!("Read FLAC header: {} bytes", flac_header.len());
|
||||
@@ -731,7 +786,10 @@ async fn broadcast_ogg_flac_stream(
|
||||
// Step 2: Create OGG-FLAC identification packet (BOS)
|
||||
// Format according to https://xiph.org/flac/ogg_mapping.html
|
||||
let ogg_flac_id = create_ogg_flac_identification(&flac_header)?;
|
||||
info!("Created OGG-FLAC identification packet: {} bytes", ogg_flac_id.len());
|
||||
info!(
|
||||
"Created OGG-FLAC identification packet: {} bytes",
|
||||
ogg_flac_id.len()
|
||||
);
|
||||
|
||||
let bos_page = ogg_writer.create_page(&ogg_flac_id, true, false, false);
|
||||
let bos_bytes = Bytes::from(bos_page);
|
||||
@@ -747,7 +805,10 @@ async fn broadcast_ogg_flac_stream(
|
||||
cached_header.extend_from_slice(&comment_bytes);
|
||||
*header_cache.write().await = Some(Bytes::from(cached_header));
|
||||
header_captured = true;
|
||||
info!("OGG-FLAC header cached ({} bytes: BOS + Vorbis Comment)", bos_bytes.len() + comment_bytes.len());
|
||||
info!(
|
||||
"OGG-FLAC header cached ({} bytes: BOS + Vorbis Comment)",
|
||||
bos_bytes.len() + comment_bytes.len()
|
||||
);
|
||||
|
||||
// Broadcast header
|
||||
let _ = broadcast_tx.send(bos_bytes);
|
||||
@@ -761,6 +822,7 @@ async fn broadcast_ogg_flac_stream(
|
||||
let mut flac_accumulator = Vec::with_capacity(32768);
|
||||
|
||||
loop {
|
||||
let read_start = std::time::Instant::now();
|
||||
match flac_stream.read(&mut read_buffer).await {
|
||||
Ok(0) => {
|
||||
// EOF - create final page with EOS flag and any remaining data
|
||||
@@ -769,7 +831,10 @@ async fn broadcast_ogg_flac_stream(
|
||||
let eos_bytes = Bytes::from(eos_page);
|
||||
total_ogg_bytes += eos_bytes.len() as u64;
|
||||
let _ = broadcast_tx.send(eos_bytes);
|
||||
info!("Sent final EOS page with {} bytes of data", flac_accumulator.len());
|
||||
info!(
|
||||
"Sent final EOS page with {} bytes of data",
|
||||
flac_accumulator.len()
|
||||
);
|
||||
} else {
|
||||
// Send empty EOS page
|
||||
let eos_page = ogg_writer.create_page(&[], false, true, false);
|
||||
@@ -779,13 +844,36 @@ async fn broadcast_ogg_flac_stream(
|
||||
info!("Sent empty EOS page");
|
||||
}
|
||||
|
||||
info!("OGG-FLAC stream ended, total OGG bytes: {}", total_ogg_bytes);
|
||||
info!(
|
||||
"OGG-FLAC stream ended, total OGG bytes: {}",
|
||||
total_ogg_bytes
|
||||
);
|
||||
break;
|
||||
}
|
||||
Ok(n) => {
|
||||
let read_duration = read_start.elapsed().as_secs_f64();
|
||||
read_count += 1;
|
||||
total_read_time += read_duration;
|
||||
|
||||
if read_duration > 0.01 {
|
||||
debug!(
|
||||
"OGG: flac_stream.read() took {:.3}s for {} bytes (avg: {:.3}s over {} reads)",
|
||||
read_duration,
|
||||
n,
|
||||
total_read_time / read_count as f64,
|
||||
read_count
|
||||
);
|
||||
}
|
||||
|
||||
// Append to accumulator
|
||||
flac_accumulator.extend_from_slice(&read_buffer[..n]);
|
||||
|
||||
trace!(
|
||||
"OGG: accumulator now {} bytes after reading {} bytes",
|
||||
flac_accumulator.len(),
|
||||
n
|
||||
);
|
||||
|
||||
// Process complete FLAC frames one at a time
|
||||
// OGG-FLAC spec requires: "Each audio data packet contains one complete FLAC frame"
|
||||
loop {
|
||||
@@ -804,7 +892,9 @@ async fn broadcast_ogg_flac_stream(
|
||||
if byte1 == 0xFF && byte2 >= 0xF8 && byte2 <= 0xFE {
|
||||
// Validate frame header with CRC-8 to avoid false positives
|
||||
if flac_frame_utils::validate_frame_header_crc(&flac_accumulator, i) {
|
||||
if let Some(samples) = flac_frame_utils::parse_flac_block_size(&flac_accumulator, i) {
|
||||
if let Some(samples) =
|
||||
flac_frame_utils::parse_flac_block_size(&flac_accumulator, i)
|
||||
{
|
||||
sync_data.push((i, samples));
|
||||
}
|
||||
}
|
||||
@@ -823,26 +913,34 @@ async fn broadcast_ogg_flac_stream(
|
||||
|
||||
// Verify first frame starts at position 0 (otherwise we have garbage data)
|
||||
if first_frame_start != 0 {
|
||||
warn!("OGG-FLAC: Skipping {} bytes of garbage data before first frame", first_frame_start);
|
||||
warn!(
|
||||
"OGG-FLAC: Skipping {} bytes of garbage data before first frame",
|
||||
first_frame_start
|
||||
);
|
||||
flac_accumulator.drain(0..first_frame_start);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract just the first frame
|
||||
let first_frame: Vec<u8> = flac_accumulator.drain(0..second_frame_start).collect();
|
||||
let first_frame: Vec<u8> =
|
||||
flac_accumulator.drain(0..second_frame_start).collect();
|
||||
|
||||
// Precise pacing based on audio timestamp
|
||||
// ╔═══════════════════════════════════════════════════════════════╗
|
||||
// ║ BACKPRESSURE INTELLIGENTE BASÉE SUR LE TIMING ║
|
||||
// ║ ║
|
||||
// ║ BroadcastPacer gère : ║
|
||||
// ║ 1. Détection TopZeroSync (audio_ts < 0.1) ║
|
||||
// ║ 2. Drop des chunks en retard (audio_ts < elapsed) ║
|
||||
// ║ 3. Pacing pour contrôler le débit (max_lead_time) ║
|
||||
// ║ ║
|
||||
// ║ 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 elapsed = start_time.elapsed().as_secs_f64();
|
||||
let lead_time = audio_timestamp - elapsed;
|
||||
|
||||
if lead_time > BROADCAST_MAX_LEAD_TIME {
|
||||
let sleep_duration = lead_time - BROADCAST_MAX_LEAD_TIME;
|
||||
debug!(
|
||||
"OGG broadcaster pacing: sleeping {:.3}s (audio_ts={:.3}s, elapsed={:.3}s, lead={:.3}s)",
|
||||
sleep_duration, audio_timestamp, elapsed, lead_time
|
||||
);
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs_f64(sleep_duration)).await;
|
||||
// Check timing et apply pacing (skip si en retard)
|
||||
if pacer.check_and_pace(audio_timestamp).await.is_err() {
|
||||
continue; // Skip ce chunk (trop en retard)
|
||||
}
|
||||
|
||||
// Update granule position (cumulative sample count)
|
||||
@@ -853,10 +951,41 @@ async fn broadcast_ogg_flac_stream(
|
||||
let ogg_bytes = Bytes::from(ogg_page);
|
||||
total_ogg_bytes += ogg_bytes.len() as u64;
|
||||
|
||||
if let Err(e) = broadcast_tx.send(ogg_bytes.clone()) {
|
||||
trace!("No active receivers for OGG-FLAC broadcast: {}", e);
|
||||
} else {
|
||||
trace!("Broadcasted OGG page with 1 FLAC frame ({} bytes), {} samples ({} bytes total with OGG overhead)", first_frame.len(), first_frame_samples, ogg_bytes.len());
|
||||
// Measure broadcast interval for burst detection
|
||||
let broadcast_interval = last_broadcast_time.elapsed().as_secs_f64();
|
||||
last_broadcast_time = std::time::Instant::now();
|
||||
broadcast_count += 1;
|
||||
|
||||
// Log if interval is unusual (too short = burst, too long = stall)
|
||||
if broadcast_interval < 0.01 || broadcast_interval > 0.1 {
|
||||
debug!(
|
||||
"OGG: broadcast interval {:.3}s ({}ms) - frame_size={} bytes, samples={} (count={})",
|
||||
broadcast_interval,
|
||||
(broadcast_interval * 1000.0) as u32,
|
||||
first_frame.len(),
|
||||
first_frame_samples,
|
||||
broadcast_count
|
||||
);
|
||||
}
|
||||
|
||||
// Periodic stats
|
||||
if broadcast_count % 100 == 0 {
|
||||
debug!(
|
||||
"OGG: {} broadcasts sent, avg_interval={:.3}s, accumulator={} bytes",
|
||||
broadcast_count,
|
||||
last_broadcast_time.elapsed().as_secs_f64() / broadcast_count as f64,
|
||||
flac_accumulator.len()
|
||||
);
|
||||
}
|
||||
|
||||
// Envoyer au broadcast
|
||||
match broadcast_tx.send(ogg_bytes.clone()) {
|
||||
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);
|
||||
}
|
||||
Err(e) => {
|
||||
trace!("No active receivers for OGG-FLAC broadcast: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -897,13 +1026,17 @@ fn extract_sample_rate_from_streaminfo(flac_header: &[u8]) -> Result<u32, AudioE
|
||||
// First metadata block should be STREAMINFO (type 0)
|
||||
let block_type = flac_header[4] & 0x7F;
|
||||
if block_type != 0 {
|
||||
return Err(AudioError::ProcessingError("First block is not STREAMINFO".into()));
|
||||
return Err(AudioError::ProcessingError(
|
||||
"First block is not STREAMINFO".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// STREAMINFO data starts at offset 8 (after magic + block header)
|
||||
// Sample rate is at offset 10-12 of STREAMINFO data (bytes 18-20 of header)
|
||||
if flac_header.len() < 21 {
|
||||
return Err(AudioError::ProcessingError("STREAMINFO block truncated".into()));
|
||||
return Err(AudioError::ProcessingError(
|
||||
"STREAMINFO block truncated".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Sample rate: 20 bits starting at byte 10 of STREAMINFO
|
||||
@@ -917,7 +1050,9 @@ fn extract_sample_rate_from_streaminfo(flac_header: &[u8]) -> Result<u32, AudioE
|
||||
let sample_rate = (byte10 << 12) | (byte11 << 4) | (byte12 >> 4);
|
||||
|
||||
if sample_rate == 0 {
|
||||
return Err(AudioError::ProcessingError("Invalid sample rate (0)".into()));
|
||||
return Err(AudioError::ProcessingError(
|
||||
"Invalid sample rate (0)".into(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(sample_rate)
|
||||
@@ -929,12 +1064,15 @@ async fn read_flac_header(stream: &mut FlacEncodedStream) -> Result<Vec<u8>, Aud
|
||||
let mut buffer = [0u8; 4];
|
||||
|
||||
// Read "fLaC" magic
|
||||
stream.read_exact(&mut buffer).await.map_err(|e| {
|
||||
AudioError::ProcessingError(format!("Failed to read FLAC magic: {}", e))
|
||||
})?;
|
||||
stream
|
||||
.read_exact(&mut buffer)
|
||||
.await
|
||||
.map_err(|e| AudioError::ProcessingError(format!("Failed to read FLAC magic: {}", e)))?;
|
||||
|
||||
if &buffer != b"fLaC" {
|
||||
return Err(AudioError::ProcessingError("Invalid FLAC stream: missing fLaC magic".into()));
|
||||
return Err(AudioError::ProcessingError(
|
||||
"Invalid FLAC stream: missing fLaC magic".into(),
|
||||
));
|
||||
}
|
||||
|
||||
header.extend_from_slice(&buffer);
|
||||
@@ -948,7 +1086,8 @@ async fn read_flac_header(stream: &mut FlacEncodedStream) -> Result<Vec<u8>, Aud
|
||||
})?;
|
||||
|
||||
let is_last = (block_header[0] & 0x80) != 0;
|
||||
let block_length = u32::from_be_bytes([0, block_header[1], block_header[2], block_header[3]]) as usize;
|
||||
let block_length =
|
||||
u32::from_be_bytes([0, block_header[1], block_header[2], block_header[3]]) as usize;
|
||||
|
||||
header.extend_from_slice(&block_header);
|
||||
|
||||
@@ -984,11 +1123,14 @@ fn create_ogg_flac_identification(flac_header: &[u8]) -> Result<Vec<u8>, AudioEr
|
||||
|
||||
let first_block_type = flac_header[4] & 0x7F; // Remove last-metadata-block flag
|
||||
if first_block_type != 0 {
|
||||
return Err(AudioError::ProcessingError("First FLAC metadata block is not STREAMINFO".into()));
|
||||
return Err(AudioError::ProcessingError(
|
||||
"First FLAC metadata block is not STREAMINFO".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Extract block length (3 bytes big-endian after type byte)
|
||||
let block_length = u32::from_be_bytes([0, flac_header[5], flac_header[6], flac_header[7]]) as usize;
|
||||
let block_length =
|
||||
u32::from_be_bytes([0, flac_header[5], flac_header[6], flac_header[7]]) as usize;
|
||||
|
||||
info!("STREAMINFO block_length = {} bytes", block_length);
|
||||
|
||||
@@ -1007,18 +1149,21 @@ fn create_ogg_flac_identification(flac_header: &[u8]) -> Result<Vec<u8>, AudioEr
|
||||
// Extract just the STREAMINFO block (type + length + data)
|
||||
let streaminfo = &flac_header[4..4 + streaminfo_size];
|
||||
|
||||
info!("Extracted STREAMINFO: {} bytes (type+length+data)", streaminfo.len());
|
||||
info!(
|
||||
"Extracted STREAMINFO: {} bytes (type+length+data)",
|
||||
streaminfo.len()
|
||||
);
|
||||
|
||||
let mut packet = Vec::new();
|
||||
|
||||
// OGG-FLAC identification header
|
||||
packet.push(0x7F); // Byte 0: 0x7F
|
||||
packet.extend_from_slice(b"FLAC"); // Bytes 1-4: "FLAC"
|
||||
packet.push(0x01); // Byte 5: Major version
|
||||
packet.push(0x00); // Byte 6: Minor version
|
||||
packet.push(0x7F); // Byte 0: 0x7F
|
||||
packet.extend_from_slice(b"FLAC"); // Bytes 1-4: "FLAC"
|
||||
packet.push(0x01); // Byte 5: Major version
|
||||
packet.push(0x00); // Byte 6: Minor version
|
||||
packet.extend_from_slice(&1u16.to_be_bytes()); // Bytes 7-8: 1 header packet (Vorbis Comment)
|
||||
packet.extend_from_slice(b"fLaC"); // Bytes 9-12: Native FLAC signature
|
||||
packet.extend_from_slice(streaminfo); // Bytes 13+: STREAMINFO block only
|
||||
packet.extend_from_slice(b"fLaC"); // Bytes 9-12: Native FLAC signature
|
||||
packet.extend_from_slice(streaminfo); // Bytes 13+: STREAMINFO block only
|
||||
|
||||
Ok(packet)
|
||||
}
|
||||
@@ -1075,7 +1220,13 @@ impl OggPageWriter {
|
||||
self.granule_position += samples;
|
||||
}
|
||||
|
||||
fn create_page(&mut self, packet_data: &[u8], is_bos: bool, is_eos: bool, is_continuation: bool) -> Vec<u8> {
|
||||
fn create_page(
|
||||
&mut self,
|
||||
packet_data: &[u8],
|
||||
is_bos: bool,
|
||||
is_eos: bool,
|
||||
is_continuation: bool,
|
||||
) -> Vec<u8> {
|
||||
use std::io::Write;
|
||||
|
||||
let mut segments = Vec::new();
|
||||
@@ -1117,7 +1268,8 @@ impl OggPageWriter {
|
||||
page.write_all(&[header_type]).unwrap();
|
||||
|
||||
// Granule position
|
||||
page.write_all(&self.granule_position.to_le_bytes()).unwrap();
|
||||
page.write_all(&self.granule_position.to_le_bytes())
|
||||
.unwrap();
|
||||
|
||||
// Stream serial number
|
||||
page.write_all(&self.stream_serial.to_le_bytes()).unwrap();
|
||||
|
||||
@@ -205,11 +205,8 @@ impl NodeLogic for PlaylistSourceLogic {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
tracing::warn!("PlaylistSourceLogic: failed to get metadata: {}", e);
|
||||
let error_marker = AudioSegment::new_error(
|
||||
0,
|
||||
0.0,
|
||||
format!("Failed to get metadata: {}", e),
|
||||
);
|
||||
let error_marker =
|
||||
AudioSegment::new_error(0, 0.0, format!("Failed to get metadata: {}", e));
|
||||
send_to_children!(error_marker);
|
||||
continue;
|
||||
}
|
||||
@@ -224,11 +221,8 @@ impl NodeLogic for PlaylistSourceLogic {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
tracing::warn!("PlaylistSourceLogic: failed to get file path: {}", e);
|
||||
let error_marker = AudioSegment::new_error(
|
||||
0,
|
||||
0.0,
|
||||
format!("Failed to get file path: {}", e),
|
||||
);
|
||||
let error_marker =
|
||||
AudioSegment::new_error(0, 0.0, format!("Failed to get file path: {}", e));
|
||||
send_to_children!(error_marker);
|
||||
continue;
|
||||
}
|
||||
@@ -290,7 +284,10 @@ async fn decode_and_emit_track(
|
||||
const MIN_FILE_SIZE: u64 = 512 * 1024; // 512 KB (prebuffer size)
|
||||
|
||||
if file_size >= MIN_FILE_SIZE || cache.is_download_complete(cache_pk) {
|
||||
tracing::trace!("decode_and_emit_track: file ready ({} bytes), starting decode", file_size);
|
||||
tracing::trace!(
|
||||
"decode_and_emit_track: file ready ({} bytes), starting decode",
|
||||
file_size
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -417,7 +414,8 @@ async fn decode_and_emit_track(
|
||||
let frames = pending.len() / frame_bytes;
|
||||
if frames > 0 {
|
||||
let timestamp_sec = total_frames as f64 / stream_info.sample_rate as f64;
|
||||
let segment = bytes_to_segment(&pending, &stream_info, frames, chunk_index, timestamp_sec)?;
|
||||
let segment =
|
||||
bytes_to_segment(&pending, &stream_info, frames, chunk_index, timestamp_sec)?;
|
||||
for tx in output {
|
||||
tx.send(segment.clone())
|
||||
.await
|
||||
@@ -609,7 +607,8 @@ impl PlaylistSource {
|
||||
chunk_frames: usize,
|
||||
poll_interval_ms: u64,
|
||||
) -> Self {
|
||||
let logic = PlaylistSourceLogic::new(playlist_handle, cache, chunk_frames, poll_interval_ms);
|
||||
let logic =
|
||||
PlaylistSourceLogic::new(playlist_handle, cache, chunk_frames, poll_interval_ms);
|
||||
Self {
|
||||
inner: Node::new_source(logic),
|
||||
}
|
||||
@@ -626,10 +625,7 @@ impl AudioPipelineNode for PlaylistSource {
|
||||
self.inner.register(child)
|
||||
}
|
||||
|
||||
async fn run(
|
||||
self: Box<Self>,
|
||||
stop_token: CancellationToken,
|
||||
) -> Result<(), AudioError> {
|
||||
async fn run(self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError> {
|
||||
Box::new(self.inner).run(stop_token).await
|
||||
}
|
||||
}
|
||||
@@ -712,9 +708,9 @@ mod tests {
|
||||
// Frame 2: L=300, R=400
|
||||
let chunk_bytes = vec![
|
||||
100u8, 0, // L1
|
||||
200, 0, // R1
|
||||
44, 1, // L2 (300 = 0x012C)
|
||||
144, 1, // R2 (400 = 0x0190)
|
||||
200, 0, // R1
|
||||
44, 1, // L2 (300 = 0x012C)
|
||||
144, 1, // R2 (400 = 0x0190)
|
||||
];
|
||||
|
||||
let info = StreamInfo {
|
||||
@@ -732,18 +728,16 @@ mod tests {
|
||||
assert_eq!(segment.timestamp_sec, 0.0);
|
||||
|
||||
match &segment.segment {
|
||||
pmoaudio::_AudioSegment::Chunk(chunk) => {
|
||||
match chunk.as_ref() {
|
||||
AudioChunk::I16(data) => {
|
||||
let frames = data.get_frames();
|
||||
assert_eq!(frames.len(), 2);
|
||||
assert_eq!(frames[0], [100, 200]);
|
||||
assert_eq!(frames[1], [300, 400]);
|
||||
assert_eq!(data.get_sample_rate(), 44100);
|
||||
}
|
||||
_ => panic!("Expected I16 chunk"),
|
||||
pmoaudio::_AudioSegment::Chunk(chunk) => match chunk.as_ref() {
|
||||
AudioChunk::I16(data) => {
|
||||
let frames = data.get_frames();
|
||||
assert_eq!(frames.len(), 2);
|
||||
assert_eq!(frames[0], [100, 200]);
|
||||
assert_eq!(frames[1], [300, 400]);
|
||||
assert_eq!(data.get_sample_rate(), 44100);
|
||||
}
|
||||
}
|
||||
_ => panic!("Expected I16 chunk"),
|
||||
},
|
||||
_ => panic!("Expected audio chunk"),
|
||||
}
|
||||
}
|
||||
@@ -753,7 +747,7 @@ mod tests {
|
||||
// Create mock PCM data (2 frames, mono, 16-bit)
|
||||
let chunk_bytes = vec![
|
||||
100u8, 0, // Frame 1
|
||||
200, 0, // Frame 2
|
||||
200, 0, // Frame 2
|
||||
];
|
||||
|
||||
let info = StreamInfo {
|
||||
@@ -808,17 +802,15 @@ mod tests {
|
||||
let segment = bytes_to_segment(&chunk_bytes, &info, 1, 0, 0.0).unwrap();
|
||||
|
||||
match &segment.segment {
|
||||
pmoaudio::_AudioSegment::Chunk(chunk) => {
|
||||
match chunk.as_ref() {
|
||||
AudioChunk::I24(data) => {
|
||||
let frames = data.get_frames();
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0][0].as_i32(), 1000);
|
||||
assert_eq!(frames[0][1].as_i32(), -1000);
|
||||
}
|
||||
_ => panic!("Expected I24 chunk"),
|
||||
pmoaudio::_AudioSegment::Chunk(chunk) => match chunk.as_ref() {
|
||||
AudioChunk::I24(data) => {
|
||||
let frames = data.get_frames();
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0][0].as_i32(), 1000);
|
||||
assert_eq!(frames[0][1].as_i32(), -1000);
|
||||
}
|
||||
}
|
||||
_ => panic!("Expected I24 chunk"),
|
||||
},
|
||||
_ => panic!("Expected audio chunk"),
|
||||
}
|
||||
}
|
||||
@@ -843,16 +835,14 @@ mod tests {
|
||||
let segment = bytes_to_segment(&chunk_bytes, &info, 1, 0, 0.0).unwrap();
|
||||
|
||||
match &segment.segment {
|
||||
pmoaudio::_AudioSegment::Chunk(chunk) => {
|
||||
match chunk.as_ref() {
|
||||
AudioChunk::I32(data) => {
|
||||
let frames = data.get_frames();
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0], [4096, 8192]);
|
||||
}
|
||||
_ => panic!("Expected I32 chunk"),
|
||||
pmoaudio::_AudioSegment::Chunk(chunk) => match chunk.as_ref() {
|
||||
AudioChunk::I32(data) => {
|
||||
let frames = data.get_frames();
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0], [4096, 8192]);
|
||||
}
|
||||
}
|
||||
_ => panic!("Expected I32 chunk"),
|
||||
},
|
||||
_ => panic!("Expected audio chunk"),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user