From c31596fdb52ed4567eaba9a26a6f42027dc07628 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Tue, 28 Oct 2025 18:41:34 +0100 Subject: [PATCH] =?UTF-8?q?Passons=20en=20st=C3=A9reo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reprise du module DSP pmoaudio --- .DS_Store | Bin 12292 -> 10244 bytes .vscode/settings.json | 10 +- Cargo.lock | 33 ++- Makefile | 24 +- Readme.md | 7 + pmoaudio/Cargo.toml | 9 + pmoaudio/src/audio_chunk.rs | 410 ++++++++++++++++++-------- pmoaudio/src/bit_depth.rs | 162 ++++++++++ pmoaudio/src/dsp/depth.rs | 111 +++++++ pmoaudio/src/dsp/gain.rs | 93 ++++++ pmoaudio/src/dsp/int_float.rs | 188 ++++++++++++ pmoaudio/src/dsp/mod.rs | 13 + pmoaudio/src/dsp/resampling.rs | 74 +++++ pmoaudio/src/lib.rs | 164 ++++++----- pmoaudio/src/nodes/buffer_node.rs | 13 +- pmoaudio/src/nodes/chromecast_sink.rs | 16 +- pmoaudio/src/nodes/decoder_node.rs | 69 +++-- pmoaudio/src/nodes/disk_sink.rs | 42 +-- pmoaudio/src/nodes/dsp_node.rs | 146 +++++---- pmoaudio/src/nodes/file_source.rs | 244 +++++++++++++++ pmoaudio/src/nodes/flac_file_sink.rs | 300 +++++++++++++++++++ pmoaudio/src/nodes/mod.rs | 2 + pmoaudio/src/nodes/mpd_sink.rs | 19 +- pmoaudio/src/nodes/sink_node.rs | 73 +++-- pmoaudio/src/nodes/source_node.rs | 29 +- pmoaudio/src/nodes/timer_node.rs | 37 ++- pmoaudio/src/nodes/volume_node.rs | 35 ++- pmoaudio/tests/integration_test.rs | 5 +- tools/test_cpu | 123 ++++++++ 29 files changed, 2036 insertions(+), 415 deletions(-) create mode 100644 pmoaudio/src/bit_depth.rs create mode 100644 pmoaudio/src/dsp/depth.rs create mode 100644 pmoaudio/src/dsp/gain.rs create mode 100644 pmoaudio/src/dsp/int_float.rs create mode 100644 pmoaudio/src/dsp/mod.rs create mode 100644 pmoaudio/src/dsp/resampling.rs create mode 100644 pmoaudio/src/nodes/file_source.rs create mode 100644 pmoaudio/src/nodes/flac_file_sink.rs create mode 100755 tools/test_cpu diff --git a/.DS_Store b/.DS_Store index 473b965f6e7de01858eb686a063a55466a34f17f..0081602c0829f90d4aea6a6e21804f096e4f1c9a 100644 GIT binary patch delta 138 zcmZokXbF&DU|?W$DortDU{C-uIe-{M3-C-V6q~3gIoUvmMH0woFa}~qpg03V2}1=# ziDyoJa#GI5i)QQ-ALMRk=V0MrG~R3?c%5(K0~JO=W{?h`LEJ#X6=c}Pi{F_i^Xup` YGE6q%QRZNRSfnvIMQ7$_O)+L>0Pe6Fr2qf` literal 12292 zcmeHNU2Ggz6+UM-iD%b88K+5;H6-5Jlt#gJ{I_mfQwL<)+i)dt+2CQ8?%_FbZ1l$tA!H;i%& z1p6>2f1z&VU z2t)`(2t)`(2t)|nIRf4us)Y9YJ0B?Gh!BVn_`o3`;zJrm)WcqV&8=nVz)c+CMZY-0 z%L~u=4v_ECUOnv9*W6mNf=9BVc3Ii`#9)^l@6&=D^{`i8b8DBK*kvd7W@hgTh2`wv zOG|R%VYiO%5dskcwFqSJYWG&(-srcWz%p<;!8Xh-?n{6ol>XN zD^JW^vR7wH*;3gV%@)sz)@j=|bCu_8?r}q3T~K$A*;XlQSb9-17@7`}^RtGf*{h>= z+0q=zJL7gJ2_>Feo8_hkmIcBI#bdb>N){R911uO^f|?T3#|FD#h_%YMdH zk-acMjUknvy%T-?IS267Q5GneK%+!!R61eWc@GT{bVg9R-j)}hi0VOICJmVoTV^t{ z$Yxw3J;)XN!|ictPTtG`NipzV${`_Q(<;&|I)*M_3P+|~ELud{L0=Ifc0tGjnY9!0 zQ$8{835{07m=01gHd(0kjYo-(A;S7cNzxe9En|Gt_zyG+&Lxre&s9%TkZ7Db=}|m&!roJ89S?B{Tll$lKrWFK%%WZn(F!~G zX_YFiw`_v+!Xg2mYYfxF8XGP$;jIB%R%Y6a9@YrZC%lWE}Eav)-F)SxPD5 zq}a_u(MZI{K&<#Lm+*ln;R%8(v?U9@0-jS@_m{DbbCB;!tu6ujisx>sy@-;VaM&2X zh={a-CIOC$CCk9Us)><^rvd7U24(CbB5FH94P;dDqnn^IuDfyk?(0ADzt|6Ia0o@O7|vO1L2TrC(~nJD(1fYPNibkD1dZs55Qq@i9D(iV z+KKR5``df<>-AP)=WZOCdWQz^)j!ztE{{xGXc-DZTmCgyH$4bg4k*rV6&L!vPjJ$2l7+3TK zb*F=z&j~b774;S5dS>&sSuR-4g4&$Vn)$MsE$JhsDTtl9lD<|FPxFplER9LR1ywCO z`m_!@V?`eaHCT6vk*1bQhHW{I>qQ53`*63bUgG(NJ^R}}`myfJ(TB%ROn&l4W7AHx zc~>$ykK~b6GA=*AE;_ zB~Lq0SIv5a9i7SK=_O>nR0kpm!Fzg>sWWCar<Kqk=|OxaoW)scmA*({q37tk^gO*pKc`>OEA%S8Mz7PK=&$q!{e%8R z|E9N@IKjKPmG9#=Zs!g@!b5zN$M_R`k{{yy7of^2aJk#dB4Cyv)-@Bzt2Nr^KX=(qEFAA4RwR|YKf3sC~25UiIQ-njSbuM)}*`{DE zsN3D<{mBOutO9k1+dS0Lp=sXv?^QwW7WY7ydr|gs#)e^lP~ApXo33H+mCpt8f$V;{$MA;kfs67a!&x z?&W@dh(FH5JOU@4;7Ok1Q#{S*;KUbrfwTN+USg9iUgb47@;YCI8-Jdk;b-|v{2YIk zzs}$A9Qp_R!;piX3_2+PtoHjK6k_kkb+8_{w>Ny1@|=bBri7uQ|?M}TPm P-|QqE?f?Hr`~QCc3Ghju diff --git a/.vscode/settings.json b/.vscode/settings.json index 2d7c8101..dce95e96 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,5 +3,13 @@ "git.enabled": false, "claude-code.environmentVariables": [ - ] + ], + // Exclusions via VS Code + "files.exclude": { + "target": true, + "**/target": true, + "node_modules": true + }, + "rust-analyzer.procMacro.enable": true, + "rust-analyzer.numThreads": 4 } \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index ddf544a0..fdd7fc13 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -522,18 +522,18 @@ checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" [[package]] name = "bytemuck" -version = "1.23.2" +version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3995eaeebcdf32f91f980d360f78732ddc061097ab4e39991ae7a6ace9194677" +checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" dependencies = [ "bytemuck_derive", ] [[package]] name = "bytemuck_derive" -version = "1.10.1" +version = "1.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f154e572231cb6ba2bd1176980827e3d5dc04cc183a75dea38109fbdd672d29" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" dependencies = [ "proc-macro2", "quote", @@ -2025,6 +2025,15 @@ dependencies = [ "libc", ] +[[package]] +name = "libsoxr-sys" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cffdc8b3b64759d2e95e3236fa6bf85fef226bf57023fa9273ec60edea8fbce" +dependencies = [ + "pkg-config", +] + [[package]] name = "libsqlite3-sys" version = "0.35.0" @@ -2656,6 +2665,11 @@ name = "pmoaudio" version = "0.1.0" dependencies = [ "async-trait", + "bytemuck", + "paste", + "pmoflac", + "soxr", + "tempfile", "tokio", "tokio-test", ] @@ -3816,6 +3830,17 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "soxr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3ca6bc65602daad89c3217255ff89c4abbe489b9dd3e56c66aa16d1addac979" +dependencies = [ + "bitflags 2.9.4", + "bytemuck", + "libsoxr-sys", +] + [[package]] name = "spin" version = "0.10.0" diff --git a/Makefile b/Makefile index 9e8b36a0..ef3912cb 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,8 @@ # Makefile pour projet Rust + Vue.js # Variables de configuration CARGO = cargo +CARGO_NIGHTLY = rustup run nightly cargo +FEATURES ?= NPM = npm WEBAPP_DIR = pmoapp/webapp DIST_DIR = $(WEBAPP_DIR)/dist @@ -14,7 +16,9 @@ YELLOW = \033[1;33m RED = \033[0;31m NC = \033[0m # No Color -.PHONY: all help build release debug test doc webapp clean install dev check fmt clippy watch +.DEFAULT_GOAL := simd + +.PHONY: all help build release debug test doc webapp clean install dev check fmt clippy watch simd scalar # Cible par défaut all: build @@ -31,13 +35,13 @@ build: webapp release ## release: Compile le binaire Rust en mode release release: webapp @echo "$(YELLOW)→ Compilation Rust (release)...$(NC)" - $(CARGO) build --release + $(CARGO) build --release $(FEATURES) @echo "$(GREEN)✓ Binaire disponible : $(RUST_TARGET)/$(BINARY_NAME)$(NC)" ## debug: Compile le binaire Rust en mode debug debug: webapp @echo "$(YELLOW)→ Compilation Rust (debug)...$(NC)" - $(CARGO) build + $(CARGO) build $(FEATURES) @echo "$(GREEN)✓ Binaire disponible : target/debug/$(BINARY_NAME)$(NC)" ## test: Exécute tous les tests Rust @@ -46,6 +50,18 @@ test: $(CARGO) test --all @echo "$(GREEN)✓ Tests terminés$(NC)" +## simd: Compile l'application en mode SIMD (nightly requis) +simd: + @echo "$(YELLOW)→ Build SIMD (nightly)...$(NC)" + $(MAKE) release CARGO="$(CARGO_NIGHTLY)" FEATURES="--features simd" + @echo "$(GREEN)✓ Build SIMD terminé$(NC)" + +## scalar: Compile l'application en mode scalaire +scalar: + @echo "$(YELLOW)→ Build scalaire...$(NC)" + $(MAKE) release FEATURES="" + @echo "$(GREEN)✓ Build scalaire terminé$(NC)" + ## test-doc: Teste les exemples dans la documentation test-doc: @echo "$(YELLOW)→ Test des exemples de documentation...$(NC)" @@ -187,4 +203,4 @@ coverage: @echo "$(YELLOW)→ Génération du rapport de couverture...$(NC)" $(CARGO) tarpaulin --out Html --output-dir target/coverage @echo "$(GREEN)✓ Rapport disponible dans target/coverage/index.html$(NC)" - \ No newline at end of file + diff --git a/Readme.md b/Readme.md index 70265fdf..0dc06507 100644 --- a/Readme.md +++ b/Readme.md @@ -98,3 +98,10 @@ jj rebase --continue pour résoudre les conflits +## Installer rust sur mac + +```bash +brew install rustup-init +rustup-init +rustup default stable +``` \ No newline at end of file diff --git a/pmoaudio/Cargo.toml b/pmoaudio/Cargo.toml index 3f00b898..0bb6aab2 100644 --- a/pmoaudio/Cargo.toml +++ b/pmoaudio/Cargo.toml @@ -3,9 +3,18 @@ name = "pmoaudio" version = "0.1.0" edition = "2021" +[features] +default = [] +simd = [] + [dependencies] tokio = { version = "1.42", features = ["full"] } async-trait = "0.1" +pmoflac = { path = "../pmoflac" } +paste = "1" +soxr = "0.6.0" +bytemuck = "1.24.0" [dev-dependencies] tokio-test = "0.4" +tempfile = "3" diff --git a/pmoaudio/src/audio_chunk.rs b/pmoaudio/src/audio_chunk.rs index 882a1699..3a6dd971 100644 --- a/pmoaudio/src/audio_chunk.rs +++ b/pmoaudio/src/audio_chunk.rs @@ -1,5 +1,7 @@ use std::sync::Arc; +use crate::{dsp, BitDepth}; + /// Représente un chunk audio stéréo avec données partagées via Arc /// /// Cette structure encapsule des données audio stéréo (canaux gauche et droit) @@ -16,43 +18,43 @@ use std::sync::Arc; /// # Exemples /// /// ``` -/// use pmoaudio::AudioChunk; +/// use pmoaudio::{AudioChunk, BitDepth}; /// /// // Créer un chunk avec des données générées -/// let left = vec![0.0, 0.1, 0.2, 0.3]; -/// let right = vec![0.0, 0.1, 0.2, 0.3]; -/// let chunk = AudioChunk::new(0, left, right, 48000); +/// let stereo = vec![[0, 100], [200, 300], [400, 500]]; +/// let chunk = AudioChunk::new(0, stereo, 48_000, BitDepth::B24); /// -/// assert_eq!(chunk.len(), 4); -/// assert_eq!(chunk.sample_rate, 48000); +/// assert_eq!(chunk.len(), 3); +/// assert_eq!(chunk.sample_rate(), 48_000); /// ``` + #[derive(Debug, Clone)] pub struct AudioChunk { - /// Numéro d'ordre du chunk dans le flux - /// - /// Permet de suivre l'ordre des chunks et détecter les pertes éventuelles - pub order: u64, + /// Numéro d’ordre dans le flux. + /// Sert à conserver la séquence et détecter d’éventuelles pertes. + order: u64, - /// Canal gauche (partagé via Arc pour éviter les clonages) - /// - /// Les samples sont en format float 32-bit, normalement entre -1.0 et 1.0 - pub left: Arc>, + /// Canal gauche, partagé et immuable. + /// Toute transformation doit créer un nouveau `AudioChunk`. + stereo: Arc<[[i32; 2]]>, - /// Canal droit (partagé via Arc pour éviter les clonages) - /// - /// Les samples sont en format float 32-bit, normalement entre -1.0 et 1.0 - pub right: Arc>, + /// Taux d’échantillonnage (Hz). + /// Exemples : 44 100, 48 000, 96 000, 192 000. + sample_rate: u32, - /// Taux d'échantillonnage en Hz + /// Profondeur de bits des échantillons audio effectifs. /// - /// Valeurs typiques: 44100, 48000, 96000, 192000 - pub sample_rate: u32, + /// Indique la résolution utile des valeurs dans les buffers. + /// Exemples : `16` pour un flux PCM 16 bits, `24` pour du PCM 24 bits, `32` pour du plein i32. + /// Ce champ permet d’adapter les traitements DSP (normalisation, conversion, etc.). + bit_depth: BitDepth, - /// Gain multiplicatif appliqué au flux audio + /// Gain appliqué au flux audio, en décibels (dB). /// - /// Valeur par défaut: 1.0 (aucun changement) - /// Valeurs typiques: 0.0 (silence) à 1.0 (volume max) - pub gain: f32, + /// Conversion : `gain_linear = 10^(gain_db / 20)` + /// Valeur par défaut : `0.0 dB` (aucune modification). + /// Exemples : `-6 dB` ≈ moitié du volume ; `+6 dB` ≈ double. + gain: f64, } impl AudioChunk { @@ -63,82 +65,120 @@ impl AudioChunk { /// # Arguments /// /// * `order` - Numéro d'ordre du chunk dans le flux - /// * `left` - Samples du canal gauche - /// * `right` - Samples du canal droit + /// * `stereo` - Samples interleavés par frame `[L, R]` /// * `sample_rate` - Taux d'échantillonnage en Hz + /// * `bit_depth` - Profondeur de bits des échantillons /// /// # Exemples /// /// ``` - /// use pmoaudio::AudioChunk; + /// use pmoaudio::{AudioChunk, BitDepth}; /// /// let chunk = AudioChunk::new( /// 0, - /// vec![0.0, 0.5, 1.0], - /// vec![0.0, 0.5, 1.0], - /// 48000 + /// vec![[0, 0], [1_000_000, 1_000_000]], + /// 48_000, + /// BitDepth::B24, /// ); /// ``` - pub fn new(order: u64, left: Vec, right: Vec, sample_rate: u32) -> Self { - Self { + pub fn new( + order: u64, + stereo: Vec<[i32; 2]>, + sample_rate: u32, + bit_depth: BitDepth, + ) -> Arc { + Arc::new(Self { order, - left: Arc::new(left), - right: Arc::new(right), + stereo: Arc::from(stereo), sample_rate, - gain: 1.0, - } + bit_depth, + gain: 0.0, + }) } - /// Crée un nouveau chunk audio avec un gain spécifique - pub fn with_gain( + /// Crée un chunk avec un gain spécifique (en dB) + pub fn with_gain_db( + order: u64, + stereo: Vec<[i32; 2]>, + sample_rate: u32, + bit_depth: BitDepth, + gain_db: f64, + ) -> Arc { + Self::new(order, stereo, sample_rate, bit_depth).set_gain_db(gain_db) + } + + /// Crée un chunk avec un gain spécifique (en gain linéaire). + /// + /// Le gain linéaire sera converti en décibels. + pub fn with_gain_linear( + order: u64, + stereo: Vec<[i32; 2]>, + sample_rate: u32, + bit_depth: BitDepth, + gain_linear: f64, + ) -> Arc { + Self::new(order, stereo, sample_rate, bit_depth).set_gain_linear(gain_linear) + } + + /// Construit un chunk à partir de deux vecteurs `i32` séparés (L/R). + pub fn from_channels_i32( + order: u64, + left: Vec, + right: Vec, + sample_rate: u32, + bit_depth: BitDepth, + ) -> Arc { + assert_eq!( + left.len(), + right.len(), + "channels must have identical length" + ); + let stereo = left + .into_iter() + .zip(right.into_iter()) + .map(|(l, r)| [l, r]) + .collect(); + Self::new(order, stereo, sample_rate, bit_depth) + } + + /// Construit un chunk à partir de vecteurs `f32` normalisés dans [-1.0, 1.0]. + pub fn from_channels_f32( order: u64, left: Vec, right: Vec, sample_rate: u32, - gain: f32, - ) -> Self { - Self { - order, - left: Arc::new(left), - right: Arc::new(right), - sample_rate, - gain, - } + bit_depth: BitDepth, + ) -> Arc { + assert_eq!( + left.len(), + right.len(), + "channels must have identical length" + ); + let stereo = left + .into_iter() + .zip(right.into_iter()) + .map(|(l, r)| [quantize_sample(l, bit_depth), quantize_sample(r, bit_depth)]) + .collect(); + Self::new(order, stereo, sample_rate, bit_depth) } - /// Crée un chunk à partir de données déjà wrappées dans Arc - /// - /// Utile pour éviter un double wrapping si les données sont déjà dans Arc. - pub fn from_arc( + /// Construit un chunk à partir de frames stéréo normalisées [-1.0, 1.0]. + pub fn from_pairs_f32( order: u64, - left: Arc>, - right: Arc>, + pairs: Vec<[f32; 2]>, sample_rate: u32, - ) -> Self { - Self { - order, - left, - right, - sample_rate, - gain: 1.0, - } - } - - /// Crée un chunk à partir de données déjà wrappées dans Arc avec gain - pub fn from_arc_with_gain( - order: u64, - left: Arc>, - right: Arc>, - sample_rate: u32, - gain: f32, - ) -> Self { - Self { - order, - left, - right, - sample_rate, - gain, - } + bit_depth: BitDepth, + ) -> Arc { + let stereo = pairs + .into_iter() + .map(|p| { + [ + quantize_sample(p[0], bit_depth), + quantize_sample(p[1], bit_depth), + ] + }) + .collect(); + Self::new(order, stereo, sample_rate, bit_depth) } /// Retourne le nombre d'échantillons par canal @@ -146,41 +186,98 @@ impl AudioChunk { /// # Exemples /// /// ``` - /// use pmoaudio::AudioChunk; + /// use pmoaudio::{AudioChunk, BitDepth}; /// - /// let chunk = AudioChunk::new(0, vec![0.0; 1000], vec![0.0; 1000], 48000); + /// let chunk = AudioChunk::new(0, vec![[0i32; 2]; 1000], 48_000, BitDepth::B24); /// assert_eq!(chunk.len(), 1000); /// ``` pub fn len(&self) -> usize { - self.left.len() + self.stereo.len() } /// Vérifie si le chunk est vide pub fn is_empty(&self) -> bool { - self.left.is_empty() + self.stereo.is_empty() + } + + /// Numéro de séquence du chunk dans le flux. + pub fn order(&self) -> u64 { + self.order + } + + /// Taux d'échantillonnage (Hz). + pub fn sample_rate(&self) -> u32 { + self.sample_rate + } + + /// Profondeur de bits effective. + pub fn bit_depth(&self) -> BitDepth { + self.bit_depth + } + + /// Gain courant en décibels. + pub fn gain_db(&self) -> f64 { + self.gain + } + + /// Gain sous forme linéaire. + pub fn gain_linear(&self) -> f64 { + db_to_linear(self.gain) + } + + /// Convertit un gain linéaire (>0) en décibels. + 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. + pub fn gain_linear_from_db(gain_db: f64) -> f64 { + db_to_linear(gain_db) + } + + /// Retourne une vue immuable sur les frames `[L,R]`. + pub fn frames(&self) -> &[[i32; 2]] { + &self.stereo + } + + /// Clone les frames stéréo dans un `Vec`. + pub fn clone_frames(&self) -> Vec<[i32; 2]> { + self.stereo.to_vec() + } + + /// Convertit les frames au format `f32` normalisé [-1.0, 1.0]. + pub fn to_pairs_f32(&self) -> Vec<[f32; 2]> { + self.stereo + .iter() + .map(|frame| { + [ + dequantize_sample(frame[0], self.bit_depth), + dequantize_sample(frame[1], self.bit_depth), + ] + }) + .collect() } /// Clone les données pour permettre une modification (Copy-on-Write) /// /// Cette méthode doit être appelée uniquement si vous avez besoin de modifier - /// les données audio. Pour une simple lecture, utilisez directement les champs - /// `left` et `right`. + /// les échantillons. Pour une simple lecture, utilisez [`frames`](Self::frames). /// /// # Exemples /// /// ``` - /// use pmoaudio::AudioChunk; + /// use pmoaudio::{AudioChunk, BitDepth}; /// - /// let chunk = AudioChunk::new(0, vec![1.0, 2.0], vec![3.0, 4.0], 48000); - /// let (mut left, mut right) = chunk.clone_data(); - /// - /// // Modifier les données - /// for sample in &mut left { - /// *sample *= 0.5; - /// } + /// let chunk = AudioChunk::new(0, vec![[1, 2], [3, 4]], 48_000, BitDepth::B24); + /// let mut frames = chunk.clone_data(); + /// frames[0][0] /= 2; /// ``` - pub fn clone_data(&self) -> (Vec, Vec) { - ((*self.left).clone(), (*self.right).clone()) + pub fn clone_data(&self) -> Vec<[i32; 2]> { + self.stereo.to_vec() + } + + pub fn set_data(&mut self, stereo: Vec<[i32; 2]>) { + self.stereo = Arc::from(stereo); } /// Applique le gain et retourne un nouveau chunk avec les données modifiées @@ -191,38 +288,78 @@ impl AudioChunk { /// # Exemples /// /// ``` - /// use pmoaudio::AudioChunk; + /// use pmoaudio::{AudioChunk, BitDepth}; /// - /// let chunk = AudioChunk::with_gain(0, vec![1.0, 2.0], vec![3.0, 4.0], 48000, 0.5); + /// let chunk = AudioChunk::from_pairs_f32( + /// 0, + /// vec![[0.5, 0.25], [0.25, 0.125]], + /// 48_000, + /// BitDepth::B24, + /// ); + /// let chunk = chunk.set_gain_linear(0.5); /// let applied = chunk.apply_gain(); + /// let frames = applied.to_pairs_f32(); /// - /// assert_eq!(applied.left[0], 0.5); - /// assert_eq!(applied.left[1], 1.0); - /// assert_eq!(applied.gain, 1.0); // Gain réinitialisé après application + /// assert!((frames[0][0] - 0.25).abs() < 1e-3); + /// assert!((applied.gain_db()).abs() < f64::EPSILON); // Gain réinitialisé après application /// ``` - pub fn apply_gain(&self) -> Self { - if (self.gain - 1.0).abs() < f32::EPSILON { - // Pas de gain à appliquer, retourner un clone - return self.clone(); + pub fn apply_gain(self: Arc) -> Arc { + if self.gain.abs() < f64::EPSILON { + // Pas de gain à appliquer, retourner la même instance + return self; } - let left: Vec = self.left.iter().map(|&s| s * self.gain).collect(); - let right: Vec = self.right.iter().map(|&s| s * self.gain).collect(); + let mut stereo = self.clone_data(); + dsp::apply_gain_stereo(&mut stereo, self.gain); - Self::new(self.order, left, right, self.sample_rate) + Self::new(self.order, stereo, self.sample_rate, self.bit_depth) + } + + pub fn set_gain_db(&self, gain: f64) -> Arc { + Arc::new(Self { + order: self.order, + stereo: self.stereo.clone(), + sample_rate: self.sample_rate, + bit_depth: self.bit_depth, + gain, + }) + } + + /// 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)) } /// Modifie le gain de ce chunk (retourne un nouveau chunk avec le même Arc mais gain différent) /// /// Cette méthode est très peu coûteuse car elle ne clone que la structure, pas les données audio. - pub fn with_modified_gain(&self, new_gain: f32) -> Self { - Self { - order: self.order, - left: self.left.clone(), - right: self.right.clone(), - sample_rate: self.sample_rate, - gain: self.gain * new_gain, // Multiplication des gains + pub fn with_modified_gain_db(&self, delta_gain_db: f64) -> Arc { + self.set_gain_db(self.gain + delta_gain_db) + } + + /// 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)) + } + + pub fn get_bit_depth(&self) -> BitDepth { + self.bit_depth + } + pub fn set_bit_depth(self: Arc, new_depth: BitDepth) -> Arc { + if self.bit_depth == new_depth { + return self; } + + let mut stereo = self.clone_data(); + dsp::bitdepth_change_stereo(&mut stereo, self.bit_depth, new_depth); + + Arc::new(Self { + order: self.order, + stereo: Arc::from(stereo), + sample_rate: self.sample_rate, + bit_depth: new_depth, + gain: self.gain, + }) } } @@ -232,26 +369,43 @@ mod tests { #[test] fn test_audio_chunk_creation() { - let left = vec![0.0, 0.1, 0.2]; - let right = vec![0.0, 0.1, 0.2]; - let chunk = AudioChunk::new(0, left, right, 48000); + let stereo: Vec<[i32; 2]> = vec![ + [0, 10], // frame 0 : L=0, R=10 + [20, 30], // frame 1 : L=20, R=30 + [40, 50], // frame 2 : L=40, R=50 + ]; + let chunk = AudioChunk::new(0, stereo, 48000, BitDepth::B24); - assert_eq!(chunk.order, 0); + assert_eq!(chunk.order(), 0); assert_eq!(chunk.len(), 3); - assert_eq!(chunk.sample_rate, 48000); + assert_eq!(chunk.sample_rate(), 48000); assert!(!chunk.is_empty()); } +} - #[test] - fn test_audio_chunk_arc_sharing() { - let left = Arc::new(vec![0.0, 0.1, 0.2]); - let right = Arc::new(vec![0.0, 0.1, 0.2]); +fn quantize_sample(sample: f32, bit_depth: BitDepth) -> i32 { + let max_value = bit_depth.max_value() as f64; + let upper = max_value - 1.0; + let lower = -max_value; + let scaled = (sample as f64 * upper).round(); + scaled.clamp(lower, upper) as i32 +} - let chunk1 = AudioChunk::from_arc(0, left.clone(), right.clone(), 48000); - let chunk2 = chunk1.clone(); +fn dequantize_sample(sample: i32, bit_depth: BitDepth) -> f32 { + let max_value = bit_depth.max_value(); + sample as f32 / max_value +} - // Vérifier que les Arc pointent vers les mêmes données - assert!(Arc::ptr_eq(&chunk1.left, &chunk2.left)); - assert!(Arc::ptr_eq(&chunk1.right, &chunk2.right)); +const MIN_GAIN_DB: f64 = -120.0; + +fn linear_to_db(gain_linear: f64) -> f64 { + if gain_linear <= 0.0 { + MIN_GAIN_DB + } else { + (20.0 * gain_linear.log10()).max(MIN_GAIN_DB) } } + +fn db_to_linear(gain_db: f64) -> f64 { + 10f64.powf(gain_db / 20.0) +} diff --git a/pmoaudio/src/bit_depth.rs b/pmoaudio/src/bit_depth.rs new file mode 100644 index 00000000..2e8c2b61 --- /dev/null +++ b/pmoaudio/src/bit_depth.rs @@ -0,0 +1,162 @@ +//! Bit depth abstraction for audio processing. +//! +//! Provides both compile-time generic types (`Bit8`, `Bit16`, …) +//! and a dynamic `BitDepth` enum for runtime selection. + +use std::fmt; + +/// Trait implemented by compile-time bit-depth marker types. +pub trait BitDepthType { + const BITS: u32; + const MAX_VALUE: f32; +} + +/// Compile-time bit depth markers +#[derive(Clone, Copy, Debug)] +pub struct Bit8; +#[derive(Clone, Copy, Debug)] +pub struct Bit16; +#[derive(Clone, Copy, Debug)] +pub struct Bit24; +#[derive(Clone, Copy, Debug)] +pub struct Bit32; + +impl BitDepthType for Bit8 { + const BITS: u32 = 8; + const MAX_VALUE: f32 = 128.0; +} +impl BitDepthType for Bit16 { + const BITS: u32 = 16; + const MAX_VALUE: f32 = 32_768.0; +} +impl BitDepthType for Bit24 { + const BITS: u32 = 24; + const MAX_VALUE: f32 = 8_388_608.0; +} +impl BitDepthType for Bit32 { + const BITS: u32 = 32; + const MAX_VALUE: f32 = 2_147_483_648.0; +} + +/// Runtime bit-depth descriptor. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum BitDepth { + B8, + B16, + B24, + B32, +} + +impl BitDepth { + /// Returns the number of bits. + #[inline(always)] + pub const fn bits(self) -> u32 { + match self { + BitDepth::B8 => 8, + BitDepth::B16 => 16, + BitDepth::B24 => 24, + BitDepth::B32 => 32, + } + } + + /// Returns the full-scale signed maximum value as `f32`. + #[inline(always)] + pub const fn max_value(self) -> f32 { + match self { + BitDepth::B8 => 128.0, + BitDepth::B16 => 32_768.0, + BitDepth::B24 => 8_388_608.0, + BitDepth::B32 => 2_147_483_648.0, + } + } + + /// Create from bit count, returning `None` if unsupported. + #[inline(always)] + pub const fn from_u32(bits: u32) -> Option { + match bits { + 8 => Some(Self::B8), + 16 => Some(Self::B16), + 24 => Some(Self::B24), + 32 => Some(Self::B32), + _ => None, + } + } + + /// Create from bit count, panicking if unsupported (non-const). + #[inline(always)] + pub fn from_u32_strict(bits: u32) -> Self { + Self::from_u32(bits).unwrap_or_else(|| panic!("Unsupported bit depth: {}", bits)) + } +} + +impl fmt::Display for BitDepth { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}-bit", self.bits()) + } +} + +/// Comparaisons d’ordre fondées sur la valeur en bits. +impl PartialOrd for BitDepth { + #[inline(always)] + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for BitDepth { + #[inline(always)] + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.bits().cmp(&other.bits()) + } +} + +/// Bridge between dynamic [`BitDepth`] and static [`BitDepthType`] markers. +/// +/// Example: +/// ``` +/// use pmoaudio::{ +/// bit_depth::dispatch_by_bitdepth, Bit8, Bit16, Bit24, Bit32, BitDepth, +/// }; +/// use pmoaudio::bit_depth::BitDepthType; +/// +/// fn type_bits() -> u32 { +/// B::BITS +/// } +/// +/// let depth = BitDepth::B16; +/// let bits = dispatch_by_bitdepth( +/// depth, +/// || type_bits::(), +/// || type_bits::(), +/// || type_bits::(), +/// || type_bits::(), +/// ); +/// assert_eq!(bits, 16); +/// ``` +#[inline(always)] +pub fn dispatch_by_bitdepth( + depth: BitDepth, + f8: F8, + f16: F16, + f24: F24, + f32: F32, +) -> R +where + F8: FnOnce() -> R, + F16: FnOnce() -> R, + F24: FnOnce() -> R, + F32: FnOnce() -> R, +{ + match depth { + BitDepth::B8 => f8(), + BitDepth::B16 => f16(), + BitDepth::B24 => f24(), + BitDepth::B32 => f32(), + } +} + +/// Conversion helper from a runtime [`BitDepth`] to a compile-time constant. +#[inline(always)] +pub fn max_value_for(depth: BitDepth) -> f32 { + depth.max_value() +} diff --git a/pmoaudio/src/dsp/depth.rs b/pmoaudio/src/dsp/depth.rs new file mode 100644 index 00000000..04e1ad2a --- /dev/null +++ b/pmoaudio/src/dsp/depth.rs @@ -0,0 +1,111 @@ +#[cfg(feature = "simd")] +use std::simd::prelude::*; +#[cfg(feature = "simd")] +use std::simd::Simd; + +use crate::BitDepth; + +#[inline(always)] +pub fn bitdepth_change_stereo(data: &mut [[i32; 2]], source_bits: BitDepth, dest_bits: BitDepth) { + use std::cmp::Ordering::*; + let obits = dest_bits.bits(); + let ibits = source_bits.bits(); + match source_bits.cmp(&dest_bits) { + Less => bitdepth_up_stereo(data, (obits - ibits) as i32), + Greater => bitdepth_down_stereo(data, (ibits - obits) as i32, obits), + Equal => (), + } +} + +#[inline(always)] +#[cfg(feature = "simd")] +fn bitdepth_up_stereo(data: &mut [[i32; 2]], shift: i32) { + const LANES: usize = 8; + let shift_vec = Simd::::splat(shift); + + // On traite 8 frames stéréo à la fois + let (chunks, remainder) = data.as_chunks_mut::(); + for blk in chunks { + // Séparer L et R localement (petit tableau sur la pile) + let mut l = [0i32; LANES]; + let mut r = [0i32; LANES]; + for j in 0..LANES { + let s = blk[j]; + l[j] = s[0]; + r[j] = s[1]; + } + + // SIMD + let vl = Simd::::from_array(l) << shift_vec; + let vr = Simd::::from_array(r) << shift_vec; + + // Écrire + for j in 0..LANES { + blk[j] = [vl[j], vr[j]]; + } + } + + // Reste scalaire + for f in remainder { + f[0] <<= shift; + f[1] <<= shift; + } +} + +#[inline(always)] +#[cfg(not(feature = "simd"))] +fn bitdepth_up_stereo(data: &mut [[i32; 2]], shift: i32) { + for frame in data.iter_mut() { + frame[0] <<= shift; + frame[1] <<= shift; + } +} + +#[inline(always)] +#[cfg(feature = "simd")] +fn bitdepth_down_stereo(data: &mut [[i32; 2]], shift: i32, dest_bits: u32) { + const LANES: usize = 8; + let shift_vec = Simd::::splat(shift); + let maxv = Simd::::splat(((1i64 << (dest_bits - 1)) - 1) as i32); + let minv = Simd::::splat((-(1i64 << (dest_bits - 1))) as i32); + + let (chunks, remainder) = data.as_chunks_mut::(); + for blk in chunks { + let mut l = [0i32; LANES]; + let mut r = [0i32; LANES]; + for j in 0..LANES { + l[j] = blk[j][0]; + r[j] = blk[j][1]; + } + + let vl = Simd::::from_array(l); + let vr = Simd::::from_array(r); + + let lq = (vl >> shift_vec).simd_clamp(minv, maxv); + let rq = (vr >> shift_vec).simd_clamp(minv, maxv); + + for j in 0..LANES { + blk[j] = [lq[j], rq[j]]; + } + } + + // Reste scalaire + for f in remainder { + f[0] = ((*f)[0] as i64 >> shift) + .clamp(-(1i64 << (dest_bits - 1)), (1i64 << (dest_bits - 1)) - 1) as i32; + f[1] = ((*f)[1] as i64 >> shift) + .clamp(-(1i64 << (dest_bits - 1)), (1i64 << (dest_bits - 1)) - 1) as i32; + } +} + +#[inline(always)] +#[cfg(not(feature = "simd"))] +fn bitdepth_down_stereo(data: &mut [[i32; 2]], shift: i32, dest_bits: u32) { + let maxv = (1i64 << (dest_bits - 1)) - 1; + let minv = -(1i64 << (dest_bits - 1)); + + for frame in data.iter_mut() { + frame[0] = ((frame[0] as i64 >> shift).clamp(minv, maxv)) as i32; + frame[1] = ((frame[1] as i64 >> shift).clamp(minv, maxv)) as i32; + } +} diff --git a/pmoaudio/src/dsp/gain.rs b/pmoaudio/src/dsp/gain.rs new file mode 100644 index 00000000..ccc24d1d --- /dev/null +++ b/pmoaudio/src/dsp/gain.rs @@ -0,0 +1,93 @@ +/// 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) { + 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); + return; + } + #[cfg(all(target_arch = "x86_64", target_feature = "avx2"))] + unsafe { + apply_gain_stereo_avx2(samples, g_q31); + return; + } + + // Fallback scalar + #[cfg(not(any( + all(target_arch = "aarch64", target_feature = "neon"), + all(target_arch = "x86_64", target_feature = "avx2") + )))] + { + apply_gain_stereo_scalar(samples, g_q31); + } +} + +#[cfg(not(any( + all(target_arch = "aarch64", target_feature = "neon"), + all(target_arch = "x86_64", target_feature = "avx2") +)))] +#[inline(always)] +fn apply_gain_stereo_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; + frame[0] = prod_l.clamp(i32::MIN as i64, i32::MAX as i64) as i32; + + // R + let prod_r = (frame[1] as i64 * g_q31 as i64 + (1 << 30)) >> 31; + frame[1] = prod_r.clamp(i32::MIN as i64, i32::MAX as i64) as i32; + } +} + +#[cfg(all(target_arch = "aarch64", target_feature = "neon"))] +#[inline(always)] +unsafe fn apply_gain_stereo_neon(samples: &mut [[i32; 2]], g_q31: i32) { + use core::arch::aarch64::*; + let gvec = vdupq_n_s32(g_q31); + let mut i = 0; + let n = samples.len() * 2; // total d'échantillons (L+R) + 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); // Q31 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_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) { + use core::arch::x86_64::*; + let g = _mm256_set1_epi32(g_q31); + let mut i = 0; + let n = samples.len() * 2; // total d'échantillons + 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_scalar(slice, g_q31); +} + +/// version mono utilisée pour le reste scalaire +#[inline(always)] +fn apply_gain_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 new file mode 100644 index 00000000..037f57e7 --- /dev/null +++ b/pmoaudio/src/dsp/int_float.rs @@ -0,0 +1,188 @@ +use bytemuck::{cast_slice, cast_slice_mut}; + +#[cfg(feature = "simd")] +use std::simd::num::{SimdFloat, SimdInt}; +#[cfg(feature = "simd")] +use std::simd::{Simd, StdFloat}; + +/// Génère une implémentation de `BitDepth` pour une profondeur donnée. +/// Exemple : +/// ```ignore +/// use pmoaudio::dsp::int_float::{BitDepth, BitMax}; +/// +/// BitMax!(8); +/// assert_eq!(::MAX_VALUE, 127.0); +/// ``` +macro_rules! BitMax { + ($bits:literal) => { + paste::paste! { + pub struct []; + + impl BitDepth for [] { + const MAX_VALUE: f32 = ((1u32 << ($bits - 1)) as f32) - 1.0; + } + } + }; +} + +pub trait BitDepth { + const MAX_VALUE: f32; // Valeur max pour normaliser vers [-1.0, +1.0] +} + +// Définir automatiquement les bit-depths +BitMax!(8); +BitMax!(16); +BitMax!(24); +BitMax!(32); + +/* ====================== CŒURS CANONIQUES EN AoS ====================== */ + +// i32 L/R -> [[f32;2]] +#[cfg(feature = "simd")] +pub fn i32_stereo_to_pairs_f32( + left: &[i32], + right: &[i32], + out_pairs: &mut [[f32; 2]], +) { + 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 / B::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 { + // AoS direct + unsafe { + *o.get_unchecked_mut(j) = [l[j], r[j]]; + } + } + } + + for (dst, (&l, &r)) in o_tail.iter_mut().zip(l_tail.iter().zip(r_tail.iter())) { + dst[0] = l as f32 * (1.0 / B::MAX_VALUE); + dst[1] = r as f32 * (1.0 / B::MAX_VALUE); + } +} + +#[cfg(not(feature = "simd"))] +pub fn i32_stereo_to_pairs_f32( + left: &[i32], + right: &[i32], + out_pairs: &mut [[f32; 2]], +) { + debug_assert_eq!(left.len(), right.len()); + debug_assert_eq!(out_pairs.len(), left.len()); + + let scale = 1.0 / B::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; + } +} + +// [[f32;2]] -> i32 L/R +#[cfg(feature = "simd")] +pub fn pairs_f32_to_i32_stereo( + input_pairs: &[[f32; 2]], + left: &mut [i32], + right: &mut [i32], +) { + debug_assert_eq!(input_pairs.len(), left.len()); + debug_assert_eq!(input_pairs.len(), right.len()); + + const LANES: usize = 8; + type Vf32 = Simd; + type Vi32 = Simd; + + let vmax = B::MAX_VALUE; + let vmin = -B::MAX_VALUE; + let vscale = Vf32::splat(vmax); + let vminv = Vf32::splat(vmin); + let vmaxv = Vf32::splat(vmax - 1.0); // évite l’overflow après round→cast + + 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() { + // AoS → deux vecteurs f32 + 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] * vmax).clamp(vmin, vmax - 1.0).round(); + let rx = (j[1] * vmax).clamp(vmin, vmax - 1.0).round(); + *l = lx as i32; + *r = rx as i32; + } +} + +#[cfg(not(feature = "simd"))] +pub fn pairs_f32_to_i32_stereo( + input_pairs: &[[f32; 2]], + left: &mut [i32], + right: &mut [i32], +) { + debug_assert_eq!(input_pairs.len(), left.len()); + debug_assert_eq!(input_pairs.len(), right.len()); + + let vmax = B::MAX_VALUE; + let vmin = -B::MAX_VALUE; + for (i, pair) in input_pairs.iter().enumerate() { + let lx = (pair[0] * vmax).clamp(vmin, vmax - 1.0).round(); + let rx = (pair[1] * vmax).clamp(vmin, vmax - 1.0).round(); + left[i] = lx as i32; + right[i] = rx as i32; + } +} + +/* ====================== WRAPPERS INTERLEAVÉS ====================== */ + +// i32 L/R -> interleaved [f32] +pub fn i32_stereo_to_interleaved_f32( + left: &[i32], + right: &[i32], + out_interleaved: &mut [f32], +) { + debug_assert_eq!(out_interleaved.len(), left.len() * 2); + let out_pairs: &mut [[f32; 2]] = cast_slice_mut(out_interleaved); + i32_stereo_to_pairs_f32::(left, right, out_pairs); +} + +// interleaved [f32] -> i32 L/R +pub fn interleaved_f32_to_i32_stereo( + input_interleaved: &[f32], + left: &mut [i32], + right: &mut [i32], +) { + debug_assert_eq!(input_interleaved.len(), left.len() * 2); + let input_pairs: &[[f32; 2]] = cast_slice(input_interleaved); + pairs_f32_to_i32_stereo::(input_pairs, left, right); +} diff --git a/pmoaudio/src/dsp/mod.rs b/pmoaudio/src/dsp/mod.rs new file mode 100644 index 00000000..074efde5 --- /dev/null +++ b/pmoaudio/src/dsp/mod.rs @@ -0,0 +1,13 @@ +pub mod depth; +pub mod gain; +pub mod int_float; +pub mod resampling; + +pub use depth::bitdepth_change_stereo; +pub use gain::apply_gain_stereo; +pub use int_float::{ + i32_stereo_to_interleaved_f32, i32_stereo_to_pairs_f32, interleaved_f32_to_i32_stereo, + pairs_f32_to_i32_stereo, +}; + +pub use resampling::resampling; diff --git a/pmoaudio/src/dsp/resampling.rs b/pmoaudio/src/dsp/resampling.rs new file mode 100644 index 00000000..12ae4781 --- /dev/null +++ b/pmoaudio/src/dsp/resampling.rs @@ -0,0 +1,74 @@ +use soxr::format::Stereo; +use soxr::params::{QualityRecipe, QualitySpec, RuntimeSpec}; +use soxr::Soxr; + +use crate::dsp::int_float::{Bit16, Bit24, Bit32, Bit8}; +use crate::dsp::{i32_stereo_to_pairs_f32, pairs_f32_to_i32_stereo}; +use crate::AudioError; + +pub struct Resampler { + source_hz: f64, + dest_hz: f64, + bit_depth: u32, + soxr: Soxr>, +} + +pub fn build_resampler( + source_hz: u32, + dest_hz: u32, + bit_depth: u32, +) -> Result { + let qrecipe = match bit_depth { + 8 => QualityRecipe::Medium, + 16 => QualityRecipe::high(), // High plutôt que Bits16 pour 16-bit + 24 => QualityRecipe::very_high(), // VeryHigh pour 24-bit + 32 => QualityRecipe::very_high(), // VeryHigh pour 32-bit + _ => unreachable!(), // Déjà vérifié plus haut + }; + + let quality = QualitySpec::new(qrecipe); // Phase response linear, no steep filter + let rt = RuntimeSpec::default(); + + let soxr = Soxr::>::new_with_params(source_hz as f64, dest_hz as f64, quality, rt) + .map_err(|e| AudioError::ProcessingError(e.to_string()))?; + + Ok(Resampler { + source_hz: source_hz as f64, + dest_hz: dest_hz as f64, + bit_depth: bit_depth, + soxr: soxr, + }) +} + +pub fn resampling(left: &[i32], right: &[i32], resampler: &mut Resampler) -> (Vec, Vec) { + if left.len() != right.len() { + panic!("Left and right channels must have the same length"); + } + let mut input = vec![[0.0f32; 2]; left.len()]; + match resampler.bit_depth { + 8 => i32_stereo_to_pairs_f32::(left, right, &mut input), + 16 => i32_stereo_to_pairs_f32::(left, right, &mut input), + 24 => i32_stereo_to_pairs_f32::(left, right, &mut input), + 32 => i32_stereo_to_pairs_f32::(left, right, &mut input), + _ => panic!("Unsupported bit depth: {}", resampler.bit_depth), + } + + let output_len = + ((input.len() as f64) * resampler.dest_hz / resampler.source_hz).ceil() as usize; + let mut output = vec![[0.0f32; 2]; output_len]; + + resampler.soxr.process(&input, &mut output).unwrap(); + + let mut oleft = vec![0i32; output.len()]; + let mut oright = vec![0i32; output.len()]; + + match resampler.bit_depth { + 8 => pairs_f32_to_i32_stereo::(&output, &mut oleft, &mut oright), + 16 => pairs_f32_to_i32_stereo::(&output, &mut oleft, &mut oright), + 24 => pairs_f32_to_i32_stereo::(&output, &mut oleft, &mut oright), + 32 => pairs_f32_to_i32_stereo::(&output, &mut oleft, &mut oright), + _ => unreachable!(), // Déjà vérifié plus haut + }; + + (oleft, oright) +} diff --git a/pmoaudio/src/lib.rs b/pmoaudio/src/lib.rs index c92db8c6..3c641184 100644 --- a/pmoaudio/src/lib.rs +++ b/pmoaudio/src/lib.rs @@ -1,85 +1,95 @@ -//! PMOAudio - Pipeline audio stéréo async optimisé -//! -//! Cette crate fournit un pipeline audio push-based async utilisant Tokio, -//! optimisé pour minimiser les clonages de données via `Arc>`. -//! -//! # Architecture -//! -//! Le pipeline est composé de nodes asynchrones qui communiquent via des channels Tokio. -//! Les données audio sont encapsulées dans des [`AudioChunk`] et partagées via `Arc` pour -//! éviter les copies inutiles. -//! -//! ## Pipeline type -//! -//! ```text -//! SourceNode → DecoderNode → DSPNode → BufferNode → TimerNode → SinkNode(s) -//! ↓ -//! Multiroom Sinks -//! (avec offsets) -//! ``` -//! -//! # Exemples -//! -//! ## Pipeline simple -//! -//! ```no_run -//! use pmoaudio::{SinkNode, SourceNode, TimerNode}; -//! -//! #[tokio::main] -//! async fn main() { -//! let (mut timer, timer_tx) = TimerNode::new(10); -//! let (sink, sink_tx) = SinkNode::new("Output".to_string(), 10); -//! -//! timer.add_subscriber(sink_tx); -//! -//! tokio::spawn(async move { timer.run().await.unwrap() }); -//! let sink_handle = tokio::spawn(async move { -//! sink.run_with_stats().await.unwrap() -//! }); -//! -//! tokio::spawn(async move { -//! let mut source = SourceNode::new(); -//! source.add_subscriber(timer_tx); -//! source.generate_chunks(30, 4800, 48000, 440.0).await.unwrap(); -//! }); -//! -//! sink_handle.await.unwrap(); -//! } -//! ``` -//! -//! ## Configuration multiroom -//! -//! ```no_run -//! use pmoaudio::{BufferNode, SinkNode}; -//! -//! #[tokio::main] -//! async fn main() { -//! let (buffer, buffer_tx) = BufferNode::new(50, 10); -//! -//! let (sink1, sink1_tx) = SinkNode::new("Room 1".to_string(), 10); -//! let (sink2, sink2_tx) = SinkNode::new("Room 2".to_string(), 10); -//! -//! // Room 1 sans délai, Room 2 avec 5 chunks de retard -//! buffer.add_subscriber_with_offset(sink1_tx, 0).await; -//! buffer.add_subscriber_with_offset(sink2_tx, 5).await; -//! -//! tokio::spawn(async move { buffer.run().await.unwrap() }); -//! // ... spawn sinks et source -//! } -//! ``` -//! -//! # Optimisations -//! -//! - **Zero-copy** : Les [`AudioChunk`] sont partagés via `Arc`, seul le pointeur est cloné -//! - **Copy-on-Write** : Les nodes DSP clonent les données uniquement si modification nécessaire -//! - **Backpressure** : Channels bounded avec `try_send` pour éviter les blocages -//! - **RwLock** : Pour partage concurrent du compteur [`TimerNode`] +#![cfg_attr(feature = "simd", feature(portable_simd))] +#![doc = r#" +PMOAudio - Pipeline audio stéréo async optimisé + +Cette crate fournit un pipeline audio push-based async utilisant Tokio, +optimisé pour minimiser les clonages de données via `Arc<[[i32; 2]]>`. + +# Architecture + +Le pipeline est composé de nodes asynchrones qui communiquent via des channels Tokio. +Les données audio sont encapsulées dans des [`AudioChunk`] et partagées via `Arc` pour +éviter les copies inutiles. + +## Pipeline type + +```text +SourceNode → DecoderNode → DSPNode → BufferNode → TimerNode → SinkNode(s) + ↓ + Multiroom Sinks + (avec offsets) +``` + +# Exemples + +## Pipeline simple + +```no_run +use pmoaudio::{SinkNode, SourceNode, TimerNode}; + +#[tokio::main] +async fn main() { + let (mut timer, timer_tx) = TimerNode::new(10); + let (sink, sink_tx) = SinkNode::new("Output".to_string(), 10); + + timer.add_subscriber(sink_tx); + + tokio::spawn(async move { timer.run().await.unwrap() }); + let sink_handle = tokio::spawn(async move { + sink.run_with_stats().await.unwrap() + }); + + tokio::spawn(async move { + let mut source = SourceNode::new(); + source.add_subscriber(timer_tx); + source.generate_chunks(30, 4800, 48000, 440.0).await.unwrap(); + }); + + sink_handle.await.unwrap(); +} +``` + +## Configuration multiroom + +```no_run +use pmoaudio::{BufferNode, SinkNode}; + +#[tokio::main] +async fn main() { + let (buffer, buffer_tx) = BufferNode::new(50, 10); + + let (sink1, sink1_tx) = SinkNode::new("Room 1".to_string(), 10); + let (sink2, sink2_tx) = SinkNode::new("Room 2".to_string(), 10); + + // Room 1 sans délai, Room 2 avec 5 chunks de retard + buffer.add_subscriber_with_offset(sink1_tx, 0).await; + buffer.add_subscriber_with_offset(sink2_tx, 5).await; + + tokio::spawn(async move { buffer.run().await.unwrap() }); + // ... spawn sinks et source +} +``` + +# Optimisations + +- **Zero-copy** : Les [`AudioChunk`] sont partagés via `Arc`, seul le pointeur est cloné +- **Copy-on-Write** : Les nodes DSP clonent les données uniquement si modification nécessaire +- **Backpressure** : Channels bounded avec `try_send` pour éviter les blocages +- **RwLock** : Pour partage concurrent du compteur [`TimerNode`] +"#] +#[cfg(feature = "simd")] +use std::simd::*; mod audio_chunk; pub mod events; mod nodes; +pub mod bit_depth; +pub mod dsp; + pub use audio_chunk::AudioChunk; +pub use bit_depth::{Bit16, Bit24, Bit32, Bit8, BitDepth}; + pub use events::{ AudioDataEvent, EventPublisher, EventReceiver, NodeEvent, NodeListener, SourceNameUpdateEvent, VolumeChangeEvent, @@ -90,6 +100,8 @@ 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, diff --git a/pmoaudio/src/nodes/buffer_node.rs b/pmoaudio/src/nodes/buffer_node.rs index 86bee223..e550d101 100644 --- a/pmoaudio/src/nodes/buffer_node.rs +++ b/pmoaudio/src/nodes/buffer_node.rs @@ -190,6 +190,7 @@ impl BufferNode { #[cfg(test)] mod tests { use super::*; + use crate::BitDepth; #[tokio::test] async fn test_buffer_node_basic() { @@ -205,14 +206,14 @@ mod tests { // Envoyer des chunks for i in 0..3 { - let chunk = AudioChunk::new(i, vec![0.0; 100], vec![0.0; 100], 48000); - tx.send(Arc::new(chunk)).await.unwrap(); + let chunk = AudioChunk::new(i, vec![[0i32; 2]; 100], 48000, BitDepth::B24); + tx.send(chunk).await.unwrap(); } // Recevoir les chunks for i in 0..3 { let chunk = out_rx.recv().await.unwrap(); - assert_eq!(chunk.order, i); + assert_eq!(chunk.order(), i); } } @@ -231,14 +232,14 @@ mod tests { // Envoyer 5 chunks for i in 0..5 { - let chunk = AudioChunk::new(i, vec![0.0; 100], vec![0.0; 100], 48000); - tx.send(Arc::new(chunk)).await.unwrap(); + let chunk = AudioChunk::new(i, vec![[0i32; 2]; 100], 48000, BitDepth::B24); + tx.send(chunk).await.unwrap(); } tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; // L'abonné devrait recevoir les chunks 0, 1, 2 (avec 2 chunks de retard) let chunk = out_rx.try_recv().unwrap(); - assert_eq!(chunk.order, 0); + assert_eq!(chunk.order(), 0); } } diff --git a/pmoaudio/src/nodes/chromecast_sink.rs b/pmoaudio/src/nodes/chromecast_sink.rs index 17b575ad..e1b43e73 100644 --- a/pmoaudio/src/nodes/chromecast_sink.rs +++ b/pmoaudio/src/nodes/chromecast_sink.rs @@ -194,10 +194,10 @@ impl ChromecastSink { // Boucle principale while let Some(chunk) = self.rx.recv().await { // Appliquer le gain si nécessaire - let chunk_to_send = if (chunk.gain - 1.0).abs() > f32::EPSILON { - chunk.apply_gain() + let chunk_to_send = if chunk.gain_db().abs() > f64::EPSILON { + Arc::clone(&chunk).apply_gain() } else { - (*chunk).clone() + Arc::clone(&chunk) }; // Envoyer au Chromecast @@ -238,7 +238,7 @@ impl ChromecastStats { pub fn record_chunk(&mut self, chunk: &AudioChunk) { self.chunks_sent += 1; self.total_samples += chunk.len() as u64; - self.total_duration_sec += chunk.len() as f64 / chunk.sample_rate as f64; + self.total_duration_sec += chunk.len() as f64 / chunk.sample_rate() as f64; } pub fn finalize(&mut self) { @@ -257,7 +257,10 @@ impl ChromecastStats { #[cfg(test)] mod tests { + use std::i32; + use super::*; + use crate::BitDepth; #[tokio::test] async fn test_chromecast_sink_basic() { @@ -273,8 +276,9 @@ mod tests { // Envoyer quelques chunks for i in 0..5 { - let chunk = AudioChunk::new(i, vec![0.5; 1000], vec![0.5; 1000], 48000); - tx.send(Arc::new(chunk)).await.unwrap(); + let stereo = vec![[i32::MAX / 2; 2]; 1000]; + let chunk = AudioChunk::new(i, stereo, 48000, BitDepth::B24); + tx.send(chunk).await.unwrap(); } drop(tx); diff --git a/pmoaudio/src/nodes/decoder_node.rs b/pmoaudio/src/nodes/decoder_node.rs index 087ca3b9..1e8bf020 100644 --- a/pmoaudio/src/nodes/decoder_node.rs +++ b/pmoaudio/src/nodes/decoder_node.rs @@ -40,41 +40,45 @@ impl DecoderNode { /// Mode mock décodage - simule un changement de sample rate pub async fn run_with_resampling(mut self, target_sample_rate: u32) -> Result<(), AudioError> { while let Some(chunk) = self.rx.recv().await { - if chunk.sample_rate == target_sample_rate { + if chunk.sample_rate() == target_sample_rate { // Pas besoin de resampling self.subscribers.push(chunk).await?; } else { // Simuler un resampling (mock simple) - let ratio = target_sample_rate as f64 / chunk.sample_rate as f64; + let ratio = target_sample_rate as f64 / chunk.sample_rate() as f64; let new_len = (chunk.len() as f64 * ratio) as usize; - let (left_data, right_data) = chunk.clone_data(); - let mut new_left = Vec::with_capacity(new_len); - let mut new_right = Vec::with_capacity(new_len); + let pairs = chunk.to_pairs_f32(); + let mut resampled = Vec::with_capacity(new_len); // Resampling linéaire simple (mock) for i in 0..new_len { let src_pos = i as f64 / ratio; let src_idx = src_pos as usize; - if src_idx < left_data.len() - 1 { + if src_idx + 1 < pairs.len() { let frac = src_pos - src_idx as f64; - let left_sample = left_data[src_idx] * (1.0 - frac as f32) - + left_data[src_idx + 1] * frac as f32; - let right_sample = right_data[src_idx] * (1.0 - frac as f32) - + right_data[src_idx + 1] * frac as f32; + let alpha = (1.0 - frac) as f32; + let beta = frac as f32; + let left_sample = pairs[src_idx][0] * alpha + pairs[src_idx + 1][0] * beta; + let right_sample = pairs[src_idx][1] * alpha + pairs[src_idx + 1][1] * beta; - new_left.push(left_sample); - new_right.push(right_sample); - } else if src_idx < left_data.len() { - new_left.push(left_data[src_idx]); - new_right.push(right_data[src_idx]); + resampled.push([left_sample, right_sample]); + } else if src_idx < pairs.len() { + resampled.push(pairs[src_idx]); } } - let new_chunk = - AudioChunk::new(chunk.order, new_left, new_right, target_sample_rate); - self.subscribers.push(Arc::new(new_chunk)).await?; + let mut new_chunk = AudioChunk::from_pairs_f32( + chunk.order(), + resampled, + target_sample_rate, + chunk.bit_depth(), + ); + if chunk.gain_db().abs() > f64::EPSILON { + new_chunk = new_chunk.set_gain_db(chunk.gain_db()); + } + self.subscribers.push(new_chunk).await?; } } Ok(()) @@ -84,6 +88,7 @@ impl DecoderNode { #[cfg(test)] mod tests { use super::*; + use crate::BitDepth; #[tokio::test] async fn test_decoder_passthrough() { @@ -97,13 +102,18 @@ mod tests { }); // Envoyer un chunk - let chunk = AudioChunk::new(0, vec![1.0, 2.0, 3.0], vec![4.0, 5.0, 6.0], 48000); - let chunk_arc = Arc::new(chunk); - tx.send(chunk_arc.clone()).await.unwrap(); + let chunk = AudioChunk::from_channels_f32( + 0, + vec![1.0, 2.0, 3.0], + vec![4.0, 5.0, 6.0], + 48000, + BitDepth::B24, + ); + tx.send(chunk.clone()).await.unwrap(); // Recevoir le chunk let received = out_rx.recv().await.unwrap(); - assert!(Arc::ptr_eq(&chunk_arc, &received)); + assert!(Arc::ptr_eq(&chunk, &received)); } #[tokio::test] @@ -118,12 +128,13 @@ mod tests { }); // Envoyer un chunk à 48000 Hz - let chunk = AudioChunk::new(0, vec![1.0; 100], vec![1.0; 100], 48000); - tx.send(Arc::new(chunk)).await.unwrap(); + let chunk = + AudioChunk::from_channels_f32(0, vec![1.0; 100], vec![1.0; 100], 48000, BitDepth::B24); + tx.send(chunk).await.unwrap(); // Recevoir le chunk resampleé let received = out_rx.recv().await.unwrap(); - assert_eq!(received.sample_rate, 96000); + assert_eq!(received.sample_rate(), 96000); // Le chunk devrait être environ 2x plus grand assert!(received.len() > 150 && received.len() < 250); } @@ -140,12 +151,12 @@ mod tests { }); // Envoyer un chunk déjà au bon sample rate - let chunk = AudioChunk::new(0, vec![1.0; 100], vec![1.0; 100], 48000); - let chunk_arc = Arc::new(chunk); - tx.send(chunk_arc.clone()).await.unwrap(); + let chunk = + AudioChunk::from_channels_f32(0, vec![1.0; 100], vec![1.0; 100], 48000, BitDepth::B24); + tx.send(chunk.clone()).await.unwrap(); // Le chunk devrait être passé sans modification let received = out_rx.recv().await.unwrap(); - assert!(Arc::ptr_eq(&chunk_arc, &received)); + assert!(Arc::ptr_eq(&chunk, &received)); } } diff --git a/pmoaudio/src/nodes/disk_sink.rs b/pmoaudio/src/nodes/disk_sink.rs index 92e76e9f..f1b37f31 100644 --- a/pmoaudio/src/nodes/disk_sink.rs +++ b/pmoaudio/src/nodes/disk_sink.rs @@ -220,10 +220,10 @@ impl DiskSink { } // Appliquer le gain avant l'écriture - let chunk_with_gain = if (chunk.gain - 1.0).abs() > f32::EPSILON { - chunk.apply_gain() + let chunk_with_gain = if chunk.gain_db().abs() > f64::EPSILON { + Arc::clone(&chunk).apply_gain() } else { - (*chunk).clone() + Arc::clone(&chunk) }; // Écrire le chunk @@ -308,26 +308,25 @@ impl AudioFileWriter { async fn write_chunk(&mut self, chunk: &AudioChunk) -> Result<(), AudioError> { // Enregistrer le sample rate du premier chunk if self.sample_rate.is_none() { - self.sample_rate = Some(chunk.sample_rate); + let sr = chunk.sample_rate(); + self.sample_rate = Some(sr); // Pour WAV, écrire l'en-tête (simplifié) if matches!(self.format, AudioFileFormat::Wav) { - self.write_wav_header(chunk.sample_rate).await?; + self.write_wav_header(sr).await?; } } - // Entrelacer les canaux gauche et droit - let mut interleaved = Vec::with_capacity(chunk.len() * 2); - for i in 0..chunk.len() { - interleaved.push(chunk.left[i]); - interleaved.push(chunk.right[i]); - } - // Convertir en bytes (little-endian 16-bit PCM) - let mut bytes = Vec::with_capacity(interleaved.len() * 2); - for &sample in &interleaved { - let sample_i16 = (sample.clamp(-1.0, 1.0) * 32767.0) as i16; + let mut bytes = Vec::with_capacity(chunk.len() * 4); + let max_val = chunk.bit_depth().max_value(); + for frame in chunk.frames() { + let left = (frame[0] as f32 / max_val).clamp(-1.0, 1.0); + let right = (frame[1] as f32 / max_val).clamp(-1.0, 1.0); + let sample_i16 = (left * 32767.0) as i16; bytes.extend_from_slice(&sample_i16.to_le_bytes()); + let sample_r16 = (right * 32767.0) as i16; + bytes.extend_from_slice(&sample_r16.to_le_bytes()); } self.file.write_all(&bytes).await.map_err(|e| { @@ -436,7 +435,7 @@ impl DiskSinkStats { pub fn record_chunk(&mut self, chunk: &AudioChunk) { self.chunks_written += 1; self.total_samples += chunk.len() as u64; - self.total_duration_sec += chunk.len() as f64 / chunk.sample_rate as f64; + self.total_duration_sec += chunk.len() as f64 / chunk.sample_rate() as f64; } pub fn finalize(&mut self) { @@ -455,6 +454,7 @@ impl DiskSinkStats { #[cfg(test)] mod tests { use super::*; + use crate::BitDepth; #[tokio::test] async fn test_disk_sink_basic() { @@ -474,8 +474,14 @@ mod tests { // Envoyer quelques chunks for i in 0..5 { - let chunk = AudioChunk::new(i, vec![0.5; 1000], vec![0.5; 1000], 48000); - tx.send(Arc::new(chunk)).await.unwrap(); + let chunk = AudioChunk::from_channels_f32( + i, + vec![0.5; 1000], + vec![0.5; 1000], + 48000, + BitDepth::B24, + ); + tx.send(chunk).await.unwrap(); } drop(tx); diff --git a/pmoaudio/src/nodes/dsp_node.rs b/pmoaudio/src/nodes/dsp_node.rs index 57383472..2c7f0d36 100644 --- a/pmoaudio/src/nodes/dsp_node.rs +++ b/pmoaudio/src/nodes/dsp_node.rs @@ -11,17 +11,17 @@ use tokio::sync::mpsc; pub struct DspNode { rx: mpsc::Receiver>, subscribers: MultiSubscriberNode, - gain: f32, + gain_db: f32, } impl DspNode { - pub fn new(channel_size: usize, gain: f32) -> (Self, mpsc::Sender>) { + pub fn new(channel_size: usize, gain_db: f32) -> (Self, mpsc::Sender>) { let (tx, rx) = mpsc::channel(channel_size); let node = Self { rx, subscribers: MultiSubscriberNode::new(), - gain, + gain_db, }; (node, tx) @@ -34,33 +34,36 @@ impl DspNode { /// Applique le gain aux chunks pub async fn run(mut self) -> Result<(), AudioError> { while let Some(chunk) = self.rx.recv().await { - if (self.gain - 1.0).abs() < f32::EPSILON { - // Gain = 1.0, pas de transformation nécessaire + if self.gain_db.abs() < f32::EPSILON { + // Gain = 0 dB, pas de transformation nécessaire self.subscribers.push(chunk).await?; - } else { - // Clone les données pour les modifier - let (mut left_data, mut right_data) = chunk.clone_data(); - - // Appliquer le gain - for sample in &mut left_data { - *sample *= self.gain; - } - for sample in &mut right_data { - *sample *= self.gain; - } - - let new_chunk = - AudioChunk::new(chunk.order, left_data, right_data, chunk.sample_rate); - - self.subscribers.push(Arc::new(new_chunk)).await?; + continue; } + + let gain_linear = AudioChunk::gain_linear_from_db(self.gain_db as f64) as f32; + let mut pairs = chunk.to_pairs_f32(); + for frame in &mut pairs { + frame[0] *= gain_linear; + frame[1] *= gain_linear; + } + + let mut new_chunk = AudioChunk::from_pairs_f32( + chunk.order(), + pairs, + chunk.sample_rate(), + chunk.bit_depth(), + ); + if chunk.gain_db().abs() > f64::EPSILON { + new_chunk = new_chunk.set_gain_db(chunk.gain_db()); + } + self.subscribers.push(new_chunk).await?; } Ok(()) } /// Met à jour le gain dynamiquement (nécessite un `Arc>` dans une version réelle) - pub fn set_gain(&mut self, gain: f32) { - self.gain = gain; + pub fn set_gain_db(&mut self, gain_db: f32) { + self.gain_db = gain_db; } } @@ -102,24 +105,26 @@ impl LowPassDspNode { #[allow(dead_code)] pub async fn run(mut self) -> Result<(), AudioError> { while let Some(chunk) = self.rx.recv().await { - let (left_data, right_data) = chunk.clone_data(); - let mut new_left = Vec::with_capacity(left_data.len()); - let mut new_right = Vec::with_capacity(right_data.len()); + let pairs = chunk.to_pairs_f32(); + let mut filtered = Vec::with_capacity(pairs.len()); - // Appliquer le filtre - for &sample in &left_data { - self.prev_left = self.prev_left + self.alpha * (sample - self.prev_left); - new_left.push(self.prev_left); + for sample in pairs.iter() { + self.prev_left = self.prev_left + self.alpha * (sample[0] - self.prev_left); + self.prev_right = self.prev_right + self.alpha * (sample[1] - self.prev_right); + filtered.push([self.prev_left, self.prev_right]); } - for &sample in &right_data { - self.prev_right = self.prev_right + self.alpha * (sample - self.prev_right); - new_right.push(self.prev_right); + let mut new_chunk = AudioChunk::from_pairs_f32( + chunk.order(), + filtered, + chunk.sample_rate(), + chunk.bit_depth(), + ); + if chunk.gain_db().abs() > f64::EPSILON { + new_chunk = new_chunk.set_gain_db(chunk.gain_db()); } - let new_chunk = AudioChunk::new(chunk.order, new_left, new_right, chunk.sample_rate); - - self.subscribers.push(Arc::new(new_chunk)).await?; + self.subscribers.push(new_chunk).await?; } Ok(()) } @@ -128,10 +133,11 @@ impl LowPassDspNode { #[cfg(test)] mod tests { use super::*; + use crate::BitDepth; #[tokio::test] async fn test_dsp_node_unity_gain() { - let (mut node, tx) = DspNode::new(10, 1.0); + let (mut node, tx) = DspNode::new(10, 0.0); let (out_tx, mut out_rx) = mpsc::channel(10); node.add_subscriber(out_tx); @@ -141,18 +147,24 @@ mod tests { }); // Envoyer un chunk - let chunk = AudioChunk::new(0, vec![1.0, 2.0, 3.0], vec![4.0, 5.0, 6.0], 48000); - let chunk_arc = Arc::new(chunk); - tx.send(chunk_arc.clone()).await.unwrap(); + let chunk = AudioChunk::from_channels_f32( + 0, + vec![0.25, 0.5, 0.75], + vec![0.1, 0.2, 0.3], + 48000, + BitDepth::B24, + ); + tx.send(chunk.clone()).await.unwrap(); // Avec gain = 1.0, le chunk ne devrait pas être cloné let received = out_rx.recv().await.unwrap(); - assert!(Arc::ptr_eq(&chunk_arc, &received)); + assert!(Arc::ptr_eq(&chunk, &received)); } #[tokio::test] async fn test_dsp_node_gain() { - let (mut node, tx) = DspNode::new(10, 2.0); + let gain_db = AudioChunk::gain_db_from_linear(2.0) as f32; + let (mut node, tx) = DspNode::new(10, gain_db); let (out_tx, mut out_rx) = mpsc::channel(10); node.add_subscriber(out_tx); @@ -162,17 +174,25 @@ mod tests { }); // Envoyer un chunk - let chunk = AudioChunk::new(0, vec![1.0, 2.0, 3.0], vec![4.0, 5.0, 6.0], 48000); - tx.send(Arc::new(chunk)).await.unwrap(); + let chunk = AudioChunk::from_channels_f32( + 0, + vec![0.25, 0.5, 0.75], + vec![0.1, 0.2, 0.3], + 48000, + BitDepth::B24, + ); + tx.send(chunk).await.unwrap(); // Vérifier que le gain a été appliqué let received = out_rx.recv().await.unwrap(); - assert_eq!(received.left[0], 2.0); - assert_eq!(received.left[1], 4.0); - assert_eq!(received.left[2], 6.0); - assert_eq!(received.right[0], 8.0); - assert_eq!(received.right[1], 10.0); - assert_eq!(received.right[2], 12.0); + let frames = received.to_pairs_f32(); + const EPS: f32 = 1e-3; + assert!((frames[0][0] - 0.5).abs() < EPS); + assert!((frames[1][0] - 1.0).abs() < EPS); + assert!((frames[2][0] - 1.0).abs() < EPS); // Clamp at full scale + assert!((frames[0][1] - 0.2).abs() < EPS); + assert!((frames[1][1] - 0.4).abs() < EPS); + assert!((frames[2][1] - 0.6).abs() < EPS); } #[tokio::test] @@ -187,25 +207,26 @@ mod tests { }); // Envoyer un chunk avec un signal carré - let chunk = AudioChunk::new( + let chunk = AudioChunk::from_channels_f32( 0, vec![1.0, 1.0, 1.0, -1.0, -1.0, -1.0], vec![1.0, 1.0, 1.0, -1.0, -1.0, -1.0], 48000, + BitDepth::B24, ); - tx.send(Arc::new(chunk)).await.unwrap(); + tx.send(chunk).await.unwrap(); // Le filtre devrait lisser le signal let received = out_rx.recv().await.unwrap(); - - // Vérifier que le signal est lissé (valeurs intermédiaires) - assert!(received.left[0].abs() < 1.0); // Premier échantillon lissé - assert!(received.left[2].abs() < 1.0); // Signal ne devrait pas atteindre 1.0 immédiatement + let frames = received.to_pairs_f32(); + assert!(frames[0][0].abs() < 1.0); // Premier échantillon lissé + assert!(frames[2][0].abs() < 1.0); // Signal ne devrait pas atteindre 1.0 immédiatement } #[tokio::test] async fn test_dsp_node_multiple_subscribers() { - let (mut node, tx) = DspNode::new(10, 0.5); + let gain_db = AudioChunk::gain_db_from_linear(0.5) as f32; + let (mut node, tx) = DspNode::new(10, gain_db); let (out_tx1, mut out_rx1) = mpsc::channel(10); let (out_tx2, mut out_rx2) = mpsc::channel(10); @@ -216,15 +237,18 @@ mod tests { node.run().await.unwrap(); }); - let chunk = AudioChunk::new(0, vec![2.0, 4.0], vec![2.0, 4.0], 48000); - tx.send(Arc::new(chunk)).await.unwrap(); + let chunk = + AudioChunk::from_channels_f32(0, vec![0.8, 0.4], vec![0.8, 0.4], 48000, BitDepth::B24); + tx.send(chunk).await.unwrap(); // Les deux abonnés devraient recevoir le même Arc let received1 = out_rx1.recv().await.unwrap(); let received2 = out_rx2.recv().await.unwrap(); assert!(Arc::ptr_eq(&received1, &received2)); - assert_eq!(received1.left[0], 1.0); // 2.0 * 0.5 - assert_eq!(received1.left[1], 2.0); // 4.0 * 0.5 + let frames = received1.to_pairs_f32(); + const EPS: f32 = 1e-3; + assert!((frames[0][0] - 0.4).abs() < EPS); // 0.8 * 0.5 + assert!((frames[1][0] - 0.2).abs() < EPS); // 0.4 * 0.5 } } diff --git a/pmoaudio/src/nodes/file_source.rs b/pmoaudio/src/nodes/file_source.rs new file mode 100644 index 00000000..7491bef9 --- /dev/null +++ b/pmoaudio/src/nodes/file_source.rs @@ -0,0 +1,244 @@ +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/pmoaudio/src/nodes/flac_file_sink.rs b/pmoaudio/src/nodes/flac_file_sink.rs new file mode 100644 index 00000000..814bd6c2 --- /dev/null +++ b/pmoaudio/src/nodes/flac_file_sink.rs @@ -0,0 +1,300 @@ +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/src/nodes/mod.rs b/pmoaudio/src/nodes/mod.rs index 6cf5dc79..0c55288a 100644 --- a/pmoaudio/src/nodes/mod.rs +++ b/pmoaudio/src/nodes/mod.rs @@ -12,6 +12,8 @@ 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; diff --git a/pmoaudio/src/nodes/mpd_sink.rs b/pmoaudio/src/nodes/mpd_sink.rs index 4b1ae09f..581b87d8 100644 --- a/pmoaudio/src/nodes/mpd_sink.rs +++ b/pmoaudio/src/nodes/mpd_sink.rs @@ -243,10 +243,10 @@ impl MpdSink { // Boucle principale while let Some(chunk) = self.rx.recv().await { // Appliquer le gain si nécessaire - let chunk_to_send = if (chunk.gain - 1.0).abs() > f32::EPSILON { - chunk.apply_gain() + let chunk_to_send = if chunk.gain_db().abs() > f64::EPSILON { + Arc::clone(&chunk).apply_gain() } else { - (*chunk).clone() + Arc::clone(&chunk) }; // Envoyer au serveur MPD @@ -329,7 +329,7 @@ impl MpdStats { pub fn record_chunk(&mut self, chunk: &AudioChunk) { self.chunks_sent += 1; self.total_samples += chunk.len() as u64; - self.total_duration_sec += chunk.len() as f64 / chunk.sample_rate as f64; + self.total_duration_sec += chunk.len() as f64 / chunk.sample_rate() as f64; } pub fn finalize(&mut self) { @@ -349,6 +349,7 @@ impl MpdStats { #[cfg(test)] mod tests { use super::*; + use crate::BitDepth; #[tokio::test] async fn test_mpd_sink_basic() { @@ -364,8 +365,14 @@ mod tests { // Envoyer quelques chunks for i in 0..5 { - let chunk = AudioChunk::new(i, vec![0.5; 1000], vec![0.5; 1000], 48000); - tx.send(Arc::new(chunk)).await.unwrap(); + let chunk = AudioChunk::from_channels_f32( + i, + vec![0.5; 1000], + vec![0.5; 1000], + 48000, + BitDepth::B24, + ); + tx.send(chunk).await.unwrap(); } drop(tx); diff --git a/pmoaudio/src/nodes/sink_node.rs b/pmoaudio/src/nodes/sink_node.rs index bc651456..bb326e29 100644 --- a/pmoaudio/src/nodes/sink_node.rs +++ b/pmoaudio/src/nodes/sink_node.rs @@ -33,9 +33,9 @@ impl SinkNode { println!( "[{}] Received chunk #{} - {} samples @ {} Hz", self.name, - chunk.order, + chunk.order(), chunk.len(), - chunk.sample_rate + chunk.sample_rate() ); } Ok(()) @@ -95,34 +95,41 @@ impl SinkStats { pub fn process_chunk(&mut self, chunk: &AudioChunk) { self.chunks_received += 1; - self.total_samples += chunk.len() as u64; - self.total_duration_sec += chunk.len() as f64 / chunk.sample_rate as f64; + let len = chunk.len() as u64; + self.total_samples += len; + self.total_duration_sec += chunk.len() as f64 / chunk.sample_rate() as f64; - // Calculer les peaks - for &sample in chunk.left.iter() { - if sample.abs() > self.peak_left { - self.peak_left = sample.abs(); + let inv_max = 1.0f32 / chunk.bit_depth().max_value(); + let mut peak_left = self.peak_left; + let mut peak_right = self.peak_right; + let mut sum_squares_left = 0.0f64; + let mut sum_squares_right = 0.0f64; + + for frame in chunk.frames() { + let left = frame[0] as f32 * inv_max; + let right = frame[1] as f32 * inv_max; + let left_abs = left.abs(); + let right_abs = right.abs(); + if left_abs > peak_left { + peak_left = left_abs; } + if right_abs > peak_right { + peak_right = right_abs; + } + let l64 = left as f64; + let r64 = right as f64; + sum_squares_left += l64 * l64; + sum_squares_right += r64 * r64; } - for &sample in chunk.right.iter() { - if sample.abs() > self.peak_right { - self.peak_right = sample.abs(); - } - } + self.peak_left = peak_left; + self.peak_right = peak_right; - // Calculer RMS (moyenne des carrés) - let sum_squares_left: f64 = chunk.left.iter().map(|&x| (x * x) as f64).sum(); - let sum_squares_right: f64 = chunk.right.iter().map(|&x| (x * x) as f64).sum(); - - self.rms_left = ((self.rms_left.powi(2) - * (self.total_samples - chunk.len() as u64) as f64 - + sum_squares_left) + let prev_samples = self.total_samples - len; + self.rms_left = ((self.rms_left.powi(2) * prev_samples as f64 + sum_squares_left) / self.total_samples as f64) .sqrt(); - self.rms_right = ((self.rms_right.powi(2) - * (self.total_samples - chunk.len() as u64) as f64 - + sum_squares_right) + self.rms_right = ((self.rms_right.powi(2) * prev_samples as f64 + sum_squares_right) / self.total_samples as f64) .sqrt(); } @@ -141,6 +148,9 @@ impl SinkStats { #[cfg(test)] mod tests { use super::*; + use crate::BitDepth; + + const BD: BitDepth = BitDepth::B24; #[tokio::test] async fn test_sink_node_silent() { @@ -150,8 +160,8 @@ mod tests { // Envoyer quelques chunks for i in 0..3 { - let chunk = AudioChunk::new(i, vec![0.0; 100], vec![0.0; 100], 48000); - tx.send(Arc::new(chunk)).await.unwrap(); + let chunk = AudioChunk::from_channels_f32(i, vec![0.0; 100], vec![0.0; 100], 48000, BD); + tx.send(chunk).await.unwrap(); } drop(tx); @@ -166,8 +176,9 @@ mod tests { // Envoyer des chunks avec signal connu for i in 0..3 { - let chunk = AudioChunk::new(i, vec![1.0; 1000], vec![0.5; 1000], 48000); - tx.send(Arc::new(chunk)).await.unwrap(); + let chunk = + AudioChunk::from_channels_f32(i, vec![1.0; 1000], vec![0.5; 1000], 48000, BD); + tx.send(chunk).await.unwrap(); } drop(tx); @@ -175,8 +186,8 @@ mod tests { assert_eq!(stats.chunks_received, 3); assert_eq!(stats.total_samples, 3000); - assert_eq!(stats.peak_left, 1.0); - assert_eq!(stats.peak_right, 0.5); + assert!((stats.peak_left - 1.0).abs() < 1e-6); + assert!((stats.peak_right - 0.5).abs() < 1e-6); assert!((stats.rms_left - 1.0).abs() < 0.001); assert!((stats.rms_right - 0.5).abs() < 0.001); } @@ -189,8 +200,8 @@ mod tests { // Envoyer des chunks for i in 0..5 { - let chunk = AudioChunk::new(i, vec![0.0; 100], vec![0.0; 100], 48000); - tx.send(Arc::new(chunk)).await.unwrap(); + let chunk = AudioChunk::from_channels_f32(i, vec![0.0; 100], vec![0.0; 100], 48000, BD); + tx.send(chunk).await.unwrap(); } drop(tx); diff --git a/pmoaudio/src/nodes/source_node.rs b/pmoaudio/src/nodes/source_node.rs index bef7e5cd..c2933f2d 100644 --- a/pmoaudio/src/nodes/source_node.rs +++ b/pmoaudio/src/nodes/source_node.rs @@ -1,6 +1,6 @@ use crate::{ nodes::{AudioError, MultiSubscriberNode}, - AudioChunk, + AudioChunk, BitDepth, }; use std::sync::Arc; use tokio::sync::mpsc; @@ -12,6 +12,8 @@ pub struct SourceNode { subscribers: MultiSubscriberNode, } +const DEFAULT_BIT_DEPTH: BitDepth = BitDepth::B24; + impl SourceNode { pub fn new() -> Self { Self { @@ -29,7 +31,7 @@ impl SourceNode { size: usize, sample_rate: u32, frequency: f32, - ) -> AudioChunk { + ) -> Arc { let mut left = Vec::with_capacity(size); let mut right = Vec::with_capacity(size); @@ -40,7 +42,7 @@ impl SourceNode { right.push(sample * 0.8); // Légèrement différent pour la stéréo } - AudioChunk::new(order, left, right, sample_rate) + AudioChunk::from_channels_f32(order, left, right, sample_rate, DEFAULT_BIT_DEPTH) } /// Génère et envoie des chunks de test @@ -53,7 +55,7 @@ impl SourceNode { ) -> Result<(), AudioError> { for i in 0..count { let chunk = Self::generate_test_chunk(i, chunk_size, sample_rate, frequency); - self.subscribers.push(Arc::new(chunk)).await?; + self.subscribers.push(chunk).await?; } Ok(()) } @@ -66,9 +68,9 @@ impl SourceNode { sample_rate: u32, ) -> Result<(), AudioError> { for i in 0..count { - let chunk = - AudioChunk::new(i, vec![0.0; chunk_size], vec![0.0; chunk_size], sample_rate); - self.subscribers.push(Arc::new(chunk)).await?; + let stereo = vec![[0i32; 2]; chunk_size]; + let chunk = AudioChunk::new(i, stereo, sample_rate, DEFAULT_BIT_DEPTH); + self.subscribers.push(chunk).await?; } Ok(()) } @@ -89,7 +91,7 @@ impl SourceNode { while start.elapsed() < duration { let chunk = Self::generate_test_chunk(order, chunk_size, sample_rate, frequency); - self.subscribers.push(Arc::new(chunk)).await?; + self.subscribers.push(chunk).await?; order += 1; @@ -124,9 +126,9 @@ mod tests { // Vérifier la réception for i in 0..3 { let chunk = rx.recv().await.unwrap(); - assert_eq!(chunk.order, i); + assert_eq!(chunk.order(), i); assert_eq!(chunk.len(), 100); - assert_eq!(chunk.sample_rate, 48000); + assert_eq!(chunk.sample_rate(), 48000); } } @@ -136,7 +138,8 @@ mod tests { // Vérifier qu'on a bien une sinusoïde // À 440 Hz avec 48000 samples/s, on devrait avoir 440 cycles - let left = &*chunk.left; + let pairs = chunk.to_pairs_f32(); + let left: Vec = pairs.iter().map(|frame| frame[0]).collect(); // Trouver les passages par zéro let mut zero_crossings = 0; @@ -161,8 +164,8 @@ mod tests { for _ in 0..2 { let chunk = rx.recv().await.unwrap(); - assert!(chunk.left.iter().all(|&x| x == 0.0)); - assert!(chunk.right.iter().all(|&x| x == 0.0)); + assert!(chunk.frames().iter().all(|frame| frame[0] == 0)); + assert!(chunk.frames().iter().all(|frame| frame[1] == 0)); } } } diff --git a/pmoaudio/src/nodes/timer_node.rs b/pmoaudio/src/nodes/timer_node.rs index c82060b5..97a960bd 100644 --- a/pmoaudio/src/nodes/timer_node.rs +++ b/pmoaudio/src/nodes/timer_node.rs @@ -93,8 +93,8 @@ impl TimerNode { // Mettre à jour le sample rate si nécessaire { let mut sr = self.current_sample_rate.write().await; - if *sr != chunk.sample_rate { - *sr = chunk.sample_rate; + if *sr != chunk.sample_rate() { + *sr = chunk.sample_rate(); } } @@ -116,8 +116,8 @@ impl TimerNode { while let Some(chunk) = self.rx.recv().await { { let mut sr = self.current_sample_rate.write().await; - if *sr != chunk.sample_rate { - *sr = chunk.sample_rate; + if *sr != chunk.sample_rate() { + *sr = chunk.sample_rate(); } } @@ -190,6 +190,7 @@ impl TimerHandle { #[cfg(test)] mod tests { use super::*; + use crate::BitDepth; #[tokio::test] async fn test_timer_node_position_calculation() { @@ -207,8 +208,9 @@ mod tests { // Envoyer 3 chunks de 1000 samples à 48000 Hz for i in 0..3 { - let chunk = AudioChunk::new(i, vec![0.0; 1000], vec![0.0; 1000], 48000); - tx.send(Arc::new(chunk)).await.unwrap(); + let stereo = vec![[0i32; 2]; 1000]; + let chunk = AudioChunk::new(i, stereo, 48000, BitDepth::B24); + tx.send(chunk).await.unwrap(); } // Attendre que les chunks soient traités @@ -237,16 +239,21 @@ mod tests { }); // Envoyer un chunk - let chunk = AudioChunk::new(42, vec![1.0, 2.0, 3.0], vec![4.0, 5.0, 6.0], 48000); - let chunk_arc = Arc::new(chunk); - tx.send(chunk_arc.clone()).await.unwrap(); + let chunk = AudioChunk::from_channels_i32( + 42, + vec![100, 200, 300], + vec![400, 500, 600], + 48000, + BitDepth::B24, + ); + tx.send(chunk.clone()).await.unwrap(); // Recevoir le chunk let received = out_rx.recv().await.unwrap(); // Vérifier que c'est le même Arc (pas de clone des données) - assert!(Arc::ptr_eq(&chunk_arc, &received)); - assert_eq!(received.order, 42); + assert!(Arc::ptr_eq(&chunk, &received)); + assert_eq!(received.order(), 42); } #[tokio::test] @@ -263,8 +270,8 @@ mod tests { }); // Chunk à 48000 Hz - let chunk1 = AudioChunk::new(0, vec![0.0; 48000], vec![0.0; 48000], 48000); - tx.send(Arc::new(chunk1)).await.unwrap(); + let chunk1 = AudioChunk::new(0, vec![[0i32; 2]; 48000], 48000, BitDepth::B24); + tx.send(chunk1).await.unwrap(); out_rx.recv().await.unwrap(); // Après 48000 samples à 48000 Hz = 1 seconde @@ -272,8 +279,8 @@ mod tests { assert!((pos1 - 1.0).abs() < 0.0001); // Chunk à 96000 Hz - let chunk2 = AudioChunk::new(1, vec![0.0; 96000], vec![0.0; 96000], 96000); - tx.send(Arc::new(chunk2)).await.unwrap(); + let chunk2 = AudioChunk::new(1, vec![[0i32; 2]; 96000], 96000, BitDepth::B24); + tx.send(chunk2).await.unwrap(); out_rx.recv().await.unwrap(); // Position calculée avec le nouveau sample rate diff --git a/pmoaudio/src/nodes/volume_node.rs b/pmoaudio/src/nodes/volume_node.rs index 21dc424f..b9c3c248 100644 --- a/pmoaudio/src/nodes/volume_node.rs +++ b/pmoaudio/src/nodes/volume_node.rs @@ -126,13 +126,14 @@ impl VolumeNode { match chunk_opt { Some(chunk) => { let local_volume = *self.volume.read().await; - let total_volume = local_volume * master_volume; + let total_volume = (local_volume * master_volume).max(0.0); - // Créer un nouveau chunk avec le gain modifié - let modified_chunk = chunk.with_modified_gain(total_volume); + // Créer un nouveau chunk avec le gain modifié (conversion vers dB) + let modified_chunk = + chunk.with_modified_gain_linear(total_volume as f64); // Envoyer aux subscribers - self.subscribers.push(Arc::new(modified_chunk)).await?; + self.subscribers.push(modified_chunk).await?; } None => { // Channel fermé, terminer @@ -255,6 +256,7 @@ impl HardwareVolumeNode { #[cfg(test)] mod tests { use super::*; + use crate::BitDepth; #[tokio::test] async fn test_volume_node_basic() { @@ -266,12 +268,13 @@ mod tests { let handle = tokio::spawn(async move { node.run().await }); // Envoyer un chunk avec gain 1.0 - let chunk = AudioChunk::with_gain(0, vec![1.0; 100], vec![1.0; 100], 48000, 1.0); - tx.send(Arc::new(chunk)).await.unwrap(); + let chunk = + AudioChunk::from_channels_f32(0, vec![1.0; 100], vec![1.0; 100], 48000, BitDepth::B24); + tx.send(chunk).await.unwrap(); // Recevoir le chunk modifié let modified = out_rx.recv().await.unwrap(); - assert!((modified.gain - 0.5).abs() < f32::EPSILON); + assert!((modified.gain_linear() - 0.5).abs() < 1e-6); drop(tx); handle.await.unwrap().unwrap(); @@ -332,8 +335,9 @@ mod tests { tokio::spawn(async move { slave.run().await }); // Envoyer un chunk au slave - let chunk = AudioChunk::with_gain(0, vec![1.0; 100], vec![1.0; 100], 48000, 1.0); - slave_tx.send(Arc::new(chunk)).await.unwrap(); + let chunk = + AudioChunk::from_channels_f32(0, vec![1.0; 100], vec![1.0; 100], 48000, BitDepth::B24); + slave_tx.send(chunk).await.unwrap(); tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; @@ -343,14 +347,15 @@ mod tests { tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; // Envoyer un autre chunk - let chunk2 = AudioChunk::with_gain(1, vec![1.0; 100], vec![1.0; 100], 48000, 1.0); - slave_tx.send(Arc::new(chunk2)).await.unwrap(); + let chunk2 = + AudioChunk::from_channels_f32(1, vec![1.0; 100], vec![1.0; 100], 48000, BitDepth::B24); + slave_tx.send(chunk2).await.unwrap(); - // Le deuxième chunk devrait avoir un gain de 0.8 * 0.5 = 0.4 - let _first = out_rx.recv().await.unwrap(); // gain = 0.8 - let second = out_rx.recv().await.unwrap(); // gain = 0.4 + // Le deuxième chunk devrait avoir un gain de 0.8 * 0.5 = 0.4 (≈ -7.96 dB) + let _first = out_rx.recv().await.unwrap(); // gain ≈ 0.8 + let second = out_rx.recv().await.unwrap(); // gain ≈ 0.4 - assert!((second.gain - 0.4).abs() < 0.01); + assert!((second.gain_linear() - 0.4).abs() < 0.01); drop(master_tx); drop(slave_tx); diff --git a/pmoaudio/tests/integration_test.rs b/pmoaudio/tests/integration_test.rs index 4c038217..0a0f76bf 100644 --- a/pmoaudio/tests/integration_test.rs +++ b/pmoaudio/tests/integration_test.rs @@ -1,13 +1,14 @@ //! Tests d'intégration pour le pipeline audio complet -use pmoaudio::{BufferNode, DecoderNode, DspNode, SinkNode, SourceNode, TimerNode}; +use pmoaudio::{AudioChunk, BufferNode, DecoderNode, DspNode, SinkNode, SourceNode, TimerNode}; #[tokio::test] async fn test_complete_pipeline() { // Créer un pipeline complet : Source → Decoder → DSP → Buffer → Timer → Sink let (mut decoder, decoder_tx) = DecoderNode::new(10); - let (mut dsp, dsp_tx) = DspNode::new(10, 0.5); // Gain de 0.5 + let gain_db = AudioChunk::gain_db_from_linear(0.5) as f32; + let (mut dsp, dsp_tx) = DspNode::new(10, gain_db); // Gain de 0.5 let (mut buffer, buffer_tx) = BufferNode::new(50, 10); let (mut timer, timer_tx) = TimerNode::new(10); let (sink, sink_tx) = SinkNode::new("Integration Test".to_string(), 10); diff --git a/tools/test_cpu b/tools/test_cpu new file mode 100755 index 00000000..82bb1071 --- /dev/null +++ b/tools/test_cpu @@ -0,0 +1,123 @@ +#!/bin/bash + +echo "=== Identification CPU ===" +echo "" + +# Architecture +ARCH=$(uname -m) +echo "Architecture: $ARCH" + +# Modèle CPU +if [ -f /proc/cpuinfo ]; then + MODEL=$(grep "model name" /proc/cpuinfo | head -1 | cut -d: -f2 | xargs) + if [ -z "$MODEL" ]; then + # Sur ARM, pas de "model name" + MODEL=$(grep "Hardware" /proc/cpuinfo | head -1 | cut -d: -f2 | xargs) + fi + echo "Modèle: $MODEL" +fi + +# Nombre de cœurs +CORES=$(nproc) +echo "Cœurs: $CORES" + +echo "" +echo "=== Capacités SIMD ===" + +if [[ "$ARCH" == "x86_64" || "$ARCH" == "i686" ]]; then + # x86/x86_64 + echo "Type: x86/x86_64" + + if grep -q "avx512" /proc/cpuinfo; then + echo "✅ AVX-512 disponible" + fi + + if grep -q "avx2" /proc/cpuinfo; then + echo "✅ AVX2 disponible (recommandé pour SIMD)" + fi + + if grep -q "avx" /proc/cpuinfo; then + echo "✅ AVX disponible" + fi + + if grep -q "sse4_2" /proc/cpuinfo; then + echo "✅ SSE4.2 disponible" + fi + + if grep -q "fma" /proc/cpuinfo; then + echo "✅ FMA (Fused Multiply-Add) disponible" + fi + +elif [[ "$ARCH" == "aarch64" ]]; then + # ARM 64-bit + echo "Type: ARM 64-bit" + + FEATURES=$(grep "Features" /proc/cpuinfo | head -1) + + if echo "$FEATURES" | grep -q "asimd"; then + echo "✅ NEON/Advanced SIMD disponible (recommandé)" + fi + + if echo "$FEATURES" | grep -q "fp"; then + echo "✅ FPU matériel disponible" + fi + + if echo "$FEATURES" | grep -q "sve"; then + echo "✅ SVE (Scalable Vector Extension) disponible" + fi + +elif [[ "$ARCH" == "armv7l" || "$ARCH" == "armv6l" ]]; then + # ARM 32-bit + echo "Type: ARM 32-bit" + + FEATURES=$(grep "Features" /proc/cpuinfo | head -1) + + if echo "$FEATURES" | grep -q "neon"; then + echo "✅ NEON disponible (SIMD)" + fi + + if echo "$FEATURES" | grep -q "vfpv4"; then + echo "✅ VFPv4 (FPU simple précision) disponible" + elif echo "$FEATURES" | grep -q "vfp"; then + echo "✅ VFP (FPU basique) disponible" + else + echo "❌ Pas de FPU matériel (Cortex-M0/M3?)" + fi +fi + +echo "" +echo "=== Recommandation pour audio DSP ===" + +if [[ "$ARCH" == "x86_64" ]]; then + if grep -q "avx2" /proc/cpuinfo; then + echo "🚀 Float f64 + SIMD (std::simd avec AVX2)" + echo " Performance optimale garantie" + else + echo "✅ Float f64 + SIMD (std::simd avec SSE)" + echo " Bonne performance" + fi + +elif [[ "$ARCH" == "aarch64" ]]; then + if grep -q "asimd" /proc/cpuinfo; then + echo "🚀 Float f64 + SIMD (std::simd avec NEON)" + echo " Performance optimale sur ARM64" + else + echo "⚠️ ARM64 sans NEON (rare)" + fi + +elif [[ "$ARCH" == "armv7l" ]]; then + if grep -q "neon" /proc/cpuinfo; then + echo "✅ Float f32 + NEON SIMD" + echo " Bonne performance sur ARM32" + elif grep -q "vfp" /proc/cpuinfo; then + echo "⚖️ Float f32 scalaire (avec FPU)" + echo " Performance correcte, équivalent à integer" + else + echo "💡 Integer fixed-point Q23" + echo " Pas de FPU, integer sera plus rapide" + fi + +elif [[ "$ARCH" == "armv6l" ]]; then + echo "💡 Integer fixed-point Q23" + echo " CPU ancien, pas de FPU performant" +fi