diff --git a/.gitignore b/.gitignore index 08ebb80f..2eec05c3 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,4 @@ upmpdcli/ /*.xml test_upnp*.cargo/ .cargo/ +setup-env.sh diff --git a/Cargo.lock b/Cargo.lock index 011554dd..9ec2bd25 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2412,6 +2412,35 @@ dependencies = [ "jni-sys", ] +[[package]] +name = "netstat2" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" +dependencies = [ + "bitflags 2.10.0", + "jni-sys", + "log", + "ndk-sys", + "num_enum", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys", +] + [[package]] name = "netlink-packet-core" version = "0.7.0" @@ -2852,6 +2881,7 @@ version = "0.1.0" dependencies = [ "async-trait", "bytemuck", + "cpal", "futures-util", "paste", "pmoflac", @@ -3289,16 +3319,6 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn 2.0.108", -] - [[package]] name = "proc-macro-crate" version = "3.4.0" diff --git a/INSTALL_LIBSOXR.md b/INSTALL_LIBSOXR.md index 7c3ae88d..a3c4129f 100644 --- a/INSTALL_LIBSOXR.md +++ b/INSTALL_LIBSOXR.md @@ -5,11 +5,11 @@ Ce document explique comment installer les dépendances système de `pmoaudio` l ## Dépendances requises 1. **libsoxr** - Nécessaire pour `ResamplingNode` (resampling audio haute qualité) -2. **libasound2** (ALSA) - Nécessaire pour `AudioSink` via rodio (lecture audio sur Linux) +2. **libasound2** (ALSA) - Nécessaire pour `AudioSink` via cpal (lecture audio sur Linux) ## Contexte -Les crates `soxr` et `rodio` nécessitent des bibliothèques système. Dans un environnement sans droits sudo, voici comment les installer localement. +Les crates `soxr` et `cpal` nécessitent des bibliothèques système. Dans un environnement sans droits sudo (comme Claude Code), voici comment les installer localement. ## Méthode : Installation locale via apt-get download @@ -22,7 +22,8 @@ cd ~/.local apt-get download libsoxr-dev libsoxr0 # Pour ALSA (AudioSink) -apt-get download libasound2-dev +# Note: libasound2t64 contient la bibliothèque partagée, libasound2-dev les headers +apt-get download libasound2-dev libasound2t64 ``` Cela télécharge les fichiers `.deb` sans les installer système-wide. @@ -36,6 +37,7 @@ dpkg -x libsoxr0_*.deb . # Extraire ALSA dpkg -x libasound2-dev_*.deb . +dpkg -x libasound2t64_*.deb . ``` Les fichiers sont extraits dans `~/.local/usr/lib/x86_64-linux-gnu/` et `~/.local/usr/include/`. @@ -45,11 +47,12 @@ Les fichiers sont extraits dans `~/.local/usr/lib/x86_64-linux-gnu/` et `~/.loca Ajouter à votre `~/.bashrc` ou exporter dans votre session : ```bash -export PKG_CONFIG_PATH="/root/.local/usr/lib/x86_64-linux-gnu/pkgconfig:$PKG_CONFIG_PATH" -export LD_LIBRARY_PATH="/root/.local/usr/lib/x86_64-linux-gnu:$LD_LIBRARY_PATH" +export PKG_CONFIG_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu/pkgconfig:$PKG_CONFIG_PATH" +export LD_LIBRARY_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu:$LD_LIBRARY_PATH" +export RUSTFLAGS="-L $HOME/.local/usr/lib/x86_64-linux-gnu" ``` -**IMPORTANT:** Remplacer `/root/` par le chemin de votre home directory (`$HOME` ou `~`). +**IMPORTANT:** Ces variables doivent être définies dans chaque session où vous compilez le projet. ### 4. Vérifier l'installation @@ -145,14 +148,111 @@ export LD_LIBRARY_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu:$LD_LIBRARY_PATH" cargo test ``` -## Notes pour Claude Code sessions +## Guide complet pour environnement Claude Code -Pour les futures sessions Claude : +### Configuration initiale (à faire une seule fois) -1. Exporter les variables d'environnement en début de session -2. NE PAS créer de fichiers `.cargo/config.toml` dans le projet -3. Si nécessaire pour les tests, les créer localement mais ne pas les committer -4. Documenter toute difficulté d'installation ici +Dans une session Claude Code (https://claude.ai/code), vous n'avez pas de droits sudo. Suivez ces étapes : + +#### 1. Installation des dépendances + +```bash +# Créer le répertoire local +mkdir -p ~/.local +cd ~/.local + +# Télécharger tous les packages nécessaires +apt-get download libsoxr-dev libsoxr0 libasound2-dev libasound2t64 + +# Extraire tous les packages +dpkg -x libsoxr-dev_*.deb . +dpkg -x libsoxr0_*.deb . +dpkg -x libasound2-dev_*.deb . +dpkg -x libasound2t64_*.deb . + +# Retourner au projet +cd /home/user/pmomusic +``` + +#### 2. Configuration des variables d'environnement + +**IMPORTANT:** Ces variables doivent être exportées dans CHAQUE session Claude Code avant de compiler : + +```bash +export PKG_CONFIG_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu/pkgconfig:$PKG_CONFIG_PATH" +export LD_LIBRARY_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu:$LD_LIBRARY_PATH" +export RUSTFLAGS="-L $HOME/.local/usr/lib/x86_64-linux-gnu" +``` + +**Astuce :** Copier ces trois lignes dans un fichier `setup-env.sh` à la racine du projet : + +```bash +cat > setup-env.sh << 'EOF' +export PKG_CONFIG_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu/pkgconfig:$PKG_CONFIG_PATH" +export LD_LIBRARY_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu:$LD_LIBRARY_PATH" +export RUSTFLAGS="-L $HOME/.local/usr/lib/x86_64-linux-gnu" +EOF +``` + +Puis dans chaque session : + +```bash +source setup-env.sh +``` + +⚠️ **NE PAS committer `setup-env.sh`** - ajouter au `.gitignore` + +#### 3. Vérifier l'installation + +```bash +# Vérifier que pkg-config trouve les bibliothèques +pkg-config --libs --cflags soxr +pkg-config --libs --cflags alsa + +# Devrait afficher quelque chose comme : +# -I/root/.local/usr/include -L/root/.local/usr/lib/x86_64-linux-gnu -lsoxr +# -I/root/.local/usr/include -L/root/.local/usr/lib/x86_64-linux-gnu -lasound +``` + +#### 4. Compiler et tester + +```bash +# Compiler le workspace complet +cargo build + +# Tester l'exemple play_and_cache de pmoparadise +cargo run --package pmoparadise --example play_and_cache --features full -- 0 +``` + +### Workflow pour chaque nouvelle session + +À chaque fois que vous démarrez une nouvelle session Claude Code : + +1. **Exporter les variables d'environnement** (ou `source setup-env.sh`) +2. Compiler avec `cargo build` +3. Exécuter les exemples ou tests + +**IMPORTANT :** Si vous oubliez d'exporter les variables, vous obtiendrez des erreurs comme : +``` +error: failed to run custom build command for `soxr-sys` +Package 'soxr' was not found in the pkg-config search path +``` + +ou + +``` +rust-lld: error: unable to find library -lasound +``` + +Solution : Exporter les variables et recompiler. + +### Notes importantes + +- ✅ Les dépendances installées dans `~/.local` persistent entre les sessions +- ✅ Les variables d'environnement doivent être réexportées à chaque nouvelle session +- ❌ NE JAMAIS créer de fichiers `.cargo/config.toml` dans le projet (chemins spécifiques) +- ❌ NE JAMAIS committer `setup-env.sh` (configuration locale) +- 💡 Sur macOS (via Homebrew) : seul `libsoxr` est nécessaire (pas d'ALSA) ## Références diff --git a/INSTALL_NOTES.md b/INSTALL_NOTES.md index 725804b3..cf2fad54 100644 --- a/INSTALL_NOTES.md +++ b/INSTALL_NOTES.md @@ -2,30 +2,34 @@ ## Prérequis système -### libsoxr (obligatoire pour pmoaudio) +### 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 +sudo apt-get install libsoxr-dev libasound2-dev # Fedora/RHEL -sudo dnf install libsoxr-devel +sudo dnf install libsoxr-devel alsa-lib-devel # Arch Linux -sudo pacman -S libsoxr +sudo pacman -S libsoxr alsa-lib -# macOS (Homebrew) +# macOS (Homebrew) - ALSA non nécessaire sur macOS brew install libsoxr # Alpine Linux -apk add soxr-dev +apk add soxr-dev alsa-lib-dev ``` -**Sans privilèges root** : Si vous n'avez pas les droits sudo, demandez à l'administrateur système d'installer `libsoxr-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`. --- diff --git a/pmoaudio/Cargo.toml b/pmoaudio/Cargo.toml index 6adfa0ba..7d569e02 100755 --- a/pmoaudio/Cargo.toml +++ b/pmoaudio/Cargo.toml @@ -19,7 +19,7 @@ soxr = "0.6.0" bytemuck = "1.24.0" reqwest = { version = "0.12", features = ["stream"] } tracing = "0.1" -rodio = "0.19" +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/nodes/audio_sink.rs b/pmoaudio/src/nodes/audio_sink.rs index ec97a8d3..37126896 100644 --- a/pmoaudio/src/nodes/audio_sink.rs +++ b/pmoaudio/src/nodes/audio_sink.rs @@ -1,34 +1,169 @@ use crate::{ + dsp::{i16_stereo_to_pairs_f32, i24_as_i32_stereo_to_pairs_f32, i32_stereo_to_interleaved_f32}, nodes::{AudioError, TypedAudioNode, DEFAULT_CHANNEL_SIZE}, pipeline::{Node, NodeLogic}, type_constraints::TypeRequirement, - AudioChunk, AudioPipelineNode, AudioSegment, SyncMarker, -}; -use rodio::{OutputStream, Sink}; -use std::sync::{ - mpsc as std_mpsc, - Arc, + AudioChunk, AudioPipelineNode, AudioSegment, BitDepth, SyncMarker, }; +use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; +use std::collections::VecDeque; +use std::sync::mpsc as std_mpsc; +use std::sync::{Arc, Mutex}; use std::thread; use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; -/// Commandes envoyées au thread rodio -enum RodioCommand { - AppendSamples { - samples: Vec, - sample_rate: u32, - }, - WaitUntilEnd, - Stop, +/// Buffer partagé entre le thread async et le callback cpal +/// Stocke les AudioChunk bruts et un buffer intermédiaire pour les samples convertis +struct SharedBuffer { + /// Queue d'AudioChunk à traiter + chunks: VecDeque>, + /// Buffer intermédiaire de samples convertis au format hardware (entrelacé) + converted_samples: VecDeque, + /// Flag pour indiquer EndOfStream + end_of_stream: bool, } -/// Sink qui joue les `AudioSegment` reçus sur la sortie audio standard via rodio. +impl SharedBuffer { + fn new() -> Self { + Self { + chunks: VecDeque::new(), + converted_samples: VecDeque::new(), + end_of_stream: false, + } + } + + fn push_chunk(&mut self, chunk: Arc) { + self.chunks.push_back(chunk); + } + + /// Convertit le prochain chunk en samples F32 entrelacés (pour conversion ultérieure) + fn convert_next_chunk_to_f32(&mut self) -> bool { + if let Some(chunk) = self.chunks.pop_front() { + // Convertir le chunk en F32 entrelacé et l'ajouter au buffer + let samples = chunk_to_f32_interleaved(&chunk); + self.converted_samples.extend(samples); + true + } else { + false + } + } + + fn pop_sample_f32(&mut self) -> Option { + if self.converted_samples.is_empty() { + // Essayer de convertir le prochain chunk + self.convert_next_chunk_to_f32(); + } + self.converted_samples.pop_front() + } + + fn is_empty(&self) -> bool { + self.chunks.is_empty() && self.converted_samples.is_empty() + } + + fn mark_end(&mut self) { + self.end_of_stream = true; + } + + fn is_finished(&self) -> bool { + self.end_of_stream && self.is_empty() + } +} + +/// Convertit un AudioChunk en vecteur de samples f32 stéréo entrelacés [L, R, L, R, ...] +/// Utilise les fonctions optimisées du module dsp +fn chunk_to_f32_interleaved(chunk: &AudioChunk) -> Vec { + let len = chunk.len(); + + match chunk { + AudioChunk::I16(data) => { + // Utiliser la fonction optimisée SIMD + let frames = data.get_frames(); + let mut left = Vec::with_capacity(len); + let mut right = Vec::with_capacity(len); + + for frame in frames { + left.push(frame[0]); + right.push(frame[1]); + } + + let mut out_pairs = vec![[0.0f32, 0.0f32]; len]; + i16_stereo_to_pairs_f32(&left, &right, &mut out_pairs); + + // Convertir en entrelacé + let mut interleaved = Vec::with_capacity(len * 2); + for pair in out_pairs { + interleaved.push(pair[0]); + interleaved.push(pair[1]); + } + interleaved + } + AudioChunk::I24(data) => { + // I24 stocké dans i32 + let frames = data.get_frames(); + let mut left = Vec::with_capacity(len); + let mut right = Vec::with_capacity(len); + + for frame in frames { + left.push(frame[0].as_i32()); + right.push(frame[1].as_i32()); + } + + let mut out_pairs = vec![[0.0f32, 0.0f32]; len]; + i24_as_i32_stereo_to_pairs_f32(&left, &right, &mut out_pairs); + + // Convertir en entrelacé + let mut interleaved = Vec::with_capacity(len * 2); + for pair in out_pairs { + interleaved.push(pair[0]); + interleaved.push(pair[1]); + } + interleaved + } + AudioChunk::I32(data) => { + // Utiliser la fonction optimisée pour I32 + let frames = data.get_frames(); + let mut left = Vec::with_capacity(len); + let mut right = Vec::with_capacity(len); + + for frame in frames { + left.push(frame[0]); + right.push(frame[1]); + } + + let mut out_interleaved = vec![0.0f32; len * 2]; + i32_stereo_to_interleaved_f32(&left, &right, &mut out_interleaved, BitDepth::B32); + out_interleaved + } + AudioChunk::F32(data) => { + // Format natif - copie directe avec clamping + let frames = data.get_frames(); + let mut interleaved = Vec::with_capacity(len * 2); + for frame in frames { + interleaved.push(frame[0].clamp(-1.0, 1.0)); + interleaved.push(frame[1].clamp(-1.0, 1.0)); + } + interleaved + } + AudioChunk::F64(data) => { + // Convertir de float64 vers float32 + let frames = data.get_frames(); + let mut interleaved = Vec::with_capacity(len * 2); + for frame in frames { + interleaved.push(frame[0].clamp(-1.0, 1.0) as f32); + interleaved.push(frame[1].clamp(-1.0, 1.0) as f32); + } + interleaved + } + } +} + +/// 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 I16 pour rodio -/// - Supporte le changement de sample rate entre les tracks +/// - Détecte automatiquement le format hardware (I16, F32, U16) +/// - Accepte tous les formats AudioChunk en entrée +/// - Convertit en utilisant les fonctions optimisées SIMD du module dsp /// - Gère TrackBoundary pour des transitions propres /// - S'arrête proprement sur EndOfStream ou CancellationToken @@ -36,20 +171,12 @@ enum RodioCommand { /// AudioSinkLogic - Logique métier pure // ═══════════════════════════════════════════════════════════════════════════ -/// Logique pure de lecture audio via rodio -pub struct AudioSinkLogic { - volume: f32, -} +/// Logique pure de lecture audio via cpal +pub struct AudioSinkLogic {} 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), - } + Self {} } } @@ -71,132 +198,221 @@ impl NodeLogic for AudioSinkLogic { tracing::debug!("AudioSinkLogic::process started"); - // Créer un channel pour communiquer avec le thread rodio - let (cmd_tx, cmd_rx) = std_mpsc::channel::(); + // Créer le buffer partagé + let buffer = Arc::new(Mutex::new(SharedBuffer::new())); + let buffer_clone = buffer.clone(); - // Spawner un thread dédié pour rodio (car OutputStream n'est pas Send) - let volume = self.volume; - let rodio_thread = thread::spawn(move || { - // Créer OutputStream et Sink dans le thread - let (_stream, stream_handle) = match OutputStream::try_default() { - Ok(s) => s, - Err(e) => { - tracing::error!("Failed to create audio output: {}", e); - return; + // 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)))?; + + let sample_format = config.sample_format(); + let sample_rate = config.sample_rate().0; + let channels = config.channels(); + + tracing::debug!( + "Output config: {} channels, {} Hz, {:?}", + channels, + sample_rate, + sample_format + ); + + // Créer un channel pour commander le thread du stream + let (stream_cmd_tx, stream_cmd_rx) = std_mpsc::channel::(); + + // Spawn un thread dédié pour le stream cpal (car Stream n'est pas Send) + let stream_thread = thread::spawn(move || { + // Créer le stream selon le format hardware + let stream = match sample_format { + cpal::SampleFormat::I16 => { + tracing::debug!("Using I16 output format"); + match device.build_output_stream( + &config.into(), + move |data: &mut [i16], _: &cpal::OutputCallbackInfo| { + let mut buf = buffer_clone.lock().unwrap(); + + // Remplir avec des samples convertis + for sample in data.iter_mut() { + let f32_sample = buf.pop_sample_f32().unwrap_or(0.0); + // Convertir F32 [-1.0, 1.0] → I16 + *sample = (f32_sample * 32767.0).clamp(-32768.0, 32767.0) as i16; + } + }, + move |err| { + tracing::error!("Audio stream error: {}", err); + }, + None, + ) { + Ok(s) => s, + Err(e) => { + tracing::error!("Failed to build I16 stream: {}", e); + return; + } + } + } + cpal::SampleFormat::U16 => { + tracing::debug!("Using U16 output format"); + match device.build_output_stream( + &config.into(), + move |data: &mut [u16], _: &cpal::OutputCallbackInfo| { + let mut buf = buffer_clone.lock().unwrap(); + + for sample in data.iter_mut() { + let f32_sample = buf.pop_sample_f32().unwrap_or(0.0); + // Convertir F32 [-1.0, 1.0] → U16 [0, 65535] + *sample = ((f32_sample + 1.0) * 32767.5).clamp(0.0, 65535.0) as u16; + } + }, + move |err| { + tracing::error!("Audio stream error: {}", err); + }, + None, + ) { + Ok(s) => s, + Err(e) => { + tracing::error!("Failed to build U16 stream: {}", e); + return; + } + } + } + cpal::SampleFormat::F32 => { + tracing::debug!("Using F32 output format"); + match device.build_output_stream( + &config.into(), + move |data: &mut [f32], _: &cpal::OutputCallbackInfo| { + let mut buf = buffer_clone.lock().unwrap(); + + for sample in data.iter_mut() { + *sample = buf.pop_sample_f32().unwrap_or(0.0); + } + }, + move |err| { + tracing::error!("Audio stream error: {}", err); + }, + None, + ) { + Ok(s) => s, + Err(e) => { + tracing::error!("Failed to build F32 stream: {}", e); + return; + } + } + } + _ => { + tracing::error!("Unsupported sample format: {:?}", sample_format); + return; } }; - let sink = match Sink::try_new(&stream_handle) { - Ok(s) => s, - Err(e) => { - tracing::error!("Failed to create sink: {}", e); - return; - } - }; - - sink.set_volume(volume); - tracing::debug!("Rodio thread initialized with volume={}", volume); - - // Boucle de traitement des commandes - while let Ok(cmd) = cmd_rx.recv() { - match cmd { - RodioCommand::AppendSamples { samples, sample_rate } => { - let buffer = rodio::buffer::SamplesBuffer::new(2, sample_rate, samples); - sink.append(buffer); - } - RodioCommand::WaitUntilEnd => { - sink.sleep_until_end(); - break; - } - RodioCommand::Stop => { - sink.stop(); - break; - } - } + // Démarrer le stream + if let Err(e) = stream.play() { + tracing::error!("Failed to start stream: {}", e); + return; } - tracing::debug!("Rodio thread exiting"); + tracing::debug!("Stream thread started"); + + // Attendre la commande d'arrêt + let _ = stream_cmd_rx.recv(); + + // Le stream se fermera automatiquement quand il sera droppé + tracing::debug!("Stream thread exiting"); }); - tracing::debug!("AudioSink initialized with volume={}", self.volume); + tracing::debug!("AudioSink initialized with format {:?}", sample_format); // 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"); - let _ = cmd_tx.send(RodioCommand::Stop); - let _ = rodio_thread.join(); + let _ = stream_cmd_tx.send(true); + let _ = stream_thread.join(); return Ok(()); } - // Recevoir le prochain segment + // Vérifier si on a fini de jouer + { + let buf = buffer.lock().unwrap(); + if buf.is_finished() { + tracing::debug!("AudioSink: finished playing all samples"); + let _ = stream_cmd_tx.send(true); + let _ = stream_thread.join(); + 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"); - let _ = cmd_tx.send(RodioCommand::Stop); - let _ = rodio_thread.join(); + // Attendre que le buffer se vide + while !buffer.lock().unwrap().is_empty() { + tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; + } + let _ = stream_cmd_tx.send(true); + let _ = stream_thread.join(); return Ok(()); } } } _ = stop_token.cancelled() => { tracing::debug!("AudioSinkLogic cancelled during recv"); - let _ = cmd_tx.send(RodioCommand::Stop); - let _ = rodio_thread.join(); + let _ = stream_cmd_tx.send(true); + let _ = stream_thread.join(); 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 rodio - let samples = chunk_to_i16_samples(chunk)?; - let sample_rate = chunk.sample_rate(); - - if samples.is_empty() { - continue; + // Ajouter le chunk au buffer (pas de conversion ici) + { + let mut buf = buffer.lock().unwrap(); + buf.push_chunk(chunk.clone()); } - // Envoyer au thread rodio - cmd_tx - .send(RodioCommand::AppendSamples { - samples, - sample_rate, - }) - .map_err(|_| { - AudioError::ProcessingError("Rodio thread died".to_string()) - })?; - tracing::trace!( - "AudioSink: sent chunk with {} frames at {}Hz", + "AudioSink: buffered chunk with {} frames at {}Hz", chunk.len(), - sample_rate + chunk.sample_rate() ); } crate::_AudioSegment::Sync(marker) => { match **marker { SyncMarker::TrackBoundary { .. } => { tracing::debug!("AudioSink: TrackBoundary received"); - // Le sink continue automatiquement - pas besoin d'attendre - // Le buffer interne de rodio gère la transition + // Le buffer continue automatiquement - pas besoin d'action } SyncMarker::EndOfStream => { tracing::debug!("AudioSink: EndOfStream received, waiting for playback to finish"); - // Demander au thread rodio d'attendre la fin - cmd_tx - .send(RodioCommand::WaitUntilEnd) - .map_err(|_| { - AudioError::ProcessingError("Rodio thread died".to_string()) - })?; - // Attendre que le thread termine - rodio_thread.join().map_err(|_| { - AudioError::ProcessingError("Failed to join rodio thread".to_string()) - })?; + // 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; + } + + let _ = stream_cmd_tx.send(true); + let _ = stream_thread.join(); return Ok(()); } SyncMarker::Error(ref message) => { @@ -204,7 +420,7 @@ impl NodeLogic for AudioSinkLogic { // Continuer la lecture malgré l'erreur } _ => { - // Ignorer les autres sync markers (TopZeroSync, Heartbeat, etc.) + // Ignorer les autres sync markers tracing::trace!("AudioSink: ignoring sync marker"); } } @@ -214,69 +430,23 @@ impl NodeLogic for AudioSinkLogic { } } -/// Convertit un AudioChunk en vecteur de samples i16 stéréo -fn chunk_to_i16_samples(chunk: &AudioChunk) -> Result, AudioError> { - let len = chunk.len(); - let mut samples = Vec::with_capacity(len * 2); // 2 channels - - match chunk { - AudioChunk::I16(data) => { - // Format natif - copie directe - for frame in data.get_frames() { - samples.push(frame[0]); - samples.push(frame[1]); - } - } - AudioChunk::I24(data) => { - // Convertir de 24-bit vers 16-bit - for frame in data.get_frames() { - let left = (frame[0].as_i32() >> 8) as i16; - let right = (frame[1].as_i32() >> 8) as i16; - samples.push(left); - samples.push(right); - } - } - AudioChunk::I32(data) => { - // Convertir de 32-bit vers 16-bit - for frame in data.get_frames() { - let left = (frame[0] >> 16) as i16; - let right = (frame[1] >> 16) as i16; - samples.push(left); - samples.push(right); - } - } - AudioChunk::F32(data) => { - // Convertir de float32 vers 16-bit - for frame in data.get_frames() { - let left = (frame[0].clamp(-1.0, 1.0) * 32767.0) as i16; - let right = (frame[1].clamp(-1.0, 1.0) * 32767.0) as i16; - samples.push(left); - samples.push(right); - } - } - AudioChunk::F64(data) => { - // Convertir de float64 vers 16-bit - for frame in data.get_frames() { - let left = (frame[0].clamp(-1.0, 1.0) * 32767.0) as i16; - let right = (frame[1].clamp(-1.0, 1.0) * 32767.0) as i16; - 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 rodio pour la lecture audio multiplateforme. Il accepte -/// tous les formats audio (I16, I24, I32, F32, F64) et les convertit -/// automatiquement en I16 pour la lecture. +/// Ce sink utilise cpal pour la lecture audio multiplateforme. Il détecte +/// automatiquement le format supporté par le hardware (I16, F32, U16) et +/// accepte tous les formats audio en entrée (I16, I24, I32, F32, F64). +/// +/// Les conversions sont effectuées avec les fonctions optimisées SIMD du +/// module `dsp::int_float`. +/// +/// # Volume +/// +/// Ce sink ne gère PAS le volume. Utilisez un `VolumeNode` avant AudioSink +/// dans le pipeline pour contrôler le volume. /// /// # Exemple /// @@ -285,15 +455,15 @@ fn chunk_to_i16_samples(chunk: &AudioChunk) -> Result, AudioError> { /// use tokio_util::sync::CancellationToken; /// /// # async fn example() -> Result<(), Box> { -/// let source = FileSource::new("audio.flac").await?; -/// let mut sink = AudioSink::new(); +/// let mut source = FileSource::new("audio.flac").await?; +/// let 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?; +/// Box::new(source).run(stop_token).await?; /// # Ok(()) /// # } /// ``` @@ -302,27 +472,17 @@ pub struct AudioSink { } impl AudioSink { - /// Crée un nouveau AudioSink avec volume par défaut (1.0) + /// Crée un nouveau AudioSink 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 { + pub fn with_channel_size(channel_size: usize) -> Self { Self { - inner: Node::new_with_input( - AudioSinkLogic::with_volume(volume), - channel_size, - ), + inner: Node::new_with_input(AudioSinkLogic::new(), channel_size), } } } @@ -369,32 +529,26 @@ mod tests { use crate::AudioChunkData; #[test] - fn test_chunk_to_i16_samples_from_i16() { - let stereo = vec![[100i16, 200i16], [300i16, 400i16]]; + fn test_chunk_to_f32_interleaved_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_i16_samples(&chunk).unwrap(); - assert_eq!(samples, vec![100, 200, 300, 400]); + let samples = chunk_to_f32_interleaved(&chunk); + assert_eq!(samples.len(), 4); + // Vérifier que les valeurs sont normalisées + assert!((samples[0] - 0.5).abs() < 0.01); + assert!((samples[1] + 0.5).abs() < 0.01); } #[test] - fn test_chunk_to_i16_samples_from_f32() { - use crate::I24; + fn test_chunk_to_f32_interleaved_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_i16_samples(&chunk).unwrap(); - // 0.5 * 32767 ≈ 16383 - // -0.5 * 32767 ≈ -16383 - // 1.0 * 32767 = 32767 - // -1.0 * 32767 = -32767 - assert_eq!(samples.len(), 4); - assert!((samples[0] - 16383).abs() <= 1); - assert!((samples[1] + 16383).abs() <= 1); - assert_eq!(samples[2], 32767); - assert_eq!(samples[3], -32767); + let samples = chunk_to_f32_interleaved(&chunk); + assert_eq!(samples, vec![0.5, -0.5, 1.0, -1.0]); } #[test] @@ -405,12 +559,6 @@ mod tests { 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() { @@ -418,4 +566,28 @@ mod tests { 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()); + + // Test avec un chunk F32 + let stereo = vec![[0.5f32, -0.5f32]]; + let chunk_data = AudioChunkData::new(stereo, 48000, 0.0); + let chunk = Arc::new(AudioChunk::F32(chunk_data)); + + buffer.push_chunk(chunk); + assert!(!buffer.is_empty()); + + // Pop quelques samples + assert_eq!(buffer.pop_sample_f32(), Some(0.5)); + assert_eq!(buffer.pop_sample_f32(), Some(-0.5)); + assert_eq!(buffer.pop_sample_f32(), None); + + buffer.mark_end(); + assert!(buffer.is_finished()); + } } diff --git a/pmoutils/Cargo.toml b/pmoutils/Cargo.toml index 2c9da0e5..cd1380c2 100644 --- a/pmoutils/Cargo.toml +++ b/pmoutils/Cargo.toml @@ -6,6 +6,6 @@ edition = "2024" [dependencies] get_if_addrs = "0.5.3" os_info = "3.8" -netstat2 = "0.11.2" +netstat2 = "0.11" sysinfo = "0.30" users = "0.11"