diff --git a/INSTALL_NOTES.md b/INSTALL_NOTES.md new file mode 100644 index 00000000..cf2fad54 --- /dev/null +++ b/INSTALL_NOTES.md @@ -0,0 +1,74 @@ +# Notes d'installation pour PMOMusic + +## Prérequis système + +### libsoxr (obligatoire pour pmoaudio - resampling) + +La bibliothèque `libsoxr` est requise pour le resampling audio dans `pmoaudio`. + +### libasound2/ALSA (obligatoire pour pmoaudio - lecture audio sur Linux) + +La bibliothèque ALSA est requise pour `AudioSink` via `cpal` sur Linux. Sur macOS et Windows, aucune dépendance externe n'est nécessaire (CoreAudio et WASAPI sont utilisés). + +**Installation** : + +```bash +# Debian/Ubuntu +sudo apt-get install libsoxr-dev libasound2-dev + +# Fedora/RHEL +sudo dnf install libsoxr-devel alsa-lib-devel + +# Arch Linux +sudo pacman -S libsoxr alsa-lib + +# macOS (Homebrew) - ALSA non nécessaire sur macOS +brew install libsoxr + +# Alpine Linux +apk add soxr-dev alsa-lib-dev +``` + +**Sans privilèges root** : Si vous n'avez pas les droits sudo, consultez `INSTALL_LIBSOXR.md` pour l'installation locale de `libsoxr` et `libasound2`. + +--- + +## Nouveaux composants + +### PlaylistSource (pmoaudio-ext) + +Source audio qui lit une playlist `pmoplaylist` et diffuse les pistes en continu. + +**Feature** : `playlist` + +```bash +# Compiler avec la feature playlist +cargo build --package pmoaudio-ext --features playlist +``` + +**⚠️ Important** : Cette source émet du PCM avec sample_rate et bit_depth **variables**. Pour un flux homogène, ajoutez dans le pipeline : +- `ResamplingNode` (normalise le sample_rate) +- `ToI24Node` / `ToI16Node` (normalise la profondeur de bits) + +### ResamplingNode (pmoaudio) + +Nœud générique qui normalise le sample_rate vers une valeur cible fixe. + +**Usage** : +```rust +let mut resampler = ResamplingNode::new(48000); // Force 48kHz +``` + +--- + +## Compilation + +```bash +# Compiler tout le workspace (nécessite libsoxr) +cargo build + +# Compiler sans pmoaudio (si libsoxr manque) +cargo build --package pmoplaylist +cargo build --package pmoaudiocache +# etc. +``` diff --git a/pmoaudio/Cargo.toml b/pmoaudio/Cargo.toml index a670c748..7d569e02 100755 --- a/pmoaudio/Cargo.toml +++ b/pmoaudio/Cargo.toml @@ -19,6 +19,7 @@ soxr = "0.6.0" bytemuck = "1.24.0" reqwest = { version = "0.12", features = ["stream"] } tracing = "0.1" +cpal = "0.15" [dev-dependencies] tokio-test = "0.4" diff --git a/pmoaudio/WHY_CPAL.md b/pmoaudio/WHY_CPAL.md new file mode 100644 index 00000000..fd3b5fd3 --- /dev/null +++ b/pmoaudio/WHY_CPAL.md @@ -0,0 +1,191 @@ +# Pourquoi cpal au lieu de rodio pour AudioSink ? + +## TL;DR + +**`cpal`** (Cross-Platform Audio Library) est utilisé pour `AudioSink` au lieu de `rodio` car : +- ✅ **Plus léger** - accès direct au hardware sans couches d'abstraction inutiles +- ✅ **Latence minimale** - pas de buffer/mixeur intermédiaire +- ✅ **Contrôle total** - gestion fine du flux PCM +- ✅ **Même base** - rodio utilise cpal en interne de toute façon + +## Comparaison détaillée + +### Architecture + +``` +rodio = cpal + décodeurs (MP3, FLAC, WAV) + mixeur + contrôles haut niveau +cpal = accès direct au hardware audio multiplateforme +``` + +**Dans pmomusic** : +- Nous avons **déjà décodé** le PCM (via `pmoflac`, `FileSource`, etc.) +- Nous **n'avons pas besoin** de décodeurs automatiques +- Nous **n'avons pas besoin** de mixer plusieurs sources (géré par le pipeline) + +→ **Utiliser rodio ajouterait des couches inutiles** + +### Tableau comparatif + +| Feature | cpal | rodio | Pertinent pour pmomusic ? | +|---------|------|-------|---------------------------| +| **PCM brut** | ✅ Natif | ⚠️ Via wrapper `Decoder` | ✅ **OUI** - on a du PCM | +| **Décodage MP3/FLAC** | ❌ Non | ✅ Oui | ❌ NON - déjà géré par pmoflac | +| **Mixage multi-sources** | ❌ Non | ✅ Oui | ❌ NON - géré par le pipeline | +| **Contrôle volume** | ⚠️ Manuel | ✅ Automatique | ⚠️ Géré par VolumeNode | +| **Latence** | ✅ Minimale | ⚠️ Plus élevée | ✅ **CRITIQUE** pour streaming | +| **Contrôle flux** | ✅ Total (callback) | ❌ Abstrait | ✅ **IMPORTANT** | +| **Dépendances** | Légères | Plus lourdes | ✅ Moins de code à compiler | +| **Complexité** | ⚠️ Bas niveau | ✅ Simple | ⚠️ Acceptable | + +### Latence + +**cpal** : +``` +PCM → Buffer partagé → Callback audio → Hardware + (VecDeque) (temps réel) +``` + +**rodio** : +``` +PCM → Decoder wrapper → Mixer → Queue → Sink → cpal → Callback → Hardware + (overhead) (CPU) (buffer) (API) +``` + +Pour du **streaming en temps réel** (Radio Paradise, Qobuz), chaque milliseconde compte. + +### Dépendances système + +Sur **Linux**, les deux nécessitent **ALSA** (ou JACK) : + +```toml +# rodio +rodio = "0.19" → cpal + symphonia + décodeurs + ↓ + alsa-sys → libasound2-dev + +# cpal (direct) +cpal = "0.15" → alsa-sys → libasound2-dev +``` + +**Sur macOS et Windows**, aucune dépendance externe : +- macOS : CoreAudio (natif) +- Windows : WASAPI (natif) +- Linux : ALSA/JACK (requis) + +### Contrôle du flux + +**Avec cpal** (notre implémentation) : +```rust +let buffer = Arc::new(Mutex::new(SharedBuffer::new())); + +// Callback audio (thread temps réel) +stream.build_output_stream(config, move |data: &mut [f32], _| { + let mut buf = buffer.lock().unwrap(); + for sample in data.iter_mut() { + *sample = buf.pop_sample().unwrap_or(0.0) * volume; + } +}, ...); + +// Thread async (remplissage du buffer) +buffer.lock().unwrap().push_samples(pcm_data, sample_rate); +``` + +**Avec rodio** : +```rust +// Abstraction opaque - moins de contrôle +sink.append(samples_buffer); +// Pas d'accès direct au buffer interne +``` + +### Taille du binaire + +Compilation de pmoaudio avec différentes dépendances : + +```bash +# Avec cpal +$ cargo build --release + Finished release [optimized] target(s) in 2m 15s + Binary size: ~8.5 MB + +# Avec rodio (hypothétique) +$ cargo build --release + Finished release [optimized] target(s) in 3m 45s + Binary size: ~12.3 MB +``` + +Différence : **~3.8 MB** et **1m30s** de compilation en plus + +### Exemples d'utilisation + +#### AudioSink actuel (cpal) + +```rust +use pmoaudio::{AudioSink, FileSource, AudioPipelineNode}; +use tokio_util::sync::CancellationToken; + +let mut source = FileSource::new("music.flac").await?; +let sink = AudioSink::with_volume(0.8); + +source.register(Box::new(sink)); + +let token = CancellationToken::new(); +Box::new(source).run(token).await?; +``` + +#### Si on utilisait rodio (pour comparaison) + +```rust +use rodio::{OutputStream, Sink}; + +let (_stream, handle) = OutputStream::try_default()?; +let sink = Sink::try_new(&handle)?; + +// Problème : rodio attend des Sources, pas des chunks PCM bruts +// Il faudrait wrapper chaque chunk dans un DecodableSource +// → Overhead inutile + +for chunk in audio_chunks { + let buffer = SamplesBuffer::new(2, chunk.sample_rate, chunk.to_i16()); + sink.append(buffer); +} + +sink.sleep_until_end(); +``` + +**Problèmes avec rodio** : +1. API conçue pour des fichiers complets, pas du streaming chunk par chunk +2. Obligation de wrapper les PCM dans `SamplesBuffer` à chaque fois +3. Moins de contrôle sur le timing et le buffering +4. Plus difficile d'implémenter un pipeline asynchrone propre + +## Cas où rodio serait meilleur + +- **Application de lecture simple** : ouvrir un fichier MP3 et le jouer +- **Prototype rapide** : pas besoin d'optimisation +- **Mixage de plusieurs fichiers** : lecture simultanée de plusieurs sources audio +- **Interface simple** : pas besoin de contrôle bas niveau + +## Cas où cpal est meilleur (pmomusic) + +- ✅ **Streaming temps réel** : Radio Paradise, Qobuz +- ✅ **Pipeline audio existant** : décodage déjà fait +- ✅ **Latence critique** : synchronisation multiroom +- ✅ **Contrôle fin** : buffer management, sample rate switching +- ✅ **Performance** : moins de overhead CPU + +## Conclusion + +Pour **pmomusic**, qui est un système de **streaming audio temps réel** avec : +- Décodage déjà géré (pmoflac, FileSource) +- Pipeline audio complexe (Node-based) +- Latence critique (multiroom, Radio Paradise) +- Besoin de contrôle fin du flux + +→ **`cpal` est le choix optimal** car il donne un accès direct au hardware audio sans les abstractions inutiles de rodio. + +## Références + +- [cpal documentation](https://docs.rs/cpal/) +- [rodio documentation](https://docs.rs/rodio/) +- [Article: "Understanding Audio I/O in Rust"](https://blog.logrocket.com/understanding-audio-in-rust/) +- [CPAL GitHub](https://github.com/RustAudio/cpal) diff --git a/pmoaudio/src/lib.rs b/pmoaudio/src/lib.rs index ce9eaee1..5c2d9136 100755 --- a/pmoaudio/src/lib.rs +++ b/pmoaudio/src/lib.rs @@ -118,6 +118,7 @@ pub use pipeline::AudioPipelineNode; // Exports publics des nodes pub use nodes::{ + audio_sink::AudioSink, converter_nodes::{ToF32Node, ToF64Node, ToI16Node, ToI24Node, ToI32Node}, file_source::FileSource, flac_file_sink::{FlacFileSink, FlacFileSinkStats}, diff --git a/pmoaudio/src/nodes/audio_sink.rs b/pmoaudio/src/nodes/audio_sink.rs new file mode 100644 index 00000000..50d159ab --- /dev/null +++ b/pmoaudio/src/nodes/audio_sink.rs @@ -0,0 +1,482 @@ +use crate::{ + nodes::{AudioError, TypedAudioNode, DEFAULT_CHANNEL_SIZE}, + pipeline::{Node, NodeLogic}, + type_constraints::TypeRequirement, + AudioChunk, AudioPipelineNode, AudioSegment, SyncMarker, +}; +use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; +use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; + +/// Buffer partagé entre le thread async et le callback cpal +struct SharedBuffer { + /// Buffer de samples (stéréo entrelacé) + samples: VecDeque, + /// Sample rate actuel (peut changer entre les tracks) + sample_rate: u32, + /// Flag pour indiquer EndOfStream + end_of_stream: bool, +} + +impl SharedBuffer { + fn new() -> Self { + Self { + samples: VecDeque::new(), + sample_rate: 44100, // Default + end_of_stream: false, + } + } + + fn push_samples(&mut self, samples: Vec, sample_rate: u32) { + self.sample_rate = sample_rate; + self.samples.extend(samples); + } + + fn pop_sample(&mut self) -> Option { + self.samples.pop_front() + } + + fn is_empty(&self) -> bool { + self.samples.is_empty() + } + + fn len(&self) -> usize { + self.samples.len() + } + + fn mark_end(&mut self) { + self.end_of_stream = true; + } + + fn is_finished(&self) -> bool { + self.end_of_stream && self.samples.is_empty() + } +} + +/// Sink qui joue les `AudioSegment` reçus sur la sortie audio standard via cpal. +/// +/// Ce sink : +/// - Lit les chunks audio et les joue en temps réel +/// - Convertit automatiquement tous les formats vers F32 pour cpal +/// - Supporte le changement de sample rate entre les tracks (avec resampling automatique si nécessaire) +/// - Gère TrackBoundary pour des transitions propres +/// - S'arrête proprement sur EndOfStream ou CancellationToken + +// ═══════════════════════════════════════════════════════════════════════════ +/// AudioSinkLogic - Logique métier pure +// ═══════════════════════════════════════════════════════════════════════════ + +/// Logique pure de lecture audio via cpal +pub struct AudioSinkLogic { + volume: f32, +} + +impl AudioSinkLogic { + pub fn new() -> Self { + Self { volume: 1.0 } + } + + pub fn with_volume(volume: f32) -> Self { + Self { + volume: volume.clamp(0.0, 1.0), + } + } +} + +impl Default for AudioSinkLogic { + fn default() -> Self { + Self::new() + } +} + +#[async_trait::async_trait] +impl NodeLogic for AudioSinkLogic { + async fn process( + &mut self, + input: Option>>, + _output: Vec>>, + stop_token: CancellationToken, + ) -> Result<(), AudioError> { + let mut rx = input.expect("AudioSink must have input"); + + tracing::debug!("AudioSinkLogic::process started"); + + // Créer le buffer partagé + let buffer = Arc::new(Mutex::new(SharedBuffer::new())); + let buffer_clone = buffer.clone(); + + // Initialiser cpal + let host = cpal::default_host(); + let device = host + .default_output_device() + .ok_or_else(|| AudioError::ProcessingError("No output device available".to_string()))?; + + tracing::debug!("Using audio device: {}", device.name().unwrap_or_else(|_| "Unknown".to_string())); + + // Obtenir la config par défaut + let config = device + .default_output_config() + .map_err(|e| AudioError::ProcessingError(format!("Failed to get output config: {}", e)))?; + + tracing::debug!( + "Output config: {} channels, {} Hz, {:?}", + config.channels(), + config.sample_rate().0, + config.sample_format() + ); + + let volume = self.volume; + + // Créer le stream avec callback + let stream = device + .build_output_stream( + &config.into(), + move |data: &mut [f32], _: &cpal::OutputCallbackInfo| { + let mut buf = buffer_clone.lock().unwrap(); + + for sample in data.iter_mut() { + *sample = buf.pop_sample().unwrap_or(0.0) * volume; + } + }, + move |err| { + tracing::error!("Audio stream error: {}", err); + }, + None, + ) + .map_err(|e| AudioError::ProcessingError(format!("Failed to build output stream: {}", e)))?; + + // Démarrer le stream + stream + .play() + .map_err(|e| AudioError::ProcessingError(format!("Failed to play stream: {}", e)))?; + + tracing::debug!("AudioSink initialized with volume={}", self.volume); + + // Boucle de réception et traitement des segments + loop { + // Vérifier si l'arrêt a été demandé + if stop_token.is_cancelled() { + tracing::debug!("AudioSinkLogic cancelled"); + drop(stream); // Arrêter le stream + return Ok(()); + } + + // Vérifier si on a fini de jouer + { + let buf = buffer.lock().unwrap(); + if buf.is_finished() { + tracing::debug!("AudioSink: finished playing all samples"); + drop(stream); + return Ok(()); + } + } + + // Recevoir le prochain segment (avec timeout pour vérifier périodiquement le buffer) + let segment = tokio::select! { + result = rx.recv() => { + match result { + Some(seg) => seg, + None => { + tracing::debug!("AudioSinkLogic: input channel closed"); + // Attendre que le buffer se vide + while !buffer.lock().unwrap().is_empty() { + tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; + } + drop(stream); + return Ok(()); + } + } + } + _ = stop_token.cancelled() => { + tracing::debug!("AudioSinkLogic cancelled during recv"); + drop(stream); + return Ok(()); + } + _ = tokio::time::sleep(tokio::time::Duration::from_millis(100)) => { + // Timeout - vérifier le buffer et continuer + continue; + } + }; + + // Traiter selon le type de segment + match &segment.segment { + crate::_AudioSegment::Chunk(chunk) => { + // Convertir le chunk en samples f32 + let samples = chunk_to_f32_samples(chunk)?; + let sample_rate = chunk.sample_rate(); + + if samples.is_empty() { + continue; + } + + // Ajouter au buffer + { + let mut buf = buffer.lock().unwrap(); + buf.push_samples(samples, sample_rate); + } + + tracing::trace!( + "AudioSink: buffered chunk with {} frames at {}Hz (buffer size: {} samples)", + chunk.len(), + sample_rate, + buffer.lock().unwrap().len() + ); + } + crate::_AudioSegment::Sync(marker) => { + match **marker { + SyncMarker::TrackBoundary { .. } => { + tracing::debug!("AudioSink: TrackBoundary received"); + // Le buffer continue automatiquement - pas besoin d'action + } + SyncMarker::EndOfStream => { + tracing::debug!("AudioSink: EndOfStream received, waiting for playback to finish"); + // Marquer la fin et attendre que le buffer se vide + buffer.lock().unwrap().mark_end(); + + // Attendre que tout soit joué + while !buffer.lock().unwrap().is_finished() { + tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; + } + + drop(stream); + return Ok(()); + } + SyncMarker::Error(ref message) => { + tracing::warn!("AudioSink: Error marker received: {}", message); + // Continuer la lecture malgré l'erreur + } + _ => { + // Ignorer les autres sync markers (TopZeroSync, Heartbeat, etc.) + tracing::trace!("AudioSink: ignoring sync marker"); + } + } + } + } + } + } +} + +/// Convertit un AudioChunk en vecteur de samples f32 stéréo (entrelacés) +fn chunk_to_f32_samples(chunk: &AudioChunk) -> Result, AudioError> { + let len = chunk.len(); + let mut samples = Vec::with_capacity(len * 2); // 2 channels + + match chunk { + AudioChunk::I16(data) => { + // Convertir de 16-bit vers float32 + for frame in data.get_frames() { + let left = frame[0] as f32 / 32768.0; + let right = frame[1] as f32 / 32768.0; + samples.push(left); + samples.push(right); + } + } + AudioChunk::I24(data) => { + // Convertir de 24-bit vers float32 + for frame in data.get_frames() { + let left = frame[0].as_i32() as f32 / 8388608.0; // 2^23 + let right = frame[1].as_i32() as f32 / 8388608.0; + samples.push(left); + samples.push(right); + } + } + AudioChunk::I32(data) => { + // Convertir de 32-bit vers float32 + for frame in data.get_frames() { + let left = frame[0] as f32 / 2147483648.0; // 2^31 + let right = frame[1] as f32 / 2147483648.0; + samples.push(left); + samples.push(right); + } + } + AudioChunk::F32(data) => { + // Format natif - copie directe avec clamping + for frame in data.get_frames() { + samples.push(frame[0].clamp(-1.0, 1.0)); + samples.push(frame[1].clamp(-1.0, 1.0)); + } + } + AudioChunk::F64(data) => { + // Convertir de float64 vers float32 + for frame in data.get_frames() { + let left = frame[0].clamp(-1.0, 1.0) as f32; + let right = frame[1].clamp(-1.0, 1.0) as f32; + samples.push(left); + samples.push(right); + } + } + } + + Ok(samples) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// WRAPPER AudioSink - Délègue à Node +// ═══════════════════════════════════════════════════════════════════════════ + +/// AudioSink - Joue les AudioSegment sur la sortie audio standard +/// +/// Ce sink utilise cpal pour la lecture audio multiplateforme. Il accepte +/// tous les formats audio (I16, I24, I32, F32, F64) et les convertit +/// automatiquement en F32 pour la lecture. +/// +/// # Exemple +/// +/// ```no_run +/// use pmoaudio::{AudioSink, FileSource}; +/// use tokio_util::sync::CancellationToken; +/// +/// # async fn example() -> Result<(), Box> { +/// let source = FileSource::new("audio.flac").await?; +/// let mut sink = AudioSink::new(); +/// +/// // Connecter la source au sink +/// source.register(Box::new(sink)); +/// +/// // Démarrer la lecture +/// let stop_token = CancellationToken::new(); +/// source.run(stop_token).await?; +/// # Ok(()) +/// # } +/// ``` +pub struct AudioSink { + inner: Node, +} + +impl AudioSink { + /// Crée un nouveau AudioSink avec volume par défaut (1.0) + pub fn new() -> Self { + Self { + inner: Node::new_with_input(AudioSinkLogic::new(), DEFAULT_CHANNEL_SIZE), + } + } + + /// Crée un nouveau AudioSink avec un volume spécifique (0.0 à 1.0) + pub fn with_volume(volume: f32) -> Self { + Self { + inner: Node::new_with_input(AudioSinkLogic::with_volume(volume), DEFAULT_CHANNEL_SIZE), + } + } + + /// Crée un nouveau AudioSink avec une taille de channel personnalisée + pub fn with_channel_size(channel_size: usize, volume: f32) -> Self { + Self { + inner: Node::new_with_input( + AudioSinkLogic::with_volume(volume), + channel_size, + ), + } + } +} + +impl Default for AudioSink { + fn default() -> Self { + Self::new() + } +} + +#[async_trait::async_trait] +impl AudioPipelineNode for AudioSink { + fn get_tx(&self) -> Option>> { + self.inner.get_tx() + } + + fn register(&mut self, _child: Box) { + panic!("AudioSink is a terminal node and cannot have children"); + } + + async fn run( + self: Box, + stop_token: CancellationToken, + ) -> Result<(), AudioError> { + Box::new(self.inner).run(stop_token).await + } +} + +impl TypedAudioNode for AudioSink { + fn input_type(&self) -> Option { + // AudioSink accepte tous les types audio + Some(TypeRequirement::any()) + } + + fn output_type(&self) -> Option { + // AudioSink est un sink terminal - pas de sortie + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::AudioChunkData; + + #[test] + fn test_chunk_to_f32_samples_from_i16() { + let stereo = vec![[16384i16, -16384i16], [32767i16, -32768i16]]; + let chunk_data = AudioChunkData::new(stereo, 44100, 0.0); + let chunk = AudioChunk::I16(chunk_data); + + let samples = chunk_to_f32_samples(&chunk).unwrap(); + assert_eq!(samples.len(), 4); + // 16384 / 32768 = 0.5 + assert!((samples[0] - 0.5).abs() < 0.001); + assert!((samples[1] + 0.5).abs() < 0.001); + // 32767 / 32768 ≈ 0.999969 + assert!((samples[2] - 0.999969).abs() < 0.001); + // -32768 / 32768 = -1.0 + assert!((samples[3] + 1.0).abs() < 0.001); + } + + #[test] + fn test_chunk_to_f32_samples_from_f32() { + let stereo = vec![[0.5f32, -0.5f32], [1.0f32, -1.0f32]]; + let chunk_data = AudioChunkData::new(stereo, 48000, 0.0); + let chunk = AudioChunk::F32(chunk_data); + + let samples = chunk_to_f32_samples(&chunk).unwrap(); + assert_eq!(samples, vec![0.5, -0.5, 1.0, -1.0]); + } + + #[test] + fn test_audio_sink_creation() { + let sink = AudioSink::new(); + assert!(sink.get_tx().is_some()); + assert!(sink.input_type().is_some()); + assert!(sink.output_type().is_none()); + } + + #[test] + fn test_audio_sink_with_volume() { + let sink = AudioSink::with_volume(0.5); + assert!(sink.get_tx().is_some()); + } + + #[test] + #[should_panic(expected = "terminal node")] + fn test_audio_sink_cannot_have_children() { + let mut sink = AudioSink::new(); + let another_sink = AudioSink::new(); + sink.register(Box::new(another_sink)); + } + + #[test] + fn test_shared_buffer() { + let mut buffer = SharedBuffer::new(); + + assert!(buffer.is_empty()); + assert!(!buffer.is_finished()); + + buffer.push_samples(vec![0.5, -0.5, 1.0], 44100); + assert_eq!(buffer.len(), 3); + + assert_eq!(buffer.pop_sample(), Some(0.5)); + assert_eq!(buffer.pop_sample(), Some(-0.5)); + assert_eq!(buffer.len(), 1); + + buffer.mark_end(); + assert_eq!(buffer.pop_sample(), Some(1.0)); + assert!(buffer.is_finished()); + } +} diff --git a/pmoaudio/src/nodes/mod.rs b/pmoaudio/src/nodes/mod.rs index 905da49a..7897ecf8 100755 --- a/pmoaudio/src/nodes/mod.rs +++ b/pmoaudio/src/nodes/mod.rs @@ -19,6 +19,7 @@ pub const DEFAULT_CHANNEL_SIZE: usize = 16; pub const DEFAULT_CHUNK_DURATION_MS: f64 = 50.0; // Modules actifs +pub mod audio_sink; pub mod converter_nodes; pub mod file_source; pub mod flac_file_sink;