//! Sink qui encode les AudioSegment au format FLAC et les stocke dans le cache audio use pmoaudio::{ nodes::{AudioError, TypedAudioNode, DEFAULT_CHANNEL_SIZE}, pipeline::{Node, NodeLogic}, type_constraints::TypeRequirement, AudioChunk, AudioPipelineNode, AudioSegment, SyncMarker, _AudioSegment, }; use pmoaudiocache::AudioTrackMetadataExt; use pmoflac::{encode_flac_stream, EncoderOptions, PcmFormat}; use std::{ collections::VecDeque, pin::Pin, sync::Arc, task::{Context, Poll}, }; use tokio::{ io::{self, AsyncRead, ReadBuf}, sync::{mpsc, RwLock}, }; use tokio_util::sync::CancellationToken; use tracing::warn; /// Sink qui encode les `AudioSegment` reçus au format FLAC et les stocke dans le cache audio. /// /// Ce sink : /// - Filtre les chunks audio et ignore les autres syncmarkers (sauf TrackBoundary et EndOfStream) /// - Crée une nouvelle entrée de cache pour chaque TrackBoundary rencontré /// - Adapte automatiquement l'encodage FLAC selon la profondeur de bit du chunk (8/16/24/32-bit) /// - Copie les métadonnées du TrackBoundary dans le cache après ingestion /// - Peut optionnellement ajouter les tracks à une playlist via `register_playlist()` /// - Termine l'encodage proprement quand il reçoit EndOfStream // ═══════════════════════════════════════════════════════════════════════════ // FlacCacheSinkLogic - Logique métier pure // ═══════════════════════════════════════════════════════════════════════════ /// Signal retourné par pump_segments indiquant pourquoi l'encodage s'est arrêté. enum StopReason { TrackBoundary(Arc>), EndOfStream, ChannelClosed, } /// Logique pure d'encodage FLAC vers le cache pub struct FlacCacheSinkLogic { cache: Arc, covers: Arc, collection: Option, encoder_options: EncoderOptions, pcm_buffer_capacity: usize, #[cfg(feature = "playlist")] playlist_handle: Option>, } impl FlacCacheSinkLogic { pub fn new( cache: Arc, covers: Arc, collection: Option, encoder_options: EncoderOptions, pcm_buffer_capacity: usize, ) -> Self { Self { cache, covers, collection, encoder_options, pcm_buffer_capacity, #[cfg(feature = "playlist")] playlist_handle: None, } } #[cfg(feature = "playlist")] pub fn set_playlist_handle(&mut self, handle: Arc) { self.playlist_handle = Some(handle); } } #[async_trait::async_trait] impl NodeLogic for FlacCacheSinkLogic { async fn process( &mut self, input: Option>>, _output: Vec>>, stop_token: CancellationToken, ) -> Result<(), AudioError> { let mut rx = input.expect("FlacCacheSink must have input"); let mut track_number = 0; loop { // Attendre le premier chunk audio pour cette track 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 return Ok(()); } }; // Extraire les informations du premier chunk let first_chunk = first_segment.as_chunk().unwrap(); let sample_rate = first_chunk.sample_rate(); let bits_per_sample = get_chunk_bit_depth(first_chunk); let format = PcmFormat { sample_rate, channels: 2, bits_per_sample, }; if let Err(err) = format.validate() { return Err(AudioError::ProcessingError(format!( "Invalid PCM format: {}", err ))); } // Créer le pipeline d'encodage pour cette track let (pcm_tx, pcm_rx) = mpsc::channel::>(self.pcm_buffer_capacity); // Préparer les options d'encodage avec les métadonnées du TrackBoundary let mut options_with_metadata = self.encoder_options.clone(); options_with_metadata.metadata = track_metadata.clone(); // Créer l'encoder let reader = ByteStreamReader::new(pcm_rx); let flac_stream = encode_flac_stream(reader, format, options_with_metadata) .await .map_err(|e| { AudioError::ProcessingError(format!("FLAC encode init failed: {}", e)) })?; // Ingérer le FLAC progressivement dans le cache // add_from_reader lance l'ingestion en arrière-plan et retourne dès que // le prebuffer (512 KB) est atteint, permettant un streaming progressif let collection_ref = self.collection.as_deref(); let cache_future = self.cache.add_from_reader( None, flac_stream, None, // Taille inconnue car streaming collection_ref, ); // Exécuter pump et add_from_reader en parallèle let pump_future = pump_track_segments( first_segment, &mut rx, pcm_tx, bits_per_sample, sample_rate, &stop_token, ); // Attendre les deux tâches en parallèle let (cache_result, pump_result) = tokio::join!(cache_future, pump_future); let pk = cache_result.map_err(|e| { AudioError::ProcessingError(format!("Failed to add to cache: {}", e)) })?; tracing::debug!("Track added to cache with pk {}, prebuffer complete", pk); let (_chunks, _samples, _duration_sec, stop_reason) = pump_result?; // Copier les métadonnées du TrackBoundary dans le cache if let Some(src_metadata) = track_metadata { let dest_metadata = self.cache.track_metadata(&pk); // Utiliser copy_metadata_into pour copier toutes les métadonnées pmometadata::copy_metadata_into(&src_metadata, &dest_metadata) .await .map_err(|e| { AudioError::ProcessingError(format!( "Failed to copy metadata to cache: {}", e )) })?; let url = match dest_metadata.read().await.get_cover_url().await { Ok(url) => url, Err(e) if e.is_transient() => None, Err(_) => { warn!("Cannot obtain cover for audio asset {}", pk); None } }; if url.is_some() { let _ = match self.covers .add_from_url(&url.unwrap(), self.collection.as_deref()) .await { Ok(pk_covers) => { dest_metadata .write() .await .set_cover_pk(Some(pk_covers)) .await } Err(_) => { warn!("Cannot obtain cover for audio asset {}", pk); Ok(Some(())) } }; } } // Ajouter à la playlist si enregistrée #[cfg(feature = "playlist")] if let Some(ref playlist_handle) = self.playlist_handle { playlist_handle.push(pk.clone()).await.map_err(|e| { AudioError::ProcessingError(format!("Failed to add to playlist: {}", e)) })?; } // Vérifier le stop_reason pour savoir si on continue match stop_reason { StopReason::TrackBoundary(_metadata) => { // Continuer avec la prochaine track track_number += 1; continue; } StopReason::EndOfStream | StopReason::ChannelClosed => { // Fin de l'encodage return Ok(()); } } } } } // ═══════════════════════════════════════════════════════════════════════════ // FlacCacheSink - Wrapper utilisant Node // ═══════════════════════════════════════════════════════════════════════════ pub struct FlacCacheSink { inner: Node, } impl FlacCacheSink { /// Crée un sink FLAC cache avec les options par défaut (compression 5, buffer de 16 segments). /// /// # Arguments /// /// * `cache` - Arc vers le cache audio où stocker les fichiers FLAC encodés pub fn new(cache: Arc, covers: Arc) -> Self { Self::with_channel_size(cache, covers, DEFAULT_CHANNEL_SIZE) } /// Crée un sink FLAC cache avec une taille de buffer MPSC personnalisée. /// /// # Arguments /// /// * `cache` - Arc vers le cache audio /// * `channel_size` - Taille du buffer MPSC (nombre de segments en attente avant backpressure) pub fn with_channel_size( cache: Arc, covers: Arc, channel_size: usize, ) -> Self { Self::with_config(cache, covers, channel_size, EncoderOptions::default(), None) } /// Crée un sink FLAC cache avec une configuration complète. /// /// # Arguments /// /// * `cache` - Arc vers le cache audio /// * `channel_size` - Taille du buffer MPSC /// * `encoder_options` - Options d'encodage FLAC (compression, etc.) /// * `collection` - Collection optionnelle à laquelle appartiennent les fichiers pub fn with_config( cache: Arc, covers: Arc, channel_size: usize, encoder_options: EncoderOptions, collection: Option, ) -> Self { let logic = FlacCacheSinkLogic::new(cache, covers, collection, encoder_options, 8); Self { inner: Node::new_with_input(logic, channel_size), } } /// Enregistre une playlist pour recevoir automatiquement les tracks sauvées dans le cache. /// /// # Arguments /// /// * `handle` - WriteHandle de la playlist qui recevra les pk des tracks #[cfg(feature = "playlist")] pub fn register_playlist(&mut self, handle: pmoplaylist::WriteHandle) { self.inner.logic_mut().set_playlist_handle(Arc::new(handle)); } } /// Attend et retourne le premier chunk audio avec les métadonnées du TrackBoundary si présent. /// Retourne une erreur si EndOfStream est reçu avant tout audio. async fn wait_for_first_audio_chunk_with_metadata( rx: &mut mpsc::Receiver>, stop_token: &CancellationToken, ) -> Result< ( Arc, Option>>, ), AudioError, > { let mut track_metadata: Option>> = None; loop { let segment = tokio::select! { result = rx.recv() => { result.ok_or_else(|| AudioError::ProcessingError("No audio data received".into()))? } _ = stop_token.cancelled() => { return Err(AudioError::ProcessingError("Cancelled".into())); } }; match &segment.segment { _AudioSegment::Chunk(chunk) => { if chunk.len() == 0 { return Err(AudioError::ProcessingError("Received empty chunk".into())); } return Ok((segment, track_metadata)); } _AudioSegment::Sync(marker) => match &**marker { SyncMarker::TrackBoundary { metadata, .. } => { // Capturer les métadonnées du TrackBoundary track_metadata = Some(metadata.clone()); continue; } SyncMarker::EndOfStream => { return Err(AudioError::ProcessingError( "EndOfStream received before any audio".into(), )); } _ => { // Ignorer TopZeroSync, Heartbeat, etc. continue; } }, } } } /// Pompe les segments pour une seule track (s'arrête au TrackBoundary). async fn pump_track_segments( first_segment: Arc, rx: &mut mpsc::Receiver>, pcm_tx: mpsc::Sender>, bits_per_sample: u8, expected_rate: u32, stop_token: &CancellationToken, ) -> Result<(u64, u64, f64, StopReason), AudioError> { let mut chunks = 0u64; let mut samples = 0u64; let mut duration_sec = 0.0f64; // Traiter le premier segment if let Some(chunk) = first_segment.as_chunk() { let pcm_bytes = chunk_to_pcm_bytes(chunk, bits_per_sample)?; if !pcm_bytes.is_empty() { // Si le send échoue, c'est que le receiver est fermé // (par exemple, le fichier était déjà en cache et add_from_reader a retourné immédiatement) if pcm_tx.send(pcm_bytes).await.is_err() { drop(pcm_tx); return Ok((chunks, samples, duration_sec, StopReason::ChannelClosed)); } chunks += 1; samples += chunk.len() as u64; duration_sec += chunk.len() as f64 / expected_rate as f64; } } // Boucle sur les segments suivants loop { let segment = tokio::select! { result = rx.recv() => { match result { Some(seg) => seg, None => { drop(pcm_tx); // Fermer le channel PCM return Ok((chunks, samples, duration_sec, StopReason::ChannelClosed)); } } } _ = stop_token.cancelled() => { drop(pcm_tx); // Fermer le channel PCM return Ok((chunks, samples, duration_sec, StopReason::ChannelClosed)); } }; match &segment.segment { _AudioSegment::Chunk(chunk) => { // Vérifier la cohérence du sample rate if chunk.sample_rate() != expected_rate { return Err(AudioError::ProcessingError(format!( "FlacCacheSink: inconsistent sample rate ({} vs {})", chunk.sample_rate(), expected_rate ))); } let pcm_bytes = chunk_to_pcm_bytes(&chunk, bits_per_sample)?; if pcm_bytes.is_empty() { continue; } // Si le send échoue, c'est que le receiver est fermé // (par exemple, le fichier était déjà en cache et add_from_reader a retourné immédiatement) if pcm_tx.send(pcm_bytes).await.is_err() { drop(pcm_tx); return Ok((chunks, samples, duration_sec, StopReason::ChannelClosed)); } chunks += 1; samples += chunk.len() as u64; duration_sec += chunk.len() as f64 / expected_rate as f64; } _AudioSegment::Sync(marker) => match &**marker { SyncMarker::TrackBoundary { metadata, .. } => { drop(pcm_tx); // Fermer le channel PCM return Ok(( chunks, samples, duration_sec, StopReason::TrackBoundary(metadata.clone()), )); } SyncMarker::EndOfStream => { drop(pcm_tx); // Fermer le channel PCM return Ok((chunks, samples, duration_sec, StopReason::EndOfStream)); } _ => {} // Ignorer les autres syncmarkers }, } } } /// Détermine la profondeur de bit d'un chunk audio fn get_chunk_bit_depth(chunk: &AudioChunk) -> u8 { match chunk { AudioChunk::I16(_) => 16, AudioChunk::I24(_) => 24, AudioChunk::I32(_) => 32, AudioChunk::F32(_) => 32, // Les flottants seront convertis en 32-bit AudioChunk::F64(_) => 32, // Les flottants seront convertis en 32-bit } } /// Convertit un chunk audio en bytes PCM avec la profondeur de bit spécifiée fn chunk_to_pcm_bytes(chunk: &AudioChunk, bits_per_sample: u8) -> Result, AudioError> { // Vérifier que le chunk est de type entier match chunk { AudioChunk::F32(_) | AudioChunk::F64(_) => { return Err(AudioError::ProcessingError( "FlacCacheSink only supports integer audio chunks (I16, I24, I32)".into(), )); } _ => {} } let len = chunk.len(); let bytes_per_frame = (bits_per_sample / 8) as usize * 2; // 2 channels let mut bytes = Vec::with_capacity(len * bytes_per_frame); // Convertir selon le type du chunk match (chunk, bits_per_sample) { // I16 source (AudioChunk::I16(data), 16) => { for frame in data.get_frames() { bytes.extend_from_slice(&frame[0].to_le_bytes()); bytes.extend_from_slice(&frame[1].to_le_bytes()); } } (AudioChunk::I16(data), 24) => { for frame in data.get_frames() { let left = (frame[0] as i32) << 8; let right = (frame[1] as i32) << 8; bytes.extend_from_slice(&left.to_le_bytes()[..3]); bytes.extend_from_slice(&right.to_le_bytes()[..3]); } } (AudioChunk::I16(data), 32) => { for frame in data.get_frames() { let left = (frame[0] as i32) << 16; let right = (frame[1] as i32) << 16; bytes.extend_from_slice(&left.to_le_bytes()); bytes.extend_from_slice(&right.to_le_bytes()); } } // I24 source (AudioChunk::I24(data), 16) => { for frame in data.get_frames() { let left = (frame[0].as_i32() >> 8) as i16; let right = (frame[1].as_i32() >> 8) as i16; bytes.extend_from_slice(&left.to_le_bytes()); bytes.extend_from_slice(&right.to_le_bytes()); } } (AudioChunk::I24(data), 24) => { for frame in data.get_frames() { bytes.extend_from_slice(&frame[0].as_i32().to_le_bytes()[..3]); bytes.extend_from_slice(&frame[1].as_i32().to_le_bytes()[..3]); } } (AudioChunk::I24(data), 32) => { for frame in data.get_frames() { let left = frame[0].as_i32() << 8; let right = frame[1].as_i32() << 8; bytes.extend_from_slice(&left.to_le_bytes()); bytes.extend_from_slice(&right.to_le_bytes()); } } // I32 source (AudioChunk::I32(data), 16) => { for frame in data.get_frames() { let left = (frame[0] >> 16) as i16; let right = (frame[1] >> 16) as i16; bytes.extend_from_slice(&left.to_le_bytes()); bytes.extend_from_slice(&right.to_le_bytes()); } } (AudioChunk::I32(data), 24) => { for frame in data.get_frames() { let left = frame[0] >> 8; let right = frame[1] >> 8; bytes.extend_from_slice(&left.to_le_bytes()[..3]); bytes.extend_from_slice(&right.to_le_bytes()[..3]); } } (AudioChunk::I32(data), 32) => { for frame in data.get_frames() { bytes.extend_from_slice(&frame[0].to_le_bytes()); bytes.extend_from_slice(&frame[1].to_le_bytes()); } } _ => { return Err(AudioError::ProcessingError(format!( "Unsupported bits_per_sample: {}", bits_per_sample ))); } } Ok(bytes) } struct ByteStreamReader { rx: mpsc::Receiver>, buffer: VecDeque, finished: bool, } impl ByteStreamReader { fn new(rx: mpsc::Receiver>) -> Self { Self { rx, buffer: VecDeque::new(), finished: false, } } } impl AsyncRead for ByteStreamReader { fn poll_read( mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>, ) -> Poll> { loop { if !self.buffer.is_empty() { let to_copy = self.buffer.len().min(buf.remaining()); if to_copy == 0 { return Poll::Ready(Ok(())); } // VecDeque::make_contiguous pour copier efficacement let slice = self.buffer.make_contiguous(); buf.put_slice(&slice[..to_copy]); self.buffer.drain(..to_copy); return Poll::Ready(Ok(())); } if self.finished { return Poll::Ready(Ok(())); } match Pin::new(&mut self.rx).poll_recv(cx) { Poll::Ready(Some(bytes)) => { if bytes.is_empty() { continue; } self.buffer.extend(bytes); } Poll::Ready(None) => { self.finished = true; return Poll::Ready(Ok(())); } Poll::Pending => return Poll::Pending, } } } } /// Statistiques pour une track individuelle. #[derive(Debug, Clone)] pub struct TrackStats { pub pk: String, pub track_number: usize, pub chunks_received: u64, pub total_samples: u64, pub total_duration_sec: f64, } /// Statistiques produites par le `FlacCacheSink`. #[derive(Debug, Clone)] pub struct FlacCacheSinkStats { pub tracks: Vec, } #[async_trait::async_trait] impl AudioPipelineNode for FlacCacheSink { fn get_tx(&self) -> Option>> { self.inner.get_tx() } fn register(&mut self, _child: Box) { panic!("FlacCacheSink is a terminal sink and cannot have children"); } async fn run(self: Box, stop_token: CancellationToken) -> Result<(), AudioError> { Box::new(self.inner).run(stop_token).await } } impl TypedAudioNode for FlacCacheSink { fn input_type(&self) -> Option { // FlacCacheSink accepte n'importe quel type entier (I16, I24, I32) // mais rejette les chunks flottants Some(TypeRequirement::any_integer()) } fn output_type(&self) -> Option { // FlacCacheSink est un sink, il ne produit pas d'audio None } }