debuggage des stream
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
357
pmoaudio/src/nodes/timer_buffer_node.rs
Normal file
357
pmoaudio/src/nodes/timer_buffer_node.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user