diff --git a/Cargo.lock b/Cargo.lock index a56a65a6..16f3b90d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -49,6 +49,28 @@ dependencies = [ "equator", ] +[[package]] +name = "alsa" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" +dependencies = [ + "alsa-sys", + "bitflags 2.10.0", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527" +dependencies = [ + "libc", + "pkg-config", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -555,6 +577,12 @@ dependencies = [ "shlex", ] +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + [[package]] name = "cexpr" version = "0.6.0" @@ -635,6 +663,16 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -728,6 +766,49 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "coreaudio-rs" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace" +dependencies = [ + "bitflags 1.3.2", + "core-foundation-sys", + "coreaudio-sys", +] + +[[package]] +name = "coreaudio-sys" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ceec7a6067e62d6f931a2baf6f3a751f4a892595bcec1461a3c94ef9949864b6" +dependencies = [ + "bindgen", +] + +[[package]] +name = "cpal" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779" +dependencies = [ + "alsa", + "core-foundation-sys", + "coreaudio-rs", + "dasp_sample", + "jni", + "js-sys", + "libc", + "mach2", + "ndk", + "ndk-context", + "oboe", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows 0.54.0", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -817,6 +898,12 @@ dependencies = [ "typenum", ] +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + [[package]] name = "data-encoding" version = "2.9.0" @@ -1472,6 +1559,12 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hound" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" + [[package]] name = "htmlescape" version = "0.3.1" @@ -1881,6 +1974,28 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + [[package]] name = "jobserver" version = "0.1.34" @@ -2268,6 +2383,35 @@ dependencies = [ "tempfile", ] +[[package]] +name = "ndk" +version = "0.8.0" +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" @@ -2461,6 +2605,51 @@ dependencies = [ "libc", ] +[[package]] +name = "num_enum" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "oboe" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb" +dependencies = [ + "jni", + "ndk", + "ndk-context", + "num-derive 0.4.2", + "num-traits", + "oboe-sys", +] + +[[package]] +name = "oboe-sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d" +dependencies = [ + "cc", +] + [[package]] name = "ogg" version = "0.8.0" @@ -2668,6 +2857,7 @@ dependencies = [ "pmoflac", "pmometadata", "reqwest", + "rodio", "soxr", "tempfile", "tokio", @@ -3108,6 +3298,15 @@ dependencies = [ "syn 2.0.108", ] +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit 0.23.7", +] + [[package]] name = "proc-macro2" version = "1.0.103" @@ -3487,6 +3686,20 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rodio" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6006a627c1a38d37f3d3a85c6575418cfe34a5392d60a686d0071e1c8d427acb" +dependencies = [ + "claxon", + "cpal", + "hound", + "lewton", + "symphonia", + "thiserror 1.0.69", +] + [[package]] name = "rusqlite" version = "0.37.0" @@ -4155,7 +4368,7 @@ dependencies = [ "ntapi", "once_cell", "rayon", - "windows", + "windows 0.52.0", ] [[package]] @@ -5014,6 +5227,16 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" +dependencies = [ + "windows-core 0.54.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.52.0" @@ -5023,6 +5246,16 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-core" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" +dependencies = [ + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -5081,6 +5314,15 @@ dependencies = [ "windows-strings 0.4.2", ] +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -5117,6 +5359,15 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -5153,6 +5404,21 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -5186,6 +5452,12 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -5198,6 +5470,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -5210,6 +5488,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -5234,6 +5518,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -5246,6 +5536,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -5258,6 +5554,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -5270,6 +5572,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" diff --git a/INSTALL_LIBSOXR.md b/INSTALL_LIBSOXR.md index 2192d311..7c3ae88d 100644 --- a/INSTALL_LIBSOXR.md +++ b/INSTALL_LIBSOXR.md @@ -1,10 +1,15 @@ -# Installation de libsoxr sans droits sudo +# Installation des dépendances système sans droits sudo -Ce document explique comment installer libsoxr localement sans privilèges administrateur, nécessaire pour compiler `pmoaudio` avec le support de resampling. +Ce document explique comment installer les dépendances système de `pmoaudio` localement sans privilèges administrateur. + +## 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) ## Contexte -Le crate `soxr` (utilisé par `ResamplingNode`) nécessite la bibliothèque système `libsoxr`. Dans un environnement sans droits sudo, voici comment l'installer localement. +Les crates `soxr` et `rodio` nécessitent des bibliothèques système. Dans un environnement sans droits sudo, voici comment les installer localement. ## Méthode : Installation locale via apt-get download @@ -12,7 +17,12 @@ Le crate `soxr` (utilisé par `ResamplingNode`) nécessite la bibliothèque syst ```bash cd ~/.local + +# Pour libsoxr (ResamplingNode) apt-get download libsoxr-dev libsoxr0 + +# Pour ALSA (AudioSink) +apt-get download libasound2-dev ``` Cela télécharge les fichiers `.deb` sans les installer système-wide. @@ -20,8 +30,12 @@ Cela télécharge les fichiers `.deb` sans les installer système-wide. ### 2. Extraire les packages ```bash +# Extraire libsoxr dpkg -x libsoxr-dev_*.deb . dpkg -x libsoxr0_*.deb . + +# Extraire ALSA +dpkg -x libasound2-dev_*.deb . ``` Les fichiers sont extraits dans `~/.local/usr/lib/x86_64-linux-gnu/` et `~/.local/usr/include/`. @@ -40,12 +54,20 @@ export LD_LIBRARY_PATH="/root/.local/usr/lib/x86_64-linux-gnu:$LD_LIBRARY_PATH" ### 4. Vérifier l'installation ```bash +# Vérifier libsoxr pkg-config --libs --cflags soxr + +# Vérifier ALSA +pkg-config --libs --cflags alsa ``` -Devrait retourner : +Devrait retourner quelque chose comme : ``` +# soxr -I/root/.local/usr/include -L/root/.local/usr/lib/x86_64-linux-gnu -lsoxr + +# alsa +-I/root/.local/usr/include -L/root/.local/usr/lib/x86_64-linux-gnu -lasound ``` ## Utilisation avec Cargo @@ -83,12 +105,13 @@ cargo test ```bash brew install libsoxr +# Note: ALSA n'est pas nécessaire sur macOS (rodio utilise CoreAudio) ``` ### Debian/Ubuntu (avec sudo) ```bash -sudo apt-get install libsoxr-dev +sudo apt-get install libsoxr-dev libasound2-dev ``` ### Fedora/RHEL @@ -99,12 +122,12 @@ sudo dnf install soxr-devel ## Troubleshooting -### Erreur : "Package 'soxr' was not found" +### Erreur : "Package 'soxr' was not found" ou "Package 'alsa' was not found" - Vérifier que `PKG_CONFIG_PATH` contient le bon chemin -- Vérifier que le fichier `soxr.pc` existe dans ce répertoire +- Vérifier que les fichiers `soxr.pc` et `alsa.pc` existent dans ce répertoire -### Erreur de link : "unable to find library -lsoxr" +### Erreur de link : "unable to find library -lsoxr" ou "-lasound" - Pour `cargo build` : vérifier `LD_LIBRARY_PATH` - Pour `cargo test` : utiliser la configuration rustflags (Option A ci-dessus) diff --git a/pmoaudio/Cargo.toml b/pmoaudio/Cargo.toml index a670c748..6adfa0ba 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" +rodio = "0.19" [dev-dependencies] tokio-test = "0.4" diff --git a/pmoaudio/examples/play_audio.rs b/pmoaudio/examples/play_audio.rs new file mode 100644 index 00000000..3a1cfa70 --- /dev/null +++ b/pmoaudio/examples/play_audio.rs @@ -0,0 +1,60 @@ +//! Exemple simple de lecture audio avec AudioSink +//! +//! Cet exemple montre comment utiliser AudioSink pour jouer un fichier audio +//! sur la sortie audio standard de la machine. +//! +//! Usage: +//! cargo run --example play_audio -- + +use pmoaudio::{AudioSink, FileSource}; +use std::env; +use tokio_util::sync::CancellationToken; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Initialiser le logging + tracing_subscriber::fmt::init(); + + // Récupérer le chemin du fichier depuis les arguments + let args: Vec = env::args().collect(); + if args.len() < 2 { + eprintln!("Usage: {} ", args[0]); + eprintln!("\nExemple:"); + eprintln!(" {} music.flac", args[0]); + std::process::exit(1); + } + + let file_path = &args[1]; + println!("Lecture de: {}", file_path); + + // Créer la source audio (lit le fichier FLAC) + let mut source = FileSource::new(file_path).await?; + + // Créer le sink audio (joue sur la sortie audio) + let sink = AudioSink::new(); + + // Connecter la source au sink + source.register(Box::new(sink)); + + println!("Démarrage de la lecture..."); + println!("Appuyez sur Ctrl+C pour arrêter"); + + // Créer un token d'annulation pour pouvoir arrêter proprement + let stop_token = CancellationToken::new(); + let stop_token_clone = stop_token.clone(); + + // Gérer Ctrl+C pour arrêt propre + tokio::spawn(async move { + tokio::signal::ctrl_c().await.expect("Failed to listen for Ctrl+C"); + println!("\nArrêt demandé..."); + stop_token_clone.cancel(); + }); + + // Lancer le pipeline + match Box::new(source).run(stop_token).await { + Ok(()) => println!("\nLecture terminée"), + Err(e) => eprintln!("\nErreur pendant la lecture: {}", e), + } + + Ok(()) +} diff --git a/pmoaudio/examples/play_with_resampling.rs b/pmoaudio/examples/play_with_resampling.rs new file mode 100644 index 00000000..b23fa792 --- /dev/null +++ b/pmoaudio/examples/play_with_resampling.rs @@ -0,0 +1,78 @@ +//! Exemple de lecture audio avec resampling et conversion de format +//! +//! Cet exemple montre comment construire un pipeline audio complet: +//! FileSource → ResamplingNode → ToI24Node → AudioSink +//! +//! Usage: +//! cargo run --example play_with_resampling -- [sample_rate] + +use pmoaudio::{AudioSink, FileSource, ResamplingNode, ToI24Node}; +use std::env; +use tokio_util::sync::CancellationToken; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Initialiser le logging + tracing_subscriber::fmt::init(); + + // Récupérer les arguments + let args: Vec = env::args().collect(); + if args.len() < 2 { + eprintln!("Usage: {} [sample_rate]", args[0]); + eprintln!("\nExemple:"); + eprintln!(" {} music.flac # Lecture normale", args[0]); + eprintln!(" {} music.flac 48000 # Resample vers 48kHz", args[0]); + std::process::exit(1); + } + + let file_path = &args[1]; + let target_sample_rate = if args.len() >= 3 { + args[2].parse::()? + } else { + 48000 // Par défaut + }; + + println!("Lecture de: {}", file_path); + println!("Sample rate cible: {} Hz", target_sample_rate); + + // Créer la source audio + let mut source = FileSource::new(file_path).await?; + + // Créer le nœud de resampling + let mut resampler = ResamplingNode::new(target_sample_rate); + + // Créer le nœud de conversion vers I24 + let mut converter = ToI24Node::new(); + + // Créer le sink audio avec volume à 80% + let sink = AudioSink::with_volume(0.8); + + // Construire le pipeline: Source → Resampler → Converter → Sink + source.register(Box::new(resampler)); + resampler.register(Box::new(converter)); + converter.register(Box::new(sink)); + + println!("Pipeline créé: FileSource → Resampling({} Hz) → ToI24 → AudioSink", + target_sample_rate); + println!("Démarrage de la lecture..."); + println!("Appuyez sur Ctrl+C pour arrêter"); + + // Créer un token d'annulation + let stop_token = CancellationToken::new(); + let stop_token_clone = stop_token.clone(); + + // Gérer Ctrl+C + tokio::spawn(async move { + tokio::signal::ctrl_c().await.expect("Failed to listen for Ctrl+C"); + println!("\nArrêt demandé..."); + stop_token_clone.cancel(); + }); + + // Lancer le pipeline + match Box::new(source).run(stop_token).await { + Ok(()) => println!("\nLecture terminée"), + Err(e) => eprintln!("\nErreur pendant la lecture: {}", e), + } + + Ok(()) +} diff --git a/pmoaudio/src/lib.rs b/pmoaudio/src/lib.rs index f0de895e..6c0e3edd 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..ec97a8d3 --- /dev/null +++ b/pmoaudio/src/nodes/audio_sink.rs @@ -0,0 +1,421 @@ +use crate::{ + 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, +}; +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, +} + +/// Sink qui joue les `AudioSegment` reçus sur la sortie audio standard via rodio. +/// +/// 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 +/// - 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 rodio +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 un channel pour communiquer avec le thread rodio + let (cmd_tx, cmd_rx) = std_mpsc::channel::(); + + // 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; + } + }; + + 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; + } + } + } + + tracing::debug!("Rodio thread exiting"); + }); + + 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"); + let _ = cmd_tx.send(RodioCommand::Stop); + let _ = rodio_thread.join(); + return Ok(()); + } + + // Recevoir le prochain segment + 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(); + return Ok(()); + } + } + } + _ = stop_token.cancelled() => { + tracing::debug!("AudioSinkLogic cancelled during recv"); + let _ = cmd_tx.send(RodioCommand::Stop); + let _ = rodio_thread.join(); + return Ok(()); + } + }; + + // 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; + } + + // 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", + chunk.len(), + 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 + } + 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()) + })?; + 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 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. +/// +/// # 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_i16_samples_from_i16() { + let stereo = vec![[100i16, 200i16], [300i16, 400i16]]; + 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]); + } + + #[test] + fn test_chunk_to_i16_samples_from_f32() { + use crate::I24; + 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); + } + + #[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)); + } +} diff --git a/pmoaudio/src/nodes/mod.rs b/pmoaudio/src/nodes/mod.rs index 9e8caa58..9f51198a 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;