debuggage des stream

This commit is contained in:
2025-11-14 10:43:53 +01:00
parent de84cbafbb
commit 1c2d30cbe9
44 changed files with 2018 additions and 717 deletions

View File

@@ -6,7 +6,9 @@ use tokio::fs::File;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let path_str = std::env::args().nth(1).expect("Usage: check_flac_bits <file.flac>");
let path_str = std::env::args()
.nth(1)
.expect("Usage: check_flac_bits <file.flac>");
let path = Path::new(&path_str);
println!("Checking: {}", path.display());

View File

@@ -88,7 +88,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
match result {
Ok(()) => {
println!();
println!("✓ Conversion completed successfully in {:.2}s", elapsed.as_secs_f64());
println!(
"✓ Conversion completed successfully in {:.2}s",
elapsed.as_secs_f64()
);
println!(" Output file: {}", output_path);
println!();

View File

@@ -45,7 +45,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Gérer Ctrl+C pour arrêt propre
tokio::spawn(async move {
tokio::signal::ctrl_c().await.expect("Failed to listen for Ctrl+C");
tokio::signal::ctrl_c()
.await
.expect("Failed to listen for Ctrl+C");
println!("\nArrêt demandé...");
stop_token_clone.cancel();
});

View File

@@ -52,8 +52,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
resampler.register(Box::new(converter));
converter.register(Box::new(sink));
println!("Pipeline créé: FileSource → Resampling({} Hz) → ToI24 → AudioSink",
target_sample_rate);
println!(
"Pipeline créé: FileSource → Resampling({} Hz) → ToI24 → AudioSink",
target_sample_rate
);
println!("Démarrage de la lecture...");
println!("Appuyez sur Ctrl+C pour arrêter");
@@ -63,7 +65,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Gérer Ctrl+C
tokio::spawn(async move {
tokio::signal::ctrl_c().await.expect("Failed to listen for Ctrl+C");
tokio::signal::ctrl_c()
.await
.expect("Failed to listen for Ctrl+C");
println!("\nArrêt demandé...");
stop_token_clone.cancel();
});

View File

@@ -678,9 +678,11 @@ impl AudioIntegerChunk {
AudioIntegerChunk::I16(d) => {
Box::new(d.get_frames().iter().map(|f| [f[0] as i32, f[1] as i32]))
}
AudioIntegerChunk::I24(d) => {
Box::new(d.get_frames().iter().map(|f| [f[0].as_i32(), f[1].as_i32()]))
}
AudioIntegerChunk::I24(d) => Box::new(
d.get_frames()
.iter()
.map(|f| [f[0].as_i32(), f[1].as_i32()]),
),
AudioIntegerChunk::I32(d) => Box::new(d.get_frames().iter().map(|f| [f[0], f[1]])),
}
}

View File

@@ -247,11 +247,7 @@ fn i16_stereo_to_pairs_f32_inner(
}
/// Convertit deux canaux i16 (L/R) en pairs f32 normalisées [-1.0, 1.0]
pub fn i16_stereo_to_pairs_f32(
left: &[i16],
right: &[i16],
out_pairs: &mut [[f32; 2]],
) {
pub fn i16_stereo_to_pairs_f32(left: &[i16], right: &[i16], out_pairs: &mut [[f32; 2]]) {
i16_stereo_to_pairs_f32_inner(left, right, out_pairs, 32768.0);
}
@@ -332,11 +328,7 @@ fn pairs_f32_to_i16_stereo_inner(
}
/// Convertit pairs f32 normalisées [-1.0, 1.0] en deux canaux i16 (L/R)
pub fn pairs_f32_to_i16_stereo(
input_pairs: &[[f32; 2]],
left: &mut [i16],
right: &mut [i16],
) {
pub fn pairs_f32_to_i16_stereo(input_pairs: &[[f32; 2]], left: &mut [i16], right: &mut [i16]) {
pairs_f32_to_i16_stereo_inner(input_pairs, left, right, 32768.0);
}
@@ -399,11 +391,7 @@ fn i24_as_i32_stereo_to_pairs_f32_inner(
}
/// Convertit deux canaux i32 (contenant des valeurs I24) en pairs f32 normalisées
pub fn i24_as_i32_stereo_to_pairs_f32(
left: &[i32],
right: &[i32],
out_pairs: &mut [[f32; 2]],
) {
pub fn i24_as_i32_stereo_to_pairs_f32(left: &[i32], right: &[i32], out_pairs: &mut [[f32; 2]]) {
i24_as_i32_stereo_to_pairs_f32_inner(left, right, out_pairs, 8388608.0);
}

View File

@@ -124,6 +124,7 @@ pub use nodes::{
flac_file_sink::{FlacFileSink, FlacFileSinkStats},
http_source::HttpSource,
resampling_node::ResamplingNode,
timer_buffer_node::TimerBufferNode,
timer_node::TimerNode,
AudioError, AudioNode, TypedAudioNode,
};

View File

@@ -220,20 +220,18 @@ impl AudioSinkLogic {
chunk.sample_rate()
);
}
crate::_AudioSegment::Sync(marker) => {
match **marker {
SyncMarker::TrackBoundary { .. } => {
tracing::debug!("AudioSink (null): TrackBoundary received");
}
SyncMarker::EndOfStream => {
tracing::debug!("AudioSink (null): EndOfStream received");
return Ok(());
}
_ => {
tracing::trace!("AudioSink (null): sync marker");
}
crate::_AudioSegment::Sync(marker) => match **marker {
SyncMarker::TrackBoundary { .. } => {
tracing::debug!("AudioSink (null): TrackBoundary received");
}
}
SyncMarker::EndOfStream => {
tracing::debug!("AudioSink (null): EndOfStream received");
return Ok(());
}
_ => {
tracing::trace!("AudioSink (null): sync marker");
}
},
}
}
}
@@ -273,12 +271,15 @@ impl NodeLogic for AudioSinkLogic {
.default_output_device()
.ok_or_else(|| AudioError::ProcessingError("No output device available".to_string()))?;
tracing::debug!("Using audio device: {}", device.name().unwrap_or_else(|_| "Unknown".to_string()));
tracing::debug!(
"Using audio device: {}",
device.name().unwrap_or_else(|_| "Unknown".to_string())
);
// Obtenir la config par défaut
let config = device
.default_output_config()
.map_err(|e| AudioError::ProcessingError(format!("Failed to get output config: {}", e)))?;
let config = device.default_output_config().map_err(|e| {
AudioError::ProcessingError(format!("Failed to get output config: {}", e))
})?;
let sample_format = config.sample_format();
let sample_rate = config.sample_rate().0;
@@ -298,9 +299,9 @@ impl NodeLogic for AudioSinkLogic {
let stream_thread = thread::spawn(move || {
// Créer le stream selon le format hardware
let stream = match sample_format {
cpal::SampleFormat::I16 => {
tracing::debug!("Using I16 output format");
match device.build_output_stream(
cpal::SampleFormat::I16 => {
tracing::debug!("Using I16 output format");
match device.build_output_stream(
&config.into(),
move |data: &mut [i16], _: &cpal::OutputCallbackInfo| {
let mut buf = buffer_clone.lock().unwrap();
@@ -323,10 +324,10 @@ impl NodeLogic for AudioSinkLogic {
return;
}
}
}
cpal::SampleFormat::U16 => {
tracing::debug!("Using U16 output format");
match device.build_output_stream(
}
cpal::SampleFormat::U16 => {
tracing::debug!("Using U16 output format");
match device.build_output_stream(
&config.into(),
move |data: &mut [u16], _: &cpal::OutputCallbackInfo| {
let mut buf = buffer_clone.lock().unwrap();
@@ -348,10 +349,10 @@ impl NodeLogic for AudioSinkLogic {
return;
}
}
}
cpal::SampleFormat::F32 => {
tracing::debug!("Using F32 output format");
match device.build_output_stream(
}
cpal::SampleFormat::F32 => {
tracing::debug!("Using F32 output format");
match device.build_output_stream(
&config.into(),
move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
let mut buf = buffer_clone.lock().unwrap();
@@ -371,10 +372,10 @@ impl NodeLogic for AudioSinkLogic {
return;
}
}
}
_ => {
tracing::error!("Unsupported sample format: {:?}", sample_format);
return;
}
_ => {
tracing::error!("Unsupported sample format: {:?}", sample_format);
return;
}
};
@@ -467,7 +468,9 @@ impl NodeLogic for AudioSinkLogic {
// Le buffer continue automatiquement - pas besoin d'action
}
SyncMarker::EndOfStream => {
tracing::debug!("AudioSink: EndOfStream received, waiting for playback to finish");
tracing::debug!(
"AudioSink: EndOfStream received, waiting for playback to finish"
);
// Marquer la fin et attendre que le buffer se vide
buffer.lock().unwrap().mark_end();
@@ -576,10 +579,7 @@ impl AudioPipelineNode for AudioSink {
panic!("AudioSink is a terminal node and cannot have children");
}
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
}
}

View File

@@ -41,7 +41,11 @@ impl NodeLogic for FileSourceLogic {
output: Vec<mpsc::Sender<Arc<AudioSegment>>>,
stop_token: CancellationToken,
) -> Result<(), AudioError> {
tracing::debug!("FileSourceLogic::process started, path={:?}, {} children", self.path, output.len());
tracing::debug!(
"FileSourceLogic::process started, path={:?}, {} children",
self.path,
output.len()
);
// Macro helper pour envoyer à tous les enfants
macro_rules! send_to_children {
@@ -55,9 +59,9 @@ impl NodeLogic for FileSourceLogic {
}
// Ouvrir le fichier
let file = File::open(&self.path).await.map_err(|e| {
AudioError::IoError(format!("Failed to open {:?}: {}", self.path, e))
})?;
let file = File::open(&self.path)
.await
.map_err(|e| AudioError::IoError(format!("Failed to open {:?}: {}", self.path, e)))?;
// Décoder le flux audio
let mut stream = decode_audio_stream(file)
@@ -256,10 +260,7 @@ impl AudioPipelineNode for FileSource {
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
}
}
@@ -399,7 +400,6 @@ fn bytes_to_segment(
}))
}
impl TypedAudioNode for FileSource {
fn input_type(&self) -> Option<TypeRequirement> {
// FileSource est une source, elle ne consomme pas d'audio
@@ -464,11 +464,7 @@ mod tests {
impl TestCollectorNode {
fn new(test_tx: mpsc::Sender<Arc<AudioSegment>>) -> Self {
let (tx, rx) = mpsc::channel(16);
Self {
tx,
rx,
test_tx,
}
Self { tx, rx, test_tx }
}
}

View File

@@ -71,7 +71,10 @@ impl NodeLogic for FlacFileSinkLogic {
let mut rx = input.expect("FlacFileSink must have input");
let mut track_number = 0;
tracing::debug!("FlacFileSinkLogic::process started, base_path={:?}", self.base_path);
tracing::debug!(
"FlacFileSinkLogic::process started, base_path={:?}",
self.base_path
);
loop {
// Vérifier si l'arrêt a été demandé
@@ -81,13 +84,14 @@ impl NodeLogic for FlacFileSinkLogic {
}
// Attendre le premier chunk audio pour cette track, en capturant les métadonnées du TrackBoundary
let (first_segment, track_metadata) = match wait_for_first_audio_chunk_with_metadata(&mut rx, &stop_token).await {
Ok(result) => result,
Err(_) => {
// Plus d'audio disponible ou arrêt demandé
return Ok(());
}
};
let (first_segment, track_metadata) =
match wait_for_first_audio_chunk_with_metadata(&mut rx, &stop_token).await {
Ok(result) => result,
Err(_) => {
// Plus d'audio disponible ou arrêt demandé
return Ok(());
}
};
// Extraire les informations du premier chunk
let first_chunk = first_segment.as_chunk().unwrap();
@@ -96,7 +100,9 @@ impl NodeLogic for FlacFileSinkLogic {
tracing::debug!(
"FlacFileSinkLogic: encoding track {} with {}bit @ {}Hz",
track_number, bits_per_sample, sample_rate
track_number,
bits_per_sample,
sample_rate
);
let format = PcmFormat {
@@ -308,10 +314,7 @@ impl FlacFileSink {
///
/// * `base_path` - Chemin de base pour les fichiers FLAC
/// * `channel_size` - Taille du buffer MPSC (nombre de segments en attente avant backpressure)
pub fn with_channel_size<P: Into<PathBuf>>(
base_path: P,
channel_size: usize,
) -> Self {
pub fn with_channel_size<P: Into<PathBuf>>(base_path: P, channel_size: usize) -> Self {
Self::with_config(base_path, channel_size, EncoderOptions::default())
}
@@ -360,7 +363,13 @@ fn generate_track_path(base_path: &Path, track_number: usize) -> PathBuf {
async fn wait_for_first_audio_chunk_with_metadata(
rx: &mut mpsc::Receiver<Arc<AudioSegment>>,
stop_token: &CancellationToken,
) -> Result<(Arc<AudioSegment>, Option<Arc<tokio::sync::RwLock<dyn pmometadata::TrackMetadata>>>), AudioError> {
) -> Result<
(
Arc<AudioSegment>,
Option<Arc<tokio::sync::RwLock<dyn pmometadata::TrackMetadata>>>,
),
AudioError,
> {
let mut track_metadata: Option<Arc<tokio::sync::RwLock<dyn pmometadata::TrackMetadata>>> = None;
loop {
@@ -738,10 +747,7 @@ impl AudioPipelineNode for FlacFileSink {
panic!("FlacFileSink is a terminal node and cannot have children");
}
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
}
}
@@ -768,7 +774,6 @@ mod tests {
#[tokio::test]
async fn test_flac_file_sink_writes_metadata() {
let temp_dir = tempfile::tempdir().unwrap();
let output_path = temp_dir.path().join("output_with_metadata.flac");
@@ -779,9 +784,8 @@ mod tests {
let sink = FlacFileSink::with_channel_size(&output_path, 16);
let tx = sink.get_tx().unwrap();
let stop_token = CancellationToken::new();
let sink_handle = tokio::spawn(async move {
Box::new(sink).run(stop_token).await.unwrap()
});
let sink_handle =
tokio::spawn(async move { Box::new(sink).run(stop_token).await.unwrap() });
// Envoyer des segments avec métadonnées
tokio::spawn(async move {
@@ -792,13 +796,25 @@ mod tests {
// TrackBoundary avec métadonnées
let mut metadata = MemoryTrackMetadata::new();
metadata.set_title(Some("Test Track Title".to_string())).await.unwrap();
metadata.set_artist(Some("Test Artist".to_string())).await.unwrap();
metadata.set_album(Some("Test Album".to_string())).await.unwrap();
metadata
.set_title(Some("Test Track Title".to_string()))
.await
.unwrap();
metadata
.set_artist(Some("Test Artist".to_string()))
.await
.unwrap();
metadata
.set_album(Some("Test Album".to_string()))
.await
.unwrap();
metadata.set_year(Some(2024)).await.unwrap();
let track_boundary =
crate::AudioSegment::new_track_boundary(0, 0.0, std::sync::Arc::new(tokio::sync::RwLock::new(metadata)));
let track_boundary = crate::AudioSegment::new_track_boundary(
0,
0.0,
std::sync::Arc::new(tokio::sync::RwLock::new(metadata)),
);
tx.send(track_boundary).await.unwrap();
// Générer et envoyer des chunks audio
@@ -900,9 +916,8 @@ mod tests {
let sink = FlacFileSink::with_channel_size(&output_path, 16);
let tx = sink.get_tx().unwrap();
let stop_token = CancellationToken::new();
let sink_handle = tokio::spawn(async move {
Box::new(sink).run(stop_token).await.unwrap()
});
let sink_handle =
tokio::spawn(async move { Box::new(sink).run(stop_token).await.unwrap() });
// Lire le fichier input et envoyer les segments au sink
tokio::spawn(async move {

View File

@@ -114,7 +114,7 @@ impl HttpSourceLogic {
self.url.clone()
}
pub fn get_chunc_frames(&self) -> usize {
pub fn get_chunc_frames(&self) -> usize {
self.chunk_frames
}
}
@@ -138,11 +138,9 @@ impl NodeLogic for HttpSourceLogic {
}
// Effectuer la requête HTTP
let response = reqwest::get(&self.url)
.await
.map_err(|e| {
AudioError::ProcessingError(format!("HTTP request failed for {}: {}", self.url, e))
})?;
let response = reqwest::get(&self.url).await.map_err(|e| {
AudioError::ProcessingError(format!("HTTP request failed for {}: {}", self.url, e))
})?;
// Vérifier le status
if !response.status().is_success() {
@@ -158,9 +156,10 @@ impl NodeLogic for HttpSourceLogic {
// Convertir le stream de bytes en AsyncRead
let bytes_stream = response.bytes_stream();
let stream_reader = StreamReader::new(bytes_stream.map(|result| {
result.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))
}));
let stream_reader =
StreamReader::new(bytes_stream.map(|result| {
result.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))
}));
// Décoder le flux audio
let mut stream = decode_audio_stream(stream_reader)
@@ -183,11 +182,8 @@ impl NodeLogic for HttpSourceLogic {
send_to_children!(AudioSegment::new_top_zero_sync());
// Émettre TrackBoundary avec les métadonnées HTTP
let track_boundary = AudioSegment::new_track_boundary(
0,
0.0,
Arc::new(tokio::sync::RwLock::new(metadata)),
);
let track_boundary =
AudioSegment::new_track_boundary(0, 0.0, Arc::new(tokio::sync::RwLock::new(metadata)));
send_to_children!(track_boundary);
// Préparer la lecture des chunks audio
@@ -523,10 +519,7 @@ impl AudioPipelineNode for HttpSource {
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
}
}
@@ -698,7 +691,10 @@ mod tests {
}
// Vérifications
assert_eq!(received_frames, frames, "Tous les frames doivent être reçus");
assert_eq!(
received_frames, frames,
"Tous les frames doivent être reçus"
);
assert!(seen_top_zero, "TopZeroSync doit être émis");
assert!(seen_track_boundary, "TrackBoundary doit être émis");
assert!(seen_eos, "EndOfStream doit être émis");
@@ -725,9 +721,10 @@ mod tests {
bits_per_sample: 16,
};
let mut flac_stream = encode_flac_stream(Cursor::new(pcm), format, EncoderOptions::default())
.await
.unwrap();
let mut flac_stream =
encode_flac_stream(Cursor::new(pcm), format, EncoderOptions::default())
.await
.unwrap();
let mut flac_data = Vec::new();
tokio::io::copy(&mut flac_stream, &mut flac_data)
@@ -798,7 +795,10 @@ mod tests {
assert!(result.is_err(), "Doit retourner une erreur pour HTTP 404");
if let Err(AudioError::ProcessingError(msg)) = result {
assert!(msg.contains("404"), "Le message d'erreur doit mentionner le code 404");
assert!(
msg.contains("404"),
"Le message d'erreur doit mentionner le code 404"
);
} else {
panic!("Le type d'erreur doit être ProcessingError");
}
@@ -854,9 +854,10 @@ mod tests {
bits_per_sample: 16,
};
let mut flac_stream = encode_flac_stream(Cursor::new(pcm), format, EncoderOptions::default())
.await
.unwrap();
let mut flac_stream =
encode_flac_stream(Cursor::new(pcm), format, EncoderOptions::default())
.await
.unwrap();
let mut flac_data = Vec::new();
tokio::io::copy(&mut flac_stream, &mut flac_data)
@@ -898,6 +899,9 @@ mod tests {
}
}
assert!(found_title, "Le nom du fichier doit être utilisé comme titre");
assert!(
found_title,
"Le nom du fichier doit être utilisé comme titre"
);
}
}

View File

@@ -25,6 +25,7 @@ pub mod file_source;
pub mod flac_file_sink;
pub mod http_source;
pub mod resampling_node;
pub mod timer_buffer_node;
pub mod timer_node;
// Modules temporairement désactivés

View File

@@ -95,7 +95,9 @@ impl ResamplingLogic {
bit_depth
);
let resampler = build_resampler(source_sr, self.target_sample_rate, bit_depth)
.map_err(|e| AudioError::ProcessingError(format!("Resampler init failed: {}", e)))?;
.map_err(|e| {
AudioError::ProcessingError(format!("Resampler init failed: {}", e))
})?;
self.current_resampler = Some(ResamplerState {
source_hz: source_sr,
resampler,
@@ -111,7 +113,12 @@ impl ResamplingLogic {
let (resampled_left, resampled_right) = resampling(&left, &right, &mut state.resampler);
// Recréer le chunk avec le nouveau sample rate
reconstruct_chunk(chunk, resampled_left, resampled_right, self.target_sample_rate)
reconstruct_chunk(
chunk,
resampled_left,
resampled_right,
self.target_sample_rate,
)
}
}
@@ -360,10 +367,7 @@ impl AudioPipelineNode for ResamplingNode {
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
}
}
@@ -418,11 +422,7 @@ mod tests {
#[test]
fn test_reconstruct_chunk_i16() {
let original = AudioChunk::I16(AudioChunkData::new(
vec![[100, 200]],
44100,
0.0,
));
let original = AudioChunk::I16(AudioChunkData::new(vec![[100, 200]], 44100, 0.0));
let left = vec![100i32, 300i32];
let right = vec![200i32, 400i32];
@@ -495,7 +495,7 @@ mod tests {
// Créer un TrackBoundary
let metadata = Arc::new(tokio::sync::RwLock::new(
pmometadata::MemoryTrackMetadata::new()
pmometadata::MemoryTrackMetadata::new(),
));
let boundary = AudioSegment::new_track_boundary(0, 0.0, metadata);
@@ -568,7 +568,11 @@ mod tests {
// 100 frames @ 44.1kHz ≈ 109 frames @ 48kHz
if let AudioChunk::I16(data) = chunk.as_ref() {
let frames = data.get_frames().len();
assert!(frames >= 105 && frames <= 115, "Expected ~109 frames, got {}", frames);
assert!(
frames >= 105 && frames <= 115,
"Expected ~109 frames, got {}",
frames
);
}
} else {
panic!("Expected audio chunk");

View File

@@ -0,0 +1,357 @@
//! TimerBufferNode - Maintient un tampon temporel capacitif avant diffusion
//!
//! Ce node implémente un buffer capacitif qui accumule un temps configurable
//! de données audio avant de les diffuser. Une fois le buffer rempli, il
//! maintient ce niveau en diffusant les données au même rythme qu'elles arrivent.
//!
//! # Use Cases
//!
//! - **Buffering initial**: Accumule N secondes de données avant de commencer la lecture
//! - **Smoothing**: Absorbe les variations de débit entre source et sink
//! - **Streaming**: Pré-charge un buffer pour éviter les coupures
//!
//! # Exemple
//!
//! ```no_run
//! use pmoaudio::{HttpSource, TimerBufferNode, AudioSink};
//!
//! let mut source = HttpSource::new(url);
//! let mut buffer = TimerBufferNode::new(3.0); // Buffer 3s avant de commencer
//! let mut sink = AudioSink::new();
//!
//! source.register(Box::new(buffer));
//! buffer.register(Box::new(sink));
//! ```
//!
//! # Architecture
//!
//! ```text
//! HttpSource → TimerBufferNode → AudioSink
//! ↓ ↓ ↓
//! Flux réseau Buffer 3s Lecture stable
//! variable capacitif sans coupures
//! ```
//!
//! Le TimerBufferNode:
//! 1. Accumule les chunks dans un buffer jusqu'à atteindre `capacity_sec`
//! 2. Une fois plein, diffuse les chunks en mode FIFO
//! 3. Maintient un niveau constant d'environ `capacity_sec` secondes
//!
//! # Markers Supportés
//!
//! - **TopZeroSync**: Vide le buffer et reset le compteur
//! - **TrackBoundary**: Passthrough transparent
//! - **Heartbeat**: Passthrough transparent
//! - **EndOfStream**: Flush le buffer restant avant propagation
//!
//! # Performance
//!
//! - **CPU**: Minimal (VecDeque efficace)
//! - **Latency**: Ajoute `capacity_sec` de buffering initial
//! - **Memory**: Proportionnel à `capacity_sec` (ex: ~3MB pour 3s @ 48kHz stéréo)
use crate::{
nodes::{AudioError, TypedAudioNode, DEFAULT_CHANNEL_SIZE, DEFAULT_CHUNK_DURATION_MS},
pipeline::{AudioPipelineNode, Node, NodeLogic},
type_constraints::TypeRequirement,
AudioSegment, SyncMarker, _AudioSegment,
};
use std::{collections::VecDeque, sync::Arc};
use tokio::sync::mpsc;
use tokio::time::Instant;
use tokio_util::sync::CancellationToken;
// ═══════════════════════════════════════════════════════════════════════════
// TimerBufferNodeLogic - Logique pure de buffering capacitif
// ═══════════════════════════════════════════════════════════════════════════
/// Logique pure de buffering temporel capacitif
///
/// Maintient un buffer de taille fixe (en secondes) et diffuse les segments
/// en mode FIFO une fois le buffer rempli.
pub struct TimerBufferNodeLogic {
/// Capacité du buffer en secondes
capacity_sec: f64,
/// Temps actuellement bufferisé en secondes
buffered_time_sec: f64,
/// Durée par défaut d'un chunk (fallback)
default_chunk_duration_sec: f64,
/// Timestamp du chunk précédent (pour estimer les durées)
prev_input_ts: Option<f64>,
/// Buffer FIFO de segments avec leurs durées
buffer: VecDeque<(Arc<AudioSegment>, f64)>,
/// Nombre de chunks traités (pour instrumentation)
chunk_count: u64,
/// Nombre de chunks flushés (pour instrumentation)
flush_count: u64,
/// Dernier log d'instrumentation
last_stats_log: Option<Instant>,
}
impl TimerBufferNodeLogic {
pub fn new(capacity_sec: f64) -> Self {
Self {
capacity_sec: capacity_sec.max(0.0),
buffered_time_sec: 0.0,
default_chunk_duration_sec: DEFAULT_CHUNK_DURATION_MS / 1000.0,
prev_input_ts: None,
buffer: VecDeque::new(),
chunk_count: 0,
flush_count: 0,
last_stats_log: None,
}
}
/// Estime la durée d'un chunk basé sur le delta de timestamps
fn estimate_duration(&mut self, ts: f64) -> f64 {
if let Some(prev) = self.prev_input_ts {
let delta = (ts - prev).clamp(0.0, 10.0);
self.prev_input_ts = Some(ts);
if delta == 0.0 {
self.default_chunk_duration_sec
} else {
delta
}
} else {
self.prev_input_ts = Some(ts);
self.default_chunk_duration_sec
}
}
/// Flush un segment du buffer vers les outputs
async fn flush_one(
&mut self,
output: &[mpsc::Sender<Arc<AudioSegment>>],
) -> Result<(), AudioError> {
if let Some((segment, duration)) = self.buffer.pop_front() {
self.flush_count += 1;
self.buffered_time_sec = (self.buffered_time_sec - duration).max(0.0);
tracing::trace!(
"TimerBufferNode: flushing segment (ts={:.3}s, duration={:.3}s, remaining={:.3}s, {} items in buffer)",
segment.timestamp_sec,
duration,
self.buffered_time_sec,
self.buffer.len()
);
for tx in output {
tx.send(segment.clone())
.await
.map_err(|_| AudioError::ChildDied)?;
}
}
Ok(())
}
fn maybe_log_stats(&mut self) {
let now = Instant::now();
let should_log = match self.last_stats_log {
None => true,
Some(last) => now.duration_since(last).as_secs() >= 1,
};
if should_log {
self.last_stats_log = Some(now);
tracing::debug!(
"TimerBufferNode stats: chunks_received={} chunks_flushed={} buffered={:.3}s capacity={:.3}s buffer_items={}",
self.chunk_count,
self.flush_count,
self.buffered_time_sec,
self.capacity_sec,
self.buffer.len()
);
}
}
}
#[async_trait::async_trait]
impl NodeLogic for TimerBufferNodeLogic {
async fn process(
&mut self,
input: Option<mpsc::Receiver<Arc<AudioSegment>>>,
output: Vec<mpsc::Sender<Arc<AudioSegment>>>,
stop_token: CancellationToken,
) -> Result<(), AudioError> {
let mut rx = input.expect("TimerBufferNode must have input");
tracing::info!(
"TimerBufferNodeLogic::process started (capacity={:.1}s), {} children",
self.capacity_sec,
output.len()
);
loop {
// ╔═══════════════════════════════════════════════════════════════╗
// ║ LOGIQUE CAPACITIVE PAR BACKPRESSURE NATURELLE ║
// ║ ║
// ║ Si le buffer >= capacity, on flush en continu (boucle) ║
// ║ sans recevoir de nouveaux segments. Cela force la ║
// ║ backpressure en amont si le sink en aval est lent. ║
// ╚═══════════════════════════════════════════════════════════════╝
if self.buffered_time_sec >= self.capacity_sec && !self.buffer.is_empty() {
self.flush_one(&output).await?;
continue;
}
let segment = tokio::select! {
_ = stop_token.cancelled() => {
tracing::debug!("TimerBufferNode cancelled");
break;
}
result = rx.recv() => {
match result {
Some(seg) => seg,
None => {
tracing::debug!("TimerBufferNode received EOF");
break;
}
}
}
};
match &segment.segment {
_AudioSegment::Sync(marker) => {
match &**marker {
SyncMarker::TopZeroSync => {
// Reset le buffer complètement
self.buffer.clear();
self.buffered_time_sec = 0.0;
self.prev_input_ts = Some(0.0);
self.chunk_count = 0;
self.flush_count = 0;
tracing::debug!("TimerBufferNode: TopZeroSync received, buffer reset");
}
_ => {
// Autres markers: passthrough transparent
}
}
// Propager le marker immédiatement
for tx in &output {
tx.send(segment.clone())
.await
.map_err(|_| AudioError::ChildDied)?;
}
}
_AudioSegment::Chunk(chunk) => {
self.chunk_count += 1;
// Calculer la durée du chunk
let frames = chunk.len() as f64;
let sample_rate = chunk.sample_rate() as f64;
let duration = if frames > 0.0 && sample_rate > 0.0 {
frames / sample_rate
} else {
self.estimate_duration(segment.timestamp_sec)
};
tracing::trace!(
"TimerBufferNode: received chunk (ts={:.3}s, duration={:.3}s, buffered={:.3}s, capacity={:.3}s)",
segment.timestamp_sec,
duration,
self.buffered_time_sec,
self.capacity_sec
);
// Ajouter le chunk au buffer
self.buffer.push_back((segment.clone(), duration));
self.buffered_time_sec += duration;
// ╔═══════════════════════════════════════════════════════════╗
// ║ FLUSH IMMÉDIAT : Vider aussi vite que possible ║
// ║ ║
// ║ Le send() bloquera si le sink est lent, créant ║
// ║ naturellement la backpressure. Le buffer se remplit ║
// ║ pendant que send() attend, jusqu'à atteindre capacity. ║
// ╚═══════════════════════════════════════════════════════════╝
self.flush_one(&output).await?;
self.maybe_log_stats();
}
}
}
// EOF reçu, flusher le buffer restant
tracing::info!(
"TimerBufferNode: EOF received, flushing remaining buffer ({:.3}s, {} items)",
self.buffered_time_sec,
self.buffer.len()
);
while !self.buffer.is_empty() {
self.flush_one(&output).await?;
}
tracing::debug!("TimerBufferNodeLogic::process finished");
Ok(())
}
}
// ═══════════════════════════════════════════════════════════════════════════
// TimerBufferNode - Wrapper utilisant Node<TimerBufferNodeLogic>
// ═══════════════════════════════════════════════════════════════════════════
pub struct TimerBufferNode {
inner: Node<TimerBufferNodeLogic>,
}
impl TimerBufferNode {
/// Crée un TimerBufferNode avec une capacité donnée
///
/// # Arguments
///
/// * `capacity_sec` - Capacité du buffer en secondes (ex: 3.0 pour 3s)
///
/// # Exemples
///
/// ```no_run
/// use pmoaudio::TimerBufferNode;
///
/// // Buffer 3 secondes avant de commencer la diffusion
/// let buffer = TimerBufferNode::new(3.0);
/// ```
pub fn new(capacity_sec: f64) -> Self {
Self::with_channel_size(capacity_sec, DEFAULT_CHANNEL_SIZE)
}
/// Crée un TimerBufferNode avec une taille de buffer MPSC personnalisée
///
/// # Arguments
///
/// * `capacity_sec` - Capacité du buffer en secondes
/// * `channel_size` - Taille du buffer MPSC (nombre de segments en attente)
pub fn with_channel_size(capacity_sec: f64, channel_size: usize) -> Self {
let logic = TimerBufferNodeLogic::new(capacity_sec);
Self {
inner: Node::new_with_input(logic, channel_size),
}
}
}
#[async_trait::async_trait]
impl AudioPipelineNode for TimerBufferNode {
fn get_tx(&self) -> Option<mpsc::Sender<Arc<AudioSegment>>> {
self.inner.get_tx()
}
fn register(&mut self, child: Box<dyn AudioPipelineNode>) {
self.inner.register(child);
}
async fn run(self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError> {
Box::new(self.inner).run(stop_token).await
}
}
impl TypedAudioNode for TimerBufferNode {
fn input_type(&self) -> Option<TypeRequirement> {
// Accepte n'importe quel type
Some(TypeRequirement::any())
}
fn output_type(&self) -> Option<TypeRequirement> {
// Passthrough: produit le même type qu'il consomme
Some(TypeRequirement::any())
}
}

View File

@@ -73,15 +73,46 @@ use tokio_util::sync::CancellationToken;
pub struct TimerNodeLogic {
/// Avance maximale tolérée en secondes (buffer)
max_lead_time_sec: f64,
/// Tolérance supplémentaire avant de resynchroniser l'horloge
catchup_slack_sec: f64,
/// Instant de référence (reset au TopZeroSync)
start_time: Option<Instant>,
/// Nombre de chunks traités (pour instrumentation)
chunk_count: u64,
/// Dernier log d'instrumentation
last_stats_log: Option<Instant>,
}
impl TimerNodeLogic {
pub fn new(max_lead_time_sec: f64) -> Self {
let max_lead = max_lead_time_sec.max(0.0);
let slack = (max_lead * 0.25).max(0.5);
Self {
max_lead_time_sec: max_lead_time_sec.max(0.0),
max_lead_time_sec: max_lead,
catchup_slack_sec: slack,
start_time: None,
chunk_count: 0,
last_stats_log: None,
}
}
fn maybe_log_stats(&mut self, chunk_timestamp: f64, elapsed: f64, lead_time: f64) {
let now = Instant::now();
let should_log = match self.last_stats_log {
None => true,
Some(last) => now.duration_since(last) >= Duration::from_secs(1),
};
if should_log {
self.last_stats_log = Some(now);
tracing::debug!(
"TimerNode stats: chunks={} ts={:.3}s elapsed={:.3}s lead={:.3}s max={:.3}s",
self.chunk_count,
chunk_timestamp,
elapsed,
lead_time,
self.max_lead_time_sec
);
}
}
}
@@ -104,10 +135,20 @@ impl NodeLogic for TimerNodeLogic {
// Macro helper pour envoyer à tous les enfants
macro_rules! send_to_children {
($segment:expr) => {
for tx in &output {
for (idx, tx) in output.iter().enumerate() {
let send_start = Instant::now();
tx.send($segment.clone())
.await
.map_err(|_| AudioError::ChildDied)?;
let send_duration = send_start.elapsed();
if send_duration.as_millis() >= 50 {
tracing::debug!(
"TimerNode: send to child {} blocked for {:.3}s (segment ts={:.3}s)",
idx,
send_duration.as_secs_f64(),
$segment.timestamp_sec
);
}
}
};
}
@@ -149,9 +190,34 @@ impl NodeLogic for TimerNodeLogic {
_AudioSegment::Chunk(_) => {
// Vérifier le pacing seulement si on a un timer de référence
if let Some(start) = self.start_time {
self.chunk_count += 1;
let chunk_timestamp = segment.timestamp_sec;
let elapsed = start.elapsed().as_secs_f64();
let lead_time = chunk_timestamp - elapsed;
let mut elapsed = start.elapsed().as_secs_f64();
let mut lead_time = chunk_timestamp - elapsed;
// Si on a accumulé beaucoup trop d'avance (source ultra rapide),
// on recale l'horloge pour éviter de dormir pendant des dizaines de secondes.
let catchup_threshold = self.max_lead_time_sec + self.catchup_slack_sec;
if lead_time > catchup_threshold {
let desired_elapsed =
(chunk_timestamp - self.max_lead_time_sec).max(0.0);
let adjust = (desired_elapsed - elapsed).max(0.0);
let new_start =
Instant::now() - Duration::from_secs_f64(desired_elapsed);
self.start_time = Some(new_start);
elapsed = desired_elapsed;
lead_time = chunk_timestamp - elapsed;
tracing::warn!(
"TimerNode: lead {:.3}s > {:.3}s (max {:.3}s + slack {:.3}s) → fast-forward clock by {:.3}s",
chunk_timestamp - start.elapsed().as_secs_f64(),
catchup_threshold,
self.max_lead_time_sec,
self.catchup_slack_sec,
adjust
);
}
self.maybe_log_stats(chunk_timestamp, elapsed, lead_time);
tracing::trace!(
"TimerNodeLogic: chunk received (ts={:.3}s, elapsed={:.3}s, lead_time={:.3}s, max_lead={:.1}s)",
@@ -159,9 +225,9 @@ impl NodeLogic for TimerNodeLogic {
);
if lead_time > self.max_lead_time_sec {
// On est trop en avance, attendre
let sleep_duration = lead_time - self.max_lead_time_sec;
tracing::debug!(
// On est trop en avance, attendre juste assez pour retomber à max_lead_time
let sleep_duration = (lead_time - self.max_lead_time_sec).max(0.0);
tracing::trace!(
"TimerNodeLogic: SLEEPING {:.3}s (lead_time={:.3}s > max={:.1}s, chunk_ts={:.3}s)",
sleep_duration,
lead_time,
@@ -274,3 +340,4 @@ impl TypedAudioNode for TimerNode {
Some(TypeRequirement::any())
}
}

View File

@@ -104,10 +104,7 @@ pub trait AudioPipelineNode: Send + 'static {
/// - Un seul `cancel()` par nœud (en sortant de la boucle de travail)
/// - L'enfant ne cancel JAMAIS le parent
/// - `cancel()` est idempotent (pas de problème si appelé plusieurs fois)
async fn run(
self: Box<Self>,
stop_token: CancellationToken,
) -> Result<(), AudioError>;
async fn run(self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError>;
/// Lance le pipeline en arrière-plan et retourne un handle de contrôle
///
@@ -148,9 +145,7 @@ pub trait AudioPipelineNode: Send + 'static {
let stop_token = CancellationToken::new();
let token_for_task = stop_token.clone();
let join_handle = tokio::spawn(async move {
self.run(token_for_task).await
});
let join_handle = tokio::spawn(async move { self.run(token_for_task).await });
PipelineHandle {
stop_token,
@@ -373,12 +368,14 @@ impl PipelineHandle {
pub async fn wait(self) -> Result<(), AudioError> {
match self.join_handle.await {
Ok(result) => result,
Err(e) if e.is_panic() => Err(AudioError::ProcessingError(
format!("Pipeline task panicked: {}", e)
)),
Err(e) => Err(AudioError::ProcessingError(
format!("Pipeline task cancelled: {}", e)
)),
Err(e) if e.is_panic() => Err(AudioError::ProcessingError(format!(
"Pipeline task panicked: {}",
e
))),
Err(e) => Err(AudioError::ProcessingError(format!(
"Pipeline task cancelled: {}",
e
))),
}
}
@@ -506,10 +503,7 @@ impl<L: NodeLogic> AudioPipelineNode for Node<L> {
self.children.push(child);
}
async fn run(
mut self: Box<Self>,
stop_token: CancellationToken,
) -> Result<(), AudioError> {
async fn run(mut self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError> {
let Node {
mut logic,
rx,
@@ -528,9 +522,7 @@ impl<L: NodeLogic> AudioPipelineNode for Node<L> {
for (i, child) in children.into_iter().enumerate() {
tracing::debug!("Spawning child {}", i);
let child_token = stop_token.child_token();
let handle = tokio::spawn(async move {
child.run(child_token).await
});
let handle = tokio::spawn(async move { child.run(child_token).await });
child_handles.push(handle);
}
tracing::debug!("All {} children spawned", child_handles.len());
@@ -573,9 +565,10 @@ impl<L: NodeLogic> AudioPipelineNode for Node<L> {
// Un enfant a paniqué
tracing::error!("Child panicked: {}", e);
if !has_error {
first_error = Some(AudioError::ProcessingError(
format!("Child task panicked: {}", e)
));
first_error = Some(AudioError::ProcessingError(format!(
"Child task panicked: {}",
e
)));
has_error = true;
}
}
@@ -595,78 +588,79 @@ impl<L: NodeLogic> AudioPipelineNode for Node<L> {
// PHASE 3: EXÉCUTER LA LOGIQUE MÉTIER EN RACE AVEC LE MONITORING
// ═══════════════════════════════════════════════════════════════════
let (stop_reason, process_result, child_monitor_consumed) = if let Some(monitor) = &mut child_monitor {
// Il y a des enfants à surveiller
tokio::select! {
// Cancel externe demandé
_ = stop_token.cancelled() => {
tracing::debug!("Node cancelled via stop_token");
(StopReason::Cancelled, Ok(()), false)
}
let (stop_reason, process_result, child_monitor_consumed) =
if let Some(monitor) = &mut child_monitor {
// Il y a des enfants à surveiller
tokio::select! {
// Cancel externe demandé
_ = stop_token.cancelled() => {
tracing::debug!("Node cancelled via stop_token");
(StopReason::Cancelled, Ok(()), false)
}
// Monitoring des enfants - retourne quand tous sont terminés ou sur erreur
child_result = monitor => {
match child_result {
Ok(Ok(())) => {
// Tous les enfants terminés avec succès
// Le parent devrait aussi terminer bientôt
tracing::debug!("All children finished successfully");
(StopReason::Completed, Ok(()), true)
// Monitoring des enfants - retourne quand tous sont terminés ou sur erreur
child_result = monitor => {
match child_result {
Ok(Ok(())) => {
// Tous les enfants terminés avec succès
// Le parent devrait aussi terminer bientôt
tracing::debug!("All children finished successfully");
(StopReason::Completed, Ok(()), true)
}
Ok(Err(e)) => {
// Un enfant a eu une erreur - arrêter immédiatement
tracing::warn!("Child error: {}", e);
(StopReason::Error(e.clone()), Err(e), true)
}
Err(e) => {
// Le monitor task a paniqué
let error = AudioError::ProcessingError(
format!("Child monitor panicked: {}", e)
);
(StopReason::Error(error.clone()), Err(error), true)
}
}
Ok(Err(e)) => {
// Un enfant a eu une erreur - arrêter immédiatement
tracing::warn!("Child error: {}", e);
(StopReason::Error(e.clone()), Err(e), true)
}
Err(e) => {
// Le monitor task a paniqué
let error = AudioError::ProcessingError(
format!("Child monitor panicked: {}", e)
);
(StopReason::Error(error.clone()), Err(error), true)
}
// Logique métier du nœud
process_result = logic.process(rx, child_txs.clone(), stop_token.clone()) => {
tracing::info!("Node logic.process() returned");
match process_result {
Ok(()) => {
tracing::info!("Node process completed successfully");
(StopReason::Completed, Ok(()), false)
}
Err(e) => {
tracing::error!("Node process error: {}", e);
(StopReason::Error(e.clone()), Err(e), false)
}
}
}
}
} else {
// Pas d'enfants (nœud terminal) - juste exécuter la logique
tokio::select! {
// Cancel externe demandé
_ = stop_token.cancelled() => {
tracing::debug!("Node cancelled via stop_token");
(StopReason::Cancelled, Ok(()), true) // true car pas de monitor à attendre
}
// Logique métier du nœud
process_result = logic.process(rx, child_txs.clone(), stop_token.clone()) => {
tracing::info!("Node logic.process() returned");
match process_result {
Ok(()) => {
tracing::info!("Node process completed successfully");
(StopReason::Completed, Ok(()), false)
}
Err(e) => {
tracing::error!("Node process error: {}", e);
(StopReason::Error(e.clone()), Err(e), false)
// Logique métier du nœud
process_result = logic.process(rx, child_txs.clone(), stop_token.clone()) => {
match process_result {
Ok(()) => {
tracing::debug!("Node process completed successfully (terminal)");
(StopReason::Completed, Ok(()), true) // true car pas de monitor
}
Err(e) => {
tracing::error!("Node process error: {}", e);
(StopReason::Error(e.clone()), Err(e), true) // true car pas de monitor
}
}
}
}
}
} else {
// Pas d'enfants (nœud terminal) - juste exécuter la logique
tokio::select! {
// Cancel externe demandé
_ = stop_token.cancelled() => {
tracing::debug!("Node cancelled via stop_token");
(StopReason::Cancelled, Ok(()), true) // true car pas de monitor à attendre
}
// Logique métier du nœud
process_result = logic.process(rx, child_txs.clone(), stop_token.clone()) => {
match process_result {
Ok(()) => {
tracing::debug!("Node process completed successfully (terminal)");
(StopReason::Completed, Ok(()), true) // true car pas de monitor
}
Err(e) => {
tracing::error!("Node process error: {}", e);
(StopReason::Error(e.clone()), Err(e), true) // true car pas de monitor
}
}
}
}
};
};
// ═══════════════════════════════════════════════════════════════════
// PHASE 4: CLEANUP COORDONNÉ

View File

@@ -4,8 +4,13 @@ use tokio::sync::RwLock;
use pmometadata::TrackMetadata;
pub enum SyncMarker {
TrackBoundary { metadata: Arc<RwLock<dyn TrackMetadata>> },
StreamMetadata { key: String, value: String },
TrackBoundary {
metadata: Arc<RwLock<dyn TrackMetadata>>,
},
StreamMetadata {
key: String,
value: String,
},
TopZeroSync,
Heartbeat,
EndOfStream,