From 6bce26c4fca1a500e18a0d1bd69de813e8eba45a Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Sat, 1 Nov 2025 21:10:57 +0100 Subject: [PATCH] Retour sur pmoaudio --- Cargo.lock | 3 + old_code/pmoaudio/nodes/file_source.rs | 244 ------ old_code/pmoaudio/nodes/flac_file_sink.rs | 300 ------- pmoaudio/Cargo.toml | 4 + pmoaudio/examples/audio_chunk_api.rs | 44 +- pmoaudio/examples/file_nodes_test.rs | 75 ++ pmoaudio/src/audio_chunk.rs | 509 +++++++++-- pmoaudio/src/audio_segment.rs | 92 +- pmoaudio/src/conversions.rs | 299 ++----- pmoaudio/src/dsp/gain_16bits.rs | 94 ++ pmoaudio/src/dsp/gain_24bits.rs | 99 +++ pmoaudio/src/dsp/{gain.rs => gain_32bits.rs} | 20 +- pmoaudio/src/dsp/int_float.rs | 306 ++++++- pmoaudio/src/dsp/mod.rs | 14 +- pmoaudio/src/lib.rs | 34 +- pmoaudio/src/macros.rs | 39 +- pmoaudio/src/nodes/converter_nodes.rs | 472 ++++++++++ pmoaudio/src/nodes/file_source.rs | 426 +++++++++ pmoaudio/src/nodes/flac_file_sink.rs | 766 ++++++++++++++++ pmoaudio/src/nodes/http_source.rs | 827 ++++++++++++++++++ .../pmoaudio => pmoaudio/src}/nodes/mod.rs | 78 +- pmoaudio/src/sample_types.rs | 25 +- pmoaudio/src/type_constraints.rs | 385 ++++++++ pmoflac/src/encoder.rs | 175 +++- pmoflac/src/transcode.rs | 5 +- pmometadata/src/lib.rs | 194 ++-- 26 files changed, 4481 insertions(+), 1048 deletions(-) delete mode 100644 old_code/pmoaudio/nodes/file_source.rs delete mode 100644 old_code/pmoaudio/nodes/flac_file_sink.rs create mode 100644 pmoaudio/examples/file_nodes_test.rs create mode 100644 pmoaudio/src/dsp/gain_16bits.rs create mode 100644 pmoaudio/src/dsp/gain_24bits.rs rename pmoaudio/src/dsp/{gain.rs => gain_32bits.rs} (80%) create mode 100644 pmoaudio/src/nodes/converter_nodes.rs create mode 100644 pmoaudio/src/nodes/file_source.rs create mode 100644 pmoaudio/src/nodes/flac_file_sink.rs create mode 100644 pmoaudio/src/nodes/http_source.rs rename {old_code/pmoaudio => pmoaudio/src}/nodes/mod.rs (59%) create mode 100644 pmoaudio/src/type_constraints.rs diff --git a/Cargo.lock b/Cargo.lock index fb7a43c9..df6869eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2630,6 +2630,7 @@ name = "pmoaudiocache" version = "0.1.0" dependencies = [ "anyhow", + "async-trait", "axum 0.8.6", "bytes", "chrono", @@ -2640,11 +2641,13 @@ dependencies = [ "pmoconfig", "pmodidl", "pmoflac", + "pmometadata", "pmoserver", "quick-xml 0.37.5", "rusqlite", "serde", "serde_json", + "tempfile", "tokio", "tracing", "tracing-subscriber", diff --git a/old_code/pmoaudio/nodes/file_source.rs b/old_code/pmoaudio/nodes/file_source.rs deleted file mode 100644 index 7491bef9..00000000 --- a/old_code/pmoaudio/nodes/file_source.rs +++ /dev/null @@ -1,244 +0,0 @@ -use crate::{ - nodes::{AudioError, MultiSubscriberNode}, - AudioChunk, BitDepth, -}; -use pmoflac::{decode_audio_stream, StreamInfo}; -use std::{path::PathBuf, sync::Arc}; -use tokio::{fs::File, io::AsyncReadExt, sync::mpsc}; - -/// FileSource - Lit un fichier audio et publie des `AudioChunk` -/// -/// Cette source utilise `pmoflac` pour décoder le fichier (FLAC/MP3/OGG/WAV/AIFF) -/// puis transforme les échantillons PCM en `AudioChunk` stéréo. -pub struct FileSource { - path: PathBuf, - chunk_frames: usize, - subscribers: MultiSubscriberNode, -} - -impl FileSource { - /// Crée une nouvelle source de fichier. - /// - /// * `path` - chemin du fichier audio à lire - /// * `chunk_frames` - nombre d'échantillons par canal par chunk - pub fn new>(path: P, chunk_frames: usize) -> Self { - Self { - path: path.into(), - chunk_frames: chunk_frames.max(1), - subscribers: MultiSubscriberNode::new(), - } - } - - /// Ajoute un abonné qui recevra les chunks décodés. - pub fn add_subscriber(&mut self, tx: mpsc::Sender>) { - self.subscribers.add_subscriber(tx); - } - - /// Lance la lecture du fichier et diffuse les chunks. - pub async fn run(self) -> Result<(), AudioError> { - let file = File::open(&self.path).await.map_err(|e| { - AudioError::ProcessingError(format!("Failed to open {:?}: {}", self.path, e)) - })?; - - let mut stream = decode_audio_stream(file) - .await - .map_err(|e| AudioError::ProcessingError(format!("Decode error: {}", e)))?; - let stream_info = stream.info().clone(); - - validate_stream(&stream_info)?; - - let frame_bytes = stream_info.bytes_per_sample() * stream_info.channels as usize; - let chunk_byte_len = self.chunk_frames * frame_bytes; - let mut pending = Vec::new(); - let mut read_buf = vec![0u8; frame_bytes * 512.max(self.chunk_frames)]; - let mut chunk_index = 0u64; - - loop { - if pending.len() < chunk_byte_len { - let read = stream.read(&mut read_buf).await.map_err(|e| { - AudioError::ProcessingError(format!("I/O error while decoding: {}", e)) - })?; - if read == 0 { - break; - } - pending.extend_from_slice(&read_buf[..read]); - } - - if pending.is_empty() { - break; - } - - let frames_in_pending = pending.len() / frame_bytes; - let frames_to_emit = frames_in_pending.min(self.chunk_frames); - let take_bytes = frames_to_emit * frame_bytes; - let chunk_bytes = pending.drain(..take_bytes).collect::>(); - - let chunk = bytes_to_chunk(&chunk_bytes, &stream_info, frames_to_emit, chunk_index)?; - self.subscribers.push(chunk).await?; - chunk_index += 1; - } - - // Reste éventuel (moins qu'un chunk complet) - if !pending.is_empty() { - let frames = pending.len() / frame_bytes; - if frames > 0 { - let chunk = bytes_to_chunk(&pending, &stream_info, frames, chunk_index)?; - self.subscribers.push(chunk).await?; - } - } - - stream - .wait() - .await - .map_err(|e| AudioError::ProcessingError(format!("Decode task failed: {}", e)))?; - - Ok(()) - } -} - -fn validate_stream(info: &StreamInfo) -> Result<(), AudioError> { - if !(1..=2).contains(&info.channels) { - return Err(AudioError::ProcessingError(format!( - "Unsupported channel count: {}", - info.channels - ))); - } - match info.bits_per_sample { - 8 | 16 | 24 | 32 => Ok(()), - other => Err(AudioError::ProcessingError(format!( - "Unsupported bit depth: {}", - other - ))), - } -} - -fn bytes_to_chunk( - chunk_bytes: &[u8], - info: &StreamInfo, - frames: usize, - order: u64, -) -> Result, AudioError> { - let bytes_per_sample = info.bytes_per_sample(); - let channels = info.channels as usize; - let frame_bytes = bytes_per_sample * channels; - - let mut left = Vec::with_capacity(frames); - let mut right = Vec::with_capacity(frames); - - for frame_idx in 0..frames { - let base = frame_idx * frame_bytes; - let l = sample_to_f32( - &chunk_bytes[base..base + bytes_per_sample], - info.bits_per_sample, - )?; - let r = if channels == 1 { - l - } else { - sample_to_f32( - &chunk_bytes[base + bytes_per_sample..base + 2 * bytes_per_sample], - info.bits_per_sample, - )? - }; - - left.push(l); - right.push(r); - } - - let bit_depth = BitDepth::from_u32_strict(info.bits_per_sample as u32); - Ok(AudioChunk::from_channels_f32( - order, - left, - right, - info.sample_rate, - bit_depth, - )) -} - -fn sample_to_f32(sample_bytes: &[u8], bits: u8) -> Result { - let sample = match bits { - 8 => i8::from_le_bytes([sample_bytes[0]]) as i32, - 16 => i16::from_le_bytes(sample_bytes.try_into().unwrap()) as i32, - 24 => { - let mut buf = [0u8; 4]; - buf[..3].copy_from_slice(sample_bytes); - // Sign extend manually - if sample_bytes[2] & 0x80 != 0 { - buf[3] = 0xFF; - } - i32::from_le_bytes(buf) - } - 32 => i32::from_le_bytes(sample_bytes.try_into().unwrap()), - other => { - return Err(AudioError::ProcessingError(format!( - "Unsupported bit depth: {}", - other - ))) - } - }; - - let max = ((1i64 << (bits as i64 - 1)).saturating_sub(1)) as f32; - Ok((sample as f32) / max) -} - -#[cfg(test)] -mod tests { - use super::*; - use pmoflac::{encode_flac_stream, EncoderOptions, PcmFormat}; - use std::io::Cursor; - use tokio::io::AsyncWriteExt; - use tokio::sync::mpsc; - - #[tokio::test] - async fn test_file_source_decodes_flac() { - let temp_dir = tempfile::tempdir().unwrap(); - let flac_path = temp_dir.path().join("test.flac"); - - let sample_rate = 48_000; - let frames = 256; - let mut pcm = Vec::with_capacity(frames * 4); - for i in 0..frames { - let sample = ((i % 32) as f32 / 31.0 * 2.0 - 1.0) * 0.5; // simple ramp - let sample_i16 = (sample * 32767.0) as i16; - pcm.extend_from_slice(&sample_i16.to_le_bytes()); - pcm.extend_from_slice(&sample_i16.to_le_bytes()); - } - - let format = PcmFormat { - sample_rate, - channels: 2, - bits_per_sample: 16, - }; - - let mut flac_stream = - encode_flac_stream(Cursor::new(pcm.clone()), format, EncoderOptions::default()) - .await - .unwrap(); - - let mut file = File::create(&flac_path).await.expect("create flac file"); - tokio::io::copy(&mut flac_stream, &mut file) - .await - .expect("write flac"); - file.flush().await.expect("flush file"); - flac_stream.wait().await.unwrap(); - - let mut source = FileSource::new(&flac_path, 64); - let (tx, mut rx) = mpsc::channel(4); - source.add_subscriber(tx); - - tokio::spawn(async move { - source.run().await.unwrap(); - }); - - let mut received = 0usize; - while let Some(chunk) = rx.recv().await { - received += chunk.len(); - assert_eq!(chunk.sample_rate(), sample_rate); - let scale = 1.0 / chunk.bit_depth().max_value(); - if let Some(frame) = chunk.frames().first() { - assert!(((frame[0] as f32) * scale).abs() <= 1.0); // sample range sanity - } - } - - assert_eq!(received, frames); - } -} diff --git a/old_code/pmoaudio/nodes/flac_file_sink.rs b/old_code/pmoaudio/nodes/flac_file_sink.rs deleted file mode 100644 index 814bd6c2..00000000 --- a/old_code/pmoaudio/nodes/flac_file_sink.rs +++ /dev/null @@ -1,300 +0,0 @@ -use crate::{nodes::AudioError, AudioChunk}; -use pmoflac::{encode_flac_stream, EncoderOptions, PcmFormat}; -use std::{ - collections::VecDeque, - path::PathBuf, - pin::Pin, - sync::Arc, - task::{Context, Poll}, -}; -use tokio::{ - fs::File, - io::{self, AsyncRead, AsyncWriteExt, ReadBuf}, - sync::mpsc, -}; - -/// Sink qui encode les `AudioChunk` reçus au format FLAC. -pub struct FlacFileSink { - rx: mpsc::Receiver>, - path: PathBuf, - encoder_options: EncoderOptions, - pcm_buffer_capacity: usize, -} - -impl FlacFileSink { - /// Crée un sink FLAC avec les options par défaut (compression 5). - pub fn new>( - path: P, - channel_size: usize, - ) -> (Self, mpsc::Sender>) { - Self::with_options(path, channel_size, EncoderOptions::default()) - } - - /// Crée un sink FLAC avec des options explicites. - pub fn with_options>( - path: P, - channel_size: usize, - encoder_options: EncoderOptions, - ) -> (Self, mpsc::Sender>) { - let (tx, rx) = mpsc::channel(channel_size); - let sink = Self { - rx, - path: path.into(), - encoder_options, - pcm_buffer_capacity: 8, - }; - (sink, tx) - } - - /// Lance l'encodage vers le fichier cible. - pub async fn run(self) -> Result { - let FlacFileSink { - mut rx, - path, - encoder_options, - pcm_buffer_capacity, - } = self; - - let first_chunk = rx.recv().await.ok_or_else(|| { - AudioError::ProcessingError("FlacFileSink: no audio data received".into()) - })?; - - if first_chunk.len() == 0 { - return Err(AudioError::ProcessingError( - "FlacFileSink: received empty chunk".into(), - )); - } - - let format = PcmFormat { - sample_rate: first_chunk.sample_rate(), - channels: 2, - bits_per_sample: 16, - }; - if let Err(err) = format.validate() { - return Err(AudioError::ProcessingError(format!( - "Invalid PCM format: {}", - err - ))); - } - - let (pcm_tx, pcm_rx) = mpsc::channel::>(pcm_buffer_capacity); - let pump_handle = tokio::spawn(pump_chunks(first_chunk, rx, pcm_tx)); - - let reader = ByteStreamReader::new(pcm_rx); - let mut flac_stream = encode_flac_stream(reader, format, encoder_options) - .await - .map_err(|e| AudioError::ProcessingError(format!("FLAC encode init failed: {}", e)))?; - - let mut output = File::create(&path).await.map_err(|e| { - AudioError::ProcessingError(format!("Failed to create {:?}: {}", path, e)) - })?; - - tokio::io::copy(&mut flac_stream, &mut output) - .await - .map_err(|e| AudioError::ProcessingError(format!("FLAC write failed: {}", e)))?; - output.flush().await.map_err(|e| { - AudioError::ProcessingError(format!("Failed to flush {:?}: {}", path, e)) - })?; - - flac_stream - .wait() - .await - .map_err(|e| AudioError::ProcessingError(format!("FLAC encoder task failed: {}", e)))?; - - let pump_stats = pump_handle - .await - .map_err(|e| AudioError::ProcessingError(format!("Pump task panicked: {}", e)))??; - - Ok(FlacFileSinkStats { - path, - chunks_received: pump_stats.chunks, - total_samples: pump_stats.samples, - total_duration_sec: pump_stats.duration_sec, - }) - } -} - -struct PumpStats { - chunks: u64, - samples: u64, - duration_sec: f64, -} - -async fn pump_chunks( - first_chunk: Arc, - mut rx: mpsc::Receiver>, - pcm_tx: mpsc::Sender>, -) -> Result { - let mut chunks = 0u64; - let mut samples = 0u64; - let mut duration_sec = 0.0f64; - let expected_rate = first_chunk.sample_rate(); - - let mut current = Some(first_chunk); - - loop { - let chunk_opt = if let Some(ch) = current.take() { - Some(ch) - } else { - rx.recv().await - }; - - let chunk = match chunk_opt { - Some(ch) => ch, - None => break, - }; - - if chunk.sample_rate() != expected_rate { - return Err(AudioError::ProcessingError(format!( - "FlacFileSink: inconsistent sample rate ({} vs {})", - chunk.sample_rate(), - expected_rate - ))); - } - - let pcm_bytes = chunk_to_pcm_bytes(&chunk); - if pcm_bytes.is_empty() { - continue; - } - - pcm_tx - .send(pcm_bytes) - .await - .map_err(|_| AudioError::SendError)?; - - chunks += 1; - samples += chunk.len() as u64; - duration_sec += chunk.len() as f64 / expected_rate as f64; - } - - Ok(PumpStats { - chunks, - samples, - duration_sec, - }) -} - -fn chunk_to_pcm_bytes(chunk: &AudioChunk) -> Vec { - let len = chunk.len(); - let mut bytes = Vec::with_capacity(len * 4); - let gain = chunk.gain_linear() as f32; - let scale = 1.0f32 / chunk.bit_depth().max_value(); - for frame in chunk.frames() { - let left = (frame[0] as f32 * scale * gain).clamp(-1.0, 1.0); - let right = (frame[1] as f32 * scale * gain).clamp(-1.0, 1.0); - let left_i16 = (left * 32767.0) as i16; - let right_i16 = (right * 32767.0) as i16; - bytes.extend_from_slice(&left_i16.to_le_bytes()); - bytes.extend_from_slice(&right_i16.to_le_bytes()); - } - 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 produites par le `FlacFileSink`. -#[derive(Debug, Clone)] -pub struct FlacFileSinkStats { - pub path: PathBuf, - pub chunks_received: u64, - pub total_samples: u64, - pub total_duration_sec: f64, -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::BitDepth; - use pmoflac::decode_flac_stream; - use tokio::io::AsyncReadExt; - - #[tokio::test] - async fn test_flac_file_sink_writes_audio() { - let temp_dir = tempfile::tempdir().unwrap(); - let output_path = temp_dir.path().join("output.flac"); - - let (sink, tx) = FlacFileSink::new(&output_path, 8); - let handle = tokio::spawn(async move { sink.run().await.unwrap() }); - - let chunk = AudioChunk::from_channels_f32( - 0, - vec![0.25; 256], - vec![0.5; 256], - 44_100, - BitDepth::B24, - ); - tx.send(chunk).await.unwrap(); - - drop(tx); - - let stats = handle.await.unwrap(); - assert_eq!(stats.chunks_received, 1); - assert_eq!(stats.total_samples, 256); - - let file = File::open(&output_path).await.unwrap(); - let mut stream = decode_flac_stream(file).await.unwrap(); - let info = stream.info().clone(); - assert_eq!(info.channels, 2); - assert_eq!(info.sample_rate, 44_100); - - let mut decoded = Vec::new(); - stream.read_to_end(&mut decoded).await.unwrap(); - stream.wait().await.unwrap(); - assert_eq!(decoded.len(), 256 * 4); // 256 frames * 2 channels * 2 bytes - } -} diff --git a/pmoaudio/Cargo.toml b/pmoaudio/Cargo.toml index 4f8f5358..02f186df 100644 --- a/pmoaudio/Cargo.toml +++ b/pmoaudio/Cargo.toml @@ -9,13 +9,17 @@ simd = [] [dependencies] tokio = { version = "1.42", features = ["full"] } +tokio-util = { version = "0.7", features = ["io"] } async-trait = "0.1" +futures-util = "0.3" pmoflac = { path = "../pmoflac" } pmometadata = { path = "../pmometadata" } paste = "1" soxr = "0.6.0" bytemuck = "1.24.0" +reqwest = { version = "0.12", features = ["stream"] } [dev-dependencies] tokio-test = "0.4" tempfile = "3" +wiremock = "0.6" diff --git a/pmoaudio/examples/audio_chunk_api.rs b/pmoaudio/examples/audio_chunk_api.rs index a96a3ac2..94c0dd35 100644 --- a/pmoaudio/examples/audio_chunk_api.rs +++ b/pmoaudio/examples/audio_chunk_api.rs @@ -30,12 +30,20 @@ fn example_create_chunks() { // Chunk I32 stéréo let stereo_i32 = vec![[1000i32, 2000i32], [3000i32, 4000i32]]; let chunk_i32 = AudioChunkData::new(stereo_i32, 48000, 0.0); - println!("Chunk I32: {} frames @ {}Hz", chunk_i32.len(), chunk_i32.sample_rate()); + println!( + "Chunk I32: {} frames @ {}Hz", + chunk_i32.len(), + chunk_i32.sample_rate() + ); // Chunk F32 stéréo (normalisé [-1.0, 1.0]) let stereo_f32 = vec![[0.5f32, -0.5f32], [0.8f32, -0.8f32]]; let chunk_f32 = AudioChunkData::new(stereo_f32, 48000, 0.0); - println!("Chunk F32: {} frames @ {}Hz", chunk_f32.len(), chunk_f32.sample_rate()); + println!( + "Chunk F32: {} frames @ {}Hz", + chunk_f32.len(), + chunk_f32.sample_rate() + ); // Chunk depuis canaux séparés let left = vec![100i32, 200i32, 300i32]; @@ -77,7 +85,10 @@ fn example_conversions() { // Utilisation des traits From/Into let chunk_i16 = AudioChunkData::new(vec![[1000i16, 2000i16]], 48000, 0.0); let chunk_i32_from_i16: std::sync::Arc> = (&*chunk_i16).into(); - println!("\nConversion I16 → I32 via Into: {} frames", chunk_i32_from_i16.len()); + println!( + "\nConversion I16 → I32 via Into: {} frames", + chunk_i32_from_i16.len() + ); println!(); } @@ -163,30 +174,33 @@ fn example_gain_manipulation() { println!(">>> Manipulation du gain\n"); // Créer un segment - let segment = AudioSegment::new_chunk( - 0, - 0.0, - vec![[1000i32, 2000i32]], - 48000, - BitDepth::B32, - ); + let segment = AudioSegment::new_chunk(0, 0.0, vec![[1000i32, 2000i32]], 48000, BitDepth::B32); println!("Gain initial: {} dB", segment.gain_db().unwrap()); // Définir un gain absolu let segment_6db = segment.with_gain_db(6.0).unwrap(); - println!("Après with_gain_db(6.0): {} dB", segment_6db.gain_db().unwrap()); + println!( + "Après with_gain_db(6.0): {} dB", + segment_6db.gain_db().unwrap() + ); // Ajuster le gain (relatif) let segment_9db = segment_6db.adjust_gain_db(3.0).unwrap(); - println!("Après adjust_gain_db(+3.0): {} dB", segment_9db.gain_db().unwrap()); + println!( + "Après adjust_gain_db(+3.0): {} dB", + segment_9db.gain_db().unwrap() + ); // Les segments originaux ne sont pas modifiés (immutabilité) - println!("Gain du segment original: {} dB", segment.gain_db().unwrap()); + println!( + "Gain du segment original: {} dB", + segment.gain_db().unwrap() + ); // Conversion gain linéaire ↔ dB - let linear_gain = db_to_linear(6.0); - let gain_db = linear_to_db(linear_gain); + let linear_gain = gain_linear_from_db(6.0); + let gain_db = gain_db_from_linear(linear_gain); println!("\n6 dB = {:.4}x (linéaire)", linear_gain); println!("{:.4}x = {:.2} dB", linear_gain, gain_db); diff --git a/pmoaudio/examples/file_nodes_test.rs b/pmoaudio/examples/file_nodes_test.rs new file mode 100644 index 00000000..ac5909dd --- /dev/null +++ b/pmoaudio/examples/file_nodes_test.rs @@ -0,0 +1,75 @@ +//! Test d'intégration pour FileSource et FlacFileSink +//! +//! Ce programme teste la chaîne complète : +//! 1. Lecture d'un fichier audio avec FileSource +//! 2. Écriture vers FLAC avec FlacFileSink +//! +//! Usage: +//! cargo run --example file_nodes_test -- + +use pmoaudio::{FileSource, FlacFileSink}; +use std::env; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Récupérer les arguments + let args: Vec = env::args().collect(); + if args.len() != 3 { + eprintln!("Usage: {} ", args[0]); + eprintln!("Example: {} input.flac output.flac", args[0]); + std::process::exit(1); + } + + let input_path = &args[1]; + let output_path = &args[2]; + + println!("Input: {}", input_path); + println!("Output: {}", output_path); + println!(); + + // Créer le pipeline: FileSource → FlacFileSink + let mut source = FileSource::new(input_path); // Calcul automatique de la taille des chunks (~50ms) + let (sink, tx) = FlacFileSink::new(output_path); // Utilise le buffer par défaut (16 segments) + + source.add_subscriber(tx); + + // Lancer le sink dans une tâche séparée + let sink_handle = tokio::spawn(async move { + println!("FlacFileSink started"); + let result = sink.run().await; + println!("FlacFileSink finished"); + result + }); + + // Lancer le source + println!("FileSource started"); + let source_result = source.run().await; + println!("FileSource finished"); + + // Vérifier les résultats + match source_result { + Ok(()) => println!("✓ FileSource completed successfully"), + Err(e) => { + eprintln!("✗ FileSource error: {}", e); + return Err(e.into()); + } + } + + let stats = sink_handle.await??; + println!("✓ FlacFileSink completed successfully"); + println!(); + println!("Statistics:"); + println!(" Tracks written: {}", stats.tracks.len()); + for (i, track) in stats.tracks.iter().enumerate() { + println!(" Track {}:", i); + println!(" Output file: {:?}", track.path); + println!(" Chunks received: {}", track.chunks_received); + println!(" Total samples: {}", track.total_samples); + println!( + " Duration: {:.2} seconds", + track.total_duration_sec + ); + } + + Ok(()) +} diff --git a/pmoaudio/src/audio_chunk.rs b/pmoaudio/src/audio_chunk.rs index befd000c..6bb68d70 100644 --- a/pmoaudio/src/audio_chunk.rs +++ b/pmoaudio/src/audio_chunk.rs @@ -1,7 +1,7 @@ //! AudioChunk : Représentation générique de données audio stéréo //! //! Cette nouvelle architecture supporte différents types de samples : -//! - Entiers : i8, i16, I24 (24-bit), i32 +//! - Entiers : i16, I24 (24-bit), i32 //! - Flottants : f32, f64 //! //! L'utilisation de génériques permet de factoriser le code tout en gardant @@ -18,7 +18,7 @@ use crate::{dsp, BitDepth, Sample, I24}; /// Représente un chunk audio stéréo typé avec partage zero-copy via Arc /// /// Cette structure générique encapsule des données audio de n'importe quel type -/// de sample (i8, i16, I24, i32, f32, f64). Les données sont partagées via `Arc` +/// de sample (i16, I24, i32, f32, f64). Les données sont partagées via `Arc` /// pour permettre un partage efficace entre plusieurs consumers sans copier. /// /// # Optimisation zero-copy @@ -118,7 +118,7 @@ impl AudioChunkData { /// Gain sous forme linéaire #[inline] pub fn gain_linear(&self) -> f64 { - db_to_linear(self.gain_db) + gain_linear_from_db(self.gain_db) } /// Retourne une vue immuable sur les frames `[L, R]` @@ -146,7 +146,7 @@ impl AudioChunkData { /// Définit le gain à l'aide d'un facteur linéaire (>0) pub fn set_gain_linear(&self, gain_linear: f64) -> Arc { - self.set_gain_db(linear_to_db(gain_linear)) + self.set_gain_db(gain_db_from_linear(gain_linear)) } /// Modifie le gain de ce chunk (ajoute un delta en dB) @@ -156,11 +156,11 @@ impl AudioChunkData { /// Modifie le gain via un facteur linéaire multiplié au gain courant pub fn with_modified_gain_linear(&self, gain_linear: f64) -> Arc { - self.with_modified_gain_db(linear_to_db(gain_linear)) + self.with_modified_gain_db(gain_db_from_linear(gain_linear)) } } -// Méthodes spécifiques pour les types entiers (i8, i16, I24, i32) +// Méthodes spécifiques pour les types entiers (i16, I24, i32) impl AudioChunkData { /// Applique le gain et retourne un nouveau chunk avec les données modifiées /// @@ -172,14 +172,18 @@ impl AudioChunkData { } let mut stereo = self.clone_frames(); - dsp::apply_gain_stereo(&mut stereo, self.gain_db); + dsp::apply_gain_stereo_i32(&mut stereo, self.gain_db); AudioChunkData::new(stereo, self.sample_rate, 0.0) } /// Construit un chunk depuis deux vecteurs `i32` séparés (L/R) pub fn from_channels(left: Vec, right: Vec, sample_rate: u32) -> Arc { - assert_eq!(left.len(), right.len(), "channels must have identical length"); + assert_eq!( + left.len(), + right.len(), + "channels must have identical length" + ); let stereo = left .into_iter() .zip(right.into_iter()) @@ -213,7 +217,7 @@ impl AudioChunkData { return self; // Pas de gain à appliquer } - let gain_linear = db_to_linear(self.gain_db) as f32; + let gain_linear = gain_linear_from_db(self.gain_db) as f32; let mut stereo = self.clone_frames(); for frame in &mut stereo { frame[0] *= gain_linear; @@ -225,7 +229,11 @@ impl AudioChunkData { /// Construit un chunk depuis deux vecteurs `f32` séparés (L/R) pub fn from_channels(left: Vec, right: Vec, sample_rate: u32) -> Arc { - assert_eq!(left.len(), right.len(), "channels must have identical length"); + assert_eq!( + left.len(), + right.len(), + "channels must have identical length" + ); let stereo = left .into_iter() .zip(right.into_iter()) @@ -243,7 +251,7 @@ impl AudioChunkData { return self; // Pas de gain à appliquer } - let gain_linear = db_to_linear(self.gain_db); + let gain_linear = gain_linear_from_db(self.gain_db); let mut stereo = self.clone_frames(); for frame in &mut stereo { frame[0] *= gain_linear; @@ -255,7 +263,11 @@ impl AudioChunkData { /// Construit un chunk depuis deux vecteurs `f64` séparés (L/R) pub fn from_channels(left: Vec, right: Vec, sample_rate: u32) -> Arc { - assert_eq!(left.len(), right.len(), "channels must have identical length"); + assert_eq!( + left.len(), + right.len(), + "channels must have identical length" + ); let stereo = left .into_iter() .zip(right.into_iter()) @@ -276,7 +288,6 @@ impl AudioChunkData { /// /// # Variantes /// -/// - `I8` : Échantillons 8-bit signés /// - `I16` : Échantillons 16-bit signés /// - `I24` : Échantillons 24-bit signés (stockés sur i32) /// - `I32` : Échantillons 32-bit signés @@ -298,7 +309,6 @@ impl AudioChunkData { /// ``` #[derive(Debug, Clone)] pub enum AudioChunk { - I8(Arc>), I16(Arc>), I24(Arc>), I32(Arc>), @@ -310,7 +320,6 @@ impl AudioChunk { /// Retourne le nombre de frames du chunk pub fn len(&self) -> usize { match self { - AudioChunk::I8(d) => d.len(), AudioChunk::I16(d) => d.len(), AudioChunk::I24(d) => d.len(), AudioChunk::I32(d) => d.len(), @@ -327,7 +336,6 @@ impl AudioChunk { /// Taux d'échantillonnage (Hz) pub fn sample_rate(&self) -> u32 { match self { - AudioChunk::I8(d) => d.sample_rate(), AudioChunk::I16(d) => d.sample_rate(), AudioChunk::I24(d) => d.sample_rate(), AudioChunk::I32(d) => d.sample_rate(), @@ -339,7 +347,6 @@ impl AudioChunk { /// Gain courant en décibels pub fn gain_db(&self) -> f64 { match self { - AudioChunk::I8(d) => d.gain_db(), AudioChunk::I16(d) => d.gain_db(), AudioChunk::I24(d) => d.gain_db(), AudioChunk::I32(d) => d.gain_db(), @@ -350,13 +357,12 @@ impl AudioChunk { /// Gain sous forme linéaire pub fn gain_linear(&self) -> f64 { - db_to_linear(self.gain_db()) + gain_linear_from_db(self.gain_db()) } /// Définit le gain en dB pub fn set_gain_db(&self, gain_db: f64) -> Self { match self { - AudioChunk::I8(d) => AudioChunk::I8(d.set_gain_db(gain_db)), AudioChunk::I16(d) => AudioChunk::I16(d.set_gain_db(gain_db)), AudioChunk::I24(d) => AudioChunk::I24(d.set_gain_db(gain_db)), AudioChunk::I32(d) => AudioChunk::I32(d.set_gain_db(gain_db)), @@ -367,7 +373,7 @@ impl AudioChunk { /// Définit le gain via un facteur linéaire pub fn set_gain_linear(&self, gain_linear: f64) -> Self { - self.set_gain_db(linear_to_db(gain_linear)) + self.set_gain_db(gain_db_from_linear(gain_linear)) } /// Modifie le gain (ajoute un delta en dB) @@ -380,31 +386,20 @@ impl AudioChunk { /// Le gain du chunk résultant est remis à 0.0 dB. pub fn apply_gain(self) -> Self { match self { - AudioChunk::I8(d) => { - // Pour i8, on convert en i32, applique gain, puis reconvertit - // TODO: optimiser avec une version directe - let gain_db = d.gain_db(); - if gain_db.abs() < f64::EPSILON { - return AudioChunk::I8(d); - } - let gain_linear = db_to_linear(gain_db) as f32; - let mut stereo = d.clone_frames(); - for frame in &mut stereo { - frame[0] = (frame[0] as f32 * gain_linear).round().clamp(-128.0, 127.0) as i8; - frame[1] = (frame[1] as f32 * gain_linear).round().clamp(-128.0, 127.0) as i8; - } - AudioChunk::I8(AudioChunkData::new(stereo, d.sample_rate(), 0.0)) - } AudioChunk::I16(d) => { let gain_db = d.gain_db(); if gain_db.abs() < f64::EPSILON { return AudioChunk::I16(d); } - let gain_linear = db_to_linear(gain_db) as f32; + let gain_linear = gain_linear_from_db(gain_db) as f32; let mut stereo = d.clone_frames(); for frame in &mut stereo { - frame[0] = (frame[0] as f32 * gain_linear).round().clamp(-32768.0, 32767.0) as i16; - frame[1] = (frame[1] as f32 * gain_linear).round().clamp(-32768.0, 32767.0) as i16; + frame[0] = (frame[0] as f32 * gain_linear) + .round() + .clamp(-32768.0, 32767.0) as i16; + frame[1] = (frame[1] as f32 * gain_linear) + .round() + .clamp(-32768.0, 32767.0) as i16; } AudioChunk::I16(AudioChunkData::new(stereo, d.sample_rate(), 0.0)) } @@ -413,11 +408,15 @@ impl AudioChunk { if gain_db.abs() < f64::EPSILON { return AudioChunk::I24(d); } - let gain_linear = db_to_linear(gain_db) as f32; + let gain_linear = gain_linear_from_db(gain_db) as f32; let mut stereo = d.clone_frames(); for frame in &mut stereo { - let l = (frame[0].as_i32() as f32 * gain_linear).round().clamp(-8_388_608.0, 8_388_607.0) as i32; - let r = (frame[1].as_i32() as f32 * gain_linear).round().clamp(-8_388_608.0, 8_388_607.0) as i32; + let l = (frame[0].as_i32() as f32 * gain_linear) + .round() + .clamp(-8_388_608.0, 8_388_607.0) as i32; + let r = (frame[1].as_i32() as f32 * gain_linear) + .round() + .clamp(-8_388_608.0, 8_388_607.0) as i32; frame[0] = I24::new_clamped(l); frame[1] = I24::new_clamped(r); } @@ -432,7 +431,6 @@ impl AudioChunk { /// Retourne le nom du type de sample pub fn type_name(&self) -> &'static str { match self { - AudioChunk::I8(_) => "i8", AudioChunk::I16(_) => "i16", AudioChunk::I24(_) => "I24", AudioChunk::I32(_) => "i32", @@ -440,6 +438,415 @@ impl AudioChunk { AudioChunk::F64(_) => "f64", } } + + /// Tente de convertir vers AudioIntegerChunk (retourne None si float) + pub fn try_as_integer(&self) -> Option { + match self { + AudioChunk::I16(d) => Some(AudioIntegerChunk::I16(d.clone())), + AudioChunk::I24(d) => Some(AudioIntegerChunk::I24(d.clone())), + AudioChunk::I32(d) => Some(AudioIntegerChunk::I32(d.clone())), + AudioChunk::F32(_) | AudioChunk::F64(_) => None, + } + } + + /// Tente de convertir vers AudioFloatChunk (retourne None si integer) + pub fn try_as_float(&self) -> Option { + match self { + AudioChunk::F32(d) => Some(AudioFloatChunk::F32(d.clone())), + AudioChunk::F64(d) => Some(AudioFloatChunk::F64(d.clone())), + AudioChunk::I16(_) | AudioChunk::I24(_) | AudioChunk::I32(_) => None, + } + } + + /// Vérifie si le chunk est de type entier + pub fn is_integer(&self) -> bool { + matches!( + self, + AudioChunk::I16(_) | AudioChunk::I24(_) | AudioChunk::I32(_) + ) + } + + /// Vérifie si le chunk est de type flottant + pub fn is_float(&self) -> bool { + matches!(self, AudioChunk::F32(_) | AudioChunk::F64(_)) + } +} + +#[derive(Debug, Clone)] +pub enum AudioIntegerChunk { + I16(Arc>), + I24(Arc>), + I32(Arc>), +} + +impl AudioIntegerChunk { + /// Retourne le nombre de frames du chunk + pub fn len(&self) -> usize { + match self { + AudioIntegerChunk::I16(d) => d.len(), + AudioIntegerChunk::I24(d) => d.len(), + AudioIntegerChunk::I32(d) => d.len(), + } + } + + /// Vérifie si le chunk est vide + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Taux d'échantillonnage (Hz) + pub fn sample_rate(&self) -> u32 { + match self { + AudioIntegerChunk::I16(d) => d.sample_rate(), + AudioIntegerChunk::I24(d) => d.sample_rate(), + AudioIntegerChunk::I32(d) => d.sample_rate(), + } + } + + /// Gain courant en décibels + pub fn gain_db(&self) -> f64 { + match self { + AudioIntegerChunk::I16(d) => d.gain_db(), + AudioIntegerChunk::I24(d) => d.gain_db(), + AudioIntegerChunk::I32(d) => d.gain_db(), + } + } + + /// Gain sous forme linéaire + pub fn gain_linear(&self) -> f64 { + gain_linear_from_db(self.gain_db()) + } + + /// Définit le gain en dB + pub fn set_gain_db(&self, gain_db: f64) -> Self { + match self { + AudioIntegerChunk::I16(d) => AudioIntegerChunk::I16(d.set_gain_db(gain_db)), + AudioIntegerChunk::I24(d) => AudioIntegerChunk::I24(d.set_gain_db(gain_db)), + AudioIntegerChunk::I32(d) => AudioIntegerChunk::I32(d.set_gain_db(gain_db)), + } + } + + /// Définit le gain via un facteur linéaire + pub fn set_gain_linear(&self, gain_linear: f64) -> Self { + self.set_gain_db(gain_db_from_linear(gain_linear)) + } + + /// Modifie le gain (ajoute un delta en dB) + pub fn with_modified_gain_db(&self, delta_gain_db: f64) -> Self { + self.set_gain_db(self.gain_db() + delta_gain_db) + } + + /// Applique le gain et retourne un nouveau chunk avec les données modifiées + /// + /// Le gain du chunk résultant est remis à 0.0 dB. + pub fn apply_gain(self) -> Self { + match self { + AudioIntegerChunk::I16(d) => { + let gain_db = d.gain_db(); + if gain_db.abs() < f64::EPSILON { + return AudioIntegerChunk::I16(d); + } + let gain_linear = gain_linear_from_db(gain_db) as f32; + let mut stereo = d.clone_frames(); + for frame in &mut stereo { + frame[0] = (frame[0] as f32 * gain_linear) + .round() + .clamp(-32768.0, 32767.0) as i16; + frame[1] = (frame[1] as f32 * gain_linear) + .round() + .clamp(-32768.0, 32767.0) as i16; + } + AudioIntegerChunk::I16(AudioChunkData::new(stereo, d.sample_rate(), 0.0)) + } + AudioIntegerChunk::I24(d) => { + let gain_db = d.gain_db(); + if gain_db.abs() < f64::EPSILON { + return AudioIntegerChunk::I24(d); + } + let gain_linear = gain_linear_from_db(gain_db) as f32; + let mut stereo = d.clone_frames(); + for frame in &mut stereo { + let l = (frame[0].as_i32() as f32 * gain_linear) + .round() + .clamp(-8_388_608.0, 8_388_607.0) as i32; + let r = (frame[1].as_i32() as f32 * gain_linear) + .round() + .clamp(-8_388_608.0, 8_388_607.0) as i32; + frame[0] = I24::new_clamped(l); + frame[1] = I24::new_clamped(r); + } + AudioIntegerChunk::I24(AudioChunkData::new(stereo, d.sample_rate(), 0.0)) + } + AudioIntegerChunk::I32(d) => AudioIntegerChunk::I32(d.apply_gain()), + } + } + + /// Retourne le nom du type de sample + pub fn type_name(&self) -> &'static str { + match self { + AudioIntegerChunk::I16(_) => "i16", + AudioIntegerChunk::I24(_) => "I24", + AudioIntegerChunk::I32(_) => "i32", + } + } + + /// Vérifie si le chunk est de type I16 + pub fn is_i16(&self) -> bool { + matches!(self, AudioIntegerChunk::I16(_)) + } + + /// Vérifie si le chunk est de type I24 + pub fn is_i24(&self) -> bool { + matches!(self, AudioIntegerChunk::I24(_)) + } + + /// Vérifie si le chunk est de type I32 + pub fn is_i32(&self) -> bool { + matches!(self, AudioIntegerChunk::I32(_)) + } + + /// Retourne la profondeur de bit du chunk + pub fn bit_depth(&self) -> u8 { + match self { + AudioIntegerChunk::I16(_) => 16, + AudioIntegerChunk::I24(_) => 24, + AudioIntegerChunk::I32(_) => 32, + } + } + + /// Convertit vers AudioChunk + pub fn as_audio_chunk(&self) -> AudioChunk { + match self { + AudioIntegerChunk::I16(d) => AudioChunk::I16(d.clone()), + AudioIntegerChunk::I24(d) => AudioChunk::I24(d.clone()), + AudioIntegerChunk::I32(d) => AudioChunk::I32(d.clone()), + } + } + + /// Convertit vers I16 (avec conversion si nécessaire) + pub fn to_i16(&self) -> AudioIntegerChunk { + match self { + AudioIntegerChunk::I16(_) => self.clone(), + AudioIntegerChunk::I24(d) => { + // I24 -> I32 -> I16 + let i32_chunk = crate::conversions::convert_i24_to_i32(d); + let converted = crate::conversions::convert_i32_to_i16(&i32_chunk); + AudioIntegerChunk::I16(converted) + } + AudioIntegerChunk::I32(d) => { + let converted = crate::conversions::convert_i32_to_i16(d); + AudioIntegerChunk::I16(converted) + } + } + } + + /// Convertit vers I24 (avec conversion si nécessaire) + pub fn to_i24(&self) -> AudioIntegerChunk { + match self { + AudioIntegerChunk::I16(d) => { + // I16 -> I32 -> I24 + let i32_chunk = crate::conversions::convert_i16_to_i32(d); + let converted = crate::conversions::convert_i32_to_i24(&i32_chunk); + AudioIntegerChunk::I24(converted) + } + AudioIntegerChunk::I24(_) => self.clone(), + AudioIntegerChunk::I32(d) => { + let converted = crate::conversions::convert_i32_to_i24(d); + AudioIntegerChunk::I24(converted) + } + } + } + + /// Convertit vers I32 (avec conversion si nécessaire) + pub fn to_i32(&self) -> AudioIntegerChunk { + match self { + AudioIntegerChunk::I16(d) => { + let converted = crate::conversions::convert_i16_to_i32(d); + AudioIntegerChunk::I32(converted) + } + AudioIntegerChunk::I24(d) => { + let converted = crate::conversions::convert_i24_to_i32(d); + AudioIntegerChunk::I32(converted) + } + AudioIntegerChunk::I32(_) => self.clone(), + } + } + + /// Retourne un itérateur sur les frames + pub fn frames(&self) -> Box + '_> { + match self { + AudioIntegerChunk::I16(d) => { + Box::new(d.frames().iter().map(|f| [f[0] as i32, f[1] as i32])) + } + AudioIntegerChunk::I24(d) => { + Box::new(d.frames().iter().map(|f| [f[0].as_i32(), f[1].as_i32()])) + } + AudioIntegerChunk::I32(d) => Box::new(d.frames().iter().map(|f| [f[0], f[1]])), + } + } +} + +impl From for AudioIntegerChunk { + /// Convertit depuis AudioChunk (panic si le chunk est float) + fn from(chunk: AudioChunk) -> Self { + match chunk { + AudioChunk::I16(d) => AudioIntegerChunk::I16(d), + AudioChunk::I24(d) => AudioIntegerChunk::I24(d), + AudioChunk::I32(d) => AudioIntegerChunk::I32(d), + AudioChunk::F32(_) | AudioChunk::F64(_) => { + panic!("Cannot convert float AudioChunk to AudioIntegerChunk") + } + } + } +} + +#[derive(Debug, Clone)] +pub enum AudioFloatChunk { + F32(Arc>), + F64(Arc>), +} + +impl AudioFloatChunk { + /// Retourne le nombre de frames du chunk + pub fn len(&self) -> usize { + match self { + AudioFloatChunk::F32(d) => d.len(), + AudioFloatChunk::F64(d) => d.len(), + } + } + + /// Vérifie si le chunk est vide + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Taux d'échantillonnage (Hz) + pub fn sample_rate(&self) -> u32 { + match self { + AudioFloatChunk::F32(d) => d.sample_rate(), + AudioFloatChunk::F64(d) => d.sample_rate(), + } + } + + /// Gain courant en décibels + pub fn gain_db(&self) -> f64 { + match self { + AudioFloatChunk::F32(d) => d.gain_db(), + AudioFloatChunk::F64(d) => d.gain_db(), + } + } + + /// Gain sous forme linéaire + pub fn gain_linear(&self) -> f64 { + gain_linear_from_db(self.gain_db()) + } + + /// Définit le gain en dB + pub fn set_gain_db(&self, gain_db: f64) -> Self { + match self { + AudioFloatChunk::F32(d) => AudioFloatChunk::F32(d.set_gain_db(gain_db)), + AudioFloatChunk::F64(d) => AudioFloatChunk::F64(d.set_gain_db(gain_db)), + } + } + + /// Définit le gain via un facteur linéaire + pub fn set_gain_linear(&self, gain_linear: f64) -> Self { + self.set_gain_db(gain_db_from_linear(gain_linear)) + } + + /// Modifie le gain (ajoute un delta en dB) + pub fn with_modified_gain_db(&self, delta_gain_db: f64) -> Self { + self.set_gain_db(self.gain_db() + delta_gain_db) + } + + /// Applique le gain et retourne un nouveau chunk avec les données modifiées + /// + /// Le gain du chunk résultant est remis à 0.0 dB. + pub fn apply_gain(self) -> Self { + match self { + AudioFloatChunk::F32(d) => AudioFloatChunk::F32(d.apply_gain()), + AudioFloatChunk::F64(d) => AudioFloatChunk::F64(d.apply_gain()), + } + } + + /// Retourne le nom du type de sample + pub fn type_name(&self) -> &'static str { + match self { + AudioFloatChunk::F32(_) => "f32", + AudioFloatChunk::F64(_) => "f64", + } + } + + /// Vérifie si le chunk est de type F32 + pub fn is_f32(&self) -> bool { + matches!(self, AudioFloatChunk::F32(_)) + } + + /// Vérifie si le chunk est de type F64 + pub fn is_f64(&self) -> bool { + matches!(self, AudioFloatChunk::F64(_)) + } + + /// Retourne la profondeur de bit du chunk (32 ou 64) + pub fn bit_depth(&self) -> u8 { + match self { + AudioFloatChunk::F32(_) => 32, + AudioFloatChunk::F64(_) => 64, + } + } + + /// Convertit vers AudioChunk + pub fn as_audio_chunk(&self) -> AudioChunk { + match self { + AudioFloatChunk::F32(d) => AudioChunk::F32(d.clone()), + AudioFloatChunk::F64(d) => AudioChunk::F64(d.clone()), + } + } + + /// Convertit vers F32 (avec conversion si nécessaire) + pub fn to_f32(&self) -> AudioFloatChunk { + match self { + AudioFloatChunk::F32(_) => self.clone(), + AudioFloatChunk::F64(d) => { + let converted = crate::conversions::convert_f64_to_f32(d); + AudioFloatChunk::F32(converted) + } + } + } + + /// Convertit vers F64 (avec conversion si nécessaire) + pub fn to_f64(&self) -> AudioFloatChunk { + match self { + AudioFloatChunk::F32(d) => { + let converted = crate::conversions::convert_f32_to_f64(d); + AudioFloatChunk::F64(converted) + } + AudioFloatChunk::F64(_) => self.clone(), + } + } + + /// Retourne un itérateur sur les frames + pub fn frames(&self) -> Box + '_> { + match self { + AudioFloatChunk::F32(d) => { + Box::new(d.frames().iter().map(|f| [f[0] as f64, f[1] as f64])) + } + AudioFloatChunk::F64(d) => Box::new(d.frames().iter().map(|f| [f[0], f[1]])), + } + } +} + +impl From for AudioFloatChunk { + /// Convertit depuis AudioChunk (panic si le chunk est entier) + fn from(chunk: AudioChunk) -> Self { + match chunk { + AudioChunk::F32(d) => AudioFloatChunk::F32(d), + AudioChunk::F64(d) => AudioFloatChunk::F64(d), + AudioChunk::I16(_) | AudioChunk::I24(_) | AudioChunk::I32(_) => { + panic!("Cannot convert integer AudioChunk to AudioFloatChunk") + } + } + } } // ============================================================================ @@ -450,7 +857,7 @@ const MIN_GAIN_DB: f64 = -120.0; /// Convertit un gain linéaire (>0) en décibels #[inline] -pub fn linear_to_db(gain_linear: f64) -> f64 { +pub fn gain_db_from_linear(gain_linear: f64) -> f64 { if gain_linear <= 0.0 { MIN_GAIN_DB } else { @@ -460,18 +867,8 @@ pub fn linear_to_db(gain_linear: f64) -> f64 { /// Convertit un gain en décibels vers un gain linéaire #[inline] -pub fn db_to_linear(gain_db: f64) -> f64 { - 10f64.powf(gain_db / 20.0) -} - -/// Convertit un gain linéaire en décibels (méthode publique pour compatibilité) -pub fn gain_db_from_linear(gain_linear: f64) -> f64 { - linear_to_db(gain_linear) -} - -/// Convertit un gain en décibels vers un gain linéaire (méthode publique pour compatibilité) pub fn gain_linear_from_db(gain_db: f64) -> f64 { - db_to_linear(gain_db) + 10f64.powf(gain_db / 20.0) } // ============================================================================ @@ -515,10 +912,10 @@ mod tests { #[test] fn test_gain_conversion() { let linear = 2.0; - let db = linear_to_db(linear); + let db = gain_db_from_linear(linear); assert!((db - 6.0206).abs() < 0.01); // 2x ≈ +6dB - let back = db_to_linear(db); + let back = gain_linear_from_db(db); assert!((back - linear).abs() < 0.001); } } diff --git a/pmoaudio/src/audio_segment.rs b/pmoaudio/src/audio_segment.rs index 16244a6e..fba2c824 100644 --- a/pmoaudio/src/audio_segment.rs +++ b/pmoaudio/src/audio_segment.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use pmometadata::TrackMetadata; -use crate::{AudioChunk, AudioChunkData, BitDepth, SyncMarker, linear_to_db}; +use crate::{gain_db_from_linear, AudioChunk, AudioChunkData, BitDepth, SyncMarker}; pub enum _AudioSegment { Chunk(Arc), @@ -60,7 +60,7 @@ impl AudioSegment { _bit_depth: BitDepth, // Conservé pour compatibilité API gain_linear: f64, ) -> Arc { - let chunk_data = AudioChunkData::new(stereo, sample_rate, linear_to_db(gain_linear)); + let chunk_data = AudioChunkData::new(stereo, sample_rate, gain_db_from_linear(gain_linear)); let chunk = AudioChunk::I32(chunk_data); Arc::new(Self { order, @@ -98,7 +98,11 @@ impl AudioSegment { sample_rate: u32, bit_depth: BitDepth, ) -> Arc { - assert_eq!(left.len(), right.len(), "channels must have identical length"); + assert_eq!( + left.len(), + right.len(), + "channels must have identical length" + ); // Convertir f32 → i32 selon le bit_depth let max_value = bit_depth.max_value(); @@ -152,9 +156,10 @@ impl AudioSegment { } pub fn new_track_boundary( - order: u64, - timestamp_sec: f64, - metadata: Arc) -> Arc { + order: u64, + timestamp_sec: f64, + metadata: Arc, + ) -> Arc { let marker = Arc::new(SyncMarker::TrackBoundary { metadata: Arc::clone(&metadata), }); @@ -166,13 +171,13 @@ impl AudioSegment { } pub fn new_stream_metadata( - order: u64, + order: u64, timestamp_sec: f64, key: String, - value: String - ) -> Arc { + value: String, + ) -> Arc { let marker = Arc::new(SyncMarker::StreamMetadata { key, value }); - + Arc::new(Self { order, timestamp_sec, @@ -183,51 +188,41 @@ impl AudioSegment { pub fn new_top_zero_sync() -> Arc { let marker = Arc::new(SyncMarker::TopZeroSync); - Arc::new(Self{ + Arc::new(Self { order: 0, timestamp_sec: 0.0, - segment: _AudioSegment::Sync(marker) + segment: _AudioSegment::Sync(marker), }) } - pub fn new_hearbeat( - order: u64, - timestamp_sec: f64, - ) -> Arc { + pub fn new_hearbeat(order: u64, timestamp_sec: f64) -> Arc { let marker = Arc::new(SyncMarker::Heartbeat); - Arc::new(Self{ - order: order, - timestamp_sec: timestamp_sec, - segment: _AudioSegment::Sync(marker) - }) - } - - pub fn new_end_of_stream( - order: u64, - timestamp_sec: f64, - ) -> Arc { - let marker = Arc::new(SyncMarker::EndOfStream); - - Arc::new(Self{ + Arc::new(Self { order: order, timestamp_sec: timestamp_sec, segment: _AudioSegment::Sync(marker), }) } - pub fn new_error( - order: u64, - timestamp_sec: f64, - error: String, - ) -> Arc { + pub fn new_end_of_stream(order: u64, timestamp_sec: f64) -> Arc { + let marker = Arc::new(SyncMarker::EndOfStream); + + Arc::new(Self { + order: order, + timestamp_sec: timestamp_sec, + segment: _AudioSegment::Sync(marker), + }) + } + + pub fn new_error(order: u64, timestamp_sec: f64, error: String) -> Arc { let marker = Arc::new(SyncMarker::Error(error)); - Arc::new(Self{ - order: order, - timestamp_sec: timestamp_sec, - segment: _AudioSegment::Sync(marker), - }) + Arc::new(Self { + order: order, + timestamp_sec: timestamp_sec, + segment: _AudioSegment::Sync(marker), + }) } pub fn is_audio_chunk(&self) -> bool { @@ -460,13 +455,7 @@ mod tests { #[test] fn test_audio_segment_gain_manipulation() { - let segment = AudioSegment::new_chunk( - 0, - 0.0, - vec![[100i32, 200i32]], - 44100, - BitDepth::B32, - ); + let segment = AudioSegment::new_chunk(0, 0.0, vec![[100i32, 200i32]], 44100, BitDepth::B32); // Test with_gain_db let segment_6db = segment.with_gain_db(6.0).unwrap(); @@ -486,13 +475,8 @@ mod tests { #[test] fn test_audio_segment_conversions() { - let segment = AudioSegment::new_chunk( - 0, - 0.0, - vec![[1000000i32, 2000000i32]], - 44100, - BitDepth::B32, - ); + let segment = + AudioSegment::new_chunk(0, 0.0, vec![[1000000i32, 2000000i32]], 44100, BitDepth::B32); // Test to_f32_chunk let f32_chunk = segment.to_f32_chunk(); diff --git a/pmoaudio/src/conversions.rs b/pmoaudio/src/conversions.rs index 716e1d91..1513c7b3 100644 --- a/pmoaudio/src/conversions.rs +++ b/pmoaudio/src/conversions.rs @@ -14,22 +14,6 @@ use crate::{dsp, AudioChunk, AudioChunkData, BitDepth, I24}; // Ces fonctions utilisent la fonction DSP optimisée SIMD `bitdepth_change_stereo` // pour les conversions i32 ↔ i32 avec différents bit depths. -/// Convertit i32 vers i8 (downsampling via bit depth change) -pub fn convert_i32_to_i8(chunk: &AudioChunkData) -> Arc> { - let mut stereo = chunk.clone_frames(); - - // Utiliser la fonction DSP optimisée pour passer de B32 → B8 - dsp::bitdepth_change_stereo(&mut stereo, BitDepth::B32, BitDepth::B8); - - // Convertir i32 → i8 (les valeurs sont maintenant dans la plage i8) - let stereo_i8: Vec<[i8; 2]> = stereo - .into_iter() - .map(|[l, r]| [l as i8, r as i8]) - .collect(); - - AudioChunkData::new(stereo_i8, chunk.sample_rate(), chunk.gain_db()) -} - /// Convertit i32 vers i16 (downsampling via bit depth change) pub fn convert_i32_to_i16(chunk: &AudioChunkData) -> Arc> { let mut stereo = chunk.clone_frames(); @@ -62,21 +46,6 @@ pub fn convert_i32_to_i24(chunk: &AudioChunkData) -> Arc) -> Arc> { - // Convertir i8 → i32 d'abord - let mut stereo: Vec<[i32; 2]> = chunk - .frames() - .iter() - .map(|[l, r]| [*l as i32, *r as i32]) - .collect(); - - // Utiliser la fonction DSP optimisée pour passer de B8 → B32 - dsp::bitdepth_change_stereo(&mut stereo, BitDepth::B8, BitDepth::B32); - - AudioChunkData::new(stereo, chunk.sample_rate(), chunk.gain_db()) -} - /// Convertit i16 vers i32 (upsampling via bit depth change) pub fn convert_i16_to_i32(chunk: &AudioChunkData) -> Arc> { // Convertir i16 → i32 d'abord @@ -142,21 +111,24 @@ pub fn convert_i32_to_f64(chunk: &AudioChunkData) -> Arc) -> Arc> { let frames = chunk.frames(); - let max_value = 8_388_608.0f32; // 2^23 + let len = frames.len(); - let stereo: Vec<[f32; 2]> = frames - .iter() - .map(|[l, r]| { - let lf = l.as_i32() as f32 / max_value; - let rf = r.as_i32() as f32 / max_value; - [lf, rf] - }) - .collect(); + // Séparer les canaux I24 en i32 + let mut left = Vec::with_capacity(len); + let mut right = Vec::with_capacity(len); + for [l, r] in frames { + left.push(l.as_i32()); + right.push(r.as_i32()); + } - AudioChunkData::new(stereo, chunk.sample_rate(), chunk.gain_db()) + // Utiliser la fonction SIMD optimisée du module DSP pour I24 + let mut out_pairs = vec![[0.0f32; 2]; len]; + dsp::i24_as_i32_stereo_to_pairs_f32(&left, &right, &mut out_pairs); + + AudioChunkData::new(out_pairs, chunk.sample_rate(), chunk.gain_db()) } /// Convertit I24 vers f64 @@ -176,21 +148,24 @@ pub fn convert_i24_to_f64(chunk: &AudioChunkData) -> Arc) -> Arc> { let frames = chunk.frames(); - let max_value = 32_768.0f32; // 2^15 + let len = frames.len(); - let stereo: Vec<[f32; 2]> = frames - .iter() - .map(|[l, r]| { - let lf = *l as f32 / max_value; - let rf = *r as f32 / max_value; - [lf, rf] - }) - .collect(); + // Séparer les canaux + let mut left = Vec::with_capacity(len); + let mut right = Vec::with_capacity(len); + for [l, r] in frames { + left.push(*l); + right.push(*r); + } - AudioChunkData::new(stereo, chunk.sample_rate(), chunk.gain_db()) + // Utiliser la fonction SIMD optimisée du module DSP + let mut out_pairs = vec![[0.0f32; 2]; len]; + dsp::i16_stereo_to_pairs_f32(&left, &right, &mut out_pairs); + + AudioChunkData::new(out_pairs, chunk.sample_rate(), chunk.gain_db()) } /// Convertit i16 vers f64 @@ -210,40 +185,6 @@ pub fn convert_i16_to_f64(chunk: &AudioChunkData) -> Arc) -> Arc> { - let frames = chunk.frames(); - let max_value = 128.0f32; // 2^7 - - let stereo: Vec<[f32; 2]> = frames - .iter() - .map(|[l, r]| { - let lf = *l as f32 / max_value; - let rf = *r as f32 / max_value; - [lf, rf] - }) - .collect(); - - AudioChunkData::new(stereo, chunk.sample_rate(), chunk.gain_db()) -} - -/// Convertit i8 vers f64 -pub fn convert_i8_to_f64(chunk: &AudioChunkData) -> Arc> { - let frames = chunk.frames(); - let max_value = 128.0f64; // 2^7 - - let stereo: Vec<[f64; 2]> = frames - .iter() - .map(|[l, r]| { - let lf = *l as f64 / max_value; - let rf = *r as f64 / max_value; - [lf, rf] - }) - .collect(); - - AudioChunkData::new(stereo, chunk.sample_rate(), chunk.gain_db()) -} - // ============================================================================ // Conversions float → int (quantization) // ============================================================================ @@ -279,19 +220,21 @@ pub fn convert_f64_to_i32(chunk: &AudioChunkData) -> Arc) -> Arc> { let frames = chunk.frames(); - let max_value = 8_388_607.0f32; // 2^23 - 1 - let min_value = -8_388_608.0f32; // -2^23 + let len = frames.len(); - let stereo: Vec<[I24; 2]> = frames - .iter() - .map(|[l, r]| { - let l_scaled = (l * max_value).clamp(min_value, max_value).round() as i32; - let r_scaled = (r * max_value).clamp(min_value, max_value).round() as i32; - [I24::new_clamped(l_scaled), I24::new_clamped(r_scaled)] - }) + // Utiliser la fonction SIMD optimisée du module DSP + let mut left = vec![0i32; len]; + let mut right = vec![0i32; len]; + dsp::pairs_f32_to_i24_as_i32_stereo(frames, &mut left, &mut right); + + // Recombiner en frames I24 + let stereo: Vec<[I24; 2]> = left + .into_iter() + .zip(right.into_iter()) + .map(|(l, r)| [I24::new_clamped(l), I24::new_clamped(r)]) .collect(); AudioChunkData::new(stereo, chunk.sample_rate(), chunk.gain_db()) @@ -315,19 +258,21 @@ pub fn convert_f64_to_i24(chunk: &AudioChunkData) -> Arc) -> Arc> { let frames = chunk.frames(); - let max_value = 32_767.0f32; // 2^15 - 1 - let min_value = -32_768.0f32; // -2^15 + let len = frames.len(); - let stereo: Vec<[i16; 2]> = frames - .iter() - .map(|[l, r]| { - let l16 = (l * max_value).clamp(min_value, max_value).round() as i16; - let r16 = (r * max_value).clamp(min_value, max_value).round() as i16; - [l16, r16] - }) + // Utiliser la fonction SIMD optimisée du module DSP + let mut left = vec![0i16; len]; + let mut right = vec![0i16; len]; + dsp::pairs_f32_to_i16_stereo(frames, &mut left, &mut right); + + // Recombiner en frames + let stereo: Vec<[i16; 2]> = left + .into_iter() + .zip(right.into_iter()) + .map(|(l, r)| [l, r]) .collect(); AudioChunkData::new(stereo, chunk.sample_rate(), chunk.gain_db()) @@ -351,42 +296,6 @@ pub fn convert_f64_to_i16(chunk: &AudioChunkData) -> Arc) -> Arc> { - let frames = chunk.frames(); - let max_value = 127.0f32; // 2^7 - 1 - let min_value = -128.0f32; // -2^7 - - let stereo: Vec<[i8; 2]> = frames - .iter() - .map(|[l, r]| { - let l8 = (l * max_value).clamp(min_value, max_value).round() as i8; - let r8 = (r * max_value).clamp(min_value, max_value).round() as i8; - [l8, r8] - }) - .collect(); - - AudioChunkData::new(stereo, chunk.sample_rate(), chunk.gain_db()) -} - -/// Convertit f64 vers i8 -pub fn convert_f64_to_i8(chunk: &AudioChunkData) -> Arc> { - let frames = chunk.frames(); - let max_value = 127.0f64; // 2^7 - 1 - let min_value = -128.0f64; // -2^7 - - let stereo: Vec<[i8; 2]> = frames - .iter() - .map(|[l, r]| { - let l8 = (l * max_value).clamp(min_value, max_value).round() as i8; - let r8 = (r * max_value).clamp(min_value, max_value).round() as i8; - [l8, r8] - }) - .collect(); - - AudioChunkData::new(stereo, chunk.sample_rate(), chunk.gain_db()) -} - // ============================================================================ // Conversions F32 ↔ F64 // ============================================================================ @@ -394,10 +303,7 @@ pub fn convert_f64_to_i8(chunk: &AudioChunkData) -> Arc> /// Convertit f32 vers f64 (upcast simple) pub fn convert_f32_to_f64(chunk: &AudioChunkData) -> Arc> { let frames = chunk.frames(); - let stereo: Vec<[f64; 2]> = frames - .iter() - .map(|[l, r]| [*l as f64, *r as f64]) - .collect(); + let stereo: Vec<[f64; 2]> = frames.iter().map(|[l, r]| [*l as f64, *r as f64]).collect(); AudioChunkData::new(stereo, chunk.sample_rate(), chunk.gain_db()) } @@ -405,10 +311,7 @@ pub fn convert_f32_to_f64(chunk: &AudioChunkData) -> Arc) -> Arc> { let frames = chunk.frames(); - let stereo: Vec<[f32; 2]> = frames - .iter() - .map(|[l, r]| [*l as f32, *r as f32]) - .collect(); + let stereo: Vec<[f32; 2]> = frames.iter().map(|[l, r]| [*l as f32, *r as f32]).collect(); AudioChunkData::new(stereo, chunk.sample_rate(), chunk.gain_db()) } @@ -420,10 +323,9 @@ pub fn convert_f64_to_f32(chunk: &AudioChunkData) -> Arc AudioChunk { match self { - AudioChunk::I8(d) => AudioChunk::F32(convert_i8_to_f32(d)), AudioChunk::I16(d) => AudioChunk::F32(convert_i16_to_f32(d)), AudioChunk::I24(d) => AudioChunk::F32(convert_i24_to_f32(d)), AudioChunk::I32(d) => AudioChunk::F32(convert_i32_to_f32(d)), @@ -434,10 +336,9 @@ impl AudioChunk { /// Convertit ce chunk vers f64 /// - /// Chaque type utilise sa plage native (I8=±2^7, I16=±2^15, I24=±2^23, I32=±2^31) + /// Chaque type utilise sa plage native (I16=±2^15, I24=±2^23, I32=±2^31) pub fn to_f64(&self) -> AudioChunk { match self { - AudioChunk::I8(d) => AudioChunk::F64(convert_i8_to_f64(d)), AudioChunk::I16(d) => AudioChunk::F64(convert_i16_to_f64(d)), AudioChunk::I24(d) => AudioChunk::F64(convert_i24_to_f64(d)), AudioChunk::I32(d) => AudioChunk::F64(convert_i32_to_f64(d)), @@ -451,7 +352,6 @@ impl AudioChunk { /// I32 = 32 bits complets (±2^31) pub fn to_i32(&self) -> AudioChunk { match self { - AudioChunk::I8(d) => AudioChunk::I32(convert_i8_to_i32(d)), AudioChunk::I16(d) => AudioChunk::I32(convert_i16_to_i32(d)), AudioChunk::I24(d) => AudioChunk::I32(convert_i24_to_i32(d)), AudioChunk::I32(d) => AudioChunk::I32(d.clone()), @@ -463,11 +363,6 @@ impl AudioChunk { /// Convertit ce chunk vers I24 pub fn to_i24(&self) -> AudioChunk { match self { - AudioChunk::I8(d) => { - // I8 → I32 → I24 - let i32_chunk = convert_i8_to_i32(d); - AudioChunk::I24(convert_i32_to_i24(&i32_chunk)) - } AudioChunk::I16(d) => { // I16 → I32 → I24 let i32_chunk = convert_i16_to_i32(d); @@ -483,11 +378,6 @@ impl AudioChunk { /// Convertit ce chunk vers i16 pub fn to_i16(&self) -> AudioChunk { match self { - AudioChunk::I8(d) => { - // I8 → I32 → I16 - let i32_chunk = convert_i8_to_i32(d); - AudioChunk::I16(convert_i32_to_i16(&i32_chunk)) - } AudioChunk::I16(d) => AudioChunk::I16(d.clone()), AudioChunk::I24(d) => { // I24 → I32 → I16 @@ -499,26 +389,6 @@ impl AudioChunk { AudioChunk::F64(d) => AudioChunk::I16(convert_f64_to_i16(d)), } } - - /// Convertit ce chunk vers i8 - pub fn to_i8(&self) -> AudioChunk { - match self { - AudioChunk::I8(d) => AudioChunk::I8(d.clone()), - AudioChunk::I16(d) => { - // I16 → I32 → I8 - let i32_chunk = convert_i16_to_i32(d); - AudioChunk::I8(convert_i32_to_i8(&i32_chunk)) - } - AudioChunk::I24(d) => { - // I24 → I32 → I8 - let i32_chunk = convert_i24_to_i32(d); - AudioChunk::I8(convert_i32_to_i8(&i32_chunk)) - } - AudioChunk::I32(d) => AudioChunk::I8(convert_i32_to_i8(d)), - AudioChunk::F32(d) => AudioChunk::I8(convert_f32_to_i8(d)), - AudioChunk::F64(d) => AudioChunk::I8(convert_f64_to_i8(d)), - } - } } // ============================================================================ @@ -527,12 +397,6 @@ impl AudioChunk { // ---------- From>> pour AudioChunk ---------- -impl From>> for AudioChunk { - fn from(data: Arc>) -> Self { - AudioChunk::I8(data) - } -} - impl From>> for AudioChunk { fn from(data: Arc>) -> Self { AudioChunk::I16(data) @@ -565,25 +429,6 @@ impl From>> for AudioChunk { // ---------- From entre AudioChunkData types (sans BitDepth requis) ---------- -// I8 conversions -impl From<&AudioChunkData> for Arc> { - fn from(chunk: &AudioChunkData) -> Self { - convert_i8_to_i32(chunk) - } -} - -impl From<&AudioChunkData> for Arc> { - fn from(chunk: &AudioChunkData) -> Self { - convert_i8_to_f32(chunk) - } -} - -impl From<&AudioChunkData> for Arc> { - fn from(chunk: &AudioChunkData) -> Self { - convert_i8_to_f64(chunk) - } -} - // I16 conversions impl From<&AudioChunkData> for Arc> { fn from(chunk: &AudioChunkData) -> Self { @@ -623,11 +468,6 @@ impl From<&AudioChunkData> for Arc> { } // I32 conversions vers types int (downsampling) -impl From<&AudioChunkData> for Arc> { - fn from(chunk: &AudioChunkData) -> Self { - convert_i32_to_i8(chunk) - } -} impl From<&AudioChunkData> for Arc> { fn from(chunk: &AudioChunkData) -> Self { @@ -661,12 +501,6 @@ impl From<&AudioChunkData> for Arc> { } } -impl From<&AudioChunkData> for Arc> { - fn from(chunk: &AudioChunkData) -> Self { - convert_f32_to_i8(chunk) - } -} - impl From<&AudioChunkData> for Arc> { fn from(chunk: &AudioChunkData) -> Self { convert_f32_to_i16(chunk) @@ -692,12 +526,6 @@ impl From<&AudioChunkData> for Arc> { } } -impl From<&AudioChunkData> for Arc> { - fn from(chunk: &AudioChunkData) -> Self { - convert_f64_to_i8(chunk) - } -} - impl From<&AudioChunkData> for Arc> { fn from(chunk: &AudioChunkData) -> Self { convert_f64_to_i16(chunk) @@ -785,10 +613,7 @@ mod tests { #[test] fn test_i24_conversions() { - let stereo = vec![ - [I24::new(1_000_000).unwrap(), I24::new(-500_000).unwrap()]; - 10 - ]; + let stereo = vec![[I24::new(1_000_000).unwrap(), I24::new(-500_000).unwrap()]; 10]; let chunk_i24 = AudioChunkData::new(stereo.clone(), 48_000, 0.0); // I24 → F32 → I24 @@ -855,10 +680,7 @@ mod tests { #[test] fn test_from_trait_i24() { // Test conversions I24 via From - let stereo_i24 = vec![ - [I24::new(1_000_000).unwrap(), I24::new(-500_000).unwrap()]; - 50 - ]; + let stereo_i24 = vec![[I24::new(1_000_000).unwrap(), I24::new(-500_000).unwrap()]; 50]; let chunk_i24 = AudioChunkData::new(stereo_i24, 48_000, 0.0); // I24 → I32 via From @@ -892,10 +714,7 @@ mod tests { #[test] fn test_from_trait_roundtrip() { // Test round-trip I24 → F32 → I24 via From - let original = vec![ - [I24::new(1_000_000).unwrap(), I24::new(-500_000).unwrap()]; - 10 - ]; + let original = vec![[I24::new(1_000_000).unwrap(), I24::new(-500_000).unwrap()]; 10]; let chunk_i24 = AudioChunkData::new(original.clone(), 48_000, 0.0); // I24 → F32 via From diff --git a/pmoaudio/src/dsp/gain_16bits.rs b/pmoaudio/src/dsp/gain_16bits.rs new file mode 100644 index 00000000..0c38367b --- /dev/null +++ b/pmoaudio/src/dsp/gain_16bits.rs @@ -0,0 +1,94 @@ +/// Applique un gain (en dB) sur des échantillons stéréo interleavés `[L,R]` +/// codés sur 16 bits signés. +pub fn apply_gain_stereo_i16(samples: &mut [[i16; 2]], gain_db: f64) { + let gain = 10f64.powf(gain_db / 20.0); + let g_q15 = (gain * (1u32 << 15) as f64).round() as i16; + + #[cfg(all(target_arch = "aarch64", target_feature = "neon"))] + unsafe { + apply_gain_stereo_i16_neon(samples, g_q15); + return; + } + #[cfg(all(target_arch = "x86_64", target_feature = "avx2"))] + unsafe { + apply_gain_stereo_i16_avx2(samples, g_q15); + return; + } + + // Fallback scalaire + #[cfg(not(any( + all(target_arch = "aarch64", target_feature = "neon"), + all(target_arch = "x86_64", target_feature = "avx2") + )))] + { + apply_gain_stereo_i16_scalar(samples, g_q15); + } +} + +#[cfg(not(any( + all(target_arch = "aarch64", target_feature = "neon"), + all(target_arch = "x86_64", target_feature = "avx2") +)))] +#[inline(always)] +fn apply_gain_stereo_i16_scalar(samples: &mut [[i16; 2]], g_q15: i16) { + for frame in samples.iter_mut() { + // L + let prod_l = (frame[0] as i32 * g_q15 as i32 + (1 << 14)) >> 15; + frame[0] = prod_l.clamp(i16::MIN as i32, i16::MAX as i32) as i16; + + // R + let prod_r = (frame[1] as i32 * g_q15 as i32 + (1 << 14)) >> 15; + frame[1] = prod_r.clamp(i16::MIN as i32, i16::MAX as i32) as i16; + } +} + +#[cfg(all(target_arch = "aarch64", target_feature = "neon"))] +#[inline(always)] +unsafe fn apply_gain_stereo_i16_neon(samples: &mut [[i16; 2]], g_q15: i16) { + use core::arch::aarch64::*; + let gvec = vdupq_n_s16(g_q15); + let mut i = 0; + let n = samples.len() * 2; + let ptr = samples.as_mut_ptr() as *mut i16; + + while i + 8 <= n { + let v = vld1q_s16(ptr.add(i)); + let res = vqdmulhq_s16(v, gvec); // Q15 multiply high + vst1q_s16(ptr.add(i), res); + i += 8; + } + + // reste scalaire + let slice = std::slice::from_raw_parts_mut(ptr.add(i), n - i); + apply_gain_i16_scalar(slice, g_q15); +} + +#[cfg(all(target_arch = "x86_64", target_feature = "avx2"))] +#[inline(always)] +unsafe fn apply_gain_stereo_i16_avx2(samples: &mut [[i16; 2]], g_q15: i16) { + use core::arch::x86_64::*; + let g = _mm256_set1_epi16(g_q15 as i16); + let mut i = 0; + let n = samples.len() * 2; + let ptr = samples.as_mut_ptr() as *mut i16; + + while i + 16 <= n { + let x = _mm256_loadu_si256(ptr.add(i) as *const __m256i); + let hi = _mm256_mulhi_epi16(x, g); + _mm256_storeu_si256(ptr.add(i) as *mut __m256i, hi); + i += 16; + } + + // reste scalaire + let slice = std::slice::from_raw_parts_mut(ptr.add(i), n - i); + apply_gain_i16_scalar(slice, g_q15); +} + +/// version mono utilisée pour le reste scalaire +#[inline(always)] +fn apply_gain_i16_scalar(samples: &mut [i16], g_q15: i16) { + for s in samples.iter_mut() { + let prod = (*s as i32 * g_q15 as i32 + (1 << 14)) >> 15; + *s = prod.clamp(i16::MIN as i32, i16::MAX as i32) as i16; + } +} diff --git a/pmoaudio/src/dsp/gain_24bits.rs b/pmoaudio/src/dsp/gain_24bits.rs new file mode 100644 index 00000000..cf83442f --- /dev/null +++ b/pmoaudio/src/dsp/gain_24bits.rs @@ -0,0 +1,99 @@ +use crate::I24; + +/// Applique un gain (en dB) sur des échantillons stéréo interleavés `[L,R]` +/// codés sur 24 bits signés (`I24`). +pub fn apply_gain_stereo_i24(samples: &mut [[I24; 2]], gain_db: f64) { + let gain = 10f64.powf(gain_db / 20.0); + // Q23 scaling + let g_q23 = (gain * (1u64 << 23) as f64).round() as i32; + + #[cfg(all(target_arch = "aarch64", target_feature = "neon"))] + unsafe { + apply_gain_stereo_i24_neon(samples, g_q23); + return; + } + #[cfg(all(target_arch = "x86_64", target_feature = "avx2"))] + unsafe { + apply_gain_stereo_i24_avx2(samples, g_q23); + return; + } + + // Fallback scalaire + #[cfg(not(any( + all(target_arch = "aarch64", target_feature = "neon"), + all(target_arch = "x86_64", target_feature = "avx2") + )))] + { + apply_gain_stereo_i24_scalar(samples, g_q23); + } +} + +#[cfg(not(any( + all(target_arch = "aarch64", target_feature = "neon"), + all(target_arch = "x86_64", target_feature = "avx2") +)))] +#[inline(always)] +fn apply_gain_stereo_i24_scalar(samples: &mut [[I24; 2]], g_q23: i32) { + for frame in samples.iter_mut() { + // L + let prod_l = (frame[0].as_i32() as i64 * g_q23 as i64 + (1 << 22)) >> 23; + let clamped_l = prod_l.clamp(I24::MIN_VALUE as i64, I24::MAX_VALUE as i64) as i32; + frame[0] = I24::new_clamped(clamped_l); + + // R + let prod_r = (frame[1].as_i32() as i64 * g_q23 as i64 + (1 << 22)) >> 23; + let clamped_r = prod_r.clamp(I24::MIN_VALUE as i64, I24::MAX_VALUE as i64) as i32; + frame[1] = I24::new_clamped(clamped_r); + } +} + +#[cfg(all(target_arch = "aarch64", target_feature = "neon"))] +#[inline(always)] +unsafe fn apply_gain_stereo_i24_neon(samples: &mut [[I24; 2]], g_q23: i32) { + use core::arch::aarch64::*; + let gvec = vdupq_n_s32(g_q23); + let mut i = 0; + let n = samples.len() * 2; + let ptr = samples.as_mut_ptr() as *mut i32; + + while i + 4 <= n { + let v = vld1q_s32(ptr.add(i)); + let res = vqdmulhq_s32(v, gvec); // Q23 multiply high + vst1q_s32(ptr.add(i), res); + i += 4; + } + + // reste scalaire + let slice = std::slice::from_raw_parts_mut(ptr.add(i), n - i); + apply_gain_i24_scalar(slice, g_q23); +} + +#[cfg(all(target_arch = "x86_64", target_feature = "avx2"))] +#[inline(always)] +unsafe fn apply_gain_stereo_i24_avx2(samples: &mut [[I24; 2]], g_q23: i32) { + use core::arch::x86_64::*; + let g = _mm256_set1_epi32(g_q23); + let mut i = 0; + let n = samples.len() * 2; + let ptr = samples.as_mut_ptr() as *mut i32; + + while i + 8 <= n { + let x = _mm256_loadu_si256(ptr.add(i) as *const __m256i); + let hi = _mm256_mulhi_epi32(x, g); + _mm256_storeu_si256(ptr.add(i) as *mut __m256i, hi); + i += 8; + } + + // reste scalaire + let slice = std::slice::from_raw_parts_mut(ptr.add(i), n - i); + apply_gain_i24_scalar(slice, g_q23); +} + +/// Version mono utilisée pour le reste scalaire. +#[inline(always)] +fn apply_gain_i24_scalar(samples: &mut [i32], g_q23: i32) { + for s in samples.iter_mut() { + let prod = (*s as i64 * g_q23 as i64 + (1 << 22)) >> 23; + *s = prod.clamp(I24::MIN_VALUE as i64, I24::MAX_VALUE as i64) as i32; + } +} diff --git a/pmoaudio/src/dsp/gain.rs b/pmoaudio/src/dsp/gain_32bits.rs similarity index 80% rename from pmoaudio/src/dsp/gain.rs rename to pmoaudio/src/dsp/gain_32bits.rs index ccc24d1d..89b0e502 100644 --- a/pmoaudio/src/dsp/gain.rs +++ b/pmoaudio/src/dsp/gain_32bits.rs @@ -1,16 +1,16 @@ /// Applique un gain (en dB) sur des échantillons stéréo interleavés `[L,R]`. -pub fn apply_gain_stereo(samples: &mut [[i32; 2]], gain_db: f64) { +pub fn apply_gain_stereo_i32(samples: &mut [[i32; 2]], gain_db: f64) { let gain = 10f64.powf(gain_db / 20.0); let g_q31 = (gain * (1u64 << 31) as f64).round() as i32; #[cfg(all(target_arch = "aarch64", target_feature = "neon"))] unsafe { - apply_gain_stereo_neon(samples, g_q31); + apply_gain_stereo_i32_neon(samples, g_q31); return; } #[cfg(all(target_arch = "x86_64", target_feature = "avx2"))] unsafe { - apply_gain_stereo_avx2(samples, g_q31); + apply_gain_stereo_i32_avx2(samples, g_q31); return; } @@ -20,7 +20,7 @@ pub fn apply_gain_stereo(samples: &mut [[i32; 2]], gain_db: f64) { all(target_arch = "x86_64", target_feature = "avx2") )))] { - apply_gain_stereo_scalar(samples, g_q31); + apply_gain_stereo_i32_scalar(samples, g_q31); } } @@ -29,7 +29,7 @@ pub fn apply_gain_stereo(samples: &mut [[i32; 2]], gain_db: f64) { all(target_arch = "x86_64", target_feature = "avx2") )))] #[inline(always)] -fn apply_gain_stereo_scalar(samples: &mut [[i32; 2]], g_q31: i32) { +fn apply_gain_stereo_i32_scalar(samples: &mut [[i32; 2]], g_q31: i32) { for frame in samples.iter_mut() { // L let prod_l = (frame[0] as i64 * g_q31 as i64 + (1 << 30)) >> 31; @@ -43,7 +43,7 @@ fn apply_gain_stereo_scalar(samples: &mut [[i32; 2]], g_q31: i32) { #[cfg(all(target_arch = "aarch64", target_feature = "neon"))] #[inline(always)] -unsafe fn apply_gain_stereo_neon(samples: &mut [[i32; 2]], g_q31: i32) { +unsafe fn apply_gain_stereo_i32_neon(samples: &mut [[i32; 2]], g_q31: i32) { use core::arch::aarch64::*; let gvec = vdupq_n_s32(g_q31); let mut i = 0; @@ -59,12 +59,12 @@ unsafe fn apply_gain_stereo_neon(samples: &mut [[i32; 2]], g_q31: i32) { // reste scalaire let slice = std::slice::from_raw_parts_mut(ptr.add(i), n - i); - apply_gain_scalar(slice, g_q31); + apply_gain_i32_scalar(slice, g_q31); } #[cfg(all(target_arch = "x86_64", target_feature = "avx2"))] #[inline(always)] -unsafe fn apply_gain_stereo_avx2(samples: &mut [[i32; 2]], g_q31: i32) { +unsafe fn apply_gain_stereo_i32_avx2(samples: &mut [[i32; 2]], g_q31: i32) { use core::arch::x86_64::*; let g = _mm256_set1_epi32(g_q31); let mut i = 0; @@ -80,12 +80,12 @@ unsafe fn apply_gain_stereo_avx2(samples: &mut [[i32; 2]], g_q31: i32) { // reste scalaire let slice = std::slice::from_raw_parts_mut(ptr.add(i), n - i); - apply_gain_scalar(slice, g_q31); + apply_gain_i32_scalar(slice, g_q31); } /// version mono utilisée pour le reste scalaire #[inline(always)] -fn apply_gain_scalar(samples: &mut [i32], g_q31: i32) { +fn apply_gain_i32_scalar(samples: &mut [i32], g_q31: i32) { for s in samples.iter_mut() { let prod = (*s as i64 * g_q31 as i64 + (1 << 30)) >> 31; *s = prod.clamp(i32::MIN as i64, i32::MAX as i64) as i32; diff --git a/pmoaudio/src/dsp/int_float.rs b/pmoaudio/src/dsp/int_float.rs index a790c907..15a6ccee 100644 --- a/pmoaudio/src/dsp/int_float.rs +++ b/pmoaudio/src/dsp/int_float.rs @@ -1,5 +1,5 @@ -use bytemuck::{cast_slice, cast_slice_mut}; use crate::BitDepth; +use bytemuck::{cast_slice, cast_slice_mut}; #[cfg(feature = "simd")] use std::simd::num::{SimdFloat, SimdInt}; @@ -183,3 +183,307 @@ pub fn interleaved_f32_to_i32_stereo( let input_pairs: &[[f32; 2]] = cast_slice(input_interleaved); pairs_f32_to_i32_stereo(input_pairs, left, right, bit_depth); } + +/* ====================== CONVERSIONS I16 ↔ F32 SIMD ====================== */ + +/// Convertit deux canaux i16 (L/R) en pairs f32 normalisées [-1.0, 1.0] +#[cfg(feature = "simd")] +fn i16_stereo_to_pairs_f32_inner( + left: &[i16], + right: &[i16], + out_pairs: &mut [[f32; 2]], + max_value: f32, +) { + debug_assert_eq!(left.len(), right.len()); + debug_assert_eq!(out_pairs.len(), left.len()); + + const LANES: usize = 8; + type Vf32 = Simd; + type Vi32 = Simd; + + let scale = Vf32::splat(1.0 / max_value); + + let (l_chunks, l_tail) = left.as_chunks::(); + let (r_chunks, r_tail) = right.as_chunks::(); + let (o_chunks, o_tail) = out_pairs.as_chunks_mut::(); + + for (k, o) in o_chunks.iter_mut().enumerate() { + // Charger i16, caster en i32 puis en f32 + let l_arr: [i32; LANES] = std::array::from_fn(|i| l_chunks[k][i] as i32); + let r_arr: [i32; LANES] = std::array::from_fn(|i| r_chunks[k][i] as i32); + + let l = Vi32::from_array(l_arr).cast::() * scale; + let r = Vi32::from_array(r_arr).cast::() * scale; + + for j in 0..LANES { + unsafe { + *o.get_unchecked_mut(j) = [l[j], r[j]]; + } + } + } + + let scale_scalar = 1.0 / max_value; + for (dst, (&l, &r)) in o_tail.iter_mut().zip(l_tail.iter().zip(r_tail.iter())) { + dst[0] = l as f32 * scale_scalar; + dst[1] = r as f32 * scale_scalar; + } +} + +#[cfg(not(feature = "simd"))] +fn i16_stereo_to_pairs_f32_inner( + left: &[i16], + right: &[i16], + out_pairs: &mut [[f32; 2]], + max_value: f32, +) { + debug_assert_eq!(left.len(), right.len()); + debug_assert_eq!(out_pairs.len(), left.len()); + + let scale = 1.0 / max_value; + for ((out, &l), &r) in out_pairs.iter_mut().zip(left).zip(right) { + out[0] = l as f32 * scale; + out[1] = r as f32 * scale; + } +} + +/// 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]], +) { + i16_stereo_to_pairs_f32_inner(left, right, out_pairs, 32768.0); +} + +/// Convertit pairs f32 normalisées [-1.0, 1.0] en deux canaux i16 (L/R) +#[cfg(feature = "simd")] +fn pairs_f32_to_i16_stereo_inner( + input_pairs: &[[f32; 2]], + left: &mut [i16], + right: &mut [i16], + max_value: f32, +) { + debug_assert_eq!(input_pairs.len(), left.len()); + debug_assert_eq!(input_pairs.len(), right.len()); + + const LANES: usize = 8; + type Vf32 = Simd; + + let vmin = -max_value; + let vmax_clamp = max_value - 1.0; + let vscale = Vf32::splat(max_value); + let vminv = Vf32::splat(vmin); + let vmaxv = Vf32::splat(vmax_clamp); + + let (in_chunks, in_tail) = input_pairs.as_chunks::(); + let (l_chunks, l_tail) = left.as_chunks_mut::(); + let (r_chunks, r_tail) = right.as_chunks_mut::(); + + for (k, blk) in in_chunks.iter().enumerate() { + let mut l_arr = [0.0f32; LANES]; + let mut r_arr = [0.0f32; LANES]; + for j in 0..LANES { + let p = blk[j]; + l_arr[j] = p[0]; + r_arr[j] = p[1]; + } + + let lq = (Vf32::from_array(l_arr) * vscale) + .simd_clamp(vminv, vmaxv) + .round() + .cast::(); + let rq = (Vf32::from_array(r_arr) * vscale) + .simd_clamp(vminv, vmaxv) + .round() + .cast::(); + + for j in 0..LANES { + l_chunks[k][j] = lq[j] as i16; + r_chunks[k][j] = rq[j] as i16; + } + } + + for (j, (l, r)) in in_tail.iter().zip(l_tail.iter_mut().zip(r_tail.iter_mut())) { + let lx = (j[0] * max_value).clamp(vmin, vmax_clamp).round(); + let rx = (j[1] * max_value).clamp(vmin, vmax_clamp).round(); + *l = lx as i16; + *r = rx as i16; + } +} + +#[cfg(not(feature = "simd"))] +fn pairs_f32_to_i16_stereo_inner( + input_pairs: &[[f32; 2]], + left: &mut [i16], + right: &mut [i16], + max_value: f32, +) { + debug_assert_eq!(input_pairs.len(), left.len()); + debug_assert_eq!(input_pairs.len(), right.len()); + + let vmin = -max_value; + let vmax_clamp = max_value - 1.0; + for (i, pair) in input_pairs.iter().enumerate() { + let lx = (pair[0] * max_value).clamp(vmin, vmax_clamp).round(); + let rx = (pair[1] * max_value).clamp(vmin, vmax_clamp).round(); + left[i] = lx as i16; + right[i] = rx as i16; + } +} + +/// 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], +) { + pairs_f32_to_i16_stereo_inner(input_pairs, left, right, 32768.0); +} + +/* ====================== CONVERSIONS I24 ↔ F32 SIMD ====================== */ + +/// Convertit deux canaux i32 (contenant des valeurs I24) en pairs f32 normalisées +#[cfg(feature = "simd")] +fn i24_as_i32_stereo_to_pairs_f32_inner( + left: &[i32], + right: &[i32], + out_pairs: &mut [[f32; 2]], + max_value: f32, +) { + debug_assert_eq!(left.len(), right.len()); + debug_assert_eq!(out_pairs.len(), left.len()); + + const LANES: usize = 8; + type Vf32 = Simd; + type Vi32 = Simd; + + let scale = Vf32::splat(1.0 / max_value); + + let (l_chunks, l_tail) = left.as_chunks::(); + let (r_chunks, r_tail) = right.as_chunks::(); + let (o_chunks, o_tail) = out_pairs.as_chunks_mut::(); + + for (k, o) in o_chunks.iter_mut().enumerate() { + let l = Vi32::from_slice(&l_chunks[k]).cast::() * scale; + let r = Vi32::from_slice(&r_chunks[k]).cast::() * scale; + + for j in 0..LANES { + unsafe { + *o.get_unchecked_mut(j) = [l[j], r[j]]; + } + } + } + + let scale_scalar = 1.0 / max_value; + for (dst, (&l, &r)) in o_tail.iter_mut().zip(l_tail.iter().zip(r_tail.iter())) { + dst[0] = l as f32 * scale_scalar; + dst[1] = r as f32 * scale_scalar; + } +} + +#[cfg(not(feature = "simd"))] +fn i24_as_i32_stereo_to_pairs_f32_inner( + left: &[i32], + right: &[i32], + out_pairs: &mut [[f32; 2]], + max_value: f32, +) { + debug_assert_eq!(left.len(), right.len()); + debug_assert_eq!(out_pairs.len(), left.len()); + + let scale = 1.0 / max_value; + for ((out, &l), &r) in out_pairs.iter_mut().zip(left).zip(right) { + out[0] = l as f32 * scale; + out[1] = r as f32 * scale; + } +} + +/// 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]], +) { + i24_as_i32_stereo_to_pairs_f32_inner(left, right, out_pairs, 8388608.0); +} + +/// Convertit pairs f32 normalisées en deux canaux i32 (valeurs I24 range) +#[cfg(feature = "simd")] +fn pairs_f32_to_i24_as_i32_stereo_inner( + input_pairs: &[[f32; 2]], + left: &mut [i32], + right: &mut [i32], + max_value: f32, +) { + debug_assert_eq!(input_pairs.len(), left.len()); + debug_assert_eq!(input_pairs.len(), right.len()); + + const LANES: usize = 8; + type Vf32 = Simd; + + let vmin = -max_value; + let vmax_clamp = max_value - 1.0; + let vscale = Vf32::splat(max_value); + let vminv = Vf32::splat(vmin); + let vmaxv = Vf32::splat(vmax_clamp); + + let (in_chunks, in_tail) = input_pairs.as_chunks::(); + let (l_chunks, l_tail) = left.as_chunks_mut::(); + let (r_chunks, r_tail) = right.as_chunks_mut::(); + + for (k, blk) in in_chunks.iter().enumerate() { + let mut l_arr = [0.0f32; LANES]; + let mut r_arr = [0.0f32; LANES]; + for j in 0..LANES { + let p = blk[j]; + l_arr[j] = p[0]; + r_arr[j] = p[1]; + } + + let lq = (Vf32::from_array(l_arr) * vscale) + .simd_clamp(vminv, vmaxv) + .round(); + let rq = (Vf32::from_array(r_arr) * vscale) + .simd_clamp(vminv, vmaxv) + .round(); + + lq.cast::().copy_to_slice(&mut l_chunks[k]); + rq.cast::().copy_to_slice(&mut r_chunks[k]); + } + + for (j, (l, r)) in in_tail.iter().zip(l_tail.iter_mut().zip(r_tail.iter_mut())) { + let lx = (j[0] * max_value).clamp(vmin, vmax_clamp).round(); + let rx = (j[1] * max_value).clamp(vmin, vmax_clamp).round(); + *l = lx as i32; + *r = rx as i32; + } +} + +#[cfg(not(feature = "simd"))] +fn pairs_f32_to_i24_as_i32_stereo_inner( + input_pairs: &[[f32; 2]], + left: &mut [i32], + right: &mut [i32], + max_value: f32, +) { + debug_assert_eq!(input_pairs.len(), left.len()); + debug_assert_eq!(input_pairs.len(), right.len()); + + let vmin = -max_value; + let vmax_clamp = max_value - 1.0; + for (i, pair) in input_pairs.iter().enumerate() { + let lx = (pair[0] * max_value).clamp(vmin, vmax_clamp).round(); + let rx = (pair[1] * max_value).clamp(vmin, vmax_clamp).round(); + left[i] = lx as i32; + right[i] = rx as i32; + } +} + +/// Convertit pairs f32 normalisées en deux canaux i32 (valeurs I24 range) +pub fn pairs_f32_to_i24_as_i32_stereo( + input_pairs: &[[f32; 2]], + left: &mut [i32], + right: &mut [i32], +) { + pairs_f32_to_i24_as_i32_stereo_inner(input_pairs, left, right, 8388608.0); +} diff --git a/pmoaudio/src/dsp/mod.rs b/pmoaudio/src/dsp/mod.rs index 83a04541..bc96fb50 100644 --- a/pmoaudio/src/dsp/mod.rs +++ b/pmoaudio/src/dsp/mod.rs @@ -1,15 +1,21 @@ //! Module DSP pour les conversions et traitements audio optimisés (SIMD) pub mod depth; -pub mod gain; +pub mod gain_16bits; +pub mod gain_24bits; +pub mod gain_32bits; pub mod int_float; pub mod resampling; pub use depth::bitdepth_change_stereo; -pub use gain::apply_gain_stereo; +pub use gain_16bits::apply_gain_stereo_i16; +pub use gain_24bits::apply_gain_stereo_i24; +pub use gain_32bits::apply_gain_stereo_i32; + pub use int_float::{ - i32_stereo_to_interleaved_f32, i32_stereo_to_pairs_f32, interleaved_f32_to_i32_stereo, - pairs_f32_to_i32_stereo, + i16_stereo_to_pairs_f32, i24_as_i32_stereo_to_pairs_f32, i32_stereo_to_interleaved_f32, + i32_stereo_to_pairs_f32, interleaved_f32_to_i32_stereo, pairs_f32_to_i16_stereo, + pairs_f32_to_i24_as_i32_stereo, pairs_f32_to_i32_stereo, }; pub use resampling::resampling; diff --git a/pmoaudio/src/lib.rs b/pmoaudio/src/lib.rs index bc2f309c..1d2004c8 100644 --- a/pmoaudio/src/lib.rs +++ b/pmoaudio/src/lib.rs @@ -81,12 +81,13 @@ async fn main() { use std::simd::*; mod audio_chunk; -pub mod events; -// mod nodes; // Temporairement déplacé hors du module -mod sync_marker; mod audio_segment; -mod sample_types; pub mod conversions; +pub mod events; +pub mod nodes; +mod sample_types; +mod sync_marker; +pub mod type_constraints; #[macro_use] mod macros; @@ -94,18 +95,32 @@ pub mod bit_depth; pub mod dsp; pub use audio_segment::{AudioSegment, _AudioSegment}; -pub use sync_marker::{SyncMarker}; +pub use sync_marker::SyncMarker; -pub use audio_chunk::{AudioChunk, AudioChunkData, db_to_linear, gain_db_from_linear, gain_linear_from_db, linear_to_db}; +pub use audio_chunk::{ + gain_db_from_linear, gain_linear_from_db, AudioChunk, AudioChunkData, AudioFloatChunk, + AudioIntegerChunk, +}; pub use bit_depth::{Bit16, Bit24, Bit32, Bit8, BitDepth}; -pub use sample_types::{I24, Sample}; - +pub use sample_types::{Sample, I24}; +pub use type_constraints::{ + check_compatibility, SampleType, TypeCategory, TypeMismatch, TypeRequirement, +}; pub use events::{ AudioDataEvent, EventPublisher, EventReceiver, NodeEvent, NodeListener, SourceNameUpdateEvent, VolumeChangeEvent, }; +// Exports publics des nodes +pub use nodes::{ + converter_nodes::{ToF32Node, ToF64Node, ToI16Node, ToI24Node, ToI32Node}, + file_source::FileSource, + flac_file_sink::{FlacFileSink, FlacFileSinkStats}, + http_source::HttpSource, + AudioError, AudioNode, MultiSubscriberNode, SingleSubscriberNode, TypedAudioNode, +}; + // Nodes temporairement désactivés /* pub use nodes::{ @@ -114,13 +129,10 @@ pub use nodes::{ decoder_node::DecoderNode, disk_sink::{AudioFileFormat, DiskSink, DiskSinkConfig, DiskSinkStats}, dsp_node::DspNode, - file_source::FileSource, - flac_file_sink::{FlacFileSink, FlacFileSinkStats}, mpd_sink::{MpdAudioFormat, MpdConfig, MpdHandle, MpdSink, MpdStats}, sink_node::{SinkNode, SinkStats}, source_node::SourceNode, timer_node::{TimerHandle, TimerNode}, volume_node::{HardwareVolumeNode, VolumeHandle, VolumeNode}, - AudioError, AudioNode, MultiSubscriberNode, SingleSubscriberNode, }; */ diff --git a/pmoaudio/src/macros.rs b/pmoaudio/src/macros.rs index ad2b60c3..e70feeaa 100644 --- a/pmoaudio/src/macros.rs +++ b/pmoaudio/src/macros.rs @@ -14,12 +14,6 @@ /// ``` #[macro_export] macro_rules! extract_chunk_data { - ($chunk:expr, I8) => { - match $chunk { - $crate::AudioChunk::I8(data) => Some(data), - _ => None, - } - }; ($chunk:expr, I16) => { match $chunk { $crate::AudioChunk::I16(data) => Some(data), @@ -68,7 +62,6 @@ macro_rules! extract_chunk_data { macro_rules! match_chunk { ($chunk:expr, $data:ident => $body:expr) => { match $chunk { - $crate::AudioChunk::I8($data) => $body, $crate::AudioChunk::I16($data) => $body, $crate::AudioChunk::I24($data) => $body, $crate::AudioChunk::I32($data) => $body, @@ -94,24 +87,11 @@ macro_rules! match_chunk { macro_rules! map_chunk { ($chunk:expr, $data:ident => $transform:expr) => { match $chunk { - $crate::AudioChunk::I8($data) => { - $crate::AudioChunk::I8($transform) - } - $crate::AudioChunk::I16($data) => { - $crate::AudioChunk::I16($transform) - } - $crate::AudioChunk::I24($data) => { - $crate::AudioChunk::I24($transform) - } - $crate::AudioChunk::I32($data) => { - $crate::AudioChunk::I32($transform) - } - $crate::AudioChunk::F32($data) => { - $crate::AudioChunk::F32($transform) - } - $crate::AudioChunk::F64($data) => { - $crate::AudioChunk::F64($transform) - } + $crate::AudioChunk::I16($data) => $crate::AudioChunk::I16($transform), + $crate::AudioChunk::I24($data) => $crate::AudioChunk::I24($transform), + $crate::AudioChunk::I32($data) => $crate::AudioChunk::I32($transform), + $crate::AudioChunk::F32($data) => $crate::AudioChunk::F32($transform), + $crate::AudioChunk::F64($data) => $crate::AudioChunk::F64($transform), } }; } @@ -130,9 +110,6 @@ macro_rules! map_chunk { /// ``` #[macro_export] macro_rules! is_chunk_type { - ($chunk:expr, I8) => { - matches!($chunk, $crate::AudioChunk::I8(_)) - }; ($chunk:expr, I16) => { matches!($chunk, $crate::AudioChunk::I16(_)) }; @@ -280,13 +257,15 @@ mod tests { let segment = AudioSegment::new_hearbeat(1, 1.0); assert!(extract_sync_marker!(&*segment).is_some()); - let audio_segment = AudioSegment::new_chunk(0, 0.0, vec![[100i32, 200i32]], 44100, BitDepth::B32); + let audio_segment = + AudioSegment::new_chunk(0, 0.0, vec![[100i32, 200i32]], 44100, BitDepth::B32); assert!(extract_sync_marker!(&*audio_segment).is_none()); } #[test] fn test_match_segment() { - let audio_segment = AudioSegment::new_chunk(0, 0.0, vec![[100i32, 200i32]], 44100, BitDepth::B32); + let audio_segment = + AudioSegment::new_chunk(0, 0.0, vec![[100i32, 200i32]], 44100, BitDepth::B32); let result = match_segment!(&*audio_segment, chunk => format!("audio: {}", chunk.type_name()), diff --git a/pmoaudio/src/nodes/converter_nodes.rs b/pmoaudio/src/nodes/converter_nodes.rs new file mode 100644 index 00000000..bf06dc88 --- /dev/null +++ b/pmoaudio/src/nodes/converter_nodes.rs @@ -0,0 +1,472 @@ +//! Nodes de conversion de type pour AudioChunk +//! +//! Ces nodes permettent de convertir les chunks audio d'un type vers un autre +//! (I16, I24, I32, F32, F64). Toutes les conversions utilisent les fonctions +//! DSP optimisées SIMD du module `crate::conversions`. +//! +//! Le designer de pipeline doit insérer manuellement ces nodes pour gérer +//! les incompatibilités de type entre producers et consumers. + +use crate::{ + nodes::{AudioError, MultiSubscriberNode, TypedAudioNode}, + type_constraints::{SampleType, TypeRequirement}, + AudioSegment, +}; +use std::sync::Arc; +use tokio::sync::mpsc; + +/// Node de conversion vers I16 +/// +/// Convertit n'importe quel type de chunk audio vers I16 (16-bit signed integer). +/// Utilise les conversions DSP SIMD optimisées. +pub struct ToI16Node { + rx: mpsc::Receiver>, + subscribers: MultiSubscriberNode, +} + +impl ToI16Node { + /// Crée un nouveau node de conversion vers I16 + pub fn new() -> (Self, mpsc::Sender>) { + Self::with_channel_size(16) + } + + /// Crée un nouveau node avec une taille de buffer spécifique + pub fn with_channel_size(channel_size: usize) -> (Self, mpsc::Sender>) { + let (tx, rx) = mpsc::channel(channel_size); + let node = Self { + rx, + subscribers: MultiSubscriberNode::new(), + }; + (node, tx) + } + + /// Ajoute un abonné qui recevra les segments audio convertis + pub fn add_subscriber(&mut self, tx: mpsc::Sender>) { + self.subscribers.add_subscriber(tx); + } + + /// Lance le traitement de conversion + pub async fn run(mut self) -> Result<(), AudioError> { + while let Some(segment) = self.rx.recv().await { + // Si c'est un syncmarker, passer directement + if !segment.is_audio_chunk() { + self.subscribers.push(segment).await?; + continue; + } + + // Convertir le chunk audio vers I16 + let converted_segment = if let Some(chunk) = segment.as_chunk() { + let converted_chunk = chunk.to_i16(); + Arc::new(AudioSegment { + order: segment.order, + timestamp_sec: segment.timestamp_sec, + segment: crate::_AudioSegment::Chunk(Arc::new(converted_chunk)), + }) + } else { + segment + }; + + self.subscribers.push(converted_segment).await?; + } + + Ok(()) + } +} + +impl TypedAudioNode for ToI16Node { + fn input_type(&self) -> Option { + // Accepte n'importe quel type + Some(TypeRequirement::any()) + } + + fn output_type(&self) -> Option { + // Produit uniquement I16 + Some(TypeRequirement::specific(SampleType::I16)) + } +} + +impl Default for ToI16Node { + fn default() -> Self { + Self::new().0 + } +} + +/// Node de conversion vers I24 +/// +/// Convertit n'importe quel type de chunk audio vers I24 (24-bit signed integer). +/// Utilise les conversions DSP SIMD optimisées. +pub struct ToI24Node { + rx: mpsc::Receiver>, + subscribers: MultiSubscriberNode, +} + +impl ToI24Node { + /// Crée un nouveau node de conversion vers I24 + pub fn new() -> (Self, mpsc::Sender>) { + Self::with_channel_size(16) + } + + /// Crée un nouveau node avec une taille de buffer spécifique + pub fn with_channel_size(channel_size: usize) -> (Self, mpsc::Sender>) { + let (tx, rx) = mpsc::channel(channel_size); + let node = Self { + rx, + subscribers: MultiSubscriberNode::new(), + }; + (node, tx) + } + + /// Ajoute un abonné qui recevra les segments audio convertis + pub fn add_subscriber(&mut self, tx: mpsc::Sender>) { + self.subscribers.add_subscriber(tx); + } + + /// Lance le traitement de conversion + pub async fn run(mut self) -> Result<(), AudioError> { + while let Some(segment) = self.rx.recv().await { + if !segment.is_audio_chunk() { + self.subscribers.push(segment).await?; + continue; + } + + let converted_segment = if let Some(chunk) = segment.as_chunk() { + let converted_chunk = chunk.to_i24(); + Arc::new(AudioSegment { + order: segment.order, + timestamp_sec: segment.timestamp_sec, + segment: crate::_AudioSegment::Chunk(Arc::new(converted_chunk)), + }) + } else { + segment + }; + + self.subscribers.push(converted_segment).await?; + } + + Ok(()) + } +} + +impl TypedAudioNode for ToI24Node { + fn input_type(&self) -> Option { + Some(TypeRequirement::any()) + } + + fn output_type(&self) -> Option { + Some(TypeRequirement::specific(SampleType::I24)) + } +} + +impl Default for ToI24Node { + fn default() -> Self { + Self::new().0 + } +} + +/// Node de conversion vers I32 +/// +/// Convertit n'importe quel type de chunk audio vers I32 (32-bit signed integer). +/// Utilise les conversions DSP SIMD optimisées. +pub struct ToI32Node { + rx: mpsc::Receiver>, + subscribers: MultiSubscriberNode, +} + +impl ToI32Node { + /// Crée un nouveau node de conversion vers I32 + pub fn new() -> (Self, mpsc::Sender>) { + Self::with_channel_size(16) + } + + /// Crée un nouveau node avec une taille de buffer spécifique + pub fn with_channel_size(channel_size: usize) -> (Self, mpsc::Sender>) { + let (tx, rx) = mpsc::channel(channel_size); + let node = Self { + rx, + subscribers: MultiSubscriberNode::new(), + }; + (node, tx) + } + + /// Ajoute un abonné qui recevra les segments audio convertis + pub fn add_subscriber(&mut self, tx: mpsc::Sender>) { + self.subscribers.add_subscriber(tx); + } + + /// Lance le traitement de conversion + pub async fn run(mut self) -> Result<(), AudioError> { + while let Some(segment) = self.rx.recv().await { + if !segment.is_audio_chunk() { + self.subscribers.push(segment).await?; + continue; + } + + let converted_segment = if let Some(chunk) = segment.as_chunk() { + let converted_chunk = chunk.to_i32(); + Arc::new(AudioSegment { + order: segment.order, + timestamp_sec: segment.timestamp_sec, + segment: crate::_AudioSegment::Chunk(Arc::new(converted_chunk)), + }) + } else { + segment + }; + + self.subscribers.push(converted_segment).await?; + } + + Ok(()) + } +} + +impl TypedAudioNode for ToI32Node { + fn input_type(&self) -> Option { + Some(TypeRequirement::any()) + } + + fn output_type(&self) -> Option { + Some(TypeRequirement::specific(SampleType::I32)) + } +} + +impl Default for ToI32Node { + fn default() -> Self { + Self::new().0 + } +} + +/// Node de conversion vers F32 +/// +/// Convertit n'importe quel type de chunk audio vers F32 (32-bit floating point). +/// Utilise les conversions DSP SIMD optimisées. +pub struct ToF32Node { + rx: mpsc::Receiver>, + subscribers: MultiSubscriberNode, +} + +impl ToF32Node { + /// Crée un nouveau node de conversion vers F32 + pub fn new() -> (Self, mpsc::Sender>) { + Self::with_channel_size(16) + } + + /// Crée un nouveau node avec une taille de buffer spécifique + pub fn with_channel_size(channel_size: usize) -> (Self, mpsc::Sender>) { + let (tx, rx) = mpsc::channel(channel_size); + let node = Self { + rx, + subscribers: MultiSubscriberNode::new(), + }; + (node, tx) + } + + /// Ajoute un abonné qui recevra les segments audio convertis + pub fn add_subscriber(&mut self, tx: mpsc::Sender>) { + self.subscribers.add_subscriber(tx); + } + + /// Lance le traitement de conversion + pub async fn run(mut self) -> Result<(), AudioError> { + while let Some(segment) = self.rx.recv().await { + if !segment.is_audio_chunk() { + self.subscribers.push(segment).await?; + continue; + } + + let converted_segment = if let Some(chunk) = segment.as_chunk() { + let converted_chunk = chunk.to_f32(); + Arc::new(AudioSegment { + order: segment.order, + timestamp_sec: segment.timestamp_sec, + segment: crate::_AudioSegment::Chunk(Arc::new(converted_chunk)), + }) + } else { + segment + }; + + self.subscribers.push(converted_segment).await?; + } + + Ok(()) + } +} + +impl TypedAudioNode for ToF32Node { + fn input_type(&self) -> Option { + Some(TypeRequirement::any()) + } + + fn output_type(&self) -> Option { + Some(TypeRequirement::specific(SampleType::F32)) + } +} + +impl Default for ToF32Node { + fn default() -> Self { + Self::new().0 + } +} + +/// Node de conversion vers F64 +/// +/// Convertit n'importe quel type de chunk audio vers F64 (64-bit floating point). +/// Utilise les conversions DSP SIMD optimisées. +pub struct ToF64Node { + rx: mpsc::Receiver>, + subscribers: MultiSubscriberNode, +} + +impl ToF64Node { + /// Crée un nouveau node de conversion vers F64 + pub fn new() -> (Self, mpsc::Sender>) { + Self::with_channel_size(16) + } + + /// Crée un nouveau node avec une taille de buffer spécifique + pub fn with_channel_size(channel_size: usize) -> (Self, mpsc::Sender>) { + let (tx, rx) = mpsc::channel(channel_size); + let node = Self { + rx, + subscribers: MultiSubscriberNode::new(), + }; + (node, tx) + } + + /// Ajoute un abonné qui recevra les segments audio convertis + pub fn add_subscriber(&mut self, tx: mpsc::Sender>) { + self.subscribers.add_subscriber(tx); + } + + /// Lance le traitement de conversion + pub async fn run(mut self) -> Result<(), AudioError> { + while let Some(segment) = self.rx.recv().await { + if !segment.is_audio_chunk() { + self.subscribers.push(segment).await?; + continue; + } + + let converted_segment = if let Some(chunk) = segment.as_chunk() { + let converted_chunk = chunk.to_f64(); + Arc::new(AudioSegment { + order: segment.order, + timestamp_sec: segment.timestamp_sec, + segment: crate::_AudioSegment::Chunk(Arc::new(converted_chunk)), + }) + } else { + segment + }; + + self.subscribers.push(converted_segment).await?; + } + + Ok(()) + } +} + +impl TypedAudioNode for ToF64Node { + fn input_type(&self) -> Option { + Some(TypeRequirement::any()) + } + + fn output_type(&self) -> Option { + Some(TypeRequirement::specific(SampleType::F64)) + } +} + +impl Default for ToF64Node { + fn default() -> Self { + Self::new().0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{AudioChunk, AudioChunkData}; + + #[tokio::test] + async fn test_to_f32_node_type_requirements() { + let (node, _tx) = ToF32Node::new(); + + // Vérifier les types d'entrée/sortie + assert_eq!( + node.input_type().unwrap().get_accepted_types().len(), + 5, + "Should accept all 5 types" + ); + assert_eq!( + node.output_type() + .unwrap() + .get_accepted_types() + .first() + .copied(), + Some(SampleType::F32), + "Should output F32 only" + ); + } + + #[tokio::test] + async fn test_to_i16_node_converts_from_i32() { + let (mut node, tx) = ToI16Node::new(); + let (out_tx, mut out_rx) = mpsc::channel(16); + node.add_subscriber(out_tx); + + // Lancer le node dans une tâche + let handle = tokio::spawn(async move { node.run().await }); + + // Créer et envoyer un chunk I32 + let stereo = vec![[1_000_000i32 << 16, -500_000i32 << 16]; 100]; + let chunk_data = AudioChunkData::new(stereo.clone(), 48_000, 0.0); + let chunk = AudioChunk::I32(chunk_data); + let segment = Arc::new(AudioSegment { + order: 0, + timestamp_sec: 0.0, + segment: crate::_AudioSegment::Chunk(Arc::new(chunk)), + }); + + tx.send(segment).await.unwrap(); + drop(tx); + + // Recevoir le chunk converti + let result = out_rx.recv().await.unwrap(); + assert!(result.is_audio_chunk()); + + if let Some(converted) = result.as_chunk() { + assert_eq!(converted.type_name(), "i16"); + assert_eq!(converted.len(), 100); + + // Vérifier la conversion (downsampling de I32 vers I16) + if let AudioChunk::I16(data) = &**converted { + for (orig, converted_frame) in stereo.iter().zip(data.frames().iter()) { + let expected_l = (orig[0] >> 16) as i16; + let expected_r = (orig[1] >> 16) as i16; + assert_eq!(converted_frame[0], expected_l); + assert_eq!(converted_frame[1], expected_r); + } + } + } + + handle.await.unwrap().unwrap(); + } + + #[tokio::test] + async fn test_syncmarkers_passthrough() { + let (mut node, tx) = ToI16Node::new(); + let (out_tx, mut out_rx) = mpsc::channel(16); + node.add_subscriber(out_tx); + + tokio::spawn(async move { + node.run().await.unwrap(); + }); + + // Envoyer un syncmarker + let top_zero = AudioSegment::new_top_zero_sync(); + tx.send(top_zero.clone()).await.unwrap(); + drop(tx); + + // Recevoir le syncmarker + let result = out_rx.recv().await.unwrap(); + assert!(!result.is_audio_chunk()); + assert!(result.as_sync_marker().is_some()); + } +} diff --git a/pmoaudio/src/nodes/file_source.rs b/pmoaudio/src/nodes/file_source.rs new file mode 100644 index 00000000..e23b8c7b --- /dev/null +++ b/pmoaudio/src/nodes/file_source.rs @@ -0,0 +1,426 @@ +use crate::{ + nodes::{AudioError, MultiSubscriberNode, TypedAudioNode, DEFAULT_CHUNK_DURATION_MS}, + type_constraints::TypeRequirement, + AudioChunk, AudioChunkData, AudioSegment, I24, +}; +use pmoflac::{decode_audio_stream, AudioFileMetadata, StreamInfo}; +use pmometadata::{MemoryTrackMetadata, TrackMetadata}; +use std::{path::PathBuf, sync::Arc, time::Duration}; +use tokio::{fs::File, io::AsyncReadExt, sync::mpsc}; + +/// FileSource - Lit un fichier audio et publie des `AudioSegment` +/// +/// Cette source utilise `pmoflac` pour décoder le fichier (FLAC/MP3/OGG/WAV/AIFF) +/// puis transforme les échantillons PCM en `AudioSegment` stéréo avec le type approprié +/// (I16, I24, ou I32) selon la profondeur de bit du fichier source. +/// +/// Le node émet trois types de syncmarkers : +/// - `TopZeroSync` au début du flux +/// - `TrackBoundary` avec les métadonnées du fichier +/// - `EndOfStream` à la fin du flux +pub struct FileSource { + path: PathBuf, + chunk_frames: usize, + subscribers: MultiSubscriberNode, +} + +impl FileSource { + /// Crée une nouvelle source de fichier avec calcul automatique de la taille des chunks. + /// + /// La taille des chunks sera calculée automatiquement pour obtenir environ 50ms + /// de latence par chunk, en fonction du sample rate du fichier. + /// + /// * `path` - chemin du fichier audio à lire + pub fn new>(path: P) -> Self { + Self::with_chunk_size(path, 0) // 0 = auto-calculer + } + + /// Crée une nouvelle source de fichier avec une taille de chunk spécifique. + /// + /// * `path` - chemin du fichier audio à lire + /// * `chunk_frames` - nombre d'échantillons par canal par chunk (0 = auto) + pub fn with_chunk_size>(path: P, chunk_frames: usize) -> Self { + Self { + path: path.into(), + chunk_frames, + subscribers: MultiSubscriberNode::new(), + } + } + + /// Ajoute un abonné qui recevra les segments audio. + pub fn add_subscriber(&mut self, tx: mpsc::Sender>) { + self.subscribers.add_subscriber(tx); + } + + /// Lance la lecture du fichier et diffuse les segments audio. + pub async fn run(self) -> Result<(), AudioError> { + // Ouvrir le fichier + let file = File::open(&self.path).await.map_err(|e| { + AudioError::ProcessingError(format!("Failed to open {:?}: {}", self.path, e)) + })?; + + // Décoder le flux audio + let mut stream = decode_audio_stream(file) + .await + .map_err(|e| AudioError::ProcessingError(format!("Decode error: {}", e)))?; + let stream_info = stream.info().clone(); + + validate_stream(&stream_info)?; + + // Calculer la taille des chunks si non spécifiée (0 = auto) + let chunk_frames = if self.chunk_frames == 0 { + // Calculer pour obtenir DEFAULT_CHUNK_DURATION_MS millisecondes + let frames = + (stream_info.sample_rate as f64 * DEFAULT_CHUNK_DURATION_MS / 1000.0) as usize; + // Arrondir à la puissance de 2 la plus proche pour optimiser les buffers + frames.next_power_of_two().max(256) + } else { + self.chunk_frames.max(1) + }; + + // Émettre TopZeroSync + let top_zero = AudioSegment::new_top_zero_sync(); + self.subscribers.push(top_zero).await?; + + // Extraire et émettre les métadonnées du fichier + match AudioFileMetadata::from_file(&self.path) { + Ok(file_metadata) => { + let mut metadata = MemoryTrackMetadata::new(); + + // Convertir AudioFileMetadata vers MemoryTrackMetadata + if let Some(title) = file_metadata.title { + let _ = metadata.set_title(Some(title)).await; + } + if let Some(artist) = file_metadata.artist { + let _ = metadata.set_artist(Some(artist)).await; + } + if let Some(album) = file_metadata.album { + let _ = metadata.set_album(Some(album)).await; + } + if let Some(year) = file_metadata.year { + let _ = metadata.set_year(Some(year)).await; + } + if let Some(duration_secs) = file_metadata.duration_secs { + let _ = metadata + .set_duration(Some(Duration::from_secs(duration_secs))) + .await; + } + + // Émettre TrackBoundary + let track_boundary = AudioSegment::new_track_boundary(0, 0.0, Arc::new(metadata)); + self.subscribers.push(track_boundary).await?; + } + Err(e) => { + eprintln!( + "Warning: Failed to extract metadata from {:?}: {}", + self.path, e + ); + // Continuer sans métadonnées + } + } + + // Préparer la lecture des chunks audio + let frame_bytes = stream_info.bytes_per_sample() * stream_info.channels as usize; + let chunk_byte_len = chunk_frames * frame_bytes; + let mut pending = Vec::new(); + let mut read_buf = vec![0u8; frame_bytes * 512.max(chunk_frames)]; + let mut chunk_index = 0u64; + let mut total_frames = 0u64; + + // Lire et émettre les chunks audio + loop { + // Remplir le buffer + if pending.len() < chunk_byte_len { + let read = stream.read(&mut read_buf).await.map_err(|e| { + AudioError::ProcessingError(format!("I/O error while decoding: {}", e)) + })?; + if read == 0 { + break; + } + pending.extend_from_slice(&read_buf[..read]); + } + + if pending.is_empty() { + break; + } + + // Extraire un chunk + let frames_in_pending = pending.len() / frame_bytes; + let frames_to_emit = frames_in_pending.min(chunk_frames); + let take_bytes = frames_to_emit * frame_bytes; + let chunk_bytes = pending.drain(..take_bytes).collect::>(); + + // Calculer le timestamp + let timestamp_sec = total_frames as f64 / stream_info.sample_rate as f64; + + // Créer le segment audio + let segment = bytes_to_segment( + &chunk_bytes, + &stream_info, + frames_to_emit, + chunk_index, + timestamp_sec, + )?; + self.subscribers.push(segment).await?; + + chunk_index += 1; + total_frames += frames_to_emit as u64; + } + + // Traiter le reste éventuel (moins qu'un chunk complet) + if !pending.is_empty() { + let frames = pending.len() / frame_bytes; + if frames > 0 { + let timestamp_sec = total_frames as f64 / stream_info.sample_rate as f64; + let segment = + bytes_to_segment(&pending, &stream_info, frames, chunk_index, timestamp_sec)?; + self.subscribers.push(segment).await?; + total_frames += frames as u64; + chunk_index += 1; + } + } + + // Émettre EndOfStream + let final_timestamp = total_frames as f64 / stream_info.sample_rate as f64; + let eos = AudioSegment::new_end_of_stream(chunk_index, final_timestamp); + self.subscribers.push(eos).await?; + + // Attendre la fin du décodage + stream + .wait() + .await + .map_err(|e| AudioError::ProcessingError(format!("Decode task failed: {}", e)))?; + + Ok(()) + } +} + +fn validate_stream(info: &StreamInfo) -> Result<(), AudioError> { + if !(1..=2).contains(&info.channels) { + return Err(AudioError::ProcessingError(format!( + "Unsupported channel count: {}", + info.channels + ))); + } + match info.bits_per_sample { + 8 | 16 | 24 | 32 => Ok(()), + other => Err(AudioError::ProcessingError(format!( + "Unsupported bit depth: {}", + other + ))), + } +} + +/// Convertit des bytes PCM en AudioSegment avec le type approprié +fn bytes_to_segment( + chunk_bytes: &[u8], + info: &StreamInfo, + frames: usize, + order: u64, + timestamp_sec: f64, +) -> Result, AudioError> { + let bytes_per_sample = info.bytes_per_sample(); + let channels = info.channels as usize; + let frame_bytes = bytes_per_sample * channels; + + // Créer le chunk du bon type selon la profondeur de bit + let chunk = match info.bits_per_sample { + 16 => { + // Type I16 + let mut stereo = Vec::with_capacity(frames); + for frame_idx in 0..frames { + let base = frame_idx * frame_bytes; + let l = i16::from_le_bytes( + chunk_bytes[base..base + bytes_per_sample] + .try_into() + .unwrap(), + ); + let r = if channels == 1 { + l + } else { + i16::from_le_bytes( + chunk_bytes[base + bytes_per_sample..base + 2 * bytes_per_sample] + .try_into() + .unwrap(), + ) + }; + stereo.push([l, r]); + } + let chunk_data = AudioChunkData::new(stereo, info.sample_rate, 0.0); + AudioChunk::I16(chunk_data) + } + 24 => { + // Type I24 + let mut stereo = Vec::with_capacity(frames); + for frame_idx in 0..frames { + let base = frame_idx * frame_bytes; + let l_i32 = { + let mut buf = [0u8; 4]; + buf[..3].copy_from_slice(&chunk_bytes[base..base + 3]); + // Sign extend + if chunk_bytes[base + 2] & 0x80 != 0 { + buf[3] = 0xFF; + } + i32::from_le_bytes(buf) + }; + let l = I24::new(l_i32).ok_or_else(|| { + AudioError::ProcessingError(format!("Invalid I24 value: {}", l_i32)) + })?; + + let r = if channels == 1 { + l + } else { + let r_i32 = { + let mut buf = [0u8; 4]; + buf[..3].copy_from_slice( + &chunk_bytes[base + bytes_per_sample..base + bytes_per_sample + 3], + ); + // Sign extend + if chunk_bytes[base + bytes_per_sample + 2] & 0x80 != 0 { + buf[3] = 0xFF; + } + i32::from_le_bytes(buf) + }; + I24::new(r_i32).ok_or_else(|| { + AudioError::ProcessingError(format!("Invalid I24 value: {}", r_i32)) + })? + }; + stereo.push([l, r]); + } + let chunk_data = AudioChunkData::new(stereo, info.sample_rate, 0.0); + AudioChunk::I24(chunk_data) + } + 32 => { + // Type I32 + let mut stereo = Vec::with_capacity(frames); + for frame_idx in 0..frames { + let base = frame_idx * frame_bytes; + let l = i32::from_le_bytes( + chunk_bytes[base..base + bytes_per_sample] + .try_into() + .unwrap(), + ); + let r = if channels == 1 { + l + } else { + i32::from_le_bytes( + chunk_bytes[base + bytes_per_sample..base + 2 * bytes_per_sample] + .try_into() + .unwrap(), + ) + }; + stereo.push([l, r]); + } + let chunk_data = AudioChunkData::new(stereo, info.sample_rate, 0.0); + AudioChunk::I32(chunk_data) + } + other => { + return Err(AudioError::ProcessingError(format!( + "Unsupported bit depth: {}", + other + ))) + } + }; + + // Créer le segment audio + Ok(Arc::new(AudioSegment { + order, + timestamp_sec, + segment: crate::_AudioSegment::Chunk(Arc::new(chunk)), + })) +} + +impl TypedAudioNode for FileSource { + fn input_type(&self) -> Option { + // FileSource est une source, elle ne consomme pas d'audio + None + } + + fn output_type(&self) -> Option { + // FileSource peut produire n'importe quel type entier (I16, I24, I32) + // selon la profondeur de bit du fichier source + Some(TypeRequirement::any_integer()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pmoflac::{encode_flac_stream, EncoderOptions, PcmFormat}; + use std::io::Cursor; + use tokio::io::AsyncWriteExt; + use tokio::sync::mpsc; + + #[tokio::test] + async fn test_file_source_decodes_flac() { + let temp_dir = tempfile::tempdir().unwrap(); + let flac_path = temp_dir.path().join("test.flac"); + + let sample_rate = 48_000; + let frames = 256; + let mut pcm = Vec::with_capacity(frames * 4); + for i in 0..frames { + let sample = ((i % 32) as f32 / 31.0 * 2.0 - 1.0) * 0.5; // simple ramp + let sample_i16 = (sample * 32767.0) as i16; + pcm.extend_from_slice(&sample_i16.to_le_bytes()); + pcm.extend_from_slice(&sample_i16.to_le_bytes()); + } + + let format = PcmFormat { + sample_rate, + channels: 2, + bits_per_sample: 16, + }; + + let mut flac_stream = + encode_flac_stream(Cursor::new(pcm.clone()), format, EncoderOptions::default()) + .await + .unwrap(); + + let mut file = File::create(&flac_path).await.expect("create flac file"); + tokio::io::copy(&mut flac_stream, &mut file) + .await + .expect("write flac"); + file.flush().await.expect("flush file"); + flac_stream.wait().await.unwrap(); + + let mut source = FileSource::with_chunk_size(&flac_path, 64); + let (tx, mut rx) = mpsc::channel(16); + source.add_subscriber(tx); + + tokio::spawn(async move { + source.run().await.unwrap(); + }); + + let mut received_frames = 0usize; + let mut received_syncmarkers = 0usize; + let mut seen_top_zero = false; + let mut seen_eos = false; + + while let Some(segment) = rx.recv().await { + if segment.is_audio_chunk() { + if let Some(chunk) = segment.as_chunk() { + received_frames += chunk.len(); + assert_eq!(chunk.sample_rate(), sample_rate); + } + } else { + received_syncmarkers += 1; + if let Some(marker) = segment.as_sync_marker() { + match **marker { + crate::SyncMarker::TopZeroSync => seen_top_zero = true, + crate::SyncMarker::EndOfStream => seen_eos = true, + crate::SyncMarker::TrackBoundary { .. } => {} + _ => {} + } + } + } + } + + // Vérifier que tous les frames ont été reçus + assert_eq!(received_frames, frames); + // Vérifier qu'on a bien reçu des syncmarkers + assert!(received_syncmarkers >= 2); // Au moins TopZeroSync et EndOfStream + assert!(seen_top_zero, "Should have received TopZeroSync"); + assert!(seen_eos, "Should have received EndOfStream"); + } +} diff --git a/pmoaudio/src/nodes/flac_file_sink.rs b/pmoaudio/src/nodes/flac_file_sink.rs new file mode 100644 index 00000000..fab4de08 --- /dev/null +++ b/pmoaudio/src/nodes/flac_file_sink.rs @@ -0,0 +1,766 @@ +use crate::{ + nodes::{AudioError, TypedAudioNode, DEFAULT_CHANNEL_SIZE}, + type_constraints::TypeRequirement, + AudioChunk, AudioSegment, SyncMarker, +}; +use pmoflac::{encode_flac_stream, EncoderOptions, PcmFormat}; +use std::{ + collections::VecDeque, + path::{Path, PathBuf}, + pin::Pin, + sync::Arc, + task::{Context, Poll}, +}; +use tokio::{ + fs::File, + io::{self, AsyncRead, AsyncWriteExt, ReadBuf}, + sync::mpsc, +}; + +/// Sink qui encode les `AudioSegment` reçus au format FLAC. +/// +/// Ce sink : +/// - Filtre les chunks audio et ignore les autres syncmarkers (sauf TrackBoundary et EndOfStream) +/// - Crée un nouveau fichier FLAC pour chaque TrackBoundary rencontré +/// - Adapte automatiquement l'encodage FLAC selon la profondeur de bit du chunk (8/16/24/32-bit) +/// - Termine l'encodage proprement quand il reçoit EndOfStream +pub struct FlacFileSink { + rx: mpsc::Receiver>, + base_path: PathBuf, + encoder_options: EncoderOptions, + pcm_buffer_capacity: usize, +} + +impl FlacFileSink { + /// Crée un sink FLAC avec les options par défaut (compression 5, buffer de 16 segments). + /// + /// # Arguments + /// + /// * `base_path` - Chemin de base pour les fichiers FLAC. Si des TrackBoundary sont reçus, + /// des fichiers seront créés avec des suffixes (_01, _02, etc.) + pub fn new>(base_path: P) -> (Self, mpsc::Sender>) { + Self::with_channel_size(base_path, DEFAULT_CHANNEL_SIZE) + } + + /// Crée un sink FLAC avec une taille de buffer MPSC personnalisée. + /// + /// # Arguments + /// + /// * `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>( + base_path: P, + channel_size: usize, + ) -> (Self, mpsc::Sender>) { + Self::with_config(base_path, channel_size, EncoderOptions::default()) + } + + /// Crée un sink FLAC avec une configuration complète. + /// + /// # Arguments + /// + /// * `base_path` - Chemin de base pour les fichiers FLAC + /// * `channel_size` - Taille du buffer MPSC + /// * `encoder_options` - Options d'encodage FLAC (compression, etc.) + pub fn with_config>( + base_path: P, + channel_size: usize, + encoder_options: EncoderOptions, + ) -> (Self, mpsc::Sender>) { + let (tx, rx) = mpsc::channel(channel_size); + let sink = Self { + rx, + base_path: base_path.into(), + encoder_options, + pcm_buffer_capacity: 8, + }; + (sink, tx) + } + + /// Lance l'encodage vers le(s) fichier(s) cible(s). + /// + /// Cette méthode crée un nouveau fichier FLAC pour chaque TrackBoundary rencontré. + /// Les fichiers sont nommés selon la convention : + /// - Track 0 : base_path.flac + /// - Track 1 : base_path_01.flac + /// - Track 2 : base_path_02.flac, etc. + pub async fn run(self) -> Result { + let FlacFileSink { + mut rx, + base_path, + encoder_options, + pcm_buffer_capacity, + } = self; + + let mut all_tracks = Vec::new(); + let mut track_number = 0; + + loop { + // 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).await { + Ok(result) => result, + Err(_) => { + // Plus d'audio disponible + if all_tracks.is_empty() { + return Err(AudioError::ProcessingError("No audio data received".into())); + } + break; + } + }; + + // 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 + ))); + } + + // Générer le chemin du fichier pour cette track + let track_path = generate_track_path(&base_path, track_number); + + // Créer le pipeline d'encodage pour cette track + let (pcm_tx, pcm_rx) = mpsc::channel::>(pcm_buffer_capacity); + + // Préparer les options d'encodage avec les métadonnées du TrackBoundary + let mut options_with_metadata = encoder_options.clone(); + options_with_metadata.metadata = track_metadata; + + // Créer l'encoder et le fichier + let reader = ByteStreamReader::new(pcm_rx); + let mut flac_stream = encode_flac_stream(reader, format, options_with_metadata) + .await + .map_err(|e| { + AudioError::ProcessingError(format!("FLAC encode init failed: {}", e)) + })?; + + let mut output = File::create(&track_path).await.map_err(|e| { + AudioError::ProcessingError(format!("Failed to create {:?}: {}", track_path, e)) + })?; + + // Exécuter pump et copy en parallèle avec tokio::select! en boucle + let pump_future = + pump_track_segments(first_segment, &mut rx, pcm_tx, bits_per_sample, sample_rate); + let copy_future = async { + let copy_result = tokio::io::copy(&mut flac_stream, &mut output).await; + let flush_result = output.flush().await; + let wait_result = flac_stream.wait().await; + + copy_result.map_err(|e| { + AudioError::ProcessingError(format!("FLAC write failed: {}", e)) + })?; + flush_result + .map_err(|e| AudioError::ProcessingError(format!("Failed to flush: {}", e)))?; + wait_result + .map_err(|e| AudioError::ProcessingError(format!("Encoder failed: {}", e)))?; + Ok::<_, AudioError>(()) + }; + + // Attendre les deux tâches en parallèle + let (copy_result, pump_result) = tokio::join!(copy_future, pump_future); + copy_result?; + let (chunks, samples, duration_sec, stop_reason) = pump_result?; + + // Ajouter les stats de cette track + all_tracks.push(TrackStats { + path: track_path, + track_number, + chunks_received: chunks, + total_samples: samples, + total_duration_sec: duration_sec, + }); + + // 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 + break; + } + } + } + + Ok(FlacFileSinkStats { tracks: all_tracks }) + } +} + +/// Génère le chemin de fichier pour une track donnée. +/// - track 0 → base_path.flac +/// - track 1 → base_path_01.flac +/// - track 2 → base_path_02.flac, etc. +fn generate_track_path(base_path: &Path, track_number: usize) -> PathBuf { + if track_number == 0 { + base_path.to_path_buf() + } else { + let stem = base_path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("output"); + let extension = base_path + .extension() + .and_then(|s| s.to_str()) + .unwrap_or("flac"); + let parent = base_path.parent().unwrap_or(Path::new(".")); + parent.join(format!("{}_{:02}.{}", stem, track_number, extension)) + } +} + +/// Signal retourné par pump_segments indiquant pourquoi l'encodage s'est arrêté. +enum StopReason { + TrackBoundary(Arc), + EndOfStream, + ChannelClosed, +} + +/// 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>, +) -> Result<(Arc, Option>), AudioError> { + let mut track_metadata: Option> = None; + + loop { + let segment = rx + .recv() + .await + .ok_or_else(|| AudioError::ProcessingError("No audio data received".into()))?; + + match &segment.segment { + crate::_AudioSegment::Chunk(chunk) => { + if chunk.len() == 0 { + return Err(AudioError::ProcessingError("Received empty chunk".into())); + } + return Ok((segment, track_metadata)); + } + crate::_AudioSegment::Sync(marker) => { + match **marker { + SyncMarker::TrackBoundary { ref 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, +) -> 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() { + pcm_tx + .send(pcm_bytes) + .await + .map_err(|_| AudioError::SendError)?; + 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 = match rx.recv().await { + Some(seg) => seg, + None => { + drop(pcm_tx); // Fermer le channel PCM + return Ok((chunks, samples, duration_sec, StopReason::ChannelClosed)); + } + }; + + match &segment.segment { + crate::_AudioSegment::Chunk(chunk) => { + // Vérifier la cohérence du sample rate + if chunk.sample_rate() != expected_rate { + return Err(AudioError::ProcessingError(format!( + "FlacFileSink: 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; + } + + pcm_tx + .send(pcm_bytes) + .await + .map_err(|_| AudioError::SendError)?; + + chunks += 1; + samples += chunk.len() as u64; + duration_sec += chunk.len() as f64 / expected_rate as f64; + } + crate::_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( + "FlacFileSink 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.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.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.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.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.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.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.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.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.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 path: PathBuf, + pub track_number: usize, + pub chunks_received: u64, + pub total_samples: u64, + pub total_duration_sec: f64, +} + +/// Statistiques produites par le `FlacFileSink`. +#[derive(Debug, Clone)] +pub struct FlacFileSinkStats { + pub tracks: Vec, +} + +impl TypedAudioNode for FlacFileSink { + fn input_type(&self) -> Option { + // FlacFileSink accepte n'importe quel type entier (I16, I24, I32) + // mais rejette les chunks flottants + Some(TypeRequirement::any_integer()) + } + + fn output_type(&self) -> Option { + // FlacFileSink est un sink, il ne produit pas d'audio + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pmoflac::{decode_flac_stream, AudioFileMetadata}; + use pmometadata::{MemoryTrackMetadata, TrackMetadata}; + use tokio::io::AsyncReadExt; + + #[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"); + + let sample_rate = 44_100; + let frames = 256; + + // Créer le sink + let (sink, tx) = FlacFileSink::with_channel_size(&output_path, 16); + let sink_handle = tokio::spawn(async move { sink.run().await.unwrap() }); + + // Envoyer des segments avec métadonnées + tokio::spawn(async move { + // TopZeroSync + tx.send(crate::AudioSegment::new_top_zero_sync()) + .await + .unwrap(); + + // 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_year(Some(2024)).await.unwrap(); + + let track_boundary = + crate::AudioSegment::new_track_boundary(0, 0.0, std::sync::Arc::new(metadata)); + tx.send(track_boundary).await.unwrap(); + + // Générer et envoyer des chunks audio + let chunk_frames = 64; + let mut order = 0u64; + let mut total_frames = 0u64; + + for chunk_start in (0..frames).step_by(chunk_frames) { + let chunk_len = (frames - chunk_start).min(chunk_frames); + let mut stereo = Vec::with_capacity(chunk_len); + + for i in 0..chunk_len { + let frame_idx = chunk_start + i; + let sample = ((frame_idx % 32) as f32 / 31.0 * 2.0 - 1.0) * 0.5; + let sample_i16 = (sample * 32767.0) as i16; + stereo.push([sample_i16, sample_i16]); + } + + let timestamp = total_frames as f64 / sample_rate as f64; + let chunk_data = crate::AudioChunkData::new(stereo, sample_rate, 0.0); + let chunk = crate::AudioChunk::I16(chunk_data); + let segment = crate::AudioSegment { + order, + timestamp_sec: timestamp, + segment: crate::_AudioSegment::Chunk(std::sync::Arc::new(chunk)), + }; + + tx.send(std::sync::Arc::new(segment)).await.unwrap(); + total_frames += chunk_len as u64; + order += 1; + } + + // EndOfStream + let final_timestamp = total_frames as f64 / sample_rate as f64; + tx.send(crate::AudioSegment::new_end_of_stream( + order, + final_timestamp, + )) + .await + .unwrap(); + + drop(tx); + }); + + sink_handle.await.unwrap(); + + // Vérifier que le fichier a été créé et contient les métadonnées + assert!(output_path.exists(), "Output file should exist"); + + // Lire les métadonnées du fichier FLAC généré + let file_metadata = AudioFileMetadata::from_file(&output_path).unwrap(); + + // Vérifier que les métadonnées ont été correctement écrites + assert_eq!(file_metadata.title, Some("Test Track Title".to_string())); + assert_eq!(file_metadata.artist, Some("Test Artist".to_string())); + assert_eq!(file_metadata.album, Some("Test Album".to_string())); + assert_eq!(file_metadata.year, Some(2024)); + } + + #[tokio::test] + async fn test_flac_file_sink_writes_audio() { + use pmoflac::{encode_flac_stream, EncoderOptions, PcmFormat}; + use std::io::Cursor; + + let temp_dir = tempfile::tempdir().unwrap(); + let input_path = temp_dir.path().join("input.flac"); + let output_path = temp_dir.path().join("output.flac"); + + // Créer un petit fichier FLAC de test (comme dans file_source test) + let sample_rate = 44_100; + let frames = 512; + let mut pcm = Vec::with_capacity(frames * 4); + for i in 0..frames { + let sample = ((i % 32) as f32 / 31.0 * 2.0 - 1.0) * 0.5; + let sample_i16 = (sample * 32767.0) as i16; + pcm.extend_from_slice(&sample_i16.to_le_bytes()); + pcm.extend_from_slice(&sample_i16.to_le_bytes()); + } + + let format = PcmFormat { + sample_rate, + channels: 2, + bits_per_sample: 16, + }; + + let mut flac_stream = + encode_flac_stream(Cursor::new(pcm.clone()), format, EncoderOptions::default()) + .await + .unwrap(); + + let mut input_file = File::create(&input_path).await.unwrap(); + tokio::io::copy(&mut flac_stream, &mut input_file) + .await + .unwrap(); + input_file.flush().await.unwrap(); + flac_stream.wait().await.unwrap(); + + // Maintenant utiliser FlacFileSink pour réécrire le fichier + let (sink, tx) = FlacFileSink::with_channel_size(&output_path, 16); + let sink_handle = tokio::spawn(async move { sink.run().await.unwrap() }); + + // Lire le fichier input et envoyer les segments au sink + tokio::spawn(async move { + let source_file = File::open(&input_path).await.unwrap(); + let mut decode_stream = pmoflac::decode_audio_stream(source_file).await.unwrap(); + let info = decode_stream.info().clone(); + + // TopZeroSync + tx.send(crate::AudioSegment::new_top_zero_sync()) + .await + .unwrap(); + + // Lire et envoyer les chunks + let mut buffer = vec![0u8; info.bytes_per_sample() * info.channels as usize * 256]; + let mut total_frames = 0u64; + let mut order = 0u64; + + loop { + let read = decode_stream.read(&mut buffer).await.unwrap(); + if read == 0 { + break; + } + + let chunk_frames = read / (info.bytes_per_sample() * info.channels as usize); + let timestamp = total_frames as f64 / info.sample_rate as f64; + + // Créer un segment I16 + let mut stereo = Vec::with_capacity(chunk_frames); + for i in 0..chunk_frames { + let offset = i * info.bytes_per_sample() * info.channels as usize; + let l = i16::from_le_bytes([buffer[offset], buffer[offset + 1]]); + let r = i16::from_le_bytes([buffer[offset + 2], buffer[offset + 3]]); + stereo.push([l, r]); + } + + let chunk_data = crate::AudioChunkData::new(stereo, info.sample_rate, 0.0); + let chunk = crate::AudioChunk::I16(chunk_data); + let segment = crate::AudioSegment { + order, + timestamp_sec: timestamp, + segment: crate::_AudioSegment::Chunk(std::sync::Arc::new(chunk)), + }; + + tx.send(std::sync::Arc::new(segment)).await.unwrap(); + total_frames += chunk_frames as u64; + order += 1; + } + + // EndOfStream + let final_timestamp = total_frames as f64 / info.sample_rate as f64; + tx.send(crate::AudioSegment::new_end_of_stream( + order, + final_timestamp, + )) + .await + .unwrap(); + + drop(tx); + decode_stream.wait().await.unwrap(); + }); + + let stats = sink_handle.await.unwrap(); + assert_eq!(stats.tracks.len(), 1); + assert!(stats.tracks[0].chunks_received > 0); + assert_eq!(stats.tracks[0].total_samples, frames as u64); + + // Vérifier que le fichier de sortie est valide + let file = File::open(&output_path).await.unwrap(); + let mut stream = decode_flac_stream(file).await.unwrap(); + let info = stream.info().clone(); + assert_eq!(info.channels, 2); + assert_eq!(info.sample_rate, sample_rate); + assert_eq!(info.bits_per_sample, 16); + + let mut decoded = Vec::new(); + stream.read_to_end(&mut decoded).await.unwrap(); + stream.wait().await.unwrap(); + assert!(decoded.len() > 0); + } +} diff --git a/pmoaudio/src/nodes/http_source.rs b/pmoaudio/src/nodes/http_source.rs new file mode 100644 index 00000000..3c84dd98 --- /dev/null +++ b/pmoaudio/src/nodes/http_source.rs @@ -0,0 +1,827 @@ +use crate::{ + nodes::{AudioError, MultiSubscriberNode, TypedAudioNode, DEFAULT_CHUNK_DURATION_MS}, + type_constraints::TypeRequirement, + AudioChunk, AudioChunkData, AudioSegment, I24, +}; +use futures_util::StreamExt; +use pmoflac::{decode_audio_stream, StreamInfo}; +use pmometadata::{MemoryTrackMetadata, TrackMetadata}; +use std::sync::Arc; +use tokio::sync::mpsc; +use tokio_util::io::StreamReader; + +/// HttpSource - Récupère un fichier audio via HTTP et publie des `AudioSegment` +/// +/// Cette source télécharge un fichier audio depuis une URL HTTP/HTTPS, +/// utilise `pmoflac` pour le décoder (FLAC/MP3/OGG/WAV/AIFF) puis transforme +/// les échantillons PCM en `AudioSegment` stéréo avec le type approprié. +/// +/// Le node émet trois types de syncmarkers : +/// - `TopZeroSync` au début du flux +/// - `TrackBoundary` avec les métadonnées extraites des headers HTTP +/// - `EndOfStream` à la fin du flux +/// +/// # Métadonnées HTTP +/// +/// Les métadonnées suivantes sont extraites des headers HTTP lorsqu'elles sont disponibles: +/// - `icy-name`: nom du stream (Icecast/Shoutcast) → utilisé comme titre +/// - `icy-url`: URL du stream source +/// - `content-type`: type MIME du contenu (ex: audio/flac, audio/mpeg) +/// +/// Si aucun header `icy-name` n'est présent, le nom du fichier est extrait de l'URL +/// et utilisé comme titre. +/// +/// # Exemples +/// +/// ## Lecture d'un fichier FLAC distant +/// +/// ```no_run +/// use pmoaudio::HttpSource; +/// use tokio::sync::mpsc; +/// +/// #[tokio::main] +/// async fn main() { +/// let mut source = HttpSource::new("http://example.com/audio.flac"); +/// let (tx, mut rx) = mpsc::channel(16); +/// source.add_subscriber(tx); +/// +/// // Lancer la lecture dans une tâche séparée +/// tokio::spawn(async move { +/// source.run().await.unwrap(); +/// }); +/// +/// // Recevoir et traiter les segments audio +/// while let Some(segment) = rx.recv().await { +/// if segment.is_audio_chunk() { +/// println!("Chunk reçu à {}s", segment.timestamp_sec); +/// } +/// } +/// } +/// ``` +/// +/// ## Stream Icecast/Shoutcast +/// +/// ```no_run +/// use pmoaudio::HttpSource; +/// use tokio::sync::mpsc; +/// +/// #[tokio::main] +/// async fn main() { +/// // Les métadonnées icy-name seront extraites automatiquement +/// let mut source = HttpSource::new("http://stream.example.com:8000/stream"); +/// let (tx, rx) = mpsc::channel(32); +/// source.add_subscriber(tx); +/// +/// tokio::spawn(async move { +/// source.run().await.unwrap(); +/// }); +/// } +/// ``` +/// +/// # Gestion des erreurs +/// +/// La méthode `run()` peut retourner les erreurs suivantes: +/// - `AudioError::ProcessingError`: échec de connexion HTTP, status code non-200, +/// erreur de décodage audio, ou format non supporté +/// +/// # Performance +/// +/// - Le téléchargement et le décodage sont effectués en streaming +/// - Pas de buffering complet du fichier en mémoire +/// - La taille des chunks audio est calculée automatiquement pour ~50ms de latence +/// - Compatible avec les streams infinis (radios web, etc.) +pub struct HttpSource { + url: String, + chunk_frames: usize, + subscribers: MultiSubscriberNode, +} + +impl HttpSource { + /// Crée une nouvelle source HTTP avec calcul automatique de la taille des chunks. + /// + /// La taille des chunks sera calculée automatiquement pour obtenir environ 50ms + /// de latence par chunk, en fonction du sample rate du fichier distant. + /// + /// # Arguments + /// + /// * `url` - URL HTTP ou HTTPS du fichier audio à télécharger + /// + /// # Exemples + /// + /// ```no_run + /// use pmoaudio::HttpSource; + /// + /// let source = HttpSource::new("http://example.com/music.flac"); + /// ``` + pub fn new>(url: S) -> Self { + Self::with_chunk_size(url, 0) + } + + /// Crée une nouvelle source HTTP avec une taille de chunk spécifique. + /// + /// # Arguments + /// + /// * `url` - URL HTTP ou HTTPS du fichier audio à télécharger + /// * `chunk_frames` - nombre d'échantillons par canal par chunk (0 = auto-calcul) + /// + /// # Exemples + /// + /// ```no_run + /// use pmoaudio::HttpSource; + /// + /// // Utiliser des chunks de 2048 frames + /// let source = HttpSource::with_chunk_size("http://example.com/music.mp3", 2048); + /// ``` + pub fn with_chunk_size>(url: S, chunk_frames: usize) -> Self { + Self { + url: url.into(), + chunk_frames, + subscribers: MultiSubscriberNode::new(), + } + } + + /// Ajoute un abonné qui recevra les segments audio. + /// + /// Chaque abonné recevra une copie (via `Arc`) de tous les segments audio + /// produits par cette source, y compris les syncmarkers. + /// + /// # Arguments + /// + /// * `tx` - Channel sender pour recevoir les `AudioSegment` + /// + /// # Exemples + /// + /// ```no_run + /// use pmoaudio::HttpSource; + /// use tokio::sync::mpsc; + /// + /// let mut source = HttpSource::new("http://example.com/audio.flac"); + /// let (tx, rx) = mpsc::channel(16); + /// source.add_subscriber(tx); + /// ``` + pub fn add_subscriber(&mut self, tx: mpsc::Sender>) { + self.subscribers.add_subscriber(tx); + } + + /// Lance le téléchargement et la lecture du flux audio. + /// + /// Cette méthode consomme `self` et exécute le pipeline complet: + /// 1. Effectue la requête HTTP GET vers l'URL spécifiée + /// 2. Vérifie le status HTTP (doit être 2xx) + /// 3. Extrait les métadonnées des headers HTTP + /// 4. Décode le flux audio en streaming + /// 5. Émet les syncmarkers et chunks audio vers les abonnés + /// + /// La méthode se termine quand le flux est complètement lu ou en cas d'erreur. + /// + /// # Erreurs + /// + /// Retourne `AudioError::ProcessingError` si: + /// - La requête HTTP échoue (réseau, DNS, etc.) + /// - Le serveur retourne un status code non-2xx + /// - Le format audio n'est pas supporté + /// - Le décodage échoue + /// - Le fichier a un nombre de canaux non supporté (doit être 1 ou 2) + /// - La profondeur de bit n'est pas supportée (doit être 8, 16, 24 ou 32 bits) + /// + /// # Exemples + /// + /// ```no_run + /// use pmoaudio::HttpSource; + /// use tokio::sync::mpsc; + /// + /// #[tokio::main] + /// async fn main() -> Result<(), Box> { + /// let mut source = HttpSource::new("http://example.com/audio.flac"); + /// let (tx, mut rx) = mpsc::channel(16); + /// source.add_subscriber(tx); + /// + /// let handle = tokio::spawn(async move { + /// source.run().await + /// }); + /// + /// // Traiter les segments + /// while let Some(segment) = rx.recv().await { + /// println!("Segment reçu: order={}", segment.order); + /// } + /// + /// handle.await??; + /// Ok(()) + /// } + /// ``` + pub async fn run(self) -> Result<(), AudioError> { + // 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)) + })?; + + // Vérifier le status + if !response.status().is_success() { + return Err(AudioError::ProcessingError(format!( + "HTTP request returned status {}: {}", + response.status(), + self.url + ))); + } + + // Extraire les métadonnées depuis les headers HTTP + let metadata = extract_metadata_from_headers(&response, &self.url).await; + + // 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)) + })); + + // Décoder le flux audio + let mut stream = decode_audio_stream(stream_reader) + .await + .map_err(|e| AudioError::ProcessingError(format!("Decode error: {}", e)))?; + let stream_info = stream.info().clone(); + + validate_stream(&stream_info)?; + + // Calculer la taille des chunks si non spécifiée (0 = auto) + let chunk_frames = if self.chunk_frames == 0 { + let frames = + (stream_info.sample_rate as f64 * DEFAULT_CHUNK_DURATION_MS / 1000.0) as usize; + frames.next_power_of_two().max(256) + } else { + self.chunk_frames.max(1) + }; + + // Émettre TopZeroSync + let top_zero = AudioSegment::new_top_zero_sync(); + self.subscribers.push(top_zero).await?; + + // Émettre TrackBoundary avec les métadonnées HTTP + let track_boundary = AudioSegment::new_track_boundary(0, 0.0, Arc::new(metadata)); + self.subscribers.push(track_boundary).await?; + + // Préparer la lecture des chunks audio + let frame_bytes = stream_info.bytes_per_sample() * stream_info.channels as usize; + let chunk_byte_len = chunk_frames * frame_bytes; + let mut pending = Vec::new(); + let mut read_buf = vec![0u8; frame_bytes * 512.max(chunk_frames)]; + let mut chunk_index = 0u64; + let mut total_frames = 0u64; + + // Lire et émettre les chunks audio + loop { + // Remplir le buffer + if pending.len() < chunk_byte_len { + use tokio::io::AsyncReadExt; + let read = stream.read(&mut read_buf).await.map_err(|e| { + AudioError::ProcessingError(format!("I/O error while decoding: {}", e)) + })?; + if read == 0 { + break; + } + pending.extend_from_slice(&read_buf[..read]); + } + + if pending.is_empty() { + break; + } + + // Extraire un chunk + let frames_in_pending = pending.len() / frame_bytes; + let frames_to_emit = frames_in_pending.min(chunk_frames); + let take_bytes = frames_to_emit * frame_bytes; + let chunk_bytes = pending.drain(..take_bytes).collect::>(); + + // Calculer le timestamp + let timestamp_sec = total_frames as f64 / stream_info.sample_rate as f64; + + // Créer le segment audio + let segment = bytes_to_segment( + &chunk_bytes, + &stream_info, + frames_to_emit, + chunk_index, + timestamp_sec, + )?; + self.subscribers.push(segment).await?; + + chunk_index += 1; + total_frames += frames_to_emit as u64; + } + + // Traiter le reste éventuel + if !pending.is_empty() { + let frames = pending.len() / frame_bytes; + if frames > 0 { + let timestamp_sec = total_frames as f64 / stream_info.sample_rate as f64; + let segment = + bytes_to_segment(&pending, &stream_info, frames, chunk_index, timestamp_sec)?; + self.subscribers.push(segment).await?; + total_frames += frames as u64; + chunk_index += 1; + } + } + + // Émettre EndOfStream + let final_timestamp = total_frames as f64 / stream_info.sample_rate as f64; + let eos = AudioSegment::new_end_of_stream(chunk_index, final_timestamp); + self.subscribers.push(eos).await?; + + // Attendre la fin du décodage + stream + .wait() + .await + .map_err(|e| AudioError::ProcessingError(format!("Decode task failed: {}", e)))?; + + Ok(()) + } +} + +/// Extrait les métadonnées disponibles depuis les headers HTTP +async fn extract_metadata_from_headers( + response: &reqwest::Response, + url: &str, +) -> MemoryTrackMetadata { + let mut metadata = MemoryTrackMetadata::new(); + let headers = response.headers(); + + // Icecast/Shoutcast stream name + if let Some(name) = headers.get("icy-name").and_then(|v| v.to_str().ok()) { + let _ = metadata.set_title(Some(name.to_string())).await; + } + + // Icecast/Shoutcast stream URL (peut être utilisé comme source) + if let Some(stream_url) = headers.get("icy-url").and_then(|v| v.to_str().ok()) { + // On pourrait stocker ça dans un champ custom si nécessaire + eprintln!("Stream URL: {}", stream_url); + } + + // Content-Type pour déterminer le format + if let Some(content_type) = headers.get("content-type").and_then(|v| v.to_str().ok()) { + eprintln!("Content-Type: {}", content_type); + // On pourrait utiliser ça pour valider le format attendu + } + + // Si aucune métadonnée spécifique n'est trouvée, utiliser l'URL comme titre + if metadata.get_title().await.ok().flatten().is_none() { + // Extraire le nom du fichier depuis l'URL + if let Some(filename) = url.rsplit('/').next() { + if !filename.is_empty() { + let _ = metadata.set_title(Some(filename.to_string())).await; + } + } + } + + metadata +} + +fn validate_stream(info: &StreamInfo) -> Result<(), AudioError> { + if !(1..=2).contains(&info.channels) { + return Err(AudioError::ProcessingError(format!( + "Unsupported channel count: {}", + info.channels + ))); + } + match info.bits_per_sample { + 8 | 16 | 24 | 32 => Ok(()), + other => Err(AudioError::ProcessingError(format!( + "Unsupported bit depth: {}", + other + ))), + } +} + +/// Convertit des bytes PCM en AudioSegment avec le type approprié +fn bytes_to_segment( + chunk_bytes: &[u8], + info: &StreamInfo, + frames: usize, + order: u64, + timestamp_sec: f64, +) -> Result, AudioError> { + let bytes_per_sample = info.bytes_per_sample(); + let channels = info.channels as usize; + let frame_bytes = bytes_per_sample * channels; + + // Créer le chunk du bon type selon la profondeur de bit + let chunk = match info.bits_per_sample { + 16 => { + // Type I16 + let mut stereo = Vec::with_capacity(frames); + for frame_idx in 0..frames { + let base = frame_idx * frame_bytes; + let l = i16::from_le_bytes( + chunk_bytes[base..base + bytes_per_sample] + .try_into() + .unwrap(), + ); + let r = if channels == 1 { + l + } else { + i16::from_le_bytes( + chunk_bytes[base + bytes_per_sample..base + 2 * bytes_per_sample] + .try_into() + .unwrap(), + ) + }; + stereo.push([l, r]); + } + let chunk_data = AudioChunkData::new(stereo, info.sample_rate, 0.0); + AudioChunk::I16(chunk_data) + } + 24 => { + // Type I24 + let mut stereo = Vec::with_capacity(frames); + for frame_idx in 0..frames { + let base = frame_idx * frame_bytes; + let l_i32 = { + let mut buf = [0u8; 4]; + buf[..3].copy_from_slice(&chunk_bytes[base..base + 3]); + // Sign extend + if chunk_bytes[base + 2] & 0x80 != 0 { + buf[3] = 0xFF; + } + i32::from_le_bytes(buf) + }; + let l = I24::new(l_i32).ok_or_else(|| { + AudioError::ProcessingError(format!("Invalid I24 value: {}", l_i32)) + })?; + + let r = if channels == 1 { + l + } else { + let r_i32 = { + let mut buf = [0u8; 4]; + buf[..3].copy_from_slice( + &chunk_bytes[base + bytes_per_sample..base + bytes_per_sample + 3], + ); + // Sign extend + if chunk_bytes[base + bytes_per_sample + 2] & 0x80 != 0 { + buf[3] = 0xFF; + } + i32::from_le_bytes(buf) + }; + I24::new(r_i32).ok_or_else(|| { + AudioError::ProcessingError(format!("Invalid I24 value: {}", r_i32)) + })? + }; + stereo.push([l, r]); + } + let chunk_data = AudioChunkData::new(stereo, info.sample_rate, 0.0); + AudioChunk::I24(chunk_data) + } + 32 => { + // Type I32 + let mut stereo = Vec::with_capacity(frames); + for frame_idx in 0..frames { + let base = frame_idx * frame_bytes; + let l = i32::from_le_bytes( + chunk_bytes[base..base + bytes_per_sample] + .try_into() + .unwrap(), + ); + let r = if channels == 1 { + l + } else { + i32::from_le_bytes( + chunk_bytes[base + bytes_per_sample..base + 2 * bytes_per_sample] + .try_into() + .unwrap(), + ) + }; + stereo.push([l, r]); + } + let chunk_data = AudioChunkData::new(stereo, info.sample_rate, 0.0); + AudioChunk::I32(chunk_data) + } + other => { + return Err(AudioError::ProcessingError(format!( + "Unsupported bit depth: {}", + other + ))) + } + }; + + // Créer le segment audio + Ok(Arc::new(AudioSegment { + order, + timestamp_sec, + segment: crate::_AudioSegment::Chunk(Arc::new(chunk)), + })) +} + +impl TypedAudioNode for HttpSource { + fn input_type(&self) -> Option { + // HttpSource est une source, elle ne consomme pas d'audio + None + } + + fn output_type(&self) -> Option { + // HttpSource peut produire n'importe quel type entier (I16, I24, I32) + // selon la profondeur de bit du fichier source + Some(TypeRequirement::any_integer()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pmoflac::{encode_flac_stream, EncoderOptions, PcmFormat}; + use std::io::Cursor; + use tokio::sync::mpsc; + use wiremock::{ + matchers::{method, path}, + Mock, MockServer, ResponseTemplate, + }; + + /// Test de création basique de HttpSource + #[test] + fn test_http_source_creation() { + let source = HttpSource::new("http://example.com/audio.flac"); + assert_eq!(source.url, "http://example.com/audio.flac"); + assert_eq!(source.chunk_frames, 0); + } + + /// Test de création avec taille de chunk personnalisée + #[test] + fn test_http_source_with_chunk_size() { + let source = HttpSource::with_chunk_size("http://example.com/audio.mp3", 1024); + assert_eq!(source.url, "http://example.com/audio.mp3"); + assert_eq!(source.chunk_frames, 1024); + } + + /// Test de téléchargement et décodage d'un fichier FLAC via HTTP + #[tokio::test] + async fn test_http_source_downloads_and_decodes_flac() { + // Créer un serveur HTTP mock + let mock_server = MockServer::start().await; + + // Générer un petit fichier FLAC de test + let sample_rate = 48_000; + let frames = 256; + let mut pcm = Vec::with_capacity(frames * 4); + for i in 0..frames { + let sample = ((i % 32) as f32 / 31.0 * 2.0 - 1.0) * 0.5; + let sample_i16 = (sample * 32767.0) as i16; + pcm.extend_from_slice(&sample_i16.to_le_bytes()); + pcm.extend_from_slice(&sample_i16.to_le_bytes()); + } + + let format = PcmFormat { + sample_rate, + channels: 2, + bits_per_sample: 16, + }; + + let mut flac_stream = + encode_flac_stream(Cursor::new(pcm.clone()), format, EncoderOptions::default()) + .await + .unwrap(); + + // Lire le FLAC encodé dans un buffer + let mut flac_data = Vec::new(); + tokio::io::copy(&mut flac_stream, &mut flac_data) + .await + .unwrap(); + flac_stream.wait().await.unwrap(); + + // Configurer le mock pour servir le fichier FLAC + Mock::given(method("GET")) + .and(path("/test.flac")) + .respond_with( + ResponseTemplate::new(200) + .set_body_bytes(flac_data) + .insert_header("content-type", "audio/flac"), + ) + .mount(&mock_server) + .await; + + // Créer la source HTTP pointant vers le mock + let url = format!("{}/test.flac", mock_server.uri()); + let mut source = HttpSource::with_chunk_size(&url, 64); + let (tx, mut rx) = mpsc::channel(16); + source.add_subscriber(tx); + + // Lancer le téléchargement et le décodage + tokio::spawn(async move { + source.run().await.unwrap(); + }); + + // Vérifier les segments reçus + let mut received_frames = 0usize; + let mut seen_top_zero = false; + let mut seen_track_boundary = false; + let mut seen_eos = false; + + while let Some(segment) = rx.recv().await { + if segment.is_audio_chunk() { + if let Some(chunk) = segment.as_chunk() { + received_frames += chunk.len(); + assert_eq!(chunk.sample_rate(), sample_rate); + } + } else if let Some(marker) = segment.as_sync_marker() { + match **marker { + crate::SyncMarker::TopZeroSync => seen_top_zero = true, + crate::SyncMarker::TrackBoundary { .. } => seen_track_boundary = true, + crate::SyncMarker::EndOfStream => seen_eos = true, + _ => {} + } + } + } + + // Vérifications + 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"); + } + + /// Test de l'extraction des métadonnées depuis les headers HTTP + #[tokio::test] + async fn test_http_source_extracts_icy_metadata() { + let mock_server = MockServer::start().await; + + // Créer un fichier FLAC minimal + let sample_rate = 48_000; + let frames = 128; + let mut pcm = Vec::with_capacity(frames * 4); + for i in 0..frames { + let sample_i16 = ((i % 100) as i16) * 100; + pcm.extend_from_slice(&sample_i16.to_le_bytes()); + pcm.extend_from_slice(&sample_i16.to_le_bytes()); + } + + let format = PcmFormat { + sample_rate, + channels: 2, + bits_per_sample: 16, + }; + + 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) + .await + .unwrap(); + flac_stream.wait().await.unwrap(); + + // Configurer le mock avec headers Icecast + Mock::given(method("GET")) + .and(path("/stream")) + .respond_with( + ResponseTemplate::new(200) + .set_body_bytes(flac_data) + .insert_header("content-type", "audio/flac") + .insert_header("icy-name", "Test Radio Stream") + .insert_header("icy-url", "http://example.com/radio"), + ) + .mount(&mock_server) + .await; + + let url = format!("{}/stream", mock_server.uri()); + let mut source = HttpSource::new(&url); + let (tx, mut rx) = mpsc::channel(16); + source.add_subscriber(tx); + + tokio::spawn(async move { + source.run().await.unwrap(); + }); + + // Chercher le TrackBoundary pour vérifier les métadonnées + let mut found_metadata = false; + while let Some(segment) = rx.recv().await { + if let Some(marker) = segment.as_sync_marker() { + if let crate::SyncMarker::TrackBoundary { metadata, .. } = &**marker { + // Vérifier que le titre extrait est "Test Radio Stream" + if let Some(title) = metadata.get_title().await.ok().flatten() { + assert_eq!(title, "Test Radio Stream"); + found_metadata = true; + } + } + } + } + + assert!(found_metadata, "Les métadonnées ICY doivent être extraites"); + } + + /// Test du comportement en cas d'erreur HTTP 404 + #[tokio::test] + async fn test_http_source_handles_404_error() { + let mock_server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/notfound.flac")) + .respond_with(ResponseTemplate::new(404)) + .mount(&mock_server) + .await; + + let url = format!("{}/notfound.flac", mock_server.uri()); + let mut source = HttpSource::new(&url); + let (tx, _rx) = mpsc::channel(16); + source.add_subscriber(tx); + + let result = source.run().await; + 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"); + } else { + panic!("Le type d'erreur doit être ProcessingError"); + } + } + + /// Test du comportement avec un format audio invalide + #[tokio::test] + async fn test_http_source_handles_invalid_audio_format() { + let mock_server = MockServer::start().await; + + // Envoyer des données invalides (pas un fichier audio) + Mock::given(method("GET")) + .and(path("/invalid.flac")) + .respond_with( + ResponseTemplate::new(200) + .set_body_bytes(b"This is not a valid audio file") + .insert_header("content-type", "audio/flac"), + ) + .mount(&mock_server) + .await; + + let url = format!("{}/invalid.flac", mock_server.uri()); + let mut source = HttpSource::new(&url); + let (tx, _rx) = mpsc::channel(16); + source.add_subscriber(tx); + + let result = source.run().await; + assert!( + result.is_err(), + "Doit retourner une erreur pour un format invalide" + ); + } + + /// Test de l'extraction du nom de fichier depuis l'URL quand pas de header icy-name + #[tokio::test] + async fn test_http_source_uses_filename_as_title() { + let mock_server = MockServer::start().await; + + let sample_rate = 48_000; + let frames = 128; + let mut pcm = Vec::with_capacity(frames * 4); + for i in 0..frames { + let sample_i16 = (i % 100) as i16; + pcm.extend_from_slice(&sample_i16.to_le_bytes()); + pcm.extend_from_slice(&sample_i16.to_le_bytes()); + } + + let format = PcmFormat { + sample_rate, + channels: 2, + bits_per_sample: 16, + }; + + 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) + .await + .unwrap(); + flac_stream.wait().await.unwrap(); + + // Sans header icy-name + Mock::given(method("GET")) + .and(path("/my-song.flac")) + .respond_with( + ResponseTemplate::new(200) + .set_body_bytes(flac_data) + .insert_header("content-type", "audio/flac"), + ) + .mount(&mock_server) + .await; + + let url = format!("{}/my-song.flac", mock_server.uri()); + let mut source = HttpSource::new(&url); + let (tx, mut rx) = mpsc::channel(16); + source.add_subscriber(tx); + + tokio::spawn(async move { + source.run().await.unwrap(); + }); + + let mut found_title = false; + while let Some(segment) = rx.recv().await { + if let Some(marker) = segment.as_sync_marker() { + if let crate::SyncMarker::TrackBoundary { metadata, .. } = &**marker { + if let Some(title) = metadata.get_title().await.ok().flatten() { + assert_eq!(title, "my-song.flac"); + found_title = true; + } + } + } + } + + assert!(found_title, "Le nom du fichier doit être utilisé comme titre"); + } +} diff --git a/old_code/pmoaudio/nodes/mod.rs b/pmoaudio/src/nodes/mod.rs similarity index 59% rename from old_code/pmoaudio/nodes/mod.rs rename to pmoaudio/src/nodes/mod.rs index 2386b203..abc378f7 100644 --- a/old_code/pmoaudio/nodes/mod.rs +++ b/pmoaudio/src/nodes/mod.rs @@ -6,20 +6,38 @@ use std::sync::Arc; use tokio::sync::mpsc; +use crate::type_constraints::{TypeMismatch, TypeRequirement}; use crate::AudioSegment; +/// Taille par défaut du buffer de channel MPSC pour les nodes +/// Cette valeur détermine combien de segments audio peuvent être mis en attente +/// avant que le producteur soit bloqué (backpressure). +pub const DEFAULT_CHANNEL_SIZE: usize = 16; + +/// Durée par défaut des chunks audio en millisecondes +/// Cette valeur détermine la latence de traitement et le compromis efficacité/réactivité. +/// 50ms offre un bon équilibre pour la plupart des applications de lecture audio. +pub const DEFAULT_CHUNK_DURATION_MS: f64 = 50.0; + +// Modules actifs +pub mod converter_nodes; +pub mod file_source; +pub mod flac_file_sink; +pub mod http_source; + +// Modules temporairement désactivés +/* pub mod buffer_node; pub mod chromecast_sink; pub mod decoder_node; pub mod disk_sink; pub mod dsp_node; -pub mod file_source; -pub mod flac_file_sink; pub mod mpd_sink; pub mod sink_node; pub mod source_node; pub mod timer_node; pub mod volume_node; +*/ /// Trait de base pour tous les nodes audio /// @@ -38,6 +56,59 @@ pub trait AudioNode: Send + Sync { async fn close(&mut self); } +/// Trait pour les nodes qui déclarent leurs types acceptés/produits +/// +/// Ce trait permet de vérifier la compatibilité des types entre nodes +/// avant de les connecter dans un pipeline. +/// +/// # Exemples +/// +/// ```no_run +/// use pmoaudio::{FileSource, FlacFileSink, TypedAudioNode}; +/// use pmoaudio::type_constraints::check_compatibility; +/// +/// // Vérifier la compatibilité avant de connecter +/// let source = FileSource::new("input.flac"); +/// let (sink, tx) = FlacFileSink::new("output.flac"); +/// +/// let source_output = source.output_type(); +/// let sink_input = sink.input_type(); +/// +/// match check_compatibility(&source_output, &sink_input) { +/// Ok(()) => println!("Types compatibles!"), +/// Err(e) => eprintln!("Types incompatibles: {}", e), +/// } +/// ``` +pub trait TypedAudioNode { + /// Retourne les types que ce node peut accepter en entrée + /// + /// Pour les sources (qui ne consomment rien), retourne `None`. + fn input_type(&self) -> Option; + + /// Retourne les types que ce node peut produire en sortie + /// + /// Pour les sinks (qui ne produisent rien), retourne `None`. + fn output_type(&self) -> Option; + + /// Vérifie si ce node peut accepter les chunks d'un producer donné + /// + /// # Erreurs + /// + /// Retourne `AudioError::TypeMismatch` si les types sont incompatibles + fn can_accept_from(&self, producer: &dyn TypedAudioNode) -> Result<(), AudioError> { + match (producer.output_type(), self.input_type()) { + (Some(prod), Some(cons)) => crate::type_constraints::check_compatibility(&prod, &cons) + .map_err(|e| AudioError::TypeMismatch(e)), + (None, Some(_)) => Err(AudioError::TypeMismatch(TypeMismatch { + producer: TypeRequirement::any(), // Placeholder + consumer: self.input_type().unwrap(), + incompatible_type: None, + })), + _ => Ok(()), // Si pas de contrainte, toujours compatible + } + } +} + /// Node avec un seul abonné (pas de clone inutile) /// /// Optimisé pour les cas où un node n'a qu'un seul destinataire. @@ -135,6 +206,8 @@ pub enum AudioError { ReceiveError, /// Erreur de traitement avec message descriptif ProcessingError(String), + /// Incompatibilité de types entre nodes + TypeMismatch(TypeMismatch), } impl std::fmt::Display for AudioError { @@ -143,6 +216,7 @@ impl std::fmt::Display for AudioError { AudioError::SendError => write!(f, "Failed to send audio chunk"), AudioError::ReceiveError => write!(f, "Failed to receive audio chunk"), AudioError::ProcessingError(msg) => write!(f, "Processing error: {}", msg), + AudioError::TypeMismatch(tm) => write!(f, "{}", tm), } } } diff --git a/pmoaudio/src/sample_types.rs b/pmoaudio/src/sample_types.rs index ff6a8002..5ea19c0d 100644 --- a/pmoaudio/src/sample_types.rs +++ b/pmoaudio/src/sample_types.rs @@ -175,23 +175,6 @@ impl TryFrom for I24 { // Implémentations du trait Sample pour tous les types // ============================================================================ -impl Sample for i8 { - const NAME: &'static str = "i8"; - const MIN: Self = i8::MIN; - const MAX: Self = i8::MAX; - const ZERO: Self = 0; - - #[inline] - fn to_f64(self) -> f64 { - self as f64 / 128.0 - } - - #[inline] - fn from_f64(value: f64) -> Self { - (value * 127.0).clamp(-128.0, 127.0).round() as i8 - } -} - impl Sample for i16 { const NAME: &'static str = "i16"; const MIN: Self = i16::MIN; @@ -222,7 +205,9 @@ impl Sample for I24 { #[inline] fn from_f64(value: f64) -> Self { - let scaled = (value * 8_388_607.0).clamp(-8_388_608.0, 8_388_607.0).round() as i32; + let scaled = (value * 8_388_607.0) + .clamp(-8_388_608.0, 8_388_607.0) + .round() as i32; I24(scaled) } } @@ -240,7 +225,9 @@ impl Sample for i32 { #[inline] fn from_f64(value: f64) -> Self { - (value * 2_147_483_647.0).clamp(-2_147_483_648.0, 2_147_483_647.0).round() as i32 + (value * 2_147_483_647.0) + .clamp(-2_147_483_648.0, 2_147_483_647.0) + .round() as i32 } } diff --git a/pmoaudio/src/type_constraints.rs b/pmoaudio/src/type_constraints.rs new file mode 100644 index 00000000..274b55b1 --- /dev/null +++ b/pmoaudio/src/type_constraints.rs @@ -0,0 +1,385 @@ +//! Système de contraintes de types pour les nodes audio +//! +//! Ce module définit les types et structures permettant de vérifier la compatibilité +//! entre les producers et consumers de chunks audio dans un pipeline. + +use crate::AudioChunk; +use std::fmt; + +/// Type d'échantillon supporté +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum SampleType { + /// Entier 16-bit + I16, + /// Entier 24-bit (I24) + I24, + /// Entier 32-bit + I32, + /// Flottant 32-bit + F32, + /// Flottant 64-bit + F64, +} + +impl SampleType { + /// Vérifie si le type est un entier + pub fn is_integer(&self) -> bool { + matches!(self, SampleType::I16 | SampleType::I24 | SampleType::I32) + } + + /// Vérifie si le type est un flottant + pub fn is_float(&self) -> bool { + matches!(self, SampleType::F32 | SampleType::F64) + } + + /// Retourne la profondeur de bit + pub fn bit_depth(&self) -> u8 { + match self { + SampleType::I16 => 16, + SampleType::I24 => 24, + SampleType::I32 | SampleType::F32 => 32, + SampleType::F64 => 64, + } + } + + /// Extrait le type d'un AudioChunk + pub fn from_audio_chunk(chunk: &AudioChunk) -> Self { + match chunk { + AudioChunk::I16(_) => SampleType::I16, + AudioChunk::I24(_) => SampleType::I24, + AudioChunk::I32(_) => SampleType::I32, + AudioChunk::F32(_) => SampleType::F32, + AudioChunk::F64(_) => SampleType::F64, + } + } +} + +impl fmt::Display for SampleType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + SampleType::I16 => write!(f, "I16"), + SampleType::I24 => write!(f, "I24"), + SampleType::I32 => write!(f, "I32"), + SampleType::F32 => write!(f, "F32"), + SampleType::F64 => write!(f, "F64"), + } + } +} + +/// Catégorie de type acceptée +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TypeCategory { + /// N'importe quel type entier (I16, I24, I32) + AnyInteger, + /// N'importe quel type flottant (F32, F64) + AnyFloat, + /// Un type spécifique uniquement + Specific(SampleType), + /// N'importe quel type (entier ou flottant) + Any, +} + +impl TypeCategory { + /// Vérifie si cette catégorie accepte le type donné + pub fn accepts(&self, sample_type: SampleType) -> bool { + match self { + TypeCategory::AnyInteger => sample_type.is_integer(), + TypeCategory::AnyFloat => sample_type.is_float(), + TypeCategory::Specific(t) => *t == sample_type, + TypeCategory::Any => true, + } + } + + /// Retourne tous les types possibles pour cette catégorie + pub fn possible_types(&self) -> Vec { + match self { + TypeCategory::AnyInteger => vec![SampleType::I16, SampleType::I24, SampleType::I32], + TypeCategory::AnyFloat => vec![SampleType::F32, SampleType::F64], + TypeCategory::Specific(t) => vec![*t], + TypeCategory::Any => vec![ + SampleType::I16, + SampleType::I24, + SampleType::I32, + SampleType::F32, + SampleType::F64, + ], + } + } +} + +impl fmt::Display for TypeCategory { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + TypeCategory::AnyInteger => write!(f, "AnyInteger (I16|I24|I32)"), + TypeCategory::AnyFloat => write!(f, "AnyFloat (F32|F64)"), + TypeCategory::Specific(t) => write!(f, "{}", t), + TypeCategory::Any => write!(f, "Any"), + } + } +} + +/// Contrainte de type pour un node +#[derive(Debug, Clone)] +pub struct TypeRequirement { + /// Catégorie de type acceptée + pub category: TypeCategory, + /// Types spécifiques acceptés (pour contraintes plus fines) + /// Si vide, utilise category.possible_types() + pub accepted_types: Vec, +} + +impl TypeRequirement { + /// Crée une contrainte pour n'importe quel type + pub fn any() -> Self { + Self { + category: TypeCategory::Any, + accepted_types: vec![], + } + } + + /// Crée une contrainte pour n'importe quel entier + pub fn any_integer() -> Self { + Self { + category: TypeCategory::AnyInteger, + accepted_types: vec![], + } + } + + /// Crée une contrainte pour n'importe quel flottant + pub fn any_float() -> Self { + Self { + category: TypeCategory::AnyFloat, + accepted_types: vec![], + } + } + + /// Crée une contrainte pour un type spécifique + pub fn specific(sample_type: SampleType) -> Self { + Self { + category: TypeCategory::Specific(sample_type), + accepted_types: vec![sample_type], + } + } + + /// Crée une contrainte avec une liste explicite de types acceptés + pub fn from_list(types: Vec) -> Self { + // Déterminer la catégorie la plus appropriée + let all_integer = types.iter().all(|t| t.is_integer()); + let all_float = types.iter().all(|t| t.is_float()); + + let category = if types.len() == 1 { + TypeCategory::Specific(types[0]) + } else if all_integer && types.len() == 3 { + TypeCategory::AnyInteger + } else if all_float && types.len() == 2 { + TypeCategory::AnyFloat + } else if types.len() == 5 { + TypeCategory::Any + } else { + // Catégorie personnalisée - on garde la liste explicite + TypeCategory::Any + }; + + Self { + category, + accepted_types: types, + } + } + + /// Vérifie si cette contrainte accepte le type donné + pub fn accepts(&self, sample_type: SampleType) -> bool { + if !self.accepted_types.is_empty() { + // Si une liste explicite est fournie, utiliser celle-ci + self.accepted_types.contains(&sample_type) + } else { + // Sinon, utiliser la catégorie + self.category.accepts(sample_type) + } + } + + /// Retourne tous les types acceptés par cette contrainte + pub fn get_accepted_types(&self) -> Vec { + if !self.accepted_types.is_empty() { + self.accepted_types.clone() + } else { + self.category.possible_types() + } + } + + /// Vérifie si cette contrainte est plus restrictive qu'une autre + pub fn is_more_restrictive_than(&self, other: &TypeRequirement) -> bool { + let my_types = self.get_accepted_types(); + let other_types = other.get_accepted_types(); + + // Je suis plus restrictif si tous mes types sont dans other_types + // et que j'en ai moins + my_types.iter().all(|t| other_types.contains(t)) && my_types.len() < other_types.len() + } +} + +impl fmt::Display for TypeRequirement { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if !self.accepted_types.is_empty() && self.accepted_types.len() < 5 { + write!( + f, + "{}", + self.accepted_types + .iter() + .map(|t| t.to_string()) + .collect::>() + .join("|") + ) + } else { + write!(f, "{}", self.category) + } + } +} + +/// Vérifie la compatibilité entre un producer et un consumer +/// +/// # Règles de compatibilité : +/// +/// 1. Si le producer produit un type spécifique et que le consumer l'accepte → compatible +/// 2. Si le producer peut produire plusieurs types (ex: AnyInteger) et que le consumer +/// accepte un type spécifique (ex: I24), le producer POURRAIT produire un type +/// incompatible → **incompatible** (nécessite conversion explicite) +/// 3. Si le producer produit un type spécifique et que le consumer accepte une catégorie +/// contenant ce type → compatible +/// +/// # Exemples : +/// +/// - Producer(Specific(I24)) + Consumer(AnyInteger) → Compatible ✓ +/// - Producer(AnyInteger) + Consumer(Specific(I24)) → Incompatible ✗ (producer peut produire I16) +/// - Producer(Specific(I24)) + Consumer(Specific(I24)) → Compatible ✓ +/// - Producer(AnyInteger) + Consumer(AnyInteger) → Compatible ✓ +pub fn check_compatibility( + producer: &TypeRequirement, + consumer: &TypeRequirement, +) -> Result<(), TypeMismatch> { + let producer_types = producer.get_accepted_types(); + let consumer_types = consumer.get_accepted_types(); + + // Vérifier si tous les types que le producer peut produire sont acceptés par le consumer + for prod_type in &producer_types { + if !consumer_types.contains(prod_type) { + return Err(TypeMismatch { + producer: producer.clone(), + consumer: consumer.clone(), + incompatible_type: Some(*prod_type), + }); + } + } + + Ok(()) +} + +/// Erreur de compatibilité de types +#[derive(Debug, Clone)] +pub struct TypeMismatch { + /// Type requirement du producer + pub producer: TypeRequirement, + /// Type requirement du consumer + pub consumer: TypeRequirement, + /// Type spécifique incompatible (si identifié) + pub incompatible_type: Option, +} + +impl fmt::Display for TypeMismatch { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "Type mismatch: producer produces {} but consumer only accepts {}", + self.producer, self.consumer + )?; + if let Some(incomp_type) = self.incompatible_type { + write!(f, " (incompatible type: {})", incomp_type)?; + } + Ok(()) + } +} + +impl std::error::Error for TypeMismatch {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sample_type_is_integer() { + assert!(SampleType::I16.is_integer()); + assert!(SampleType::I24.is_integer()); + assert!(SampleType::I32.is_integer()); + assert!(!SampleType::F32.is_integer()); + assert!(!SampleType::F64.is_integer()); + } + + #[test] + fn test_sample_type_is_float() { + assert!(!SampleType::I16.is_float()); + assert!(SampleType::F32.is_float()); + assert!(SampleType::F64.is_float()); + } + + #[test] + fn test_type_category_accepts() { + let any_int = TypeCategory::AnyInteger; + assert!(any_int.accepts(SampleType::I16)); + assert!(any_int.accepts(SampleType::I24)); + assert!(any_int.accepts(SampleType::I32)); + assert!(!any_int.accepts(SampleType::F32)); + + let specific = TypeCategory::Specific(SampleType::I24); + assert!(!specific.accepts(SampleType::I16)); + assert!(specific.accepts(SampleType::I24)); + assert!(!specific.accepts(SampleType::I32)); + } + + #[test] + fn test_compatibility_specific_to_category() { + // Producer(Specific(I24)) + Consumer(AnyInteger) → Compatible + let producer = TypeRequirement::specific(SampleType::I24); + let consumer = TypeRequirement::any_integer(); + assert!(check_compatibility(&producer, &consumer).is_ok()); + } + + #[test] + fn test_compatibility_category_to_specific() { + // Producer(AnyInteger) + Consumer(Specific(I24)) → Incompatible + let producer = TypeRequirement::any_integer(); + let consumer = TypeRequirement::specific(SampleType::I24); + assert!(check_compatibility(&producer, &consumer).is_err()); + } + + #[test] + fn test_compatibility_same_specific() { + // Producer(Specific(I24)) + Consumer(Specific(I24)) → Compatible + let producer = TypeRequirement::specific(SampleType::I24); + let consumer = TypeRequirement::specific(SampleType::I24); + assert!(check_compatibility(&producer, &consumer).is_ok()); + } + + #[test] + fn test_compatibility_same_category() { + // Producer(AnyInteger) + Consumer(AnyInteger) → Compatible + let producer = TypeRequirement::any_integer(); + let consumer = TypeRequirement::any_integer(); + assert!(check_compatibility(&producer, &consumer).is_ok()); + } + + #[test] + fn test_compatibility_integer_to_float() { + // Producer(AnyInteger) + Consumer(AnyFloat) → Incompatible + let producer = TypeRequirement::any_integer(); + let consumer = TypeRequirement::any_float(); + assert!(check_compatibility(&producer, &consumer).is_err()); + } + + #[test] + fn test_type_requirement_from_list() { + let req = TypeRequirement::from_list(vec![SampleType::I24, SampleType::I32]); + assert!(req.accepts(SampleType::I24)); + assert!(req.accepts(SampleType::I32)); + assert!(!req.accepts(SampleType::I16)); + assert!(!req.accepts(SampleType::F32)); + } +} diff --git a/pmoflac/src/encoder.rs b/pmoflac/src/encoder.rs index 7ccd87b5..f0fd140b 100644 --- a/pmoflac/src/encoder.rs +++ b/pmoflac/src/encoder.rs @@ -1,5 +1,5 @@ use std::{ - ffi::c_void, + ffi::{c_void, CString}, io, pin::Pin, task::{Context, Poll}, @@ -85,8 +85,24 @@ impl tokio::io::AsyncRead for FlacEncodedStream { } } +use std::sync::Arc; + +/// Extracted metadata values for FLAC encoding. +/// +/// This is a simple struct containing the extracted values from TrackMetadata, +/// used to pass metadata into the blocking encoder task. +#[derive(Debug, Clone, Default)] +struct ExtractedMetadata { + title: Option, + artist: Option, + album: Option, + year: Option, + genre: Option, + track_number: Option, +} + /// Options for configuring FLAC encoding. -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct EncoderOptions { /// Compression level (0-12). Higher means better compression but slower. /// Default: 5 (balanced) @@ -102,6 +118,10 @@ pub struct EncoderOptions { /// Block size in samples (optional). If None, libFLAC chooses automatically. /// Typical values: 1152, 2304, 4096. pub block_size: Option, + + /// Metadata to embed in the FLAC file (Vorbis Comments). + /// Default: None (no metadata) + pub metadata: Option>, } impl Default for EncoderOptions { @@ -111,10 +131,23 @@ impl Default for EncoderOptions { verify: false, total_samples: None, block_size: None, + metadata: None, } } } +impl std::fmt::Debug for EncoderOptions { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("EncoderOptions") + .field("compression_level", &self.compression_level) + .field("verify", &self.verify) + .field("total_samples", &self.total_samples) + .field("block_size", &self.block_size) + .field("metadata", &self.metadata.as_ref().map(|_| "Some(...)")) + .finish() + } +} + /// Encodes PCM audio data into a FLAC stream. /// /// This function spawns background tasks to perform the encoding asynchronously. @@ -212,6 +245,33 @@ where )); } + // Extract metadata before spawn_blocking (since TrackMetadata has async methods) + let extracted_metadata = if let Some(metadata) = &options.metadata { + let title = metadata.get_title().await.ok().flatten(); + let artist = metadata.get_artist().await.ok().flatten(); + let album = metadata.get_album().await.ok().flatten(); + let year = metadata.get_year().await.ok().flatten(); + + // Try to extract genre and track_number from extra fields + let extra = metadata.get_extra().await.ok().flatten(); + let genre = extra.as_ref().and_then(|e| e.get("genre").cloned()); + let track_number = extra.as_ref().and_then(|e| { + e.get("track_number") + .and_then(|s| s.parse::().ok()) + }); + + Some(ExtractedMetadata { + title, + artist, + album, + year, + genre, + track_number, + }) + } else { + None + }; + let (pcm_tx, pcm_rx) = mpsc::channel::>(CHANNEL_CAPACITY); let format_for_reader = format; tokio::spawn(async move { @@ -228,6 +288,7 @@ where run_encoder( format_for_encoder, options_for_encoder, + extracted_metadata, pcm_rx, flac_tx, init_tx, @@ -306,9 +367,112 @@ where Ok(()) } +/// RAII guard for FLAC metadata block +struct MetadataGuard { + metadata: *mut libflac_sys::FLAC__StreamMetadata, +} + +impl Drop for MetadataGuard { + fn drop(&mut self) { + unsafe { + if !self.metadata.is_null() { + libflac_sys::FLAC__metadata_object_delete(self.metadata); + } + } + } +} + +/// Sets up Vorbis Comment metadata for the FLAC encoder +unsafe fn setup_metadata( + encoder: *mut libflac_sys::FLAC__StreamEncoder, + metadata: &ExtractedMetadata, +) -> Result { + use libflac_sys::*; + + // Create a Vorbis Comment block + let meta = FLAC__metadata_object_new(FLAC__METADATA_TYPE_VORBIS_COMMENT); + if meta.is_null() { + return Err(FlacError::LibFlacInit( + "Failed to create metadata block".into(), + )); + } + + let guard = MetadataGuard { metadata: meta }; + + // Helper to append a Vorbis comment + let append_comment = |field_name: &str, value: &str| -> Result<(), FlacError> { + let c_field_name = CString::new(field_name).map_err(|_| { + FlacError::LibFlacInit("Failed to create CString for field name".into()) + })?; + let c_value = CString::new(value).map_err(|_| { + FlacError::LibFlacInit("Failed to create CString for field value".into()) + })?; + + let mut entry: FLAC__StreamMetadata_VorbisComment_Entry = std::mem::zeroed(); + let success = FLAC__metadata_object_vorbiscomment_entry_from_name_value_pair( + &mut entry as *mut _, + c_field_name.as_ptr(), + c_value.as_ptr(), + ); + + if success == 0 { + return Err(FlacError::LibFlacInit(format!( + "Failed to create metadata entry for {}", + field_name + ))); + } + + let append_success = + FLAC__metadata_object_vorbiscomment_append_comment(meta, entry, 0 /* copy */); + + if append_success == 0 { + return Err(FlacError::LibFlacInit(format!( + "Failed to append metadata entry for {}", + field_name + ))); + } + + Ok(()) + }; + + // Add all available metadata fields + if let Some(title) = &metadata.title { + append_comment("TITLE", title)?; + } + if let Some(artist) = &metadata.artist { + append_comment("ARTIST", artist)?; + } + if let Some(album) = &metadata.album { + append_comment("ALBUM", album)?; + } + if let Some(year) = metadata.year { + append_comment("DATE", &year.to_string())?; + } + if let Some(genre) = &metadata.genre { + append_comment("GENRE", genre)?; + } + if let Some(track_number) = metadata.track_number { + append_comment("TRACKNUMBER", &track_number.to_string())?; + } + + // Set the metadata on the encoder + let mut metadata_array = [meta]; + let set_success = + FLAC__stream_encoder_set_metadata(encoder, metadata_array.as_mut_ptr(), 1); + + if set_success == 0 { + return Err(FlacError::LibFlacInit( + "Failed to set metadata on encoder".into(), + )); + } + + Ok(guard) +} + fn run_encoder( format: PcmFormat, options: EncoderOptions, + metadata: Option, mut rx: mpsc::Receiver>, tx: mpsc::Sender, FlacError>>, init_tx: oneshot::Sender>, @@ -375,6 +539,13 @@ fn run_encoder( )?; } + // Setup metadata if provided + let _metadata_guard = if let Some(meta) = metadata { + Some(setup_metadata(encoder, &meta)?) + } else { + None + }; + let init_status = FLAC__stream_encoder_init_stream( encoder, Some(write_callback), diff --git a/pmoflac/src/transcode.rs b/pmoflac/src/transcode.rs index 345f70cd..685da1fe 100644 --- a/pmoflac/src/transcode.rs +++ b/pmoflac/src/transcode.rs @@ -15,8 +15,9 @@ use tokio::io::{AsyncRead, AsyncReadExt, BufReader, ReadBuf}; use crate::{ autodetect::{decode_audio_stream, DecodeAudioError, DecodedAudioStream}, - encode_flac_stream, prefixed_reader::PrefixedReader, EncoderOptions, FlacEncodedStream, - FlacError, PcmFormat, StreamInfo, + encode_flac_stream, + prefixed_reader::PrefixedReader, + EncoderOptions, FlacEncodedStream, FlacError, PcmFormat, StreamInfo, }; const READ_CHUNK: usize = 4096; diff --git a/pmometadata/src/lib.rs b/pmometadata/src/lib.rs index 0a737cc8..4c9c6876 100644 --- a/pmometadata/src/lib.rs +++ b/pmometadata/src/lib.rs @@ -32,13 +32,13 @@ //! ``` #![allow(async_fn_in_trait)] +use async_trait::async_trait; +use std::sync::Arc; use std::{ collections::HashMap, time::{Duration, SystemTime}, }; use tokio::sync::RwLock; -use std::sync::Arc; -use async_trait::async_trait; /// Helper macro for copying a single metadata field. macro_rules! copy_a_metadata { @@ -157,7 +157,7 @@ pub trait TrackMetadata: Send + Sync { async fn get_title(&self) -> MetadataResult { Err(MetadataError::NotImplemented) } - + async fn set_title(&mut self, _value: Option) -> MetadataResult<()> { Err(MetadataError::NotImplemented) } @@ -165,7 +165,7 @@ pub trait TrackMetadata: Send + Sync { async fn get_artist(&self) -> MetadataResult { Err(MetadataError::NotImplemented) } - + async fn set_artist(&mut self, _value: Option) -> MetadataResult<()> { Err(MetadataError::NotImplemented) } @@ -173,7 +173,7 @@ pub trait TrackMetadata: Send + Sync { async fn get_album(&self) -> MetadataResult { Err(MetadataError::NotImplemented) } - + async fn set_album(&mut self, _value: Option) -> MetadataResult<()> { Err(MetadataError::NotImplemented) } @@ -181,7 +181,7 @@ pub trait TrackMetadata: Send + Sync { async fn get_year(&self) -> MetadataResult { Err(MetadataError::NotImplemented) } - + async fn set_year(&mut self, _value: Option) -> MetadataResult<()> { Err(MetadataError::NotImplemented) } @@ -189,7 +189,7 @@ pub trait TrackMetadata: Send + Sync { async fn get_duration(&self) -> MetadataResult { Err(MetadataError::NotImplemented) } - + async fn set_duration(&mut self, _value: Option) -> MetadataResult<()> { Err(MetadataError::NotImplemented) } @@ -197,7 +197,7 @@ pub trait TrackMetadata: Send + Sync { async fn get_track_id(&self) -> MetadataResult { Err(MetadataError::NotImplemented) } - + async fn set_track_id(&mut self, _value: Option) -> MetadataResult<()> { Err(MetadataError::NotImplemented) } @@ -205,7 +205,7 @@ pub trait TrackMetadata: Send + Sync { async fn get_channel_id(&self) -> MetadataResult { Err(MetadataError::NotImplemented) } - + async fn set_channel_id(&mut self, _value: Option) -> MetadataResult<()> { Err(MetadataError::NotImplemented) } @@ -213,7 +213,7 @@ pub trait TrackMetadata: Send + Sync { async fn get_event(&self) -> MetadataResult { Err(MetadataError::NotImplemented) } - + async fn set_event(&mut self, _value: Option) -> MetadataResult<()> { Err(MetadataError::NotImplemented) } @@ -221,7 +221,7 @@ pub trait TrackMetadata: Send + Sync { async fn get_rating(&self) -> MetadataResult { Err(MetadataError::NotImplemented) } - + async fn set_rating(&mut self, _value: Option) -> MetadataResult<()> { Err(MetadataError::NotImplemented) } @@ -229,7 +229,7 @@ pub trait TrackMetadata: Send + Sync { async fn get_cover_url(&self) -> MetadataResult { Err(MetadataError::NotImplemented) } - + async fn set_cover_url(&mut self, _value: Option) -> MetadataResult<()> { Err(MetadataError::NotImplemented) } @@ -237,7 +237,7 @@ pub trait TrackMetadata: Send + Sync { async fn get_cover_pk(&self) -> MetadataResult { Err(MetadataError::NotImplemented) } - + async fn set_cover_pk(&mut self, _value: Option) -> MetadataResult<()> { Err(MetadataError::NotImplemented) } @@ -245,7 +245,7 @@ pub trait TrackMetadata: Send + Sync { async fn get_extra(&self) -> MetadataResult> { Err(MetadataError::NotImplemented) } - + async fn set_extra(&mut self, _value: Option>) -> MetadataResult<()> { Err(MetadataError::NotImplemented) } @@ -253,7 +253,7 @@ pub trait TrackMetadata: Send + Sync { async fn get_updated_at(&self) -> MetadataResult { Err(MetadataError::NotImplemented) } - + async fn touch(&mut self) -> MetadataResult<()> { Err(MetadataError::NotImplemented) } @@ -318,8 +318,8 @@ where let src_guard = src.read().await; copy_metadata!( - src_guard, dest, title, artist, album, year, duration, track_id, - channel_id, event, rating, cover_url, cover_pk, extra + src_guard, dest, title, artist, album, year, duration, track_id, channel_id, event, rating, + cover_url, cover_pk, extra ); // Try to update the timestamp, but ignore transient errors @@ -359,7 +359,7 @@ impl TrackMetadata for MemoryTrackMetadata { async fn get_title(&self) -> MetadataResult { Ok(self.title.clone()) } - + async fn set_title(&mut self, value: Option) -> MetadataResult<()> { self.title = value; self.touch().await?; @@ -369,7 +369,7 @@ impl TrackMetadata for MemoryTrackMetadata { async fn get_artist(&self) -> MetadataResult { Ok(self.artist.clone()) } - + async fn set_artist(&mut self, value: Option) -> MetadataResult<()> { self.artist = value; self.touch().await?; @@ -379,7 +379,7 @@ impl TrackMetadata for MemoryTrackMetadata { async fn get_album(&self) -> MetadataResult { Ok(self.album.clone()) } - + async fn set_album(&mut self, value: Option) -> MetadataResult<()> { self.album = value; self.touch().await?; @@ -389,7 +389,7 @@ impl TrackMetadata for MemoryTrackMetadata { async fn get_year(&self) -> MetadataResult { Ok(self.year) } - + async fn set_year(&mut self, value: Option) -> MetadataResult<()> { self.year = value; self.touch().await?; @@ -399,7 +399,7 @@ impl TrackMetadata for MemoryTrackMetadata { async fn get_duration(&self) -> MetadataResult { Ok(self.duration) } - + async fn set_duration(&mut self, value: Option) -> MetadataResult<()> { self.duration = value; self.touch().await?; @@ -409,7 +409,7 @@ impl TrackMetadata for MemoryTrackMetadata { async fn get_track_id(&self) -> MetadataResult { Ok(self.track_id.clone()) } - + async fn set_track_id(&mut self, value: Option) -> MetadataResult<()> { self.track_id = value; self.touch().await?; @@ -419,7 +419,7 @@ impl TrackMetadata for MemoryTrackMetadata { async fn get_channel_id(&self) -> MetadataResult { Ok(self.channel_id.clone()) } - + async fn set_channel_id(&mut self, value: Option) -> MetadataResult<()> { self.channel_id = value; self.touch().await?; @@ -429,7 +429,7 @@ impl TrackMetadata for MemoryTrackMetadata { async fn get_event(&self) -> MetadataResult { Ok(self.event.clone()) } - + async fn set_event(&mut self, value: Option) -> MetadataResult<()> { self.event = value; self.touch().await?; @@ -439,7 +439,7 @@ impl TrackMetadata for MemoryTrackMetadata { async fn get_rating(&self) -> MetadataResult { Ok(self.rating) } - + async fn set_rating(&mut self, value: Option) -> MetadataResult<()> { self.rating = value; self.touch().await?; @@ -449,7 +449,7 @@ impl TrackMetadata for MemoryTrackMetadata { async fn get_cover_url(&self) -> MetadataResult { Ok(self.cover_url.clone()) } - + async fn set_cover_url(&mut self, value: Option) -> MetadataResult<()> { self.cover_url = value; self.touch().await?; @@ -459,7 +459,7 @@ impl TrackMetadata for MemoryTrackMetadata { async fn get_cover_pk(&self) -> MetadataResult { Ok(self.cover_pk.clone()) } - + async fn set_cover_pk(&mut self, value: Option) -> MetadataResult<()> { self.cover_pk = value; self.touch().await?; @@ -469,7 +469,7 @@ impl TrackMetadata for MemoryTrackMetadata { async fn get_extra(&self) -> MetadataResult> { Ok(self.extra.clone()) } - + async fn set_extra(&mut self, value: Option>) -> MetadataResult<()> { self.extra = value; self.touch().await?; @@ -479,7 +479,7 @@ impl TrackMetadata for MemoryTrackMetadata { async fn get_updated_at(&self) -> MetadataResult { Ok(self.updated_at) } - + async fn touch(&mut self) -> MetadataResult<()> { self.updated_at = Some(SystemTime::now()); Ok(Some(())) @@ -518,7 +518,10 @@ mod tests { async fn test_memory_metadata_set_get_artist() { let mut metadata = MemoryTrackMetadata::new(); - metadata.set_artist(Some("Artist Name".to_string())).await.unwrap(); + metadata + .set_artist(Some("Artist Name".to_string())) + .await + .unwrap(); assert_eq!( metadata.get_artist().await.unwrap(), Some("Artist Name".to_string()) @@ -529,7 +532,10 @@ mod tests { async fn test_memory_metadata_set_get_album() { let mut metadata = MemoryTrackMetadata::new(); - metadata.set_album(Some("Album Name".to_string())).await.unwrap(); + metadata + .set_album(Some("Album Name".to_string())) + .await + .unwrap(); assert_eq!( metadata.get_album().await.unwrap(), Some("Album Name".to_string()) @@ -565,7 +571,10 @@ mod tests { async fn test_memory_metadata_set_get_track_id() { let mut metadata = MemoryTrackMetadata::new(); - metadata.set_track_id(Some("track123".to_string())).await.unwrap(); + metadata + .set_track_id(Some("track123".to_string())) + .await + .unwrap(); assert_eq!( metadata.get_track_id().await.unwrap(), Some("track123".to_string()) @@ -576,7 +585,10 @@ mod tests { async fn test_memory_metadata_set_get_channel_id() { let mut metadata = MemoryTrackMetadata::new(); - metadata.set_channel_id(Some("channel456".to_string())).await.unwrap(); + metadata + .set_channel_id(Some("channel456".to_string())) + .await + .unwrap(); assert_eq!( metadata.get_channel_id().await.unwrap(), Some("channel456".to_string()) @@ -587,7 +599,10 @@ mod tests { async fn test_memory_metadata_set_get_event() { let mut metadata = MemoryTrackMetadata::new(); - metadata.set_event(Some("event789".to_string())).await.unwrap(); + metadata + .set_event(Some("event789".to_string())) + .await + .unwrap(); assert_eq!( metadata.get_event().await.unwrap(), Some("event789".to_string()) @@ -598,7 +613,10 @@ mod tests { async fn test_memory_metadata_set_get_cover_url() { let mut metadata = MemoryTrackMetadata::new(); - metadata.set_cover_url(Some("https://example.com/cover.jpg".to_string())).await.unwrap(); + metadata + .set_cover_url(Some("https://example.com/cover.jpg".to_string())) + .await + .unwrap(); assert_eq!( metadata.get_cover_url().await.unwrap(), Some("https://example.com/cover.jpg".to_string()) @@ -609,7 +627,10 @@ mod tests { async fn test_memory_metadata_set_get_cover_pk() { let mut metadata = MemoryTrackMetadata::new(); - metadata.set_cover_pk(Some("pk123".to_string())).await.unwrap(); + metadata + .set_cover_pk(Some("pk123".to_string())) + .await + .unwrap(); assert_eq!( metadata.get_cover_pk().await.unwrap(), Some("pk123".to_string()) @@ -632,7 +653,10 @@ mod tests { let mut metadata = MemoryTrackMetadata::new(); metadata.set_title(Some("Title".to_string())).await.unwrap(); - assert_eq!(metadata.get_title().await.unwrap(), Some("Title".to_string())); + assert_eq!( + metadata.get_title().await.unwrap(), + Some("Title".to_string()) + ); metadata.set_title(None).await.unwrap(); assert_eq!(metadata.get_title().await.unwrap(), None); @@ -670,8 +694,12 @@ mod tests { #[tokio::test] async fn test_copy_metadata_into_basic() { let mut src = MemoryTrackMetadata::new(); - src.set_title(Some("Source Title".to_string())).await.unwrap(); - src.set_artist(Some("Source Artist".to_string())).await.unwrap(); + src.set_title(Some("Source Title".to_string())) + .await + .unwrap(); + src.set_artist(Some("Source Artist".to_string())) + .await + .unwrap(); src.set_year(Some(2024)).await.unwrap(); let dest = MemoryTrackMetadata::new(); @@ -682,8 +710,14 @@ mod tests { copy_metadata_into(&src_lock, &dest_lock).await.unwrap(); let dest_guard = dest_lock.read().await; - assert_eq!(dest_guard.get_title().await.unwrap(), Some("Source Title".to_string())); - assert_eq!(dest_guard.get_artist().await.unwrap(), Some("Source Artist".to_string())); + assert_eq!( + dest_guard.get_title().await.unwrap(), + Some("Source Title".to_string()) + ); + assert_eq!( + dest_guard.get_artist().await.unwrap(), + Some("Source Artist".to_string()) + ); assert_eq!(dest_guard.get_year().await.unwrap(), Some(2024)); } @@ -701,7 +735,10 @@ mod tests { copy_metadata_into(&src_lock, &dest_lock).await.unwrap(); let dest_guard = dest_lock.read().await; - assert_eq!(dest_guard.get_title().await.unwrap(), Some("Title".to_string())); + assert_eq!( + dest_guard.get_title().await.unwrap(), + Some("Title".to_string()) + ); assert_eq!(dest_guard.get_artist().await.unwrap(), None); } @@ -729,12 +766,20 @@ mod tests { src.set_artist(Some("Artist".to_string())).await.unwrap(); src.set_album(Some("Album".to_string())).await.unwrap(); src.set_year(Some(2024)).await.unwrap(); - src.set_duration(Some(Duration::from_secs(180))).await.unwrap(); - src.set_track_id(Some("track123".to_string())).await.unwrap(); - src.set_channel_id(Some("channel456".to_string())).await.unwrap(); + src.set_duration(Some(Duration::from_secs(180))) + .await + .unwrap(); + src.set_track_id(Some("track123".to_string())) + .await + .unwrap(); + src.set_channel_id(Some("channel456".to_string())) + .await + .unwrap(); src.set_event(Some("event789".to_string())).await.unwrap(); src.set_rating(Some(4.5)).await.unwrap(); - src.set_cover_url(Some("https://example.com/cover.jpg".to_string())).await.unwrap(); + src.set_cover_url(Some("https://example.com/cover.jpg".to_string())) + .await + .unwrap(); src.set_cover_pk(Some("pk123".to_string())).await.unwrap(); let mut extra = HashMap::new(); @@ -749,17 +794,44 @@ mod tests { copy_metadata_into(&src_lock, &dest_lock).await.unwrap(); let dest_guard = dest_lock.read().await; - assert_eq!(dest_guard.get_title().await.unwrap(), Some("Title".to_string())); - assert_eq!(dest_guard.get_artist().await.unwrap(), Some("Artist".to_string())); - assert_eq!(dest_guard.get_album().await.unwrap(), Some("Album".to_string())); + assert_eq!( + dest_guard.get_title().await.unwrap(), + Some("Title".to_string()) + ); + assert_eq!( + dest_guard.get_artist().await.unwrap(), + Some("Artist".to_string()) + ); + assert_eq!( + dest_guard.get_album().await.unwrap(), + Some("Album".to_string()) + ); assert_eq!(dest_guard.get_year().await.unwrap(), Some(2024)); - assert_eq!(dest_guard.get_duration().await.unwrap(), Some(Duration::from_secs(180))); - assert_eq!(dest_guard.get_track_id().await.unwrap(), Some("track123".to_string())); - assert_eq!(dest_guard.get_channel_id().await.unwrap(), Some("channel456".to_string())); - assert_eq!(dest_guard.get_event().await.unwrap(), Some("event789".to_string())); + assert_eq!( + dest_guard.get_duration().await.unwrap(), + Some(Duration::from_secs(180)) + ); + assert_eq!( + dest_guard.get_track_id().await.unwrap(), + Some("track123".to_string()) + ); + assert_eq!( + dest_guard.get_channel_id().await.unwrap(), + Some("channel456".to_string()) + ); + assert_eq!( + dest_guard.get_event().await.unwrap(), + Some("event789".to_string()) + ); assert_eq!(dest_guard.get_rating().await.unwrap(), Some(4.5)); - assert_eq!(dest_guard.get_cover_url().await.unwrap(), Some("https://example.com/cover.jpg".to_string())); - assert_eq!(dest_guard.get_cover_pk().await.unwrap(), Some("pk123".to_string())); + assert_eq!( + dest_guard.get_cover_url().await.unwrap(), + Some("https://example.com/cover.jpg".to_string()) + ); + assert_eq!( + dest_guard.get_cover_pk().await.unwrap(), + Some("pk123".to_string()) + ); assert_eq!(dest_guard.get_extra().await.unwrap(), Some(extra)); } @@ -781,7 +853,13 @@ mod tests { // Verify the data was copied let dest_guard = dest_lock.read().await; - assert_eq!(dest_guard.get_title().await.unwrap(), Some("Title".to_string())); - assert_eq!(dest_guard.get_artist().await.unwrap(), Some("Artist".to_string())); + assert_eq!( + dest_guard.get_title().await.unwrap(), + Some("Title".to_string()) + ); + assert_eq!( + dest_guard.get_artist().await.unwrap(), + Some("Artist".to_string()) + ); } }