Merge pull request 'push-mqttpywkpspw' (#19) from push-mqttpywkpspw into main
Reviewed-on: #19
This commit was merged in pull request #19.
This commit is contained in:
16
.gitignore
vendored
16
.gitignore
vendored
@@ -8,13 +8,24 @@
|
||||
**/*.o
|
||||
**/*.o.d
|
||||
**/*.a
|
||||
**/*.flac
|
||||
**/*.aif
|
||||
**/*.aiff
|
||||
**/*.wav
|
||||
**/*.opus
|
||||
**/*.mp4
|
||||
**/*.mp3
|
||||
**/*.ogg
|
||||
xxx
|
||||
/dcai/
|
||||
**/.pmomusic.yml
|
||||
**/.pmomusic_covers/**
|
||||
**/.pmomusic_audio/**
|
||||
/.pmomusic
|
||||
.DS_Store
|
||||
/target/
|
||||
.pmomusic_covers
|
||||
target
|
||||
/.pmomusic_covers
|
||||
/.pmomusic_audio/**
|
||||
C/src/soxr-0.1.3/Release/tests
|
||||
**/Release/
|
||||
**/Debug/
|
||||
@@ -25,3 +36,4 @@ all.txt
|
||||
pmo_src.txt
|
||||
upmpdcli/
|
||||
/*.xml
|
||||
test_upnp
|
||||
10
.vscode/settings.json
vendored
10
.vscode/settings.json
vendored
@@ -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
|
||||
}
|
||||
1364
Cargo.lock
generated
1364
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -18,4 +18,6 @@ members = [
|
||||
"pmoparadise",
|
||||
"pmosource",
|
||||
"pmoplaylist",
|
||||
"pmoflac",
|
||||
"pmometadata",
|
||||
]
|
||||
|
||||
22
Makefile
22
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)"
|
||||
|
||||
@@ -12,6 +12,7 @@ pmosource = { path = "../pmosource", features = ["server"] }
|
||||
pmoserver = { path = "../pmoserver" }
|
||||
pmocovers = { path = "../pmocovers", features = ["pmoserver"] }
|
||||
pmoaudiocache = { path = "../pmoaudiocache", features = ["pmoserver"]}
|
||||
pmoaudio-ext = { path = "../pmoaudio-ext", features = ["all"] }
|
||||
pmoapp = { path = "../pmoapp", features = ["pmoserver"] }
|
||||
|
||||
tokio = { version = "1.35", features = ["rt-multi-thread", "macros", "sync", "time","signal"] }
|
||||
@@ -20,3 +21,4 @@ tracing-subscriber = "0.3.20"
|
||||
axum = "0.8.4"
|
||||
serde_json = "1.0.145"
|
||||
utoipa = "5.4"
|
||||
console-subscriber = "0.4.1"
|
||||
|
||||
@@ -3,16 +3,19 @@ use pmomediarenderer::MEDIA_RENDERER;
|
||||
use pmomediaserver::{MEDIA_SERVER, sources::SourcesExt};
|
||||
use pmoserver::Server;
|
||||
use pmosource::MusicSourceExt;
|
||||
use pmoupnp::{UpnpServerExt, upnp_api::UpnpApiExt};
|
||||
use pmoupnp::UpnpServerExt;
|
||||
use tracing::info;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// ========== PHASE 1 : Infrastructure UPnP ==========
|
||||
let mut server = Server::create_upnp_server().await?;
|
||||
// #[cfg(tokio_unstable)]
|
||||
// console_subscriber::init();
|
||||
|
||||
// Routes personnalisées de l'application
|
||||
let server = Server::create_upnp_server().await?; // Routes personnalisées de l'application
|
||||
server
|
||||
.write()
|
||||
.await
|
||||
.add_route("/info", || async {
|
||||
serde_json::json!({"version": "1.0.0"})
|
||||
})
|
||||
@@ -21,6 +24,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Initialiser le système de gestion des sources musicales avec API REST
|
||||
info!("📡 Initializing music sources management system...");
|
||||
server
|
||||
.write()
|
||||
.await
|
||||
.init_music_sources()
|
||||
.await
|
||||
.expect("Failed to initialize music sources API");
|
||||
@@ -36,12 +41,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// }
|
||||
|
||||
// Enregistrer Radio Paradise (inclut l'initialisation de l'API)
|
||||
if let Err(e) = server.register_paradise().await {
|
||||
if let Err(e) = server.write().await.register_paradise().await {
|
||||
tracing::warn!("⚠️ Failed to register Radio Paradise: {}", e);
|
||||
}
|
||||
|
||||
// Lister toutes les sources enregistrées
|
||||
let sources = server.list_music_sources().await;
|
||||
let sources = server.read().await.list_music_sources().await;
|
||||
info!("✅ {} music source(s) registered", sources.len());
|
||||
for source in sources {
|
||||
info!(" - {} ({})", source.name(), source.id());
|
||||
@@ -51,6 +56,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
info!("📡 Registering UPnP devices...");
|
||||
|
||||
let renderer_instance = server
|
||||
.write()
|
||||
.await
|
||||
.register_device(MEDIA_RENDERER.clone())
|
||||
.await
|
||||
.expect("Failed to register MediaRenderer");
|
||||
@@ -62,6 +69,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
);
|
||||
|
||||
let server_instance = server
|
||||
.write()
|
||||
.await
|
||||
.register_device(MEDIA_SERVER.clone())
|
||||
.await
|
||||
.expect("Failed to register MediaServer");
|
||||
@@ -74,16 +83,20 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
// Ajouter la webapp via le trait WebAppExt
|
||||
info!("📡 Registering Web application...");
|
||||
server.add_webapp_with_redirect::<Webapp>("/app").await;
|
||||
server
|
||||
.write()
|
||||
.await
|
||||
.add_webapp_with_redirect::<Webapp>("/app")
|
||||
.await;
|
||||
|
||||
// ========== PHASE 3 : Démarrage du serveur ==========
|
||||
|
||||
info!("🌐 Starting HTTP server...");
|
||||
server.start().await;
|
||||
server.write().await.start().await;
|
||||
|
||||
info!("✅ PMOMusic is ready!");
|
||||
info!("Press Ctrl+C to stop...");
|
||||
server.wait().await;
|
||||
server.write().await.wait().await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -1,5 +1,5 @@
|
||||
HTTP/1.1 500 Internal Server Error
|
||||
HTTP/1.1 200 OK
|
||||
content-type: text/xml; charset="utf-8"
|
||||
content-length: 597
|
||||
date: Sun, 19 Oct 2025 19:06:41 GMT
|
||||
content-length: 1593
|
||||
date: Mon, 20 Oct 2025 17:44:48 GMT
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::{
|
||||
nodes::{AudioError, MultiSubscriberNode},
|
||||
AudioChunk,
|
||||
AudioSegment,
|
||||
};
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::Arc;
|
||||
@@ -8,7 +8,7 @@ use tokio::sync::{mpsc, RwLock};
|
||||
|
||||
/// Subscriber avec son propre offset dans le buffer
|
||||
struct BufferSubscriber {
|
||||
tx: mpsc::Sender<Arc<AudioChunk>>,
|
||||
tx: mpsc::Sender<Arc<AudioSegment>>,
|
||||
offset: usize, // Position dans le buffer circulaire
|
||||
}
|
||||
|
||||
@@ -48,10 +48,10 @@ struct BufferSubscriber {
|
||||
/// }
|
||||
/// ```
|
||||
pub struct BufferNode {
|
||||
buffer: Arc<RwLock<VecDeque<Arc<AudioChunk>>>>,
|
||||
buffer: Arc<RwLock<VecDeque<Arc<AudioSegment>>>>,
|
||||
subscribers: Arc<RwLock<Vec<BufferSubscriber>>>,
|
||||
buffer_size: usize,
|
||||
rx: mpsc::Receiver<Arc<AudioChunk>>,
|
||||
rx: mpsc::Receiver<Arc<AudioSegment>>,
|
||||
next_subscribers: MultiSubscriberNode, // Pour passer au node suivant
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ impl BufferNode {
|
||||
/// # Arguments
|
||||
/// * `buffer_size` - Taille maximale du buffer circulaire
|
||||
/// * `channel_size` - Taille du channel bounded pour backpressure
|
||||
pub fn new(buffer_size: usize, channel_size: usize) -> (Self, mpsc::Sender<Arc<AudioChunk>>) {
|
||||
pub fn new(buffer_size: usize, channel_size: usize) -> (Self, mpsc::Sender<Arc<AudioSegment>>) {
|
||||
let (tx, rx) = mpsc::channel(channel_size);
|
||||
|
||||
let node = Self {
|
||||
@@ -78,7 +78,7 @@ impl BufferNode {
|
||||
/// Ajoute un abonné avec un offset spécifique (pour multiroom)
|
||||
pub async fn add_subscriber_with_offset(
|
||||
&self,
|
||||
tx: mpsc::Sender<Arc<AudioChunk>>,
|
||||
tx: mpsc::Sender<Arc<AudioSegment>>,
|
||||
offset: usize,
|
||||
) {
|
||||
let mut subs = self.subscribers.write().await;
|
||||
@@ -86,12 +86,12 @@ impl BufferNode {
|
||||
}
|
||||
|
||||
/// Ajoute un abonné sans offset (commence au chunk courant)
|
||||
pub async fn add_subscriber(&self, tx: mpsc::Sender<Arc<AudioChunk>>) {
|
||||
pub async fn add_subscriber(&self, tx: mpsc::Sender<Arc<AudioSegment>>) {
|
||||
self.add_subscriber_with_offset(tx, 0).await;
|
||||
}
|
||||
|
||||
/// Ajoute un abonné pour le node suivant (sans buffer)
|
||||
pub fn add_next_subscriber(&mut self, tx: mpsc::Sender<Arc<AudioChunk>>) {
|
||||
pub fn add_next_subscriber(&mut self, tx: mpsc::Sender<Arc<AudioSegment>>) {
|
||||
self.next_subscribers.add_subscriber(tx);
|
||||
}
|
||||
|
||||
@@ -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 = AudioSegment::AudioChunk(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 = AudioSegment::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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -11,17 +11,17 @@ use tokio::sync::mpsc;
|
||||
pub struct DspNode {
|
||||
rx: mpsc::Receiver<Arc<AudioChunk>>,
|
||||
subscribers: MultiSubscriberNode,
|
||||
gain: f32,
|
||||
gain_db: f32,
|
||||
}
|
||||
|
||||
impl DspNode {
|
||||
pub fn new(channel_size: usize, gain: f32) -> (Self, mpsc::Sender<Arc<AudioChunk>>) {
|
||||
pub fn new(channel_size: usize, gain_db: f32) -> (Self, mpsc::Sender<Arc<AudioChunk>>) {
|
||||
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;
|
||||
continue;
|
||||
}
|
||||
|
||||
let new_chunk =
|
||||
AudioChunk::new(chunk.order, left_data, right_data, chunk.sample_rate);
|
||||
|
||||
self.subscribers.push(Arc::new(new_chunk)).await?;
|
||||
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<RwLock<f32>>` 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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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<AudioChunk> {
|
||||
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<f32> = 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
@@ -5,7 +5,7 @@
|
||||
//! cargo run -p pmoplaylist --example basic_usage
|
||||
//! ```
|
||||
|
||||
use pmoplaylist::{DEFAULT_IMAGE, FifoPlaylist, Track};
|
||||
use pmoplaylist::{FifoPlaylist, Track, DEFAULT_IMAGE};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
@@ -8,7 +8,7 @@
|
||||
//! cargo run -p pmoplaylist --example http_server_integration
|
||||
//! ```
|
||||
|
||||
use pmoplaylist::{DEFAULT_IMAGE, FifoPlaylist, Track};
|
||||
use pmoplaylist::{FifoPlaylist, Track, DEFAULT_IMAGE};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[tokio::main]
|
||||
@@ -10,7 +10,7 @@
|
||||
//! cargo run -p pmoplaylist --example radio_streaming
|
||||
//! ```
|
||||
|
||||
use pmoplaylist::{DEFAULT_IMAGE, FifoPlaylist, Track};
|
||||
use pmoplaylist::{FifoPlaylist, Track, DEFAULT_IMAGE};
|
||||
use std::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
|
||||
2
pmoapp/src/lib.rs
Normal file → Executable file
2
pmoapp/src/lib.rs
Normal file → Executable file
@@ -238,8 +238,6 @@
|
||||
//! - [Vite Documentation](https://vitejs.dev/)
|
||||
|
||||
use rust_embed::RustEmbed;
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
|
||||
/// Structure représentant l'application web embarquée.
|
||||
///
|
||||
|
||||
@@ -93,8 +93,8 @@
|
||||
</div>
|
||||
<div class="pk">{{ track.pk }}</div>
|
||||
<div class="meta">
|
||||
<span v-if="track.metadata?.duration_ms">
|
||||
{{ formatDuration(track.metadata.duration_ms) }}
|
||||
<span v-if="durationMs(track) !== undefined">
|
||||
{{ formatDuration(durationMs(track)!) }}
|
||||
</span>
|
||||
<span v-if="track.metadata?.sample_rate">
|
||||
{{ formatSampleRate(track.metadata.sample_rate) }}
|
||||
@@ -102,6 +102,9 @@
|
||||
<span v-if="track.metadata?.bitrate">
|
||||
{{ formatBitrate(track.metadata.bitrate) }}
|
||||
</span>
|
||||
<span v-if="conversionLabel(track)">
|
||||
{{ conversionLabel(track) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="collection" v-if="track.collection">
|
||||
{{ track.collection }}
|
||||
@@ -147,15 +150,22 @@
|
||||
<p v-if="selectedTrack.metadata.year"><strong>Year:</strong> {{ selectedTrack.metadata.year }}</p>
|
||||
<p v-if="selectedTrack.metadata.genre"><strong>Genre:</strong> {{ selectedTrack.metadata.genre }}</p>
|
||||
<p v-if="selectedTrack.metadata.track_number"><strong>Track:</strong> {{ selectedTrack.metadata.track_number }}</p>
|
||||
<p v-if="selectedTrack.metadata.duration_ms"><strong>Duration:</strong> {{ formatDuration(selectedTrack.metadata.duration_ms) }}</p>
|
||||
<p v-if="durationMs(selectedTrack) !== undefined">
|
||||
<strong>Duration:</strong> {{ formatDuration(durationMs(selectedTrack)!) }}
|
||||
</p>
|
||||
<p v-if="selectedTrack.metadata.sample_rate"><strong>Sample Rate:</strong> {{ formatSampleRate(selectedTrack.metadata.sample_rate) }}</p>
|
||||
<p v-if="selectedTrack.metadata.bitrate"><strong>Bitrate:</strong> {{ formatBitrate(selectedTrack.metadata.bitrate) }}</p>
|
||||
<p v-if="selectedTrack.metadata.channels"><strong>Channels:</strong> {{ selectedTrack.metadata.channels }}</p>
|
||||
<p v-if="conversionLabel(selectedTrack)"><strong>Conversion:</strong> {{ conversionLabel(selectedTrack) }}</p>
|
||||
</div>
|
||||
<div class="cache-section">
|
||||
<h4>Cache Info</h4>
|
||||
<p><strong>PK:</strong> {{ selectedTrack.pk }}</p>
|
||||
<p><strong>Source URL:</strong> <a :href="selectedTrack.source_url" target="_blank">{{ selectedTrack.source_url }}</a></p>
|
||||
<p v-if="resolveTrackOrigin(selectedTrack)">
|
||||
<strong>Source URL:</strong>
|
||||
<a :href="resolveTrackOrigin(selectedTrack)" target="_blank">{{ resolveTrackOrigin(selectedTrack) }}</a>
|
||||
</p>
|
||||
<p v-else><strong>Source URL:</strong> Unknown</p>
|
||||
<p><strong>Hits:</strong> {{ selectedTrack.hits }}</p>
|
||||
<p v-if="selectedTrack.collection"><strong>Collection:</strong> {{ selectedTrack.collection }}</p>
|
||||
<p v-if="selectedTrack.last_used"><strong>Last Used:</strong> {{ formatDate(selectedTrack.last_used) }}</p>
|
||||
@@ -206,6 +216,8 @@ import {
|
||||
consolidateCache,
|
||||
getTrackUrl,
|
||||
getOriginalTrackUrl,
|
||||
getOriginUrl,
|
||||
getDurationMs,
|
||||
formatDuration,
|
||||
formatBitrate,
|
||||
formatSampleRate,
|
||||
@@ -388,6 +400,14 @@ function copyTrackUrl(pk: string) {
|
||||
alert("✅ URL copied!");
|
||||
}
|
||||
|
||||
function resolveTrackOrigin(track: AudioCacheEntry | null): string | undefined {
|
||||
return track ? getOriginUrl(track) : undefined;
|
||||
}
|
||||
|
||||
function durationMs(track: AudioCacheEntry | null): number | undefined {
|
||||
return track ? getDurationMs(track.metadata) : undefined;
|
||||
}
|
||||
|
||||
function formatDate(dateString: string) {
|
||||
const d = new Date(dateString);
|
||||
const diff = Date.now() - d.getTime();
|
||||
@@ -398,6 +418,37 @@ function formatDate(dateString: string) {
|
||||
return d.toLocaleDateString();
|
||||
}
|
||||
|
||||
function formatConversion(
|
||||
conversion?: { mode?: string; input_codec?: string; details?: string } | null
|
||||
): string | undefined {
|
||||
if (!conversion || !conversion.mode) return undefined;
|
||||
const modeLower = conversion.mode.toLowerCase();
|
||||
const modeLabel =
|
||||
modeLower === "passthrough"
|
||||
? "Passthrough"
|
||||
: modeLower === "transcode"
|
||||
? "Transcoded"
|
||||
: conversion.mode.charAt(0).toUpperCase() + conversion.mode.slice(1);
|
||||
|
||||
if (conversion.input_codec) {
|
||||
const codec = conversion.input_codec.toUpperCase();
|
||||
if (modeLower === "passthrough") {
|
||||
return `${modeLabel} (${codec})`;
|
||||
}
|
||||
return `${modeLabel} (${codec} → FLAC)`;
|
||||
}
|
||||
|
||||
if (conversion.details) {
|
||||
return `${modeLabel} – ${conversion.details}`;
|
||||
}
|
||||
|
||||
return modeLabel;
|
||||
}
|
||||
|
||||
function conversionLabel(track: AudioCacheEntry | null): string | undefined {
|
||||
return formatConversion(track?.metadata?.conversion ?? undefined);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refreshTracks();
|
||||
});
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
<div class="image-wrapper">
|
||||
<img
|
||||
:src="getImageUrl(image.pk, 256)"
|
||||
:alt="image.source_url"
|
||||
:alt="resolveOrigin(image) || image.pk"
|
||||
loading="lazy"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
@@ -81,8 +81,8 @@
|
||||
</div>
|
||||
<div class="image-info">
|
||||
<div class="pk">{{ image.pk }}</div>
|
||||
<div class="url" :title="image.source_url">
|
||||
{{ truncateUrl(image.source_url) }}
|
||||
<div class="url" :title="resolveOrigin(image) || 'Unknown source'">
|
||||
{{ truncateUrl(resolveOrigin(image)) }}
|
||||
</div>
|
||||
<div class="meta">
|
||||
<span v-if="image.last_used" class="last-used">
|
||||
@@ -108,13 +108,17 @@
|
||||
<button class="modal-close" @click="selectedImage = null">✕</button>
|
||||
<img
|
||||
:src="getImageUrl(selectedImage.pk)"
|
||||
:alt="selectedImage.source_url"
|
||||
:alt="resolveOrigin(selectedImage) || selectedImage.pk"
|
||||
class="modal-image"
|
||||
/>
|
||||
<div class="modal-info">
|
||||
<h3>Image Details</h3>
|
||||
<p><strong>PK:</strong> {{ selectedImage.pk }}</p>
|
||||
<p><strong>Source URL:</strong> <a :href="selectedImage.source_url" target="_blank">{{ selectedImage.source_url }}</a></p>
|
||||
<p v-if="resolveOrigin(selectedImage)">
|
||||
<strong>Source URL:</strong>
|
||||
<a :href="resolveOrigin(selectedImage)" target="_blank">{{ resolveOrigin(selectedImage) }}</a>
|
||||
</p>
|
||||
<p v-else><strong>Source URL:</strong> Unknown</p>
|
||||
<p><strong>Hits:</strong> {{ selectedImage.hits }}</p>
|
||||
<p v-if="selectedImage.last_used"><strong>Last Used:</strong> {{ formatDate(selectedImage.last_used) }}</p>
|
||||
<div class="modal-actions">
|
||||
@@ -141,6 +145,8 @@ import {
|
||||
purgeCache,
|
||||
consolidateCache,
|
||||
getImageUrl,
|
||||
getOriginUrl,
|
||||
waitForDownload,
|
||||
} from "../services/coverCache";
|
||||
|
||||
// --- États ---
|
||||
@@ -190,11 +196,16 @@ async function handleAddImage() {
|
||||
isAdding.value = true; addError.value=""; addSuccess.value="";
|
||||
try {
|
||||
const result = await addImage(newImageUrl.value);
|
||||
addSuccess.value = `Image downloading... PK: ${result.pk}`;
|
||||
|
||||
// Attendre que le téléchargement et la transformation soient terminés
|
||||
await waitForDownload(result.pk);
|
||||
|
||||
addSuccess.value = `Image added! PK: ${result.pk}`;
|
||||
newImageUrl.value = "";
|
||||
await refreshImages();
|
||||
} catch(e:any) { addError.value = e.message ?? "Failed to add image"; }
|
||||
finally { isAdding.value=false; setTimeout(()=>addSuccess.value="",1500); }
|
||||
finally { isAdding.value=false; setTimeout(()=>addSuccess.value="",3000); }
|
||||
}
|
||||
|
||||
async function handleDeleteImage(pk:string){
|
||||
@@ -223,7 +234,14 @@ function copyImageUrl(pk:string){
|
||||
alert("✅ URL copied!");
|
||||
}
|
||||
|
||||
function truncateUrl(url:string,maxLength=40){ return url.length<=maxLength?url:url.slice(0,maxLength-3)+"..."; }
|
||||
function resolveOrigin(entry: CacheEntry | null): string | undefined {
|
||||
return entry ? getOriginUrl(entry) : undefined;
|
||||
}
|
||||
|
||||
function truncateUrl(url?:string,maxLength=40){
|
||||
if(!url) return "Unknown source";
|
||||
return url.length<=maxLength?url:url.slice(0,maxLength-3)+"...";
|
||||
}
|
||||
function formatDate(dateString:string){
|
||||
const d=new Date(dateString), diff=Date.now()-d.getTime(), days=Math.floor(diff/(1000*60*60*24));
|
||||
if(days===0)return"Today"; if(days===1)return"Yesterday"; if(days<7)return`${days} days ago`; return d.toLocaleDateString();
|
||||
|
||||
@@ -126,7 +126,7 @@ const filteredLogs = computed(() => {
|
||||
// Fonction pour mettre à jour le niveau de log côté serveur
|
||||
async function updateServerLogLevel() {
|
||||
try {
|
||||
const response = await fetch('/api/log_setup', {
|
||||
const response = await fetch('/api/logs/log_setup', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -164,7 +164,7 @@ async function updateServerLogLevel() {
|
||||
// Charger le niveau de log actuel au démarrage
|
||||
async function loadServerLogLevel() {
|
||||
try {
|
||||
const response = await fetch('/api/log_setup')
|
||||
const response = await fetch('/api/logs/log_setup')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
serverLogLevel.value = data.current_level
|
||||
|
||||
@@ -166,64 +166,221 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="channel-tracks-section">
|
||||
<div class="channel-status-section">
|
||||
<div class="section-header">
|
||||
<h3>🎧 Channel Tracks (Source API)</h3>
|
||||
<button class="btn-secondary" @click="fetchChannelTracks" :disabled="channelBrowseLoading">
|
||||
<span v-if="channelBrowseLoading">⏳ Refreshing…</span>
|
||||
<span v-else>Refresh Tracks</span>
|
||||
<h3>📡 Channel Status</h3>
|
||||
<button class="btn-secondary" @click="fetchChannelStatus" :disabled="channelStatusLoading">
|
||||
<span v-if="channelStatusLoading">⏳ Refreshing…</span>
|
||||
<span v-else>Refresh Status</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="channelStatusError" class="error-message">
|
||||
❌ {{ channelStatusError }}
|
||||
</div>
|
||||
<div v-else-if="channelStatus">
|
||||
<div class="status-grid">
|
||||
<div class="status-card">
|
||||
<div class="status-label">Channel</div>
|
||||
<div class="status-value">{{ channelStatus.slug }}</div>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<div class="status-label">Active Clients</div>
|
||||
<div class="status-value">{{ channelStatus.active_clients }}</div>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<div class="status-label">Queue Length</div>
|
||||
<div class="status-value">{{ channelStatus.queue_length }}</div>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<div class="status-label">Update ID</div>
|
||||
<div class="status-value">{{ channelStatus.update_id }}</div>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<div class="status-label">Last Change</div>
|
||||
<div class="status-value">
|
||||
{{ channelStatus.last_change ? formatTimestamp(new Date(channelStatus.last_change)) : '—' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<div class="status-label">History Entries</div>
|
||||
<div class="status-value">
|
||||
{{ channelStatus.history_entries }} / {{ channelStatus.history_max_tracks }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<div class="status-label">Cache Collection</div>
|
||||
<div class="status-value">{{ channelStatus.cache_collection_id }}</div>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<div class="status-label">Cache Tracks</div>
|
||||
<div class="status-value">
|
||||
{{ channelStatus.cache_cached_tracks }} / {{ channelStatus.cache_total_tracks }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-meta" v-if="channelStatusLastUpdated">
|
||||
Last refresh: {{ formatTimestamp(channelStatusLastUpdated) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="channel-tracks-section">
|
||||
<div class="section-header">
|
||||
<h3>🎧 Live Playlist</h3>
|
||||
<div class="section-actions">
|
||||
<button class="btn-tertiary" @click="refreshChannelData" :disabled="channelPlaylistLoading || channelStatusLoading">
|
||||
<span v-if="channelPlaylistLoading || channelStatusLoading">⏳ Refreshing…</span>
|
||||
<span v-else>Refresh All</span>
|
||||
</button>
|
||||
<button class="btn-secondary" @click="fetchChannelPlaylist" :disabled="channelPlaylistLoading">
|
||||
<span v-if="channelPlaylistLoading">⏳ Loading…</span>
|
||||
<span v-else>Refresh Playlist</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-meta">
|
||||
<span>Object:</span>
|
||||
<code>{{ channelBrowse.object_id || channelObjectId(selectedChannel) }}</code>
|
||||
<span>Items:</span>
|
||||
<strong>{{ channelBrowse.returned_items }}</strong>
|
||||
<span v-if="channelBrowse.total">/ {{ channelBrowse.total }}</span>
|
||||
<span v-if="channelBrowse.update_id">Update ID: {{ channelBrowse.update_id }}</span>
|
||||
<span>Queue length:</span>
|
||||
<strong>{{ channelPlaylist.queue_length }}</strong>
|
||||
<span v-if="channelPlaylist.update_id">Update ID: {{ channelPlaylist.update_id }}</span>
|
||||
<span v-if="channelPlaylistLastUpdated">
|
||||
Last refresh: {{ formatTimestamp(channelPlaylistLastUpdated) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="channelBrowseError" class="error-message">
|
||||
❌ {{ channelBrowseError }}
|
||||
<div v-if="channelPlaylistError" class="error-message">
|
||||
❌ {{ channelPlaylistError }}
|
||||
</div>
|
||||
<div v-else-if="channelBrowseLoading" class="loading-message">
|
||||
⏳ Loading channel tracks…
|
||||
<div v-else-if="channelPlaylistLoading" class="loading-message">
|
||||
⏳ Loading playlist…
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-if="channelBrowse.containers.length" class="sub-container-notice">
|
||||
{{ channelBrowse.containers.length }} sub container(s) available.
|
||||
</div>
|
||||
<div v-if="channelBrowse.items.length" class="track-grid">
|
||||
<div v-if="channelPlaylist.items.length" class="track-grid">
|
||||
<div
|
||||
v-for="item in channelBrowse.items"
|
||||
:key="item.id"
|
||||
:class="['track-card', { active: activeTrackId === item.id }]"
|
||||
v-for="item in channelPlaylist.items"
|
||||
:key="trackObjectId(item)"
|
||||
:class="['track-card', { active: activeTrackId === trackObjectId(item) }]"
|
||||
>
|
||||
<div class="track-card-header">
|
||||
<span :class="cacheStatusClass(item.cache_status)">
|
||||
{{ cacheStatusLabel(item.cache_status) }}
|
||||
</span>
|
||||
<span class="track-metadata">Pending listeners: {{ item.pending_clients }}</span>
|
||||
</div>
|
||||
<div class="track-headline">
|
||||
<div class="track-title">{{ item.title }}</div>
|
||||
<div class="track-artist">{{ item.artist || item.creator || 'Unknown artist' }}</div>
|
||||
<div class="track-artist">{{ item.artist || 'Unknown artist' }}</div>
|
||||
</div>
|
||||
<div class="track-meta">
|
||||
<span v-if="item.album">{{ item.album }}</span>
|
||||
<span v-if="item.resources && item.resources.length && item.resources[0].duration">
|
||||
⏱ {{ item.resources[0].duration }}
|
||||
</span>
|
||||
<span v-if="item.duration_ms">⏱ {{ formatDuration(item.duration_ms) }}</span>
|
||||
<span v-if="item.elapsed_ms">▶️ @{{ formatDuration(item.elapsed_ms) }}</span>
|
||||
<span v-if="item.started_at">🕒 {{ formatTimestamp(new Date(item.started_at)) }}</span>
|
||||
<span v-if="item.cache_status?.size_bytes">💾 {{ formatBytes(item.cache_status.size_bytes) }}</span>
|
||||
</div>
|
||||
<div class="track-actions">
|
||||
<button class="btn-secondary" @click="playTrackItem(item)">▶️ Play Track</button>
|
||||
<button
|
||||
class="btn-secondary"
|
||||
@click="requestCacheForTrack(item)"
|
||||
:disabled="trackExtrasFor(item).cacheRequestLoading"
|
||||
>
|
||||
<span v-if="trackExtrasFor(item).cacheRequestLoading">⏳ Caching…</span>
|
||||
<span v-else>💾 Request Cache</span>
|
||||
</button>
|
||||
<button
|
||||
class="btn-tertiary"
|
||||
@click="refreshTrackCacheStatus(item)"
|
||||
:disabled="trackExtrasFor(item).cacheStatusLoading"
|
||||
>
|
||||
<span v-if="trackExtrasFor(item).cacheStatusLoading">⏳ Updating…</span>
|
||||
<span v-else>🔄 Cache Status</span>
|
||||
</button>
|
||||
<button
|
||||
class="btn-tertiary"
|
||||
@click="fetchTrackFormats(item)"
|
||||
:disabled="trackExtrasFor(item).formatsLoading"
|
||||
>
|
||||
<span v-if="trackExtrasFor(item).formatsLoading">⏳ Formats…</span>
|
||||
<span v-else>🎚️ Formats</span>
|
||||
</button>
|
||||
<a
|
||||
v-for="resource in item.resources"
|
||||
:key="resource.url"
|
||||
:href="resource.url"
|
||||
v-if="trackExtrasFor(item).uri"
|
||||
:href="trackExtrasFor(item).uri"
|
||||
target="_blank"
|
||||
class="stream-link"
|
||||
>
|
||||
Open resource
|
||||
Open resolved URI
|
||||
</a>
|
||||
</div>
|
||||
<div v-if="trackExtrasFor(item).cacheRequestMessage" class="inline-success">
|
||||
✅ {{ trackExtrasFor(item).cacheRequestMessage }}
|
||||
</div>
|
||||
<div v-if="trackExtrasFor(item).cacheError" class="inline-error">
|
||||
❌ {{ trackExtrasFor(item).cacheError }}
|
||||
</div>
|
||||
<div v-if="trackExtrasFor(item).resolveError" class="inline-error">
|
||||
❌ Resolve error: {{ trackExtrasFor(item).resolveError }}
|
||||
</div>
|
||||
<div v-if="trackExtrasFor(item).formatsError" class="inline-error">
|
||||
❌ Formats error: {{ trackExtrasFor(item).formatsError }}
|
||||
</div>
|
||||
<div
|
||||
v-if="trackExtrasFor(item).formats && trackExtrasFor(item).formats.length"
|
||||
class="formats-list"
|
||||
>
|
||||
<div
|
||||
v-for="format in trackExtrasFor(item).formats"
|
||||
:key="format.format_id"
|
||||
class="format-row"
|
||||
>
|
||||
<strong>{{ format.format_id }}</strong>
|
||||
<span>{{ format.mime_type }}</span>
|
||||
<span v-if="format.sample_rate">{{ format.sample_rate }} Hz</span>
|
||||
<span v-if="format.bit_depth">{{ format.bit_depth }} bit</span>
|
||||
<span v-if="format.bitrate">{{ format.bitrate }} kbps</span>
|
||||
<span v-if="format.channels">{{ format.channels }} ch</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-placeholder">
|
||||
No cached tracks yet for this channel. Refresh after playback starts.
|
||||
No tracks currently queued. Try refreshing after playback starts.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="channel-history-section">
|
||||
<div class="section-header">
|
||||
<h3>🕰️ Recent History</h3>
|
||||
<button class="btn-secondary" @click="fetchChannelHistory" :disabled="channelHistoryLoading">
|
||||
<span v-if="channelHistoryLoading">⏳ Loading…</span>
|
||||
<span v-else>Refresh History</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="section-meta" v-if="channelHistoryLastUpdated">
|
||||
Last refresh: {{ formatTimestamp(channelHistoryLastUpdated) }}
|
||||
</div>
|
||||
<div v-if="channelHistoryError" class="error-message">
|
||||
❌ {{ channelHistoryError }}
|
||||
</div>
|
||||
<div v-else-if="channelHistoryLoading" class="loading-message">
|
||||
⏳ Loading history…
|
||||
</div>
|
||||
<div v-else class="history-list">
|
||||
<div v-if="channelHistory.length === 0" class="empty-placeholder">
|
||||
No history entries yet.
|
||||
</div>
|
||||
<div
|
||||
v-for="entry in channelHistory"
|
||||
:key="`${entry.track_id}-${entry.started_at}`"
|
||||
class="history-item"
|
||||
>
|
||||
<div class="history-title">{{ entry.title }}</div>
|
||||
<div class="history-meta">
|
||||
<span>{{ entry.artist }}</span>
|
||||
<span v-if="entry.album">• {{ entry.album }}</span>
|
||||
<span>• {{ formatDuration(entry.duration_ms) }}</span>
|
||||
<span>• {{ formatTimestamp(new Date(entry.started_at)) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -370,18 +527,31 @@ const blockSearchLoading = ref(false)
|
||||
const blockSearchError = ref('')
|
||||
const channelsError = ref('')
|
||||
const bitratesError = ref('')
|
||||
const channelBrowse = ref({
|
||||
object_id: '',
|
||||
containers: [],
|
||||
const channelStatus = ref(null)
|
||||
const channelStatusLoading = ref(false)
|
||||
const channelStatusError = ref('')
|
||||
const channelStatusLastUpdated = ref(null)
|
||||
|
||||
const channelPlaylist = ref({
|
||||
items: [],
|
||||
returned_containers: 0,
|
||||
returned_items: 0,
|
||||
total: 0,
|
||||
update_id: 0
|
||||
queue_length: 0,
|
||||
update_id: 0,
|
||||
slug: '',
|
||||
channel_id: selectedChannel.value
|
||||
})
|
||||
const channelBrowseLoading = ref(false)
|
||||
const channelBrowseError = ref('')
|
||||
const channelPlaylistLoading = ref(false)
|
||||
const channelPlaylistError = ref('')
|
||||
const channelPlaylistLastUpdated = ref(null)
|
||||
|
||||
const channelHistory = ref([])
|
||||
const channelHistoryLoading = ref(false)
|
||||
const channelHistoryError = ref('')
|
||||
const channelHistoryLastUpdated = ref(null)
|
||||
|
||||
const trackExtras = ref({})
|
||||
let refreshTimerId = null
|
||||
let channelRefreshTimerId = null
|
||||
const CHANNEL_REFRESH_INTERVAL = 7000
|
||||
|
||||
// Format duration from milliseconds to MM:SS
|
||||
function formatDuration(ms) {
|
||||
@@ -431,6 +601,10 @@ function channelObjectId(channelId) {
|
||||
return `${SOURCE_ID}:channel:${channelId}`
|
||||
}
|
||||
|
||||
function trackObjectId(item) {
|
||||
return item?.track_id || item?.id || item?.object_id || ''
|
||||
}
|
||||
|
||||
function playAudio(url) {
|
||||
if (!url) {
|
||||
audioError.value = 'No audio URL available'
|
||||
@@ -483,35 +657,308 @@ async function refreshNowPlaying() {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchChannelTracks() {
|
||||
channelBrowseLoading.value = true
|
||||
channelBrowseError.value = ''
|
||||
function cacheStatusLabel(cacheInfo) {
|
||||
const status = cacheInfo?.status || 'not_cached'
|
||||
switch (status) {
|
||||
case 'cached':
|
||||
return 'Cached'
|
||||
case 'caching':
|
||||
return cacheInfo?.progress != null
|
||||
? `Caching ${(cacheInfo.progress * 100).toFixed(0)}%`
|
||||
: 'Caching'
|
||||
case 'failed':
|
||||
return 'Failed'
|
||||
case 'not_cached':
|
||||
default:
|
||||
return 'Not cached'
|
||||
}
|
||||
}
|
||||
|
||||
function cacheStatusClass(cacheInfo) {
|
||||
const status = cacheInfo?.status || 'not_cached'
|
||||
return {
|
||||
'status-badge': true,
|
||||
cached: status === 'cached',
|
||||
caching: status === 'caching',
|
||||
failed: status === 'failed',
|
||||
pending: status === 'not_cached'
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (typeof bytes !== 'number' || !Number.isFinite(bytes) || bytes <= 0) {
|
||||
return '0 B'
|
||||
}
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
let value = bytes
|
||||
let unitIndex = 0
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024
|
||||
unitIndex += 1
|
||||
}
|
||||
return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`
|
||||
}
|
||||
|
||||
async function fetchChannelStatus({ silent = false } = {}) {
|
||||
if (!silent) {
|
||||
channelStatusLoading.value = true
|
||||
}
|
||||
channelStatusError.value = ''
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
params.set('object_id', channelObjectId(selectedChannel.value))
|
||||
params.set('requested_count', '0')
|
||||
|
||||
const response = await fetch(`${SOURCE_API_BASE}/${SOURCE_ID}/browse?${params.toString()}`)
|
||||
const response = await fetch(`${API_BASE}/channels/${selectedChannel.value}/status`)
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
channelBrowse.value = {
|
||||
object_id: data.object_id || channelObjectId(selectedChannel.value),
|
||||
containers: data.containers ?? [],
|
||||
items: data.items ?? [],
|
||||
returned_containers: data.returned_containers ?? (data.containers?.length ?? 0),
|
||||
returned_items: data.returned_items ?? (data.items?.length ?? 0),
|
||||
total: data.total ?? ((data.containers?.length ?? 0) + (data.items?.length ?? 0)),
|
||||
update_id: data.update_id ?? 0
|
||||
}
|
||||
channelStatus.value = await response.json()
|
||||
channelStatusLastUpdated.value = new Date()
|
||||
} catch (e) {
|
||||
channelBrowseError.value = `Failed to load channel tracks: ${e.message}`
|
||||
console.error('Error fetching channel tracks:', e)
|
||||
channelStatusError.value = `Failed to load channel status: ${e.message}`
|
||||
console.error('Error fetching channel status:', e)
|
||||
} finally {
|
||||
channelBrowseLoading.value = false
|
||||
if (!silent) {
|
||||
channelStatusLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchChannelPlaylist({ silent = false, limit = 24 } = {}) {
|
||||
if (!silent) {
|
||||
channelPlaylistLoading.value = true
|
||||
}
|
||||
channelPlaylistError.value = ''
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (limit != null) {
|
||||
params.set('limit', String(limit))
|
||||
}
|
||||
const response = await fetch(
|
||||
`${API_BASE}/channels/${selectedChannel.value}/playlist?${params.toString()}`
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
const data = await response.json()
|
||||
channelPlaylist.value = {
|
||||
...data,
|
||||
items: (data.items || []).map((item) => ({
|
||||
...item,
|
||||
cache_status: item.cache_status || { status: 'not_cached', progress: 0 }
|
||||
}))
|
||||
}
|
||||
channelPlaylistLastUpdated.value = new Date()
|
||||
const validIds = new Set(channelPlaylist.value.items.map((item) => trackObjectId(item)).filter(Boolean))
|
||||
trackExtras.value = Object.fromEntries(
|
||||
Object.entries(trackExtras.value).filter(([id]) => validIds.has(id))
|
||||
)
|
||||
} catch (e) {
|
||||
channelPlaylistError.value = `Failed to load playlist: ${e.message}`
|
||||
console.error('Error fetching channel playlist:', e)
|
||||
} finally {
|
||||
if (!silent) {
|
||||
channelPlaylistLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchChannelHistory({ silent = false, limit = 25 } = {}) {
|
||||
if (!silent) {
|
||||
channelHistoryLoading.value = true
|
||||
}
|
||||
channelHistoryError.value = ''
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (limit != null) {
|
||||
params.set('limit', String(limit))
|
||||
}
|
||||
const response = await fetch(
|
||||
`${API_BASE}/channels/${selectedChannel.value}/history?${params.toString()}`
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
const data = await response.json()
|
||||
channelHistory.value = data.entries || []
|
||||
channelHistoryLastUpdated.value = new Date()
|
||||
} catch (e) {
|
||||
channelHistoryError.value = `Failed to load history: ${e.message}`
|
||||
console.error('Error fetching channel history:', e)
|
||||
} finally {
|
||||
if (!silent) {
|
||||
channelHistoryLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshChannelData({ silent = false } = {}) {
|
||||
await Promise.all([
|
||||
fetchChannelStatus({ silent }),
|
||||
fetchChannelPlaylist({ silent }),
|
||||
fetchChannelHistory({ silent })
|
||||
])
|
||||
}
|
||||
|
||||
function updateTrackExtras(trackId, patch) {
|
||||
if (!trackId) {
|
||||
return
|
||||
}
|
||||
const current = trackExtras.value[trackId] || {
|
||||
uri: '',
|
||||
lastResolvedAt: null,
|
||||
resolving: false,
|
||||
resolveError: '',
|
||||
formats: [],
|
||||
formatsLoading: false,
|
||||
formatsError: '',
|
||||
cacheRequestLoading: false,
|
||||
cacheRequestMessage: '',
|
||||
cacheError: '',
|
||||
cacheStatusLoading: false
|
||||
}
|
||||
trackExtras.value = {
|
||||
...trackExtras.value,
|
||||
[trackId]: {
|
||||
...current,
|
||||
...patch
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function trackExtrasFor(item) {
|
||||
const trackId = trackObjectId(item)
|
||||
return trackExtras.value[trackId] || {}
|
||||
}
|
||||
|
||||
async function resolveTrackUri(trackId) {
|
||||
if (!trackId) {
|
||||
throw new Error('Missing track identifier')
|
||||
}
|
||||
updateTrackExtras(trackId, { resolving: true, resolveError: '' })
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
params.set('object_id', trackId)
|
||||
const response = await fetch(`${SOURCE_API_BASE}/${SOURCE_ID}/resolve?${params.toString()}`)
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
const data = await response.json()
|
||||
updateTrackExtras(trackId, {
|
||||
resolving: false,
|
||||
uri: data.uri,
|
||||
lastResolvedAt: new Date()
|
||||
})
|
||||
return data.uri
|
||||
} catch (e) {
|
||||
updateTrackExtras(trackId, { resolving: false, resolveError: e.message })
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshTrackCacheStatus(item) {
|
||||
const trackId = trackObjectId(item)
|
||||
if (!trackId) {
|
||||
return
|
||||
}
|
||||
updateTrackExtras(trackId, { cacheStatusLoading: true, cacheError: '' })
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
params.set('object_id', trackId)
|
||||
const response = await fetch(
|
||||
`${SOURCE_API_BASE}/${SOURCE_ID}/cache/status?${params.toString()}`
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
const data = await response.json()
|
||||
const status = data.status || { status: 'not_cached' }
|
||||
channelPlaylist.value = {
|
||||
...channelPlaylist.value,
|
||||
items: channelPlaylist.value.items.map((entry) =>
|
||||
trackObjectId(entry) === trackId ? { ...entry, cache_status: status } : entry
|
||||
)
|
||||
}
|
||||
updateTrackExtras(trackId, { cacheStatusLoading: false, cacheError: '', cacheStatus: status })
|
||||
} catch (e) {
|
||||
updateTrackExtras(trackId, { cacheStatusLoading: false, cacheError: e.message })
|
||||
console.error('Error refreshing cache status:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function requestCacheForTrack(item) {
|
||||
const trackId = trackObjectId(item)
|
||||
if (!trackId) {
|
||||
return
|
||||
}
|
||||
updateTrackExtras(trackId, {
|
||||
cacheRequestLoading: true,
|
||||
cacheRequestMessage: '',
|
||||
cacheError: ''
|
||||
})
|
||||
try {
|
||||
const response = await fetch(`${SOURCE_API_BASE}/${SOURCE_ID}/cache`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ object_id: trackId })
|
||||
})
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({}))
|
||||
const message = body?.error || response.statusText
|
||||
throw new Error(message)
|
||||
}
|
||||
const data = await response.json()
|
||||
const status = data.status || data?.cache_status
|
||||
if (status) {
|
||||
channelPlaylist.value = {
|
||||
...channelPlaylist.value,
|
||||
items: channelPlaylist.value.items.map((entry) =>
|
||||
trackObjectId(entry) === trackId ? { ...entry, cache_status: status } : entry
|
||||
)
|
||||
}
|
||||
}
|
||||
updateTrackExtras(trackId, {
|
||||
cacheRequestLoading: false,
|
||||
cacheRequestMessage: 'Cache request accepted'
|
||||
})
|
||||
} catch (e) {
|
||||
updateTrackExtras(trackId, {
|
||||
cacheRequestLoading: false,
|
||||
cacheRequestMessage: '',
|
||||
cacheError: e.message
|
||||
})
|
||||
console.error('Error requesting cache:', e)
|
||||
} finally {
|
||||
await refreshTrackCacheStatus(item)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTrackFormats(item) {
|
||||
const trackId = trackObjectId(item)
|
||||
if (!trackId) {
|
||||
return
|
||||
}
|
||||
const extras = trackExtrasFor(item)
|
||||
if (extras.formats?.length && !extras.formatsError) {
|
||||
return
|
||||
}
|
||||
updateTrackExtras(trackId, { formatsLoading: true, formatsError: '' })
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
params.set('object_id', trackId)
|
||||
const response = await fetch(
|
||||
`${SOURCE_API_BASE}/${SOURCE_ID}/formats?${params.toString()}`
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
const data = await response.json()
|
||||
updateTrackExtras(trackId, {
|
||||
formatsLoading: false,
|
||||
formats: data.formats || []
|
||||
})
|
||||
} catch (e) {
|
||||
updateTrackExtras(trackId, { formatsLoading: false, formatsError: e.message })
|
||||
console.error('Error fetching track formats:', e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -569,15 +1016,17 @@ function selectChannel(channelId) {
|
||||
}
|
||||
|
||||
async function changeChannel() {
|
||||
trackExtras.value = {}
|
||||
await refreshNowPlaying()
|
||||
await fetchChannelTracks()
|
||||
await refreshChannelData()
|
||||
blockSearchResult.value = null
|
||||
blockSearchError.value = ''
|
||||
}
|
||||
|
||||
async function changeBitrate() {
|
||||
trackExtras.value = {}
|
||||
await refreshNowPlaying()
|
||||
await fetchChannelTracks()
|
||||
await refreshChannelData()
|
||||
blockSearchResult.value = null
|
||||
blockSearchError.value = ''
|
||||
}
|
||||
@@ -678,16 +1127,38 @@ function clearBlockSearch() {
|
||||
blockSearchError.value = ''
|
||||
}
|
||||
|
||||
function playTrackItem(item) {
|
||||
const resource = item?.resources?.find(res => res.url)
|
||||
if (!resource) {
|
||||
audioError.value = 'No audio resource available for this track'
|
||||
async function playTrackItem(item) {
|
||||
const trackId = trackObjectId(item)
|
||||
if (!trackId) {
|
||||
audioError.value = 'Unable to determine track identifier'
|
||||
isPlaying.value = false
|
||||
return
|
||||
}
|
||||
|
||||
activeTrackId.value = item.id
|
||||
playAudio(resource.url)
|
||||
try {
|
||||
let uri = null
|
||||
try {
|
||||
uri = await resolveTrackUri(trackId)
|
||||
} catch (resolveError) {
|
||||
console.warn('Falling back to direct resource due to resolve error:', resolveError)
|
||||
}
|
||||
|
||||
if (!uri) {
|
||||
const fallback = item?.resources?.find((res) => res.url)?.url
|
||||
uri = fallback
|
||||
}
|
||||
|
||||
if (!uri) {
|
||||
throw new Error('No audio resource available for this track')
|
||||
}
|
||||
|
||||
activeTrackId.value = trackId
|
||||
playAudio(uri)
|
||||
} catch (e) {
|
||||
audioError.value = e.message
|
||||
isPlaying.value = false
|
||||
activeTrackId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on mount
|
||||
@@ -695,7 +1166,7 @@ onMounted(async () => {
|
||||
await fetchChannels()
|
||||
await fetchBitrates()
|
||||
await refreshNowPlaying()
|
||||
await fetchChannelTracks()
|
||||
await refreshChannelData()
|
||||
|
||||
// Auto-refresh every 30 seconds
|
||||
refreshTimerId = window.setInterval(() => {
|
||||
@@ -703,12 +1174,22 @@ onMounted(async () => {
|
||||
refreshNowPlaying()
|
||||
}
|
||||
}, 30000)
|
||||
|
||||
// Auto-refresh channel tracks every few seconds
|
||||
channelRefreshTimerId = window.setInterval(() => {
|
||||
if (!channelPlaylistLoading.value && !channelStatusLoading.value) {
|
||||
refreshChannelData({ silent: true })
|
||||
}
|
||||
}, CHANNEL_REFRESH_INTERVAL)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (refreshTimerId) {
|
||||
clearInterval(refreshTimerId)
|
||||
}
|
||||
if (channelRefreshTimerId) {
|
||||
clearInterval(channelRefreshTimerId)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1250,6 +1731,13 @@ onUnmounted(() => {
|
||||
color: #9aa0a6;
|
||||
}
|
||||
|
||||
.section-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.loading-message {
|
||||
margin-top: 16px;
|
||||
color: #9aa0a6;
|
||||
@@ -1288,6 +1776,88 @@ onUnmounted(() => {
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: #9aa0a6;
|
||||
}
|
||||
|
||||
.status-value {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
color: #f5f5f5;
|
||||
}
|
||||
|
||||
.track-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.status-badge.cached {
|
||||
background: rgba(46, 204, 113, 0.18);
|
||||
border-color: rgba(46, 204, 113, 0.45);
|
||||
color: #2ecc71;
|
||||
}
|
||||
|
||||
.status-badge.caching {
|
||||
background: rgba(255, 193, 7, 0.15);
|
||||
border-color: rgba(255, 193, 7, 0.4);
|
||||
color: #ffc107;
|
||||
}
|
||||
|
||||
.status-badge.failed {
|
||||
background: rgba(255, 87, 34, 0.18);
|
||||
border-color: rgba(255, 87, 34, 0.5);
|
||||
color: #ff7043;
|
||||
}
|
||||
|
||||
.status-badge.pending {
|
||||
background: rgba(0, 212, 255, 0.12);
|
||||
border-color: rgba(0, 212, 255, 0.4);
|
||||
color: #00d4ff;
|
||||
}
|
||||
|
||||
.track-metadata {
|
||||
font-size: 0.7rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.track-card.active {
|
||||
border-color: rgba(46, 204, 113, 0.8);
|
||||
box-shadow: 0 0 12px rgba(46, 204, 113, 0.25);
|
||||
@@ -1324,6 +1894,63 @@ onUnmounted(() => {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.inline-error {
|
||||
margin-top: 6px;
|
||||
font-size: 0.8rem;
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.inline-success {
|
||||
margin-top: 6px;
|
||||
font-size: 0.8rem;
|
||||
color: #2ecc71;
|
||||
}
|
||||
|
||||
.formats-list {
|
||||
margin-top: 10px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
padding-top: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 0.8rem;
|
||||
color: #9aa0a6;
|
||||
}
|
||||
|
||||
.format-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.history-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.history-item {
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.history-title {
|
||||
font-weight: 600;
|
||||
color: #f5f5f5;
|
||||
}
|
||||
|
||||
.history-meta {
|
||||
margin-top: 4px;
|
||||
font-size: 0.8rem;
|
||||
color: #9aa0a6;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.audio-player-container {
|
||||
margin: 12px 0 24px;
|
||||
padding: 16px;
|
||||
|
||||
@@ -2,27 +2,39 @@
|
||||
* Service API pour interagir avec le cache de pistes audio
|
||||
*/
|
||||
|
||||
export interface AudioMetadata {
|
||||
export interface AudioCacheMetadata {
|
||||
origin_url?: string;
|
||||
title?: string;
|
||||
artist?: string;
|
||||
album?: string;
|
||||
year?: number;
|
||||
genre?: string;
|
||||
track_number?: number;
|
||||
track_total?: number;
|
||||
disc_number?: number;
|
||||
disc_total?: number;
|
||||
duration_ms?: number;
|
||||
duration_secs?: number;
|
||||
sample_rate?: number;
|
||||
bitrate?: number;
|
||||
channels?: number;
|
||||
conversion?: ConversionInfo;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface AudioCacheEntry {
|
||||
pk: string;
|
||||
source_url: string;
|
||||
id: string | null;
|
||||
hits: number;
|
||||
last_used: string | null;
|
||||
collection?: string;
|
||||
metadata?: AudioMetadata;
|
||||
collection?: string | null;
|
||||
metadata?: AudioCacheMetadata | null;
|
||||
}
|
||||
|
||||
export interface ConversionInfo {
|
||||
mode: string;
|
||||
input_codec?: string;
|
||||
details?: string;
|
||||
}
|
||||
|
||||
export interface AddTrackRequest {
|
||||
@@ -38,9 +50,13 @@ export interface AddTrackResponse {
|
||||
|
||||
export interface DownloadStatus {
|
||||
pk: string;
|
||||
status: "pending" | "downloading" | "completed" | "failed";
|
||||
progress?: number;
|
||||
in_progress: boolean;
|
||||
finished: boolean;
|
||||
current_size?: number;
|
||||
transformed_size?: number;
|
||||
expected_size?: number;
|
||||
error?: string;
|
||||
conversion?: ConversionInfo;
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
@@ -48,6 +64,28 @@ export interface ApiError {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function getOriginUrl(entry: AudioCacheEntry): string | undefined {
|
||||
const metadata = entry.metadata;
|
||||
if (metadata && typeof metadata === "object") {
|
||||
const origin = (metadata as { origin_url?: unknown }).origin_url;
|
||||
if (typeof origin === "string" && origin.trim().length > 0) {
|
||||
return origin;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getDurationMs(metadata?: AudioCacheMetadata | null): number | undefined {
|
||||
if (!metadata) return undefined;
|
||||
if (typeof metadata.duration_ms === "number" && !Number.isNaN(metadata.duration_ms)) {
|
||||
return metadata.duration_ms;
|
||||
}
|
||||
if (typeof metadata.duration_secs === "number" && !Number.isNaN(metadata.duration_secs)) {
|
||||
return metadata.duration_secs * 1000;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste toutes les pistes en cache
|
||||
*/
|
||||
|
||||
@@ -2,11 +2,18 @@
|
||||
* Service API pour interagir avec le cache d'images de couvertures
|
||||
*/
|
||||
|
||||
export interface CacheMetadata {
|
||||
origin_url?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface CacheEntry {
|
||||
pk: string;
|
||||
source_url: string;
|
||||
id: string | null;
|
||||
collection?: string | null;
|
||||
hits: number;
|
||||
last_used: string | null;
|
||||
metadata?: CacheMetadata | null;
|
||||
}
|
||||
|
||||
export interface AddImageRequest {
|
||||
@@ -24,6 +31,14 @@ export interface ApiError {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface DownloadStatus {
|
||||
pk: string;
|
||||
finished: boolean;
|
||||
current_size?: number;
|
||||
expected_size?: number;
|
||||
transformed_size?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste toutes les images en cache
|
||||
*/
|
||||
@@ -109,9 +124,68 @@ export async function consolidateCache(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère le statut du téléchargement d'une image
|
||||
*/
|
||||
export async function getDownloadStatus(pk: string): Promise<DownloadStatus> {
|
||||
const response = await fetch(`/api/covers/${pk}/status`);
|
||||
if (!response.ok) {
|
||||
const error: ApiError = await response.json();
|
||||
throw new Error(error.message || "Failed to get download status");
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Attend que le téléchargement d'une image soit terminé
|
||||
*
|
||||
* @param pk - Clé primaire de l'image
|
||||
* @param maxWaitMs - Temps maximum d'attente en millisecondes (défaut: 30000)
|
||||
* @param pollIntervalMs - Intervalle entre les vérifications en millisecondes (défaut: 500)
|
||||
*/
|
||||
export async function waitForDownload(
|
||||
pk: string,
|
||||
maxWaitMs: number = 30000,
|
||||
pollIntervalMs: number = 500
|
||||
): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < maxWaitMs) {
|
||||
try {
|
||||
const status = await getDownloadStatus(pk);
|
||||
if (status.finished) {
|
||||
return; // Téléchargement terminé
|
||||
}
|
||||
} catch (error) {
|
||||
// Si l'API retourne une erreur, on continue d'attendre
|
||||
console.warn(`Error checking download status for ${pk}:`, error);
|
||||
}
|
||||
|
||||
// Attendre avant la prochaine vérification
|
||||
await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
|
||||
}
|
||||
|
||||
// Timeout atteint, on lance une dernière vérification
|
||||
const finalStatus = await getDownloadStatus(pk);
|
||||
if (!finalStatus.finished) {
|
||||
console.warn(`Download timeout for ${pk}, but continuing anyway`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère l'URL pour afficher une image
|
||||
*/
|
||||
export function getOriginUrl(entry: CacheEntry): string | undefined {
|
||||
const metadata = entry.metadata;
|
||||
if (metadata && typeof metadata === "object") {
|
||||
const origin = (metadata as { origin_url?: unknown }).origin_url;
|
||||
if (typeof origin === "string" && origin.trim().length > 0) {
|
||||
return origin;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getImageUrl(pk: string, size?: number): string {
|
||||
if (size) {
|
||||
return `/covers/image/${pk}/${size}`;
|
||||
|
||||
30
pmoaudio-ext/Cargo.toml
Normal file
30
pmoaudio-ext/Cargo.toml
Normal file
@@ -0,0 +1,30 @@
|
||||
[package]
|
||||
name = "pmoaudio-ext"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
# Core audio types
|
||||
pmoaudio = { path = "../pmoaudio" }
|
||||
pmocovers = { path = "../pmocovers"}
|
||||
|
||||
# Optional dependencies for cache-sink feature
|
||||
pmoaudiocache = { path = "../pmoaudiocache", optional = true }
|
||||
pmoflac = { path = "../pmoflac", optional = true }
|
||||
pmometadata = { path = "../pmometadata", optional = true }
|
||||
|
||||
# Optional dependency for playlist integration
|
||||
pmoplaylist = { path = "../pmoplaylist", optional = true }
|
||||
# Async runtime
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
tokio-util = { version = "0.7" }
|
||||
async-trait = "0.1"
|
||||
|
||||
# Utilities
|
||||
tracing = "0.1"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
cache-sink = ["dep:pmoaudiocache", "dep:pmoflac", "dep:pmometadata"]
|
||||
playlist = ["dep:pmoplaylist"]
|
||||
all = ["cache-sink", "playlist"]
|
||||
30
pmoaudio-ext/src/lib.rs
Executable file
30
pmoaudio-ext/src/lib.rs
Executable file
@@ -0,0 +1,30 @@
|
||||
//! Extensions pour pmoaudio
|
||||
//!
|
||||
//! Cette crate fournit des nodes d'extension pour pmoaudio qui dépendent
|
||||
//! d'autres crates du projet. Elle permet d'éviter les dépendances cycliques
|
||||
//! en plaçant ces extensions en "bout de chaîne" de dépendances.
|
||||
//!
|
||||
//! # Features
|
||||
//!
|
||||
//! - `cache-sink` : Active le `FlacCacheSink` qui encode l'audio en FLAC et le stocke dans pmoaudiocache
|
||||
//! - `playlist` : Active l'intégration avec pmoplaylist pour les sinks
|
||||
//! - `all` : Active toutes les features d'un coup
|
||||
//!
|
||||
//! # Architecture
|
||||
//!
|
||||
//! Cette crate dépend de :
|
||||
//! - `pmoaudio` : Types de base (AudioSegment, AudioError, etc.)
|
||||
//! - `pmoaudiocache` (optionnel) : Cache audio pour le stockage FLAC
|
||||
//! - `pmoflac` (optionnel) : Encodage FLAC
|
||||
//! - `pmometadata` (optionnel) : Gestion des métadonnées
|
||||
//! - `pmoplaylist` (optionnel) : Intégration playlist
|
||||
//!
|
||||
//! Aucune des crates ci-dessus ne dépend de `pmoaudio-ext`, évitant ainsi
|
||||
//! tout cycle de dépendances.
|
||||
|
||||
#[cfg(feature = "cache-sink")]
|
||||
pub mod sinks;
|
||||
|
||||
// Re-exports pour faciliter l'utilisation
|
||||
#[cfg(feature = "cache-sink")]
|
||||
pub use sinks::*;
|
||||
677
pmoaudio-ext/src/sinks/flac_cache_sink.rs
Executable file
677
pmoaudio-ext/src/sinks/flac_cache_sink.rs
Executable file
@@ -0,0 +1,677 @@
|
||||
//! Sink qui encode les AudioSegment au format FLAC et les stocke dans le cache audio
|
||||
|
||||
use pmoaudio::{
|
||||
nodes::{AudioError, TypedAudioNode, DEFAULT_CHANNEL_SIZE},
|
||||
pipeline::{Node, NodeLogic},
|
||||
type_constraints::TypeRequirement,
|
||||
AudioChunk, AudioPipelineNode, AudioSegment, SyncMarker, _AudioSegment,
|
||||
};
|
||||
use pmoaudiocache::AudioTrackMetadataExt;
|
||||
use pmoflac::{encode_flac_stream, EncoderOptions, PcmFormat};
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
io::Cursor,
|
||||
pin::Pin,
|
||||
sync::Arc,
|
||||
task::{Context, Poll},
|
||||
};
|
||||
use tokio::{
|
||||
io::{self, AsyncRead, ReadBuf},
|
||||
sync::{mpsc, RwLock},
|
||||
};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::warn;
|
||||
|
||||
/// Sink qui encode les `AudioSegment` reçus au format FLAC et les stocke dans le cache audio.
|
||||
///
|
||||
/// Ce sink :
|
||||
/// - Filtre les chunks audio et ignore les autres syncmarkers (sauf TrackBoundary et EndOfStream)
|
||||
/// - Crée une nouvelle entrée de cache pour chaque TrackBoundary rencontré
|
||||
/// - Adapte automatiquement l'encodage FLAC selon la profondeur de bit du chunk (8/16/24/32-bit)
|
||||
/// - Copie les métadonnées du TrackBoundary dans le cache après ingestion
|
||||
/// - Peut optionnellement ajouter les tracks à une playlist via `register_playlist()`
|
||||
/// - Termine l'encodage proprement quand il reçoit EndOfStream
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// FlacCacheSinkLogic - Logique métier pure
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Signal retourné par pump_segments indiquant pourquoi l'encodage s'est arrêté.
|
||||
enum StopReason {
|
||||
TrackBoundary(Arc<RwLock<dyn pmometadata::TrackMetadata>>),
|
||||
EndOfStream,
|
||||
ChannelClosed,
|
||||
}
|
||||
|
||||
/// Logique pure d'encodage FLAC vers le cache
|
||||
pub struct FlacCacheSinkLogic {
|
||||
cache: Arc<pmoaudiocache::Cache>,
|
||||
covers: Arc<pmocovers::Cache>,
|
||||
collection: Option<String>,
|
||||
encoder_options: EncoderOptions,
|
||||
pcm_buffer_capacity: usize,
|
||||
#[cfg(feature = "playlist")]
|
||||
playlist_handle: Option<Arc<pmoplaylist::WriteHandle>>,
|
||||
}
|
||||
|
||||
impl FlacCacheSinkLogic {
|
||||
pub fn new(
|
||||
cache: Arc<pmoaudiocache::Cache>,
|
||||
covers: Arc<pmocovers::Cache>,
|
||||
collection: Option<String>,
|
||||
encoder_options: EncoderOptions,
|
||||
pcm_buffer_capacity: usize,
|
||||
) -> Self {
|
||||
Self {
|
||||
cache,
|
||||
covers,
|
||||
collection,
|
||||
encoder_options,
|
||||
pcm_buffer_capacity,
|
||||
#[cfg(feature = "playlist")]
|
||||
playlist_handle: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "playlist")]
|
||||
pub fn set_playlist_handle(&mut self, handle: Arc<pmoplaylist::WriteHandle>) {
|
||||
self.playlist_handle = Some(handle);
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl NodeLogic for FlacCacheSinkLogic {
|
||||
async fn process(
|
||||
&mut self,
|
||||
input: Option<mpsc::Receiver<Arc<AudioSegment>>>,
|
||||
_output: Vec<mpsc::Sender<Arc<AudioSegment>>>,
|
||||
stop_token: CancellationToken,
|
||||
) -> Result<(), AudioError> {
|
||||
let mut rx = input.expect("FlacCacheSink must have input");
|
||||
let mut track_number = 0;
|
||||
|
||||
loop {
|
||||
// Attendre le premier chunk audio pour cette track
|
||||
let (first_segment, track_metadata) =
|
||||
match wait_for_first_audio_chunk_with_metadata(&mut rx, &stop_token).await {
|
||||
Ok(result) => result,
|
||||
Err(_) => {
|
||||
// Plus d'audio disponible
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
// Extraire les informations du premier chunk
|
||||
let first_chunk = first_segment.as_chunk().unwrap();
|
||||
let sample_rate = first_chunk.sample_rate();
|
||||
let bits_per_sample = get_chunk_bit_depth(first_chunk);
|
||||
|
||||
let format = PcmFormat {
|
||||
sample_rate,
|
||||
channels: 2,
|
||||
bits_per_sample,
|
||||
};
|
||||
if let Err(err) = format.validate() {
|
||||
return Err(AudioError::ProcessingError(format!(
|
||||
"Invalid PCM format: {}",
|
||||
err
|
||||
)));
|
||||
}
|
||||
|
||||
// Créer le pipeline d'encodage pour cette track
|
||||
let (pcm_tx, pcm_rx) = mpsc::channel::<Vec<u8>>(self.pcm_buffer_capacity);
|
||||
|
||||
// Préparer les options d'encodage avec les métadonnées du TrackBoundary
|
||||
let mut options_with_metadata = self.encoder_options.clone();
|
||||
options_with_metadata.metadata = track_metadata.clone();
|
||||
|
||||
// Créer l'encoder
|
||||
let reader = ByteStreamReader::new(pcm_rx);
|
||||
let mut flac_stream = encode_flac_stream(reader, format, options_with_metadata)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AudioError::ProcessingError(format!("FLAC encode init failed: {}", e))
|
||||
})?;
|
||||
|
||||
// Créer un buffer pour collecter le FLAC encodé
|
||||
let mut flac_buffer = Vec::new();
|
||||
|
||||
// Exécuter pump et copy en parallèle
|
||||
let pump_future = pump_track_segments(
|
||||
first_segment,
|
||||
&mut rx,
|
||||
pcm_tx,
|
||||
bits_per_sample,
|
||||
sample_rate,
|
||||
&stop_token,
|
||||
);
|
||||
let copy_future = async {
|
||||
tokio::io::copy(&mut flac_stream, &mut flac_buffer)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AudioError::ProcessingError(format!("FLAC write failed: {}", e))
|
||||
})?;
|
||||
flac_stream
|
||||
.wait()
|
||||
.await
|
||||
.map_err(|e| AudioError::ProcessingError(format!("Encoder failed: {}", e)))?;
|
||||
Ok::<_, AudioError>(())
|
||||
};
|
||||
|
||||
// Attendre les deux tâches en parallèle
|
||||
let (copy_result, pump_result) = tokio::join!(copy_future, pump_future);
|
||||
copy_result?;
|
||||
let (_chunks, _samples, _duration_sec, stop_reason) = pump_result?;
|
||||
|
||||
// Ingérer le FLAC dans le cache
|
||||
let flac_reader = Cursor::new(flac_buffer.clone());
|
||||
let collection_ref = self.collection.as_deref();
|
||||
let pk = self.cache
|
||||
.add_from_reader(
|
||||
None,
|
||||
flac_reader,
|
||||
Some(flac_buffer.len() as u64),
|
||||
collection_ref,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AudioError::ProcessingError(format!("Failed to add to cache: {}", e))
|
||||
})?;
|
||||
|
||||
// Copier les métadonnées du TrackBoundary dans le cache
|
||||
if let Some(src_metadata) = track_metadata {
|
||||
let dest_metadata = self.cache.track_metadata(&pk);
|
||||
|
||||
// Utiliser copy_metadata_into pour copier toutes les métadonnées
|
||||
pmometadata::copy_metadata_into(&src_metadata, &dest_metadata)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AudioError::ProcessingError(format!(
|
||||
"Failed to copy metadata to cache: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
let url = match dest_metadata.read().await.get_cover_url().await {
|
||||
Ok(url) => url,
|
||||
Err(e) if e.is_transient() => None,
|
||||
Err(_) => {
|
||||
warn!("Cannot obtain cover for audio asset {}", pk);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
if url.is_some() {
|
||||
let _ = match self.covers
|
||||
.add_from_url(&url.unwrap(), self.collection.as_deref())
|
||||
.await
|
||||
{
|
||||
Ok(pk_covers) => {
|
||||
dest_metadata
|
||||
.write()
|
||||
.await
|
||||
.set_cover_pk(Some(pk_covers))
|
||||
.await
|
||||
}
|
||||
Err(_) => {
|
||||
warn!("Cannot obtain cover for audio asset {}", pk);
|
||||
Ok(Some(()))
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Ajouter à la playlist si enregistrée
|
||||
#[cfg(feature = "playlist")]
|
||||
if let Some(ref playlist_handle) = self.playlist_handle {
|
||||
playlist_handle.push(pk.clone()).await.map_err(|e| {
|
||||
AudioError::ProcessingError(format!("Failed to add to playlist: {}", e))
|
||||
})?;
|
||||
}
|
||||
|
||||
// Vérifier le stop_reason pour savoir si on continue
|
||||
match stop_reason {
|
||||
StopReason::TrackBoundary(_metadata) => {
|
||||
// Continuer avec la prochaine track
|
||||
track_number += 1;
|
||||
continue;
|
||||
}
|
||||
StopReason::EndOfStream | StopReason::ChannelClosed => {
|
||||
// Fin de l'encodage
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// FlacCacheSink - Wrapper utilisant Node<FlacCacheSinkLogic>
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
pub struct FlacCacheSink {
|
||||
inner: Node<FlacCacheSinkLogic>,
|
||||
#[cfg(feature = "playlist")]
|
||||
playlist_handle_pending: Option<Arc<pmoplaylist::WriteHandle>>,
|
||||
}
|
||||
|
||||
impl FlacCacheSink {
|
||||
/// Crée un sink FLAC cache avec les options par défaut (compression 5, buffer de 16 segments).
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `cache` - Arc vers le cache audio où stocker les fichiers FLAC encodés
|
||||
pub fn new(cache: Arc<pmoaudiocache::Cache>, covers: Arc<pmocovers::Cache>) -> Self {
|
||||
Self::with_channel_size(cache, covers, DEFAULT_CHANNEL_SIZE)
|
||||
}
|
||||
|
||||
/// Crée un sink FLAC cache avec une taille de buffer MPSC personnalisée.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `cache` - Arc vers le cache audio
|
||||
/// * `channel_size` - Taille du buffer MPSC (nombre de segments en attente avant backpressure)
|
||||
pub fn with_channel_size(
|
||||
cache: Arc<pmoaudiocache::Cache>,
|
||||
covers: Arc<pmocovers::Cache>,
|
||||
channel_size: usize,
|
||||
) -> Self {
|
||||
Self::with_config(cache, covers, channel_size, EncoderOptions::default(), None)
|
||||
}
|
||||
|
||||
/// Crée un sink FLAC cache avec une configuration complète.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `cache` - Arc vers le cache audio
|
||||
/// * `channel_size` - Taille du buffer MPSC
|
||||
/// * `encoder_options` - Options d'encodage FLAC (compression, etc.)
|
||||
/// * `collection` - Collection optionnelle à laquelle appartiennent les fichiers
|
||||
pub fn with_config(
|
||||
cache: Arc<pmoaudiocache::Cache>,
|
||||
covers: Arc<pmocovers::Cache>,
|
||||
channel_size: usize,
|
||||
encoder_options: EncoderOptions,
|
||||
collection: Option<String>,
|
||||
) -> Self {
|
||||
let logic = FlacCacheSinkLogic::new(cache, covers, collection, encoder_options, 8);
|
||||
Self {
|
||||
inner: Node::new_with_input(logic, channel_size),
|
||||
#[cfg(feature = "playlist")]
|
||||
playlist_handle_pending: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Enregistre une playlist pour recevoir automatiquement les tracks sauvées dans le cache.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `handle` - WriteHandle de la playlist qui recevra les pk des tracks
|
||||
#[cfg(feature = "playlist")]
|
||||
pub fn register_playlist(&mut self, handle: pmoplaylist::WriteHandle) {
|
||||
self.playlist_handle_pending = Some(Arc::new(handle));
|
||||
}
|
||||
}
|
||||
|
||||
/// Attend et retourne le premier chunk audio avec les métadonnées du TrackBoundary si présent.
|
||||
/// Retourne une erreur si EndOfStream est reçu avant tout audio.
|
||||
async fn wait_for_first_audio_chunk_with_metadata(
|
||||
rx: &mut mpsc::Receiver<Arc<AudioSegment>>,
|
||||
stop_token: &CancellationToken,
|
||||
) -> Result<
|
||||
(
|
||||
Arc<AudioSegment>,
|
||||
Option<Arc<RwLock<dyn pmometadata::TrackMetadata>>>,
|
||||
),
|
||||
AudioError,
|
||||
> {
|
||||
let mut track_metadata: Option<Arc<RwLock<dyn pmometadata::TrackMetadata>>> = None;
|
||||
|
||||
loop {
|
||||
let segment = tokio::select! {
|
||||
result = rx.recv() => {
|
||||
result.ok_or_else(|| AudioError::ProcessingError("No audio data received".into()))?
|
||||
}
|
||||
_ = stop_token.cancelled() => {
|
||||
return Err(AudioError::ProcessingError("Cancelled".into()));
|
||||
}
|
||||
};
|
||||
|
||||
match &segment.segment {
|
||||
_AudioSegment::Chunk(chunk) => {
|
||||
if chunk.len() == 0 {
|
||||
return Err(AudioError::ProcessingError("Received empty chunk".into()));
|
||||
}
|
||||
return Ok((segment, track_metadata));
|
||||
}
|
||||
_AudioSegment::Sync(marker) => match &**marker {
|
||||
SyncMarker::TrackBoundary { metadata, .. } => {
|
||||
// Capturer les métadonnées du TrackBoundary
|
||||
track_metadata = Some(metadata.clone());
|
||||
continue;
|
||||
}
|
||||
SyncMarker::EndOfStream => {
|
||||
return Err(AudioError::ProcessingError(
|
||||
"EndOfStream received before any audio".into(),
|
||||
));
|
||||
}
|
||||
_ => {
|
||||
// Ignorer TopZeroSync, Heartbeat, etc.
|
||||
continue;
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pompe les segments pour une seule track (s'arrête au TrackBoundary).
|
||||
async fn pump_track_segments(
|
||||
first_segment: Arc<AudioSegment>,
|
||||
rx: &mut mpsc::Receiver<Arc<AudioSegment>>,
|
||||
pcm_tx: mpsc::Sender<Vec<u8>>,
|
||||
bits_per_sample: u8,
|
||||
expected_rate: u32,
|
||||
stop_token: &CancellationToken,
|
||||
) -> Result<(u64, u64, f64, StopReason), AudioError> {
|
||||
let mut chunks = 0u64;
|
||||
let mut samples = 0u64;
|
||||
let mut duration_sec = 0.0f64;
|
||||
|
||||
// Traiter le premier segment
|
||||
if let Some(chunk) = first_segment.as_chunk() {
|
||||
let pcm_bytes = chunk_to_pcm_bytes(chunk, bits_per_sample)?;
|
||||
if !pcm_bytes.is_empty() {
|
||||
pcm_tx
|
||||
.send(pcm_bytes)
|
||||
.await
|
||||
.map_err(|_| AudioError::SendError)?;
|
||||
chunks += 1;
|
||||
samples += chunk.len() as u64;
|
||||
duration_sec += chunk.len() as f64 / expected_rate as f64;
|
||||
}
|
||||
}
|
||||
|
||||
// Boucle sur les segments suivants
|
||||
loop {
|
||||
let segment = tokio::select! {
|
||||
result = rx.recv() => {
|
||||
match result {
|
||||
Some(seg) => seg,
|
||||
None => {
|
||||
drop(pcm_tx); // Fermer le channel PCM
|
||||
return Ok((chunks, samples, duration_sec, StopReason::ChannelClosed));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = stop_token.cancelled() => {
|
||||
drop(pcm_tx); // Fermer le channel PCM
|
||||
return Ok((chunks, samples, duration_sec, StopReason::ChannelClosed));
|
||||
}
|
||||
};
|
||||
|
||||
match &segment.segment {
|
||||
_AudioSegment::Chunk(chunk) => {
|
||||
// Vérifier la cohérence du sample rate
|
||||
if chunk.sample_rate() != expected_rate {
|
||||
return Err(AudioError::ProcessingError(format!(
|
||||
"FlacCacheSink: inconsistent sample rate ({} vs {})",
|
||||
chunk.sample_rate(),
|
||||
expected_rate
|
||||
)));
|
||||
}
|
||||
|
||||
let pcm_bytes = chunk_to_pcm_bytes(&chunk, bits_per_sample)?;
|
||||
if pcm_bytes.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
pcm_tx
|
||||
.send(pcm_bytes)
|
||||
.await
|
||||
.map_err(|_| AudioError::SendError)?;
|
||||
|
||||
chunks += 1;
|
||||
samples += chunk.len() as u64;
|
||||
duration_sec += chunk.len() as f64 / expected_rate as f64;
|
||||
}
|
||||
_AudioSegment::Sync(marker) => match &**marker {
|
||||
SyncMarker::TrackBoundary { metadata, .. } => {
|
||||
drop(pcm_tx); // Fermer le channel PCM
|
||||
return Ok((
|
||||
chunks,
|
||||
samples,
|
||||
duration_sec,
|
||||
StopReason::TrackBoundary(metadata.clone()),
|
||||
));
|
||||
}
|
||||
SyncMarker::EndOfStream => {
|
||||
drop(pcm_tx); // Fermer le channel PCM
|
||||
return Ok((chunks, samples, duration_sec, StopReason::EndOfStream));
|
||||
}
|
||||
_ => {} // Ignorer les autres syncmarkers
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Détermine la profondeur de bit d'un chunk audio
|
||||
fn get_chunk_bit_depth(chunk: &AudioChunk) -> u8 {
|
||||
match chunk {
|
||||
AudioChunk::I16(_) => 16,
|
||||
AudioChunk::I24(_) => 24,
|
||||
AudioChunk::I32(_) => 32,
|
||||
AudioChunk::F32(_) => 32, // Les flottants seront convertis en 32-bit
|
||||
AudioChunk::F64(_) => 32, // Les flottants seront convertis en 32-bit
|
||||
}
|
||||
}
|
||||
|
||||
/// Convertit un chunk audio en bytes PCM avec la profondeur de bit spécifiée
|
||||
fn chunk_to_pcm_bytes(chunk: &AudioChunk, bits_per_sample: u8) -> Result<Vec<u8>, AudioError> {
|
||||
// Vérifier que le chunk est de type entier
|
||||
match chunk {
|
||||
AudioChunk::F32(_) | AudioChunk::F64(_) => {
|
||||
return Err(AudioError::ProcessingError(
|
||||
"FlacCacheSink only supports integer audio chunks (I16, I24, I32)".into(),
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let len = chunk.len();
|
||||
let bytes_per_frame = (bits_per_sample / 8) as usize * 2; // 2 channels
|
||||
let mut bytes = Vec::with_capacity(len * bytes_per_frame);
|
||||
|
||||
// Convertir selon le type du chunk
|
||||
match (chunk, bits_per_sample) {
|
||||
// I16 source
|
||||
(AudioChunk::I16(data), 16) => {
|
||||
for frame in data.get_frames() {
|
||||
bytes.extend_from_slice(&frame[0].to_le_bytes());
|
||||
bytes.extend_from_slice(&frame[1].to_le_bytes());
|
||||
}
|
||||
}
|
||||
(AudioChunk::I16(data), 24) => {
|
||||
for frame in data.get_frames() {
|
||||
let left = (frame[0] as i32) << 8;
|
||||
let right = (frame[1] as i32) << 8;
|
||||
bytes.extend_from_slice(&left.to_le_bytes()[..3]);
|
||||
bytes.extend_from_slice(&right.to_le_bytes()[..3]);
|
||||
}
|
||||
}
|
||||
(AudioChunk::I16(data), 32) => {
|
||||
for frame in data.get_frames() {
|
||||
let left = (frame[0] as i32) << 16;
|
||||
let right = (frame[1] as i32) << 16;
|
||||
bytes.extend_from_slice(&left.to_le_bytes());
|
||||
bytes.extend_from_slice(&right.to_le_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
// I24 source
|
||||
(AudioChunk::I24(data), 16) => {
|
||||
for frame in data.get_frames() {
|
||||
let left = (frame[0].as_i32() >> 8) as i16;
|
||||
let right = (frame[1].as_i32() >> 8) as i16;
|
||||
bytes.extend_from_slice(&left.to_le_bytes());
|
||||
bytes.extend_from_slice(&right.to_le_bytes());
|
||||
}
|
||||
}
|
||||
(AudioChunk::I24(data), 24) => {
|
||||
for frame in data.get_frames() {
|
||||
bytes.extend_from_slice(&frame[0].as_i32().to_le_bytes()[..3]);
|
||||
bytes.extend_from_slice(&frame[1].as_i32().to_le_bytes()[..3]);
|
||||
}
|
||||
}
|
||||
(AudioChunk::I24(data), 32) => {
|
||||
for frame in data.get_frames() {
|
||||
let left = frame[0].as_i32() << 8;
|
||||
let right = frame[1].as_i32() << 8;
|
||||
bytes.extend_from_slice(&left.to_le_bytes());
|
||||
bytes.extend_from_slice(&right.to_le_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
// I32 source
|
||||
(AudioChunk::I32(data), 16) => {
|
||||
for frame in data.get_frames() {
|
||||
let left = (frame[0] >> 16) as i16;
|
||||
let right = (frame[1] >> 16) as i16;
|
||||
bytes.extend_from_slice(&left.to_le_bytes());
|
||||
bytes.extend_from_slice(&right.to_le_bytes());
|
||||
}
|
||||
}
|
||||
(AudioChunk::I32(data), 24) => {
|
||||
for frame in data.get_frames() {
|
||||
let left = frame[0] >> 8;
|
||||
let right = frame[1] >> 8;
|
||||
bytes.extend_from_slice(&left.to_le_bytes()[..3]);
|
||||
bytes.extend_from_slice(&right.to_le_bytes()[..3]);
|
||||
}
|
||||
}
|
||||
(AudioChunk::I32(data), 32) => {
|
||||
for frame in data.get_frames() {
|
||||
bytes.extend_from_slice(&frame[0].to_le_bytes());
|
||||
bytes.extend_from_slice(&frame[1].to_le_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
_ => {
|
||||
return Err(AudioError::ProcessingError(format!(
|
||||
"Unsupported bits_per_sample: {}",
|
||||
bits_per_sample
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
struct ByteStreamReader {
|
||||
rx: mpsc::Receiver<Vec<u8>>,
|
||||
buffer: VecDeque<u8>,
|
||||
finished: bool,
|
||||
}
|
||||
|
||||
impl ByteStreamReader {
|
||||
fn new(rx: mpsc::Receiver<Vec<u8>>) -> 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<io::Result<()>> {
|
||||
loop {
|
||||
if !self.buffer.is_empty() {
|
||||
let to_copy = self.buffer.len().min(buf.remaining());
|
||||
if to_copy == 0 {
|
||||
return Poll::Ready(Ok(()));
|
||||
}
|
||||
|
||||
// VecDeque::make_contiguous pour copier efficacement
|
||||
let slice = self.buffer.make_contiguous();
|
||||
buf.put_slice(&slice[..to_copy]);
|
||||
self.buffer.drain(..to_copy);
|
||||
return Poll::Ready(Ok(()));
|
||||
}
|
||||
|
||||
if self.finished {
|
||||
return Poll::Ready(Ok(()));
|
||||
}
|
||||
|
||||
match Pin::new(&mut self.rx).poll_recv(cx) {
|
||||
Poll::Ready(Some(bytes)) => {
|
||||
if bytes.is_empty() {
|
||||
continue;
|
||||
}
|
||||
self.buffer.extend(bytes);
|
||||
}
|
||||
Poll::Ready(None) => {
|
||||
self.finished = true;
|
||||
return Poll::Ready(Ok(()));
|
||||
}
|
||||
Poll::Pending => return Poll::Pending,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Statistiques pour une track individuelle.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TrackStats {
|
||||
pub pk: String,
|
||||
pub track_number: usize,
|
||||
pub chunks_received: u64,
|
||||
pub total_samples: u64,
|
||||
pub total_duration_sec: f64,
|
||||
}
|
||||
|
||||
/// Statistiques produites par le `FlacCacheSink`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FlacCacheSinkStats {
|
||||
pub tracks: Vec<TrackStats>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl AudioPipelineNode for FlacCacheSink {
|
||||
fn get_tx(&self) -> Option<mpsc::Sender<Arc<AudioSegment>>> {
|
||||
self.inner.get_tx()
|
||||
}
|
||||
|
||||
fn register(&mut self, _child: Box<dyn AudioPipelineNode>) {
|
||||
panic!("FlacCacheSink is a terminal sink and cannot have children");
|
||||
}
|
||||
|
||||
async fn run(mut self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError> {
|
||||
// Transférer le playlist_handle_pending à la logique si présent
|
||||
#[cfg(feature = "playlist")]
|
||||
if let Some(handle) = self.playlist_handle_pending.take() {
|
||||
// FIXME: Node devrait exposer une méthode logic_mut() pour permettre
|
||||
// la configuration post-construction. Pour l'instant, on ignore ce handle.
|
||||
// L'utilisateur devra configurer la playlist avant construction.
|
||||
let _ = handle;
|
||||
}
|
||||
|
||||
Box::new(self.inner).run(stop_token).await
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedAudioNode for FlacCacheSink {
|
||||
fn input_type(&self) -> Option<TypeRequirement> {
|
||||
// FlacCacheSink accepte n'importe quel type entier (I16, I24, I32)
|
||||
// mais rejette les chunks flottants
|
||||
Some(TypeRequirement::any_integer())
|
||||
}
|
||||
|
||||
fn output_type(&self) -> Option<TypeRequirement> {
|
||||
// FlacCacheSink est un sink, il ne produit pas d'audio
|
||||
None
|
||||
}
|
||||
}
|
||||
11
pmoaudio-ext/src/sinks/mod.rs
Executable file
11
pmoaudio-ext/src/sinks/mod.rs
Executable file
@@ -0,0 +1,11 @@
|
||||
//! Sinks d'extension pour pmoaudio
|
||||
//!
|
||||
//! Ce module contient des sinks qui dépendent de multiples crates
|
||||
//! et ne peuvent pas être placés directement dans pmoaudio sans créer
|
||||
//! de dépendances cycliques.
|
||||
|
||||
#[cfg(feature = "cache-sink")]
|
||||
mod flac_cache_sink;
|
||||
|
||||
#[cfg(feature = "cache-sink")]
|
||||
pub use flac_cache_sink::{FlacCacheSink, FlacCacheSinkStats, TrackStats};
|
||||
16
pmoaudio/Cargo.toml
Normal file → Executable file
16
pmoaudio/Cargo.toml
Normal file → Executable file
@@ -3,9 +3,25 @@ name = "pmoaudio"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
simd = []
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1.42", features = ["full"] }
|
||||
tokio-util = { version = "0.7", features = ["io"] }
|
||||
async-trait = "0.1"
|
||||
futures-util = "0.3"
|
||||
pmoflac = { path = "../pmoflac" }
|
||||
pmometadata = { path = "../pmometadata" }
|
||||
paste = "1"
|
||||
soxr = "0.6.0"
|
||||
bytemuck = "1.24.0"
|
||||
reqwest = { version = "0.12", features = ["stream"] }
|
||||
tracing = "0.1"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = "0.4"
|
||||
tempfile = "3"
|
||||
wiremock = "0.6"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
342
pmoaudio/REFACTORING_SUMMARY.md
Normal file
342
pmoaudio/REFACTORING_SUMMARY.md
Normal file
@@ -0,0 +1,342 @@
|
||||
# PMOAudio - Refactoring Summary
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
Refactoring complet du système audio pour supporter plusieurs types de samples (entiers et flottants) avec une architecture générique optimisée pour le temps réel.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Option choisie: Générique + Enum plat
|
||||
|
||||
- **`AudioChunkData<T: Sample>`**: Structure générique pour factoriser le code
|
||||
- **`AudioChunk`**: Enum plat avec 6 variants (I8, I16, I24, I32, F32, F64)
|
||||
- **`Sample` trait**: Interface unifiée pour tous les types de samples
|
||||
|
||||
## Nouveaux fichiers créés
|
||||
|
||||
### 1. `src/sample_types.rs`
|
||||
Définition du trait `Sample` et du type `I24` (24-bit audio).
|
||||
|
||||
**Features principales:**
|
||||
- Type `I24` wrapper sur `i32` avec validation de plage (±2^23)
|
||||
- Trait `Sample` implémenté pour: i8, i16, I24, i32, f32, f64
|
||||
- Conversions normalisées vers/depuis f64 et f32
|
||||
- Tests unitaires complets
|
||||
|
||||
### 2. `src/conversions.rs`
|
||||
Module complet de conversions entre tous les types audio.
|
||||
|
||||
**Features principales:**
|
||||
- **Conversions Int → Int**: Utilise `bitdepth_change_stereo` avec SIMD
|
||||
- **Conversions Int → Float**: Utilise `i32_stereo_to_pairs_f32` avec SIMD
|
||||
- **Conversions Float → Int**: Utilise `pairs_f32_to_i32_stereo` avec SIMD
|
||||
- **Conversions Float → Float**: Direct avec cast
|
||||
- **34 implémentations From/Into** pour conversions ergonomiques
|
||||
- Tests de round-trip et validation
|
||||
|
||||
**Point clé**: Les conversions I32 ↔ F32/F64 n'ont **pas besoin** de paramètre BitDepth car le type définit lui-même sa résolution (I32 = ±2^31).
|
||||
|
||||
### 3. `src/macros.rs`
|
||||
Macros pour simplifier la manipulation des AudioChunk et AudioSegment.
|
||||
|
||||
**Macros disponibles:**
|
||||
- `extract_chunk_data!(chunk, TYPE)` - Extrait les données typées
|
||||
- `match_chunk!(chunk, data => expr)` - Pattern matching unifié
|
||||
- `map_chunk!(chunk, data => transform)` - Transformation préservant le type
|
||||
- `is_chunk_type!(chunk, TYPE)` - Prédicat de type
|
||||
- `extract_audio_chunk!(segment)` - Extrait AudioChunk d'un segment
|
||||
- `extract_sync_marker!(segment)` - Extrait SyncMarker d'un segment
|
||||
- `match_segment!(segment, chunk => ..., marker => ...)` - Match sur segment
|
||||
|
||||
**Tests**: 7 tests unitaires
|
||||
|
||||
## Fichiers modifiés
|
||||
|
||||
### 1. `src/audio_chunk.rs` - Refactoring complet
|
||||
|
||||
**Avant:**
|
||||
```rust
|
||||
pub struct AudioChunk {
|
||||
stereo: Arc<[[i32; 2]]>,
|
||||
sample_rate: u32,
|
||||
bit_depth: BitDepth,
|
||||
}
|
||||
```
|
||||
|
||||
**Après:**
|
||||
```rust
|
||||
pub struct AudioChunkData<T: Sample> {
|
||||
stereo: Arc<[[T; 2]]>,
|
||||
sample_rate: u32,
|
||||
gain_db: f64, // Toujours en dB
|
||||
}
|
||||
|
||||
pub enum AudioChunk {
|
||||
I8(Arc<AudioChunkData<i8>>),
|
||||
I16(Arc<AudioChunkData<i16>>),
|
||||
I24(Arc<AudioChunkData<I24>>),
|
||||
I32(Arc<AudioChunkData<i32>>),
|
||||
F32(Arc<AudioChunkData<f32>>),
|
||||
F64(Arc<AudioChunkData<f64>>),
|
||||
}
|
||||
```
|
||||
|
||||
**Nouvelles méthodes:**
|
||||
- `AudioChunk::to_f32()`, `to_f64()`, `to_i32()` - Conversions de type
|
||||
- `AudioChunk::set_gain_db()` - Modification du gain
|
||||
- `AudioChunk::type_name()` - Nom du type runtime
|
||||
- Implémentations spécialisées pour i32, f32, f64
|
||||
|
||||
**Tests**: 4 tests unitaires
|
||||
|
||||
### 2. `src/audio_segment.rs` - Helpers ergonomiques
|
||||
|
||||
**Nouvelles méthodes d'accès:**
|
||||
- `as_chunk()` - Récupère le AudioChunk
|
||||
- `as_sync_marker()` - Récupère le SyncMarker
|
||||
- `as_track_metadata()` - Extrait les métadonnées de track
|
||||
- `as_error()` - Récupère le message d'erreur
|
||||
|
||||
**Helpers de conversion:**
|
||||
- `to_f32_chunk()` - Convertit vers F32
|
||||
- `to_i32_chunk()` - Convertit vers I32
|
||||
|
||||
**Helpers de propriétés:**
|
||||
- `sample_rate()` - Sample rate du chunk
|
||||
- `frame_count()` - Nombre de frames
|
||||
- `gain_db()` - Gain en dB
|
||||
- `chunk_type_name()` - Type du chunk
|
||||
|
||||
**Manipulation du gain:**
|
||||
- `with_gain_db(gain_db)` - Nouveau segment avec gain absolu
|
||||
- `adjust_gain_db(delta_db)` - Nouveau segment avec gain relatif
|
||||
|
||||
**Tests**: 4 tests unitaires
|
||||
|
||||
### 3. `src/dsp/int_float.rs` - Simplification
|
||||
|
||||
**Changements:**
|
||||
- ❌ Suppression du trait `BitDepthType` obsolète
|
||||
- ❌ Suppression des types `Bit8`, `Bit16`, `Bit24`, `Bit32`
|
||||
- ✅ Utilisation de l'enum `BitDepth` du module principal
|
||||
- ✅ Fonctions SIMD préservées et optimisées
|
||||
- ✅ Paramètres runtime au lieu de génériques
|
||||
|
||||
### 4. `src/dsp/resampling.rs` - Mise à jour BitDepth
|
||||
|
||||
**Changements:**
|
||||
- Type `ResamplingError` créé (remplace `AudioError` manquant)
|
||||
- `Resampler.bit_depth: u32` → `BitDepth`
|
||||
- Match sur les variants d'enum au lieu de valeurs numériques
|
||||
- Qualité de resampling adaptée au bit depth (VeryHigh pour 24/32-bit)
|
||||
|
||||
### 5. `src/lib.rs` - Exports et organisation
|
||||
|
||||
**Ajouts:**
|
||||
- `mod macros` avec `#[macro_use]`
|
||||
- `pub use sample_types::{I24, Sample}`
|
||||
- `pub use audio_segment::_AudioSegment` (pour les macros)
|
||||
- `pub mod conversions`
|
||||
|
||||
**Temporairement désactivé:**
|
||||
- `mod nodes` (commenté)
|
||||
|
||||
## Statistiques de tests
|
||||
|
||||
### Tests réussis: **35/35** ✅
|
||||
|
||||
**Répartition:**
|
||||
- `audio_chunk`: 4 tests
|
||||
- `audio_segment`: 4 tests
|
||||
- `conversions`: 12 tests
|
||||
- `macros`: 7 tests
|
||||
- `sample_types`: 5 tests
|
||||
- `events`: 3 tests
|
||||
|
||||
### Couverture des conversions
|
||||
|
||||
**From/Into implémentations: 34 au total**
|
||||
|
||||
- Wrapper conversions (6): AudioChunkData → AudioChunk
|
||||
- I16 ↔ I32 (2)
|
||||
- I24 ↔ I32 (2)
|
||||
- I32 ↔ F32 (2)
|
||||
- I32 ↔ F64 (2)
|
||||
- F32 ↔ F64 (2)
|
||||
- Et toutes les autres combinaisons...
|
||||
|
||||
## Optimisations
|
||||
|
||||
### Performance temps réel
|
||||
- **Objectif**: Audio 192kHz/24-bit stéréo en temps réel
|
||||
- **SIMD**: Toutes les conversions critiques utilisent les fonctions SIMD du module DSP
|
||||
- **Zero-copy**: Partage via `Arc<[[T; 2]]>`
|
||||
- **Lazy evaluation**: Le gain n'est appliqué que lors de la lecture des frames
|
||||
|
||||
### Harmonisation du gain
|
||||
- ✅ **Tous les gains en dB** (décibels)
|
||||
- ✅ Helpers de conversion: `db_to_linear()`, `linear_to_db()`
|
||||
- ❌ Plus d'interfaces linéaires (sauf helpers de conversion)
|
||||
|
||||
## Exemple d'utilisation
|
||||
|
||||
Voir [`examples/audio_chunk_api.rs`](examples/audio_chunk_api.rs) pour une démonstration complète.
|
||||
|
||||
### Création rapide
|
||||
```rust
|
||||
// Chunk I32
|
||||
let chunk = AudioChunkData::new(
|
||||
vec![[1000i32, 2000i32]],
|
||||
48000,
|
||||
0.0
|
||||
);
|
||||
|
||||
// Segment avec gain
|
||||
let segment = AudioSegment::new_chunk_with_gain_db(
|
||||
0, 0.0,
|
||||
vec![[1000i32, 2000i32]],
|
||||
48000,
|
||||
BitDepth::B32,
|
||||
6.0 // +6 dB
|
||||
);
|
||||
```
|
||||
|
||||
### Conversions
|
||||
```rust
|
||||
// Via méthodes
|
||||
let chunk_f32 = audio_chunk.to_f32();
|
||||
|
||||
// Via From/Into
|
||||
let chunk_i32: Arc<AudioChunkData<i32>> = (&*chunk_i16).into();
|
||||
```
|
||||
|
||||
### Macros
|
||||
```rust
|
||||
// Type checking
|
||||
if is_chunk_type!(&chunk, I32) {
|
||||
// ...
|
||||
}
|
||||
|
||||
// Pattern matching universel
|
||||
match_chunk!(&chunk, data => {
|
||||
println!("{} frames", data.len());
|
||||
});
|
||||
|
||||
// Transformation
|
||||
let with_gain = map_chunk!(&chunk, data => {
|
||||
data.set_gain_db(6.0)
|
||||
});
|
||||
```
|
||||
|
||||
### Helpers AudioSegment
|
||||
```rust
|
||||
// Accès ergonomique
|
||||
if let Some(sr) = segment.sample_rate() {
|
||||
println!("Sample rate: {}", sr);
|
||||
}
|
||||
|
||||
// Manipulation du gain
|
||||
let louder = segment.adjust_gain_db(3.0)?;
|
||||
|
||||
// Conversion
|
||||
let f32_chunk = segment.to_f32_chunk()?;
|
||||
```
|
||||
|
||||
## Points clés de design
|
||||
|
||||
### 1. Type = Résolution
|
||||
Chaque type définit sa propre résolution:
|
||||
- I8 = ±2^7 (128)
|
||||
- I16 = ±2^15 (32,768)
|
||||
- I24 = ±2^23 (8,388,608)
|
||||
- I32 = ±2^31 (2,147,483,648)
|
||||
- F32 / F64 = normalisé [-1.0, 1.0]
|
||||
|
||||
**Conséquence**: Pas besoin de paramètre `BitDepth` pour les conversions I32 ↔ Float.
|
||||
|
||||
### 2. Gain toujours en dB
|
||||
- Plus de gains linéaires dans l'API principale
|
||||
- Conversions disponibles via helpers si nécessaire
|
||||
- Évaluation paresseuse du gain
|
||||
|
||||
### 3. Immutabilité
|
||||
- Toutes les modifications créent de nouvelles instances
|
||||
- Partage efficace via `Arc`
|
||||
- Pas de copy-on-write nécessaire pour les données audio
|
||||
|
||||
### 4. Stéréo strict
|
||||
- Format fixe: `[[T; 2]]` (gauche, droite)
|
||||
- Pas de support multicanal pour l'instant
|
||||
- Optimisé pour le cas d'usage principal
|
||||
|
||||
## Compilation et tests
|
||||
|
||||
```bash
|
||||
# Build
|
||||
cargo build --package pmoaudio
|
||||
|
||||
# Tests
|
||||
cargo test --package pmoaudio --lib
|
||||
|
||||
# Exemple
|
||||
cargo run --package pmoaudio --example audio_chunk_api
|
||||
```
|
||||
|
||||
**Statut**: ✅ Compilation sans erreur, tous les tests passent
|
||||
|
||||
## Travail futur (optionnel)
|
||||
|
||||
Les tâches suivantes ont été identifiées mais ne sont pas critiques:
|
||||
|
||||
1. **Benchmark temps réel 192kHz/24-bit**
|
||||
- Valider les performances en conditions réelles
|
||||
- Mesurer l'overhead des conversions
|
||||
|
||||
2. **Macros avancées**
|
||||
- Macros procédurales pour génération de code
|
||||
- DSL pour pipelines audio
|
||||
|
||||
3. **Support multicanal**
|
||||
- Format `[[T; N]]` générique
|
||||
- Gestion des configurations surround
|
||||
|
||||
4. **Réactivation des Nodes**
|
||||
- Mise à jour avec la nouvelle API
|
||||
- Tests d'intégration complets
|
||||
|
||||
## Notes de migration
|
||||
|
||||
Pour le code existant utilisant l'ancienne API:
|
||||
|
||||
### AudioChunk
|
||||
**Avant:**
|
||||
```rust
|
||||
let chunk = AudioChunk::new(stereo, 48000, BitDepth::B32);
|
||||
let gain = chunk.gain_linear();
|
||||
```
|
||||
|
||||
**Après:**
|
||||
```rust
|
||||
let chunk_data = AudioChunkData::new(stereo, 48000, 0.0);
|
||||
let chunk = AudioChunk::I32(chunk_data);
|
||||
let gain = chunk.gain_linear(); // Toujours disponible
|
||||
```
|
||||
|
||||
### AudioSegment
|
||||
**Avant:**
|
||||
```rust
|
||||
segment.chunk.sample_rate
|
||||
```
|
||||
|
||||
**Après:**
|
||||
```rust
|
||||
segment.sample_rate().unwrap() // Avec helper
|
||||
// ou
|
||||
segment.as_chunk().unwrap().sample_rate() // Direct
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Date**: 2025-11-01
|
||||
**Version**: PMOAudio 0.1.0
|
||||
**Status**: ✅ Refactoring complet, tous les tests passent
|
||||
208
pmoaudio/examples/audio_chunk_api.rs
Executable file
208
pmoaudio/examples/audio_chunk_api.rs
Executable file
@@ -0,0 +1,208 @@
|
||||
//! Exemples d'utilisation de l'API AudioChunk et AudioSegment
|
||||
//!
|
||||
//! Ce fichier démontre les différentes façons de créer et manipuler
|
||||
//! des chunks audio avec la nouvelle architecture générique.
|
||||
|
||||
use pmoaudio::*;
|
||||
|
||||
fn main() {
|
||||
println!("=== Exemples d'utilisation de l'API AudioChunk ===\n");
|
||||
|
||||
// ============ Création de chunks de différents types ============
|
||||
example_create_chunks();
|
||||
|
||||
// ============ Conversions entre types ============
|
||||
example_conversions();
|
||||
|
||||
// ============ Utilisation des macros ============
|
||||
example_macros();
|
||||
|
||||
// ============ AudioSegment et helpers ============
|
||||
example_audio_segments();
|
||||
|
||||
// ============ Manipulation du gain ============
|
||||
example_gain_manipulation();
|
||||
}
|
||||
|
||||
fn example_create_chunks() {
|
||||
println!(">>> Création de chunks audio\n");
|
||||
|
||||
// Chunk I32 stéréo
|
||||
let stereo_i32 = vec![[1000i32, 2000i32], [3000i32, 4000i32]];
|
||||
let chunk_i32 = AudioChunkData::new(stereo_i32, 48000, 0.0);
|
||||
println!(
|
||||
"Chunk I32: {} frames @ {}Hz",
|
||||
chunk_i32.len(),
|
||||
chunk_i32.get_sample_rate()
|
||||
);
|
||||
|
||||
// Chunk F32 stéréo (normalisé [-1.0, 1.0])
|
||||
let stereo_f32 = vec![[0.5f32, -0.5f32], [0.8f32, -0.8f32]];
|
||||
let chunk_f32 = AudioChunkData::new(stereo_f32, 48000, 0.0);
|
||||
println!(
|
||||
"Chunk F32: {} frames @ {}Hz",
|
||||
chunk_f32.len(),
|
||||
chunk_f32.get_sample_rate()
|
||||
);
|
||||
|
||||
// Chunk depuis canaux séparés
|
||||
let left = vec![100i32, 200i32, 300i32];
|
||||
let right = vec![150i32, 250i32, 350i32];
|
||||
let chunk_from_channels = AudioChunkData::<i32>::from_channels(left, right, 44100);
|
||||
println!("Chunk from channels: {} frames", chunk_from_channels.len());
|
||||
|
||||
// Chunk avec gain
|
||||
let chunk_with_gain = AudioChunkData::new(
|
||||
vec![[1000i32, 2000i32]],
|
||||
48000,
|
||||
6.0, // +6 dB
|
||||
);
|
||||
println!("Chunk with gain: {} dB\n", chunk_with_gain.get_gain_db());
|
||||
}
|
||||
|
||||
fn example_conversions() {
|
||||
println!(">>> Conversions entre types\n");
|
||||
|
||||
// Créer un chunk I32
|
||||
let i32_data = vec![[1_000_000i32, 2_000_000i32]];
|
||||
let chunk_i32 = AudioChunkData::new(i32_data, 48000, 0.0);
|
||||
let audio_chunk = AudioChunk::I32(chunk_i32);
|
||||
|
||||
println!("Type original: {}", audio_chunk.type_name());
|
||||
|
||||
// Conversion vers F32
|
||||
let audio_chunk_f32 = audio_chunk.to_f32();
|
||||
println!("Après conversion to_f32: {}", audio_chunk_f32.type_name());
|
||||
|
||||
// Conversion vers F64
|
||||
let audio_chunk_f64 = audio_chunk_f32.to_f64();
|
||||
println!("Après conversion to_f64: {}", audio_chunk_f64.type_name());
|
||||
|
||||
// Retour vers I32
|
||||
let audio_chunk_back = audio_chunk_f64.to_i32();
|
||||
println!("Après conversion to_i32: {}", audio_chunk_back.type_name());
|
||||
|
||||
// Utilisation des traits From/Into
|
||||
let chunk_i16 = AudioChunkData::new(vec![[1000i16, 2000i16]], 48000, 0.0);
|
||||
let chunk_i32_from_i16: std::sync::Arc<AudioChunkData<i32>> = (&*chunk_i16).into();
|
||||
println!(
|
||||
"\nConversion I16 → I32 via Into: {} frames",
|
||||
chunk_i32_from_i16.len()
|
||||
);
|
||||
|
||||
println!();
|
||||
}
|
||||
|
||||
fn example_macros() {
|
||||
println!(">>> Utilisation des macros\n");
|
||||
|
||||
// Créer différents types de chunks
|
||||
let chunk_i32 = AudioChunk::I32(AudioChunkData::new(vec![[100i32, 200i32]], 48000, 0.0));
|
||||
let chunk_f32 = AudioChunk::F32(AudioChunkData::new(vec![[0.5f32, -0.5f32]], 48000, 0.0));
|
||||
|
||||
// Macro is_chunk_type!
|
||||
println!("chunk_i32 is I32: {}", is_chunk_type!(&chunk_i32, I32));
|
||||
println!("chunk_i32 is F32: {}", is_chunk_type!(&chunk_i32, F32));
|
||||
println!("chunk_f32 is F32: {}", is_chunk_type!(&chunk_f32, F32));
|
||||
|
||||
// Macro extract_chunk_data!
|
||||
if let Some(data) = extract_chunk_data!(&chunk_i32, I32) {
|
||||
println!("\nExtracted I32 data: {} frames", data.len());
|
||||
}
|
||||
|
||||
// Macro match_chunk! pour traiter n'importe quel type
|
||||
let frame_count = match_chunk!(&chunk_i32, data => {
|
||||
data.len()
|
||||
});
|
||||
println!("Frame count via match_chunk: {}", frame_count);
|
||||
|
||||
// Macro map_chunk! pour transformer tout en préservant le type
|
||||
let chunk_with_gain = map_chunk!(&chunk_i32, data => {
|
||||
data.set_gain_db(6.0)
|
||||
});
|
||||
println!("\nGain après map_chunk: {} dB", chunk_with_gain.gain_db());
|
||||
|
||||
println!();
|
||||
}
|
||||
|
||||
fn example_audio_segments() {
|
||||
println!(">>> AudioSegment et helpers\n");
|
||||
|
||||
// Créer un segment audio
|
||||
let segment = AudioSegment::new_chunk(
|
||||
0,
|
||||
0.0,
|
||||
vec![[1000i32, 2000i32], [3000i32, 4000i32]],
|
||||
48000,
|
||||
BitDepth::B32,
|
||||
);
|
||||
|
||||
// Accès aux propriétés via les helpers
|
||||
println!("Segment info:");
|
||||
println!(" - Type: {}", segment.chunk_type_name().unwrap());
|
||||
println!(" - Sample rate: {} Hz", segment.sample_rate().unwrap());
|
||||
println!(" - Frame count: {}", segment.frame_count().unwrap());
|
||||
println!(" - Gain: {} dB", segment.gain_db().unwrap());
|
||||
|
||||
// Conversion du chunk
|
||||
if let Some(f32_chunk) = segment.to_f32_chunk() {
|
||||
println!("\nChunk converti en F32: {}", f32_chunk.type_name());
|
||||
}
|
||||
|
||||
// Créer un marqueur de sync
|
||||
let heartbeat = AudioSegment::new_hearbeat(1, 1.0);
|
||||
println!("\nHeartbeat segment:");
|
||||
println!(" - Is audio: {}", heartbeat.is_audio_chunk());
|
||||
println!(" - Is heartbeat: {}", heartbeat.is_heartbeat());
|
||||
|
||||
// Macro extract_audio_chunk!
|
||||
if let Some(chunk) = extract_audio_chunk!(&*segment) {
|
||||
println!("\nExtracted chunk type: {}", chunk.type_name());
|
||||
}
|
||||
|
||||
// Macro match_segment!
|
||||
let info = match_segment!(&*segment,
|
||||
chunk => format!("Audio chunk: {}", chunk.type_name()),
|
||||
_marker => "Sync marker".to_string()
|
||||
);
|
||||
println!("Segment info via macro: {}", info);
|
||||
|
||||
println!();
|
||||
}
|
||||
|
||||
fn example_gain_manipulation() {
|
||||
println!(">>> Manipulation du gain\n");
|
||||
|
||||
// Créer un segment
|
||||
let segment = AudioSegment::new_chunk(0, 0.0, vec![[1000i32, 2000i32]], 48000, BitDepth::B32);
|
||||
|
||||
println!("Gain initial: {} dB", segment.gain_db().unwrap());
|
||||
|
||||
// Définir un gain absolu
|
||||
let segment_6db = segment.with_gain_db(6.0).unwrap();
|
||||
println!(
|
||||
"Après with_gain_db(6.0): {} dB",
|
||||
segment_6db.gain_db().unwrap()
|
||||
);
|
||||
|
||||
// Ajuster le gain (relatif)
|
||||
let segment_9db = segment_6db.adjust_gain_db(3.0).unwrap();
|
||||
println!(
|
||||
"Après adjust_gain_db(+3.0): {} dB",
|
||||
segment_9db.gain_db().unwrap()
|
||||
);
|
||||
|
||||
// Les segments originaux ne sont pas modifiés (immutabilité)
|
||||
println!(
|
||||
"Gain du segment original: {} dB",
|
||||
segment.gain_db().unwrap()
|
||||
);
|
||||
|
||||
// Conversion gain linéaire ↔ dB
|
||||
let linear_gain = gain_linear_from_db(6.0);
|
||||
let gain_db = gain_db_from_linear(linear_gain);
|
||||
println!("\n6 dB = {:.4}x (linéaire)", linear_gain);
|
||||
println!("{:.4}x = {:.2} dB", linear_gain, gain_db);
|
||||
|
||||
println!();
|
||||
}
|
||||
27
pmoaudio/examples/check_flac_bits.rs
Executable file
27
pmoaudio/examples/check_flac_bits.rs
Executable file
@@ -0,0 +1,27 @@
|
||||
//! Vérifie la profondeur de bit d'un fichier FLAC
|
||||
|
||||
use pmoflac::decode_audio_stream;
|
||||
use std::path::Path;
|
||||
use tokio::fs::File;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let path_str = std::env::args().nth(1).expect("Usage: check_flac_bits <file.flac>");
|
||||
let path = Path::new(&path_str);
|
||||
|
||||
println!("Checking: {}", path.display());
|
||||
println!();
|
||||
|
||||
// decode_audio_stream pour lire StreamInfo
|
||||
let file = File::open(&path).await?;
|
||||
let stream = decode_audio_stream(file).await?;
|
||||
let info = stream.info().clone();
|
||||
|
||||
println!("StreamInfo from FLAC:");
|
||||
println!(" bits_per_sample: {}", info.bits_per_sample);
|
||||
println!(" sample_rate: {}", info.sample_rate);
|
||||
println!(" channels: {}", info.channels);
|
||||
println!(" bytes_per_sample: {}", info.bytes_per_sample());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
110
pmoaudio/examples/convert_to_flac24.rs
Executable file
110
pmoaudio/examples/convert_to_flac24.rs
Executable file
@@ -0,0 +1,110 @@
|
||||
//! Convertisseur de fichiers audio vers FLAC 24-bit
|
||||
//!
|
||||
//! Ce programme démontre l'utilisation de la chaîne :
|
||||
//! 1. FileSource - Lecture d'un fichier audio (FLAC, MP3, OGG, WAV, AIFF)
|
||||
//! 2. ToI24Node - Conversion vers 24-bit signed integer
|
||||
//! 3. FlacFileSink - Écriture au format FLAC
|
||||
//!
|
||||
//! La nouvelle architecture AudioPipelineNode permet de :
|
||||
//! - Construire le pipeline en enregistrant des enfants avec register()
|
||||
//! - Insérer des nœuds de conversion de type pour garantir la profondeur de bit souhaitée
|
||||
//! - Lancer tout le pipeline avec un seul appel à run() sur la racine
|
||||
//! - Arrêter proprement tout le pipeline avec un CancellationToken
|
||||
//!
|
||||
//! Usage:
|
||||
//! cargo run --example convert_to_flac24 -- <input_file> <output_file>
|
||||
//!
|
||||
//! Exemple:
|
||||
//! cargo run --example convert_to_flac24 -- input.mp3 output.flac
|
||||
//! cargo run --example convert_to_flac24 -- input16bit.flac output24bit.flac
|
||||
|
||||
use pmoaudio::{AudioPipelineNode, FileSource, FlacFileSink, ToI24Node};
|
||||
use std::env;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Initialiser tracing pour le debug
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||||
.init();
|
||||
|
||||
// Récupérer les arguments
|
||||
let args: Vec<String> = env::args().collect();
|
||||
if args.len() != 3 {
|
||||
eprintln!("Usage: {} <input_file> <output_file>", args[0]);
|
||||
eprintln!();
|
||||
eprintln!("Converts any audio file to FLAC with 24-bit depth.");
|
||||
eprintln!();
|
||||
eprintln!("Supported input formats:");
|
||||
eprintln!(" - FLAC (8/16/24/32-bit)");
|
||||
eprintln!(" - MP3");
|
||||
eprintln!(" - OGG Vorbis");
|
||||
eprintln!(" - WAV");
|
||||
eprintln!(" - AIFF");
|
||||
eprintln!();
|
||||
eprintln!("Example:");
|
||||
eprintln!(" {} input.mp3 output.flac", args[0]);
|
||||
eprintln!(" {} input16bit.flac output24bit.flac", args[0]);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let input_path = &args[1];
|
||||
let output_path = &args[2];
|
||||
|
||||
println!("=== Audio to FLAC 24-bit Converter ===");
|
||||
println!();
|
||||
println!("Input: {}", input_path);
|
||||
println!("Output: {}", output_path);
|
||||
println!();
|
||||
println!("Pipeline: FileSource → ToI24Node → FlacFileSink");
|
||||
println!();
|
||||
|
||||
// Créer le pipeline: FileSource → ToI24Node → FlacFileSink
|
||||
let mut source = FileSource::new(input_path);
|
||||
|
||||
// Le ToI24Node convertit tous les chunks audio en 24-bit
|
||||
// Cela garantit que le FlacFileSink encodera en 24-bit
|
||||
let mut converter = ToI24Node::new();
|
||||
|
||||
let sink = FlacFileSink::new(output_path);
|
||||
|
||||
// Construire la chaîne: source → converter → sink
|
||||
converter.register(Box::new(sink));
|
||||
source.register(converter);
|
||||
|
||||
// Créer un token d'arrêt pour contrôle manuel si besoin
|
||||
let stop_token = CancellationToken::new();
|
||||
|
||||
// Lancer tout le pipeline - run() spawne automatiquement tous les enfants
|
||||
println!("Processing...");
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
let result = Box::new(source).run(stop_token).await;
|
||||
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
// Vérifier le résultat
|
||||
match result {
|
||||
Ok(()) => {
|
||||
println!();
|
||||
println!("✓ Conversion completed successfully in {:.2}s", elapsed.as_secs_f64());
|
||||
println!(" Output file: {}", output_path);
|
||||
println!();
|
||||
|
||||
// Afficher des informations supplémentaires si possible
|
||||
if let Ok(metadata) = std::fs::metadata(output_path) {
|
||||
let size_mb = metadata.len() as f64 / (1024.0 * 1024.0);
|
||||
println!(" File size: {:.2} MB", size_mb);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!();
|
||||
eprintln!("✗ Conversion error: {}", e);
|
||||
eprintln!();
|
||||
return Err(e.into());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
68
pmoaudio/examples/file_nodes_test.rs
Executable file
68
pmoaudio/examples/file_nodes_test.rs
Executable file
@@ -0,0 +1,68 @@
|
||||
//! Test d'intégration pour FileSource et FlacFileSink avec la nouvelle architecture AudioPipelineNode
|
||||
//!
|
||||
//! Ce programme teste la chaîne complète :
|
||||
//! 1. Lecture d'un fichier audio avec FileSource
|
||||
//! 2. Écriture vers FLAC avec FlacFileSink
|
||||
//!
|
||||
//! La nouvelle architecture permet de :
|
||||
//! - Construire le pipeline en enregistrant des enfants avec register()
|
||||
//! - Lancer tout le pipeline avec un seul appel à run() sur la racine
|
||||
//! - Arrêter proprement tout le pipeline avec un CancellationToken
|
||||
//!
|
||||
//! Usage:
|
||||
//! cargo run --example file_nodes_test -- <input_file> <output_file>
|
||||
|
||||
use pmoaudio::{AudioPipelineNode, FileSource, FlacFileSink};
|
||||
use std::env;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Récupérer les arguments
|
||||
let args: Vec<String> = env::args().collect();
|
||||
if args.len() != 3 {
|
||||
eprintln!("Usage: {} <input_file> <output_file>", args[0]);
|
||||
eprintln!("Example: {} input.flac output.flac", args[0]);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let input_path = &args[1];
|
||||
let output_path = &args[2];
|
||||
|
||||
println!("Input: {}", input_path);
|
||||
println!("Output: {}", output_path);
|
||||
println!();
|
||||
|
||||
// Créer le pipeline: FileSource → FlacFileSink
|
||||
let mut source = FileSource::new(input_path);
|
||||
let sink = FlacFileSink::new(output_path);
|
||||
|
||||
// Enregistrer le sink comme enfant de la source
|
||||
source.register(Box::new(sink));
|
||||
|
||||
// Créer un token d'arrêt pour contrôle manuel si besoin
|
||||
let stop_token = CancellationToken::new();
|
||||
|
||||
// Lancer tout le pipeline - run() spawne automatiquement tous les enfants
|
||||
println!("Pipeline started");
|
||||
println!(" FileSource: reading from {}", input_path);
|
||||
println!(" FlacFileSink: writing to {}", output_path);
|
||||
|
||||
let result = Box::new(source).run(stop_token).await;
|
||||
|
||||
// Vérifier le résultat
|
||||
match result {
|
||||
Ok(()) => {
|
||||
println!();
|
||||
println!("✓ Pipeline completed successfully");
|
||||
println!(" Output file: {}", output_path);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!();
|
||||
eprintln!("✗ Pipeline error: {}", e);
|
||||
return Err(e.into());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
1036
pmoaudio/src/audio_chunk.rs
Normal file → Executable file
1036
pmoaudio/src/audio_chunk.rs
Normal file → Executable file
File diff suppressed because it is too large
Load Diff
511
pmoaudio/src/audio_segment.rs
Executable file
511
pmoaudio/src/audio_segment.rs
Executable file
@@ -0,0 +1,511 @@
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use pmometadata::TrackMetadata;
|
||||
|
||||
use crate::{gain_db_from_linear, AudioChunk, AudioChunkData, BitDepth, SyncMarker};
|
||||
|
||||
pub enum _AudioSegment {
|
||||
Chunk(Arc<AudioChunk>),
|
||||
Sync(Arc<SyncMarker>),
|
||||
}
|
||||
|
||||
pub struct AudioSegment {
|
||||
pub order: u64,
|
||||
pub timestamp_sec: f64,
|
||||
pub segment: _AudioSegment,
|
||||
}
|
||||
|
||||
impl AudioSegment {
|
||||
/// Crée un nouveau segment audio depuis des frames i32
|
||||
pub fn new_chunk(
|
||||
order: u64,
|
||||
timestamp_sec: f64,
|
||||
stereo: Vec<[i32; 2]>,
|
||||
sample_rate: u32,
|
||||
_bit_depth: BitDepth, // Conservé pour compatibilité API
|
||||
) -> Arc<Self> {
|
||||
let chunk_data = AudioChunkData::new(stereo, sample_rate, 0.0);
|
||||
let chunk = AudioChunk::I32(chunk_data);
|
||||
Arc::new(Self {
|
||||
order,
|
||||
timestamp_sec,
|
||||
segment: _AudioSegment::Chunk(Arc::new(chunk)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Crée un nouveau segment audio avec gain (dB)
|
||||
pub fn new_chunk_with_gain_db(
|
||||
order: u64,
|
||||
timestamp_sec: f64,
|
||||
stereo: Vec<[i32; 2]>,
|
||||
sample_rate: u32,
|
||||
_bit_depth: BitDepth, // Conservé pour compatibilité API
|
||||
gain_db: f64,
|
||||
) -> Arc<Self> {
|
||||
let chunk_data = AudioChunkData::new(stereo, sample_rate, gain_db);
|
||||
let chunk = AudioChunk::I32(chunk_data);
|
||||
Arc::new(Self {
|
||||
order,
|
||||
timestamp_sec,
|
||||
segment: _AudioSegment::Chunk(Arc::new(chunk)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Crée un nouveau segment audio avec gain linéaire
|
||||
pub fn new_chunk_with_gain_linear(
|
||||
order: u64,
|
||||
timestamp_sec: f64,
|
||||
stereo: Vec<[i32; 2]>,
|
||||
sample_rate: u32,
|
||||
_bit_depth: BitDepth, // Conservé pour compatibilité API
|
||||
gain_linear: f64,
|
||||
) -> Arc<Self> {
|
||||
let chunk_data = AudioChunkData::new(stereo, sample_rate, gain_db_from_linear(gain_linear));
|
||||
let chunk = AudioChunk::I32(chunk_data);
|
||||
Arc::new(Self {
|
||||
order,
|
||||
timestamp_sec,
|
||||
segment: _AudioSegment::Chunk(Arc::new(chunk)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Crée un segment audio depuis deux canaux i32 séparés (L/R)
|
||||
pub fn new_chunk_from_channels_i32(
|
||||
order: u64,
|
||||
timestamp_sec: f64,
|
||||
left: Vec<i32>,
|
||||
right: Vec<i32>,
|
||||
sample_rate: u32,
|
||||
_bit_depth: BitDepth, // Conservé pour compatibilité API
|
||||
) -> Arc<Self> {
|
||||
let chunk_data = AudioChunkData::<i32>::from_channels(left, right, sample_rate);
|
||||
let chunk = AudioChunk::I32(chunk_data);
|
||||
Arc::new(Self {
|
||||
order,
|
||||
timestamp_sec,
|
||||
segment: _AudioSegment::Chunk(Arc::new(chunk)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Crée un segment audio depuis deux canaux f32 normalisés (L/R)
|
||||
///
|
||||
/// Convertit f32 normalisé [-1.0, 1.0] → i32 selon le bit_depth spécifié
|
||||
pub fn new_chunk_from_channels_f32(
|
||||
order: u64,
|
||||
timestamp_sec: f64,
|
||||
left: Vec<f32>,
|
||||
right: Vec<f32>,
|
||||
sample_rate: u32,
|
||||
bit_depth: BitDepth,
|
||||
) -> Arc<Self> {
|
||||
assert_eq!(
|
||||
left.len(),
|
||||
right.len(),
|
||||
"channels must have identical length"
|
||||
);
|
||||
|
||||
// Convertir f32 → i32 selon le bit_depth
|
||||
let max_value = bit_depth.max_value();
|
||||
let stereo: Vec<[i32; 2]> = left
|
||||
.into_iter()
|
||||
.zip(right.into_iter())
|
||||
.map(|(l, r)| {
|
||||
let l_scaled = (l * max_value).clamp(-max_value, max_value - 1.0).round() as i32;
|
||||
let r_scaled = (r * max_value).clamp(-max_value, max_value - 1.0).round() as i32;
|
||||
[l_scaled, r_scaled]
|
||||
})
|
||||
.collect();
|
||||
|
||||
let chunk_data = AudioChunkData::new(stereo, sample_rate, 0.0);
|
||||
let chunk = AudioChunk::I32(chunk_data);
|
||||
Arc::new(Self {
|
||||
order,
|
||||
timestamp_sec,
|
||||
segment: _AudioSegment::Chunk(Arc::new(chunk)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Crée un segment audio depuis des frames f32 normalisées
|
||||
///
|
||||
/// Convertit f32 normalisé [-1.0, 1.0] → i32 selon le bit_depth spécifié
|
||||
pub fn new_chunk_from_pairs_f32(
|
||||
order: u64,
|
||||
timestamp_sec: f64,
|
||||
pairs: Vec<[f32; 2]>,
|
||||
sample_rate: u32,
|
||||
bit_depth: BitDepth,
|
||||
) -> Arc<Self> {
|
||||
// Convertir f32 → i32 selon le bit_depth
|
||||
let max_value = bit_depth.max_value();
|
||||
let stereo: Vec<[i32; 2]> = pairs
|
||||
.into_iter()
|
||||
.map(|[l, r]| {
|
||||
let l_scaled = (l * max_value).clamp(-max_value, max_value - 1.0).round() as i32;
|
||||
let r_scaled = (r * max_value).clamp(-max_value, max_value - 1.0).round() as i32;
|
||||
[l_scaled, r_scaled]
|
||||
})
|
||||
.collect();
|
||||
|
||||
let chunk_data = AudioChunkData::new(stereo, sample_rate, 0.0);
|
||||
let chunk = AudioChunk::I32(chunk_data);
|
||||
Arc::new(Self {
|
||||
order,
|
||||
timestamp_sec,
|
||||
segment: _AudioSegment::Chunk(Arc::new(chunk)),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn new_track_boundary(
|
||||
order: u64,
|
||||
timestamp_sec: f64,
|
||||
metadata: Arc<RwLock<dyn TrackMetadata>>,
|
||||
) -> Arc<Self> {
|
||||
let marker = Arc::new(SyncMarker::TrackBoundary {
|
||||
metadata: Arc::clone(&metadata),
|
||||
});
|
||||
Arc::new(Self {
|
||||
order,
|
||||
timestamp_sec,
|
||||
segment: _AudioSegment::Sync(marker),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn new_stream_metadata(
|
||||
order: u64,
|
||||
timestamp_sec: f64,
|
||||
key: String,
|
||||
value: String,
|
||||
) -> Arc<Self> {
|
||||
let marker = Arc::new(SyncMarker::StreamMetadata { key, value });
|
||||
|
||||
Arc::new(Self {
|
||||
order,
|
||||
timestamp_sec,
|
||||
segment: _AudioSegment::Sync(marker),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn new_top_zero_sync() -> Arc<Self> {
|
||||
let marker = Arc::new(SyncMarker::TopZeroSync);
|
||||
|
||||
Arc::new(Self {
|
||||
order: 0,
|
||||
timestamp_sec: 0.0,
|
||||
segment: _AudioSegment::Sync(marker),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn new_hearbeat(order: u64, timestamp_sec: f64) -> Arc<Self> {
|
||||
let marker = Arc::new(SyncMarker::Heartbeat);
|
||||
|
||||
Arc::new(Self {
|
||||
order: order,
|
||||
timestamp_sec: timestamp_sec,
|
||||
segment: _AudioSegment::Sync(marker),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn new_end_of_stream(order: u64, timestamp_sec: f64) -> Arc<Self> {
|
||||
let marker = Arc::new(SyncMarker::EndOfStream);
|
||||
|
||||
Arc::new(Self {
|
||||
order: order,
|
||||
timestamp_sec: timestamp_sec,
|
||||
segment: _AudioSegment::Sync(marker),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn new_error(order: u64, timestamp_sec: f64, error: String) -> Arc<Self> {
|
||||
let marker = Arc::new(SyncMarker::Error(error));
|
||||
|
||||
Arc::new(Self {
|
||||
order: order,
|
||||
timestamp_sec: timestamp_sec,
|
||||
segment: _AudioSegment::Sync(marker),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_audio_chunk(&self) -> bool {
|
||||
matches!(self.segment, _AudioSegment::Chunk(_))
|
||||
}
|
||||
|
||||
pub fn is_track_boundary(&self) -> bool {
|
||||
matches!(
|
||||
self.segment,
|
||||
_AudioSegment::Sync(ref marker)
|
||||
if matches!(**marker,
|
||||
SyncMarker::TrackBoundary { .. }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn is_stream_metadata(&self) -> bool {
|
||||
matches!(
|
||||
self.segment,
|
||||
_AudioSegment::Sync(ref marker)
|
||||
if matches!(**marker,
|
||||
SyncMarker::StreamMetadata { .. }
|
||||
)
|
||||
)
|
||||
}
|
||||
pub fn is_heartbeat(&self) -> bool {
|
||||
matches!(
|
||||
self.segment,
|
||||
_AudioSegment::Sync(ref marker)
|
||||
if matches!(**marker, SyncMarker::Heartbeat)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn is_top_zero_sync(&self) -> bool {
|
||||
matches!(
|
||||
self.segment,
|
||||
_AudioSegment::Sync(ref marker)
|
||||
if matches!(**marker, SyncMarker::TopZeroSync)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn is_end_of_stream(&self) -> bool {
|
||||
matches!(
|
||||
self.segment,
|
||||
_AudioSegment::Sync(ref marker)
|
||||
if matches!(**marker, SyncMarker::EndOfStream)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn is_error(&self) -> bool {
|
||||
matches!(
|
||||
self.segment,
|
||||
_AudioSegment::Sync(ref marker)
|
||||
if matches!(**marker, SyncMarker::Error(_))
|
||||
)
|
||||
}
|
||||
|
||||
// ============ Accesseurs typés pour AudioChunk ============
|
||||
|
||||
/// Récupère le AudioChunk si ce segment est un chunk audio
|
||||
pub fn as_chunk(&self) -> Option<&Arc<AudioChunk>> {
|
||||
match &self.segment {
|
||||
_AudioSegment::Chunk(chunk) => Some(chunk),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère le SyncMarker si ce segment est un marqueur de sync
|
||||
pub fn as_sync_marker(&self) -> Option<&Arc<SyncMarker>> {
|
||||
match &self.segment {
|
||||
_AudioSegment::Sync(marker) => Some(marker),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère les métadatas du track si c'est un TrackBoundary
|
||||
pub fn as_track_metadata(&self) -> Option<&Arc<RwLock<dyn TrackMetadata>>> {
|
||||
match &self.segment {
|
||||
_AudioSegment::Sync(marker) => match &**marker {
|
||||
SyncMarker::TrackBoundary { metadata } => Some(metadata),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère le message d'erreur si c'est un marqueur Error
|
||||
pub fn as_error(&self) -> Option<&str> {
|
||||
match &self.segment {
|
||||
_AudioSegment::Sync(marker) => match &**marker {
|
||||
SyncMarker::Error(msg) => Some(msg.as_str()),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convertit l'AudioChunk vers F32 si c'est un chunk audio
|
||||
pub fn to_f32_chunk(&self) -> Option<AudioChunk> {
|
||||
self.as_chunk().map(|chunk| chunk.to_f32())
|
||||
}
|
||||
|
||||
/// Convertit l'AudioChunk vers I32 si c'est un chunk audio
|
||||
pub fn to_i32_chunk(&self) -> Option<AudioChunk> {
|
||||
self.as_chunk().map(|chunk| chunk.to_i32())
|
||||
}
|
||||
|
||||
/// Récupère le sample rate du chunk audio
|
||||
pub fn sample_rate(&self) -> Option<u32> {
|
||||
self.as_chunk().map(|chunk| chunk.sample_rate())
|
||||
}
|
||||
|
||||
/// Récupère le nombre de frames du chunk audio
|
||||
pub fn frame_count(&self) -> Option<usize> {
|
||||
self.as_chunk().map(|chunk| chunk.len())
|
||||
}
|
||||
|
||||
/// Récupère le gain en dB du chunk audio
|
||||
pub fn gain_db(&self) -> Option<f64> {
|
||||
self.as_chunk().map(|chunk| chunk.gain_db())
|
||||
}
|
||||
|
||||
/// Récupère le type du chunk audio (nom du type: "i32", "f32", etc.)
|
||||
pub fn chunk_type_name(&self) -> Option<&'static str> {
|
||||
self.as_chunk().map(|chunk| chunk.type_name())
|
||||
}
|
||||
|
||||
/// Crée un nouveau segment avec le gain modifié (si c'est un chunk audio)
|
||||
pub fn with_gain_db(&self, gain_db: f64) -> Option<Arc<Self>> {
|
||||
self.as_chunk().map(|chunk| {
|
||||
let new_chunk = chunk.set_gain_db(gain_db);
|
||||
Arc::new(Self {
|
||||
order: self.order,
|
||||
timestamp_sec: self.timestamp_sec,
|
||||
segment: _AudioSegment::Chunk(Arc::new(new_chunk)),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Crée un nouveau segment avec le gain ajusté (relatif, si c'est un chunk audio)
|
||||
pub fn adjust_gain_db(&self, delta_db: f64) -> Option<Arc<Self>> {
|
||||
self.as_chunk().map(|chunk| {
|
||||
let new_gain = chunk.gain_db() + delta_db;
|
||||
let new_chunk = chunk.set_gain_db(new_gain);
|
||||
Arc::new(Self {
|
||||
order: self.order,
|
||||
timestamp_sec: self.timestamp_sec,
|
||||
segment: _AudioSegment::Chunk(Arc::new(new_chunk)),
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl TryInto<Arc<AudioChunk>> for AudioSegment {
|
||||
type Error = ();
|
||||
|
||||
fn try_into(self) -> Result<Arc<AudioChunk>, Self::Error> {
|
||||
match self.segment {
|
||||
_AudioSegment::Chunk(chunk) => Ok(chunk),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryInto<Arc<SyncMarker>> for AudioSegment {
|
||||
type Error = ();
|
||||
|
||||
fn try_into(self) -> Result<Arc<SyncMarker>, Self::Error> {
|
||||
match self.segment {
|
||||
_AudioSegment::Sync(marker) => Ok(marker),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> TryInto<&'a Arc<AudioChunk>> for &'a AudioSegment {
|
||||
type Error = ();
|
||||
|
||||
fn try_into(self) -> Result<&'a Arc<AudioChunk>, Self::Error> {
|
||||
match &self.segment {
|
||||
_AudioSegment::Chunk(ref chunk) => Ok(chunk),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> TryInto<&'a Arc<SyncMarker>> for &'a AudioSegment {
|
||||
type Error = ();
|
||||
|
||||
fn try_into(self) -> Result<&'a Arc<SyncMarker>, Self::Error> {
|
||||
match &self.segment {
|
||||
_AudioSegment::Sync(ref marker) => Ok(marker),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_audio_segment_accessors() {
|
||||
// Test avec un chunk audio
|
||||
let segment = AudioSegment::new_chunk(
|
||||
42,
|
||||
1.5,
|
||||
vec![[100i32, 200i32], [300i32, 400i32]],
|
||||
48000,
|
||||
BitDepth::B32,
|
||||
);
|
||||
|
||||
assert!(segment.is_audio_chunk());
|
||||
assert!(!segment.is_heartbeat());
|
||||
assert!(segment.as_chunk().is_some());
|
||||
assert!(segment.as_sync_marker().is_none());
|
||||
assert_eq!(segment.sample_rate(), Some(48000));
|
||||
assert_eq!(segment.frame_count(), Some(2));
|
||||
assert_eq!(segment.gain_db(), Some(0.0));
|
||||
assert_eq!(segment.chunk_type_name(), Some("i32"));
|
||||
|
||||
// Test avec un marqueur sync
|
||||
let sync_segment = AudioSegment::new_hearbeat(10, 2.0);
|
||||
assert!(!sync_segment.is_audio_chunk());
|
||||
assert!(sync_segment.is_heartbeat());
|
||||
assert!(sync_segment.as_chunk().is_none());
|
||||
assert!(sync_segment.as_sync_marker().is_some());
|
||||
assert_eq!(sync_segment.sample_rate(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_audio_segment_gain_manipulation() {
|
||||
let segment = AudioSegment::new_chunk(0, 0.0, vec![[100i32, 200i32]], 44100, BitDepth::B32);
|
||||
|
||||
// Test with_gain_db
|
||||
let segment_6db = segment.with_gain_db(6.0).unwrap();
|
||||
assert_eq!(segment_6db.gain_db(), Some(6.0));
|
||||
assert_eq!(segment_6db.order, 0);
|
||||
assert_eq!(segment_6db.timestamp_sec, 0.0);
|
||||
|
||||
// Test adjust_gain_db
|
||||
let segment_plus_3db = segment_6db.adjust_gain_db(3.0).unwrap();
|
||||
assert_eq!(segment_plus_3db.gain_db(), Some(9.0));
|
||||
|
||||
// Test sur un sync marker (devrait retourner None)
|
||||
let sync = AudioSegment::new_hearbeat(1, 1.0);
|
||||
assert!(sync.with_gain_db(6.0).is_none());
|
||||
assert!(sync.adjust_gain_db(3.0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_audio_segment_conversions() {
|
||||
let segment =
|
||||
AudioSegment::new_chunk(0, 0.0, vec![[1000000i32, 2000000i32]], 44100, BitDepth::B32);
|
||||
|
||||
// Test to_f32_chunk
|
||||
let f32_chunk = segment.to_f32_chunk();
|
||||
assert!(f32_chunk.is_some());
|
||||
assert_eq!(f32_chunk.unwrap().type_name(), "f32");
|
||||
|
||||
// Test to_i32_chunk
|
||||
let i32_chunk = segment.to_i32_chunk();
|
||||
assert!(i32_chunk.is_some());
|
||||
assert_eq!(i32_chunk.unwrap().type_name(), "i32");
|
||||
|
||||
// Test sur un sync marker
|
||||
let sync = AudioSegment::new_hearbeat(1, 1.0);
|
||||
assert!(sync.to_f32_chunk().is_none());
|
||||
assert!(sync.to_i32_chunk().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_audio_segment_error_marker() {
|
||||
let error_msg = "Test error message";
|
||||
let segment = AudioSegment::new_error(5, 2.5, error_msg.to_string());
|
||||
|
||||
assert!(segment.is_error());
|
||||
assert_eq!(segment.as_error(), Some(error_msg));
|
||||
|
||||
// Autre type de segment ne devrait pas être une erreur
|
||||
let sync = AudioSegment::new_hearbeat(1, 1.0);
|
||||
assert!(!sync.is_error());
|
||||
assert_eq!(sync.as_error(), None);
|
||||
}
|
||||
}
|
||||
162
pmoaudio/src/bit_depth.rs
Executable file
162
pmoaudio/src/bit_depth.rs
Executable file
@@ -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<Self> {
|
||||
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<std::cmp::Ordering> {
|
||||
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<B: BitDepthType>() -> u32 {
|
||||
/// B::BITS
|
||||
/// }
|
||||
///
|
||||
/// let depth = BitDepth::B16;
|
||||
/// let bits = dispatch_by_bitdepth(
|
||||
/// depth,
|
||||
/// || type_bits::<Bit8>(),
|
||||
/// || type_bits::<Bit16>(),
|
||||
/// || type_bits::<Bit24>(),
|
||||
/// || type_bits::<Bit32>(),
|
||||
/// );
|
||||
/// assert_eq!(bits, 16);
|
||||
/// ```
|
||||
#[inline(always)]
|
||||
pub fn dispatch_by_bitdepth<R, F8, F16, F24, F32>(
|
||||
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()
|
||||
}
|
||||
751
pmoaudio/src/conversions.rs
Executable file
751
pmoaudio/src/conversions.rs
Executable file
@@ -0,0 +1,751 @@
|
||||
//! Conversions entre différents types de AudioChunk
|
||||
//!
|
||||
//! Ce module fournit des conversions optimisées (SIMD où possible) entre
|
||||
//! tous les types de samples audio supportés.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{dsp, AudioChunk, AudioChunkData, BitDepth, I24};
|
||||
|
||||
// ============================================================================
|
||||
// Conversions int → int (changement de bit depth)
|
||||
// ============================================================================
|
||||
//
|
||||
// Ces fonctions utilisent la fonction DSP optimisée SIMD `bitdepth_change_stereo`
|
||||
// pour les conversions i32 ↔ i32 avec différents bit depths.
|
||||
|
||||
/// Convertit i32 vers i16 (downsampling via bit depth change)
|
||||
pub fn convert_i32_to_i16(chunk: &AudioChunkData<i32>) -> Arc<AudioChunkData<i16>> {
|
||||
let mut stereo = chunk.clone_frames();
|
||||
|
||||
// Utiliser la fonction DSP optimisée pour passer de B32 → B16
|
||||
dsp::bitdepth_change_stereo(&mut stereo, BitDepth::B32, BitDepth::B16);
|
||||
|
||||
// Convertir i32 → i16 (les valeurs sont maintenant dans la plage i16)
|
||||
let stereo_i16: Vec<[i16; 2]> = stereo
|
||||
.into_iter()
|
||||
.map(|[l, r]| [l as i16, r as i16])
|
||||
.collect();
|
||||
|
||||
AudioChunkData::new(stereo_i16, chunk.get_sample_rate(), chunk.get_gain_db())
|
||||
}
|
||||
|
||||
/// Convertit i32 vers I24 (downsampling via bit depth change)
|
||||
pub fn convert_i32_to_i24(chunk: &AudioChunkData<i32>) -> Arc<AudioChunkData<I24>> {
|
||||
let mut stereo = chunk.clone_frames();
|
||||
|
||||
// Utiliser la fonction DSP optimisée pour passer de B32 → B24
|
||||
dsp::bitdepth_change_stereo(&mut stereo, BitDepth::B32, BitDepth::B24);
|
||||
|
||||
// Convertir i32 → I24 (les valeurs sont maintenant dans la plage I24)
|
||||
let stereo_i24: Vec<[I24; 2]> = stereo
|
||||
.into_iter()
|
||||
.map(|[l, r]| [I24::new_clamped(l), I24::new_clamped(r)])
|
||||
.collect();
|
||||
|
||||
AudioChunkData::new(stereo_i24, chunk.get_sample_rate(), chunk.get_gain_db())
|
||||
}
|
||||
|
||||
/// Convertit i16 vers i32 (upsampling via bit depth change)
|
||||
pub fn convert_i16_to_i32(chunk: &AudioChunkData<i16>) -> Arc<AudioChunkData<i32>> {
|
||||
// Convertir i16 → i32 d'abord
|
||||
let mut stereo: Vec<[i32; 2]> = chunk
|
||||
.get_frames()
|
||||
.iter()
|
||||
.map(|[l, r]| [*l as i32, *r as i32])
|
||||
.collect();
|
||||
|
||||
// Utiliser la fonction DSP optimisée pour passer de B16 → B32
|
||||
dsp::bitdepth_change_stereo(&mut stereo, BitDepth::B16, BitDepth::B32);
|
||||
|
||||
AudioChunkData::new(stereo, chunk.get_sample_rate(), chunk.get_gain_db())
|
||||
}
|
||||
|
||||
/// Convertit I24 vers i32 (upsampling via bit depth change)
|
||||
pub fn convert_i24_to_i32(chunk: &AudioChunkData<I24>) -> Arc<AudioChunkData<i32>> {
|
||||
// Convertir I24 → i32 d'abord
|
||||
let mut stereo: Vec<[i32; 2]> = chunk
|
||||
.get_frames()
|
||||
.iter()
|
||||
.map(|[l, r]| [l.as_i32(), r.as_i32()])
|
||||
.collect();
|
||||
|
||||
// Utiliser la fonction DSP optimisée pour passer de B24 → B32
|
||||
dsp::bitdepth_change_stereo(&mut stereo, BitDepth::B24, BitDepth::B32);
|
||||
|
||||
AudioChunkData::new(stereo, chunk.get_sample_rate(), chunk.get_gain_db())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Conversions int → float (normalisation)
|
||||
// ============================================================================
|
||||
|
||||
/// Convertit i32 vers f32 via les fonctions DSP optimisées SIMD
|
||||
///
|
||||
/// I32 = 32 bits complets, donc normalisation par 2^31
|
||||
pub fn convert_i32_to_f32(chunk: &AudioChunkData<i32>) -> Arc<AudioChunkData<f32>> {
|
||||
let frames = chunk.get_frames();
|
||||
let len = frames.len();
|
||||
|
||||
// Séparer les canaux pour utiliser les fonctions DSP SIMD
|
||||
let mut left = Vec::with_capacity(len);
|
||||
let mut right = Vec::with_capacity(len);
|
||||
for [l, r] in frames {
|
||||
left.push(*l);
|
||||
right.push(*r);
|
||||
}
|
||||
|
||||
// Utiliser la fonction SIMD optimisée du module DSP avec BitDepth::B32
|
||||
let mut out_pairs = vec![[0.0f32; 2]; len];
|
||||
dsp::i32_stereo_to_pairs_f32(&left, &right, &mut out_pairs, BitDepth::B32);
|
||||
|
||||
AudioChunkData::new(out_pairs, chunk.get_sample_rate(), chunk.get_gain_db())
|
||||
}
|
||||
|
||||
/// Convertit i32 vers f64
|
||||
///
|
||||
/// I32 = 32 bits complets, donc normalisation par 2^31
|
||||
pub fn convert_i32_to_f64(chunk: &AudioChunkData<i32>) -> Arc<AudioChunkData<f64>> {
|
||||
// Via f32 puis upcast
|
||||
let f32_chunk = convert_i32_to_f32(chunk);
|
||||
convert_f32_to_f64(&f32_chunk)
|
||||
}
|
||||
|
||||
/// Convertit I24 vers f32 via les fonctions DSP optimisées SIMD
|
||||
pub fn convert_i24_to_f32(chunk: &AudioChunkData<I24>) -> Arc<AudioChunkData<f32>> {
|
||||
let frames = chunk.get_frames();
|
||||
let len = frames.len();
|
||||
|
||||
// Séparer les canaux I24 en i32
|
||||
let mut left = Vec::with_capacity(len);
|
||||
let mut right = Vec::with_capacity(len);
|
||||
for [l, r] in frames {
|
||||
left.push(l.as_i32());
|
||||
right.push(r.as_i32());
|
||||
}
|
||||
|
||||
// Utiliser la fonction SIMD optimisée du module DSP pour I24
|
||||
let mut out_pairs = vec![[0.0f32; 2]; len];
|
||||
dsp::i24_as_i32_stereo_to_pairs_f32(&left, &right, &mut out_pairs);
|
||||
|
||||
AudioChunkData::new(out_pairs, chunk.get_sample_rate(), chunk.get_gain_db())
|
||||
}
|
||||
|
||||
/// Convertit I24 vers f64
|
||||
pub fn convert_i24_to_f64(chunk: &AudioChunkData<I24>) -> Arc<AudioChunkData<f64>> {
|
||||
let frames = chunk.get_frames();
|
||||
let max_value = 8_388_608.0f64; // 2^23
|
||||
|
||||
let stereo: Vec<[f64; 2]> = frames
|
||||
.iter()
|
||||
.map(|[l, r]| {
|
||||
let lf = l.as_i32() as f64 / max_value;
|
||||
let rf = r.as_i32() as f64 / max_value;
|
||||
[lf, rf]
|
||||
})
|
||||
.collect();
|
||||
|
||||
AudioChunkData::new(stereo, chunk.get_sample_rate(), chunk.get_gain_db())
|
||||
}
|
||||
|
||||
/// Convertit i16 vers f32 via les fonctions DSP optimisées SIMD
|
||||
pub fn convert_i16_to_f32(chunk: &AudioChunkData<i16>) -> Arc<AudioChunkData<f32>> {
|
||||
let frames = chunk.get_frames();
|
||||
let len = frames.len();
|
||||
|
||||
// Séparer les canaux
|
||||
let mut left = Vec::with_capacity(len);
|
||||
let mut right = Vec::with_capacity(len);
|
||||
for [l, r] in frames {
|
||||
left.push(*l);
|
||||
right.push(*r);
|
||||
}
|
||||
|
||||
// Utiliser la fonction SIMD optimisée du module DSP
|
||||
let mut out_pairs = vec![[0.0f32; 2]; len];
|
||||
dsp::i16_stereo_to_pairs_f32(&left, &right, &mut out_pairs);
|
||||
|
||||
AudioChunkData::new(out_pairs, chunk.get_sample_rate(), chunk.get_gain_db())
|
||||
}
|
||||
|
||||
/// Convertit i16 vers f64
|
||||
pub fn convert_i16_to_f64(chunk: &AudioChunkData<i16>) -> Arc<AudioChunkData<f64>> {
|
||||
let frames = chunk.get_frames();
|
||||
let max_value = 32_768.0f64; // 2^15
|
||||
|
||||
let stereo: Vec<[f64; 2]> = frames
|
||||
.iter()
|
||||
.map(|[l, r]| {
|
||||
let lf = *l as f64 / max_value;
|
||||
let rf = *r as f64 / max_value;
|
||||
[lf, rf]
|
||||
})
|
||||
.collect();
|
||||
|
||||
AudioChunkData::new(stereo, chunk.get_sample_rate(), chunk.get_gain_db())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Conversions float → int (quantization)
|
||||
// ============================================================================
|
||||
|
||||
/// Convertit f32 vers i32 via les fonctions DSP optimisées SIMD
|
||||
///
|
||||
/// I32 = 32 bits complets, donc quantization vers ±2^31
|
||||
pub fn convert_f32_to_i32(chunk: &AudioChunkData<f32>) -> Arc<AudioChunkData<i32>> {
|
||||
let frames = chunk.get_frames();
|
||||
let len = frames.len();
|
||||
|
||||
// Utiliser la fonction SIMD optimisée du module DSP avec BitDepth::B32
|
||||
let mut left = vec![0i32; len];
|
||||
let mut right = vec![0i32; len];
|
||||
dsp::pairs_f32_to_i32_stereo(frames, &mut left, &mut right, BitDepth::B32);
|
||||
|
||||
// Recombiner en frames
|
||||
let stereo: Vec<[i32; 2]> = left
|
||||
.into_iter()
|
||||
.zip(right.into_iter())
|
||||
.map(|(l, r)| [l, r])
|
||||
.collect();
|
||||
|
||||
AudioChunkData::new(stereo, chunk.get_sample_rate(), chunk.get_gain_db())
|
||||
}
|
||||
|
||||
/// Convertit f64 vers i32 (via f32)
|
||||
///
|
||||
/// I32 = 32 bits complets, donc quantization vers ±2^31
|
||||
pub fn convert_f64_to_i32(chunk: &AudioChunkData<f64>) -> Arc<AudioChunkData<i32>> {
|
||||
// Downcast f64 → f32 puis quantize
|
||||
let f32_chunk = convert_f64_to_f32(chunk);
|
||||
convert_f32_to_i32(&f32_chunk)
|
||||
}
|
||||
|
||||
/// Convertit f32 vers I24 via les fonctions DSP optimisées SIMD
|
||||
pub fn convert_f32_to_i24(chunk: &AudioChunkData<f32>) -> Arc<AudioChunkData<I24>> {
|
||||
let frames = chunk.get_frames();
|
||||
let len = frames.len();
|
||||
|
||||
// Utiliser la fonction SIMD optimisée du module DSP
|
||||
let mut left = vec![0i32; len];
|
||||
let mut right = vec![0i32; len];
|
||||
dsp::pairs_f32_to_i24_as_i32_stereo(frames, &mut left, &mut right);
|
||||
|
||||
// Recombiner en frames I24
|
||||
let stereo: Vec<[I24; 2]> = left
|
||||
.into_iter()
|
||||
.zip(right.into_iter())
|
||||
.map(|(l, r)| [I24::new_clamped(l), I24::new_clamped(r)])
|
||||
.collect();
|
||||
|
||||
AudioChunkData::new(stereo, chunk.get_sample_rate(), chunk.get_gain_db())
|
||||
}
|
||||
|
||||
/// Convertit f64 vers I24
|
||||
pub fn convert_f64_to_i24(chunk: &AudioChunkData<f64>) -> Arc<AudioChunkData<I24>> {
|
||||
let frames = chunk.get_frames();
|
||||
let max_value = 8_388_607.0f64; // 2^23 - 1
|
||||
let min_value = -8_388_608.0f64; // -2^23
|
||||
|
||||
let stereo: Vec<[I24; 2]> = frames
|
||||
.iter()
|
||||
.map(|[l, r]| {
|
||||
let l_scaled = (l * max_value).clamp(min_value, max_value).round() as i32;
|
||||
let r_scaled = (r * max_value).clamp(min_value, max_value).round() as i32;
|
||||
[I24::new_clamped(l_scaled), I24::new_clamped(r_scaled)]
|
||||
})
|
||||
.collect();
|
||||
|
||||
AudioChunkData::new(stereo, chunk.get_sample_rate(), chunk.get_gain_db())
|
||||
}
|
||||
|
||||
/// Convertit f32 vers i16 via les fonctions DSP optimisées SIMD
|
||||
pub fn convert_f32_to_i16(chunk: &AudioChunkData<f32>) -> Arc<AudioChunkData<i16>> {
|
||||
let frames = chunk.get_frames();
|
||||
let len = frames.len();
|
||||
|
||||
// Utiliser la fonction SIMD optimisée du module DSP
|
||||
let mut left = vec![0i16; len];
|
||||
let mut right = vec![0i16; len];
|
||||
dsp::pairs_f32_to_i16_stereo(frames, &mut left, &mut right);
|
||||
|
||||
// Recombiner en frames
|
||||
let stereo: Vec<[i16; 2]> = left
|
||||
.into_iter()
|
||||
.zip(right.into_iter())
|
||||
.map(|(l, r)| [l, r])
|
||||
.collect();
|
||||
|
||||
AudioChunkData::new(stereo, chunk.get_sample_rate(), chunk.get_gain_db())
|
||||
}
|
||||
|
||||
/// Convertit f64 vers i16
|
||||
pub fn convert_f64_to_i16(chunk: &AudioChunkData<f64>) -> Arc<AudioChunkData<i16>> {
|
||||
let frames = chunk.get_frames();
|
||||
let max_value = 32_767.0f64; // 2^15 - 1
|
||||
let min_value = -32_768.0f64; // -2^15
|
||||
|
||||
let stereo: Vec<[i16; 2]> = frames
|
||||
.iter()
|
||||
.map(|[l, r]| {
|
||||
let l16 = (l * max_value).clamp(min_value, max_value).round() as i16;
|
||||
let r16 = (r * max_value).clamp(min_value, max_value).round() as i16;
|
||||
[l16, r16]
|
||||
})
|
||||
.collect();
|
||||
|
||||
AudioChunkData::new(stereo, chunk.get_sample_rate(), chunk.get_gain_db())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Conversions F32 ↔ F64
|
||||
// ============================================================================
|
||||
|
||||
/// Convertit f32 vers f64 (upcast simple)
|
||||
pub fn convert_f32_to_f64(chunk: &AudioChunkData<f32>) -> Arc<AudioChunkData<f64>> {
|
||||
let frames = chunk.get_frames();
|
||||
let stereo: Vec<[f64; 2]> = frames.iter().map(|[l, r]| [*l as f64, *r as f64]).collect();
|
||||
|
||||
AudioChunkData::new(stereo, chunk.get_sample_rate(), chunk.get_gain_db())
|
||||
}
|
||||
|
||||
/// Convertit f64 vers f32 (downcast simple)
|
||||
pub fn convert_f64_to_f32(chunk: &AudioChunkData<f64>) -> Arc<AudioChunkData<f32>> {
|
||||
let frames = chunk.get_frames();
|
||||
let stereo: Vec<[f32; 2]> = frames.iter().map(|[l, r]| [*l as f32, *r as f32]).collect();
|
||||
|
||||
AudioChunkData::new(stereo, chunk.get_sample_rate(), chunk.get_gain_db())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Méthodes de conversion sur AudioChunk enum
|
||||
// ============================================================================
|
||||
|
||||
impl AudioChunk {
|
||||
/// Convertit ce chunk vers f32
|
||||
///
|
||||
/// Chaque type utilise sa plage native (I16=±2^15, I24=±2^23, I32=±2^31)
|
||||
pub fn to_f32(&self) -> AudioChunk {
|
||||
match self {
|
||||
AudioChunk::I16(d) => AudioChunk::F32(convert_i16_to_f32(d)),
|
||||
AudioChunk::I24(d) => AudioChunk::F32(convert_i24_to_f32(d)),
|
||||
AudioChunk::I32(d) => AudioChunk::F32(convert_i32_to_f32(d)),
|
||||
AudioChunk::F32(d) => AudioChunk::F32(d.clone()),
|
||||
AudioChunk::F64(d) => AudioChunk::F32(convert_f64_to_f32(d)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convertit ce chunk vers f64
|
||||
///
|
||||
/// Chaque type utilise sa plage native (I16=±2^15, I24=±2^23, I32=±2^31)
|
||||
pub fn to_f64(&self) -> AudioChunk {
|
||||
match self {
|
||||
AudioChunk::I16(d) => AudioChunk::F64(convert_i16_to_f64(d)),
|
||||
AudioChunk::I24(d) => AudioChunk::F64(convert_i24_to_f64(d)),
|
||||
AudioChunk::I32(d) => AudioChunk::F64(convert_i32_to_f64(d)),
|
||||
AudioChunk::F32(d) => AudioChunk::F64(convert_f32_to_f64(d)),
|
||||
AudioChunk::F64(d) => AudioChunk::F64(d.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convertit ce chunk vers i32
|
||||
///
|
||||
/// I32 = 32 bits complets (±2^31)
|
||||
pub fn to_i32(&self) -> AudioChunk {
|
||||
match self {
|
||||
AudioChunk::I16(d) => AudioChunk::I32(convert_i16_to_i32(d)),
|
||||
AudioChunk::I24(d) => AudioChunk::I32(convert_i24_to_i32(d)),
|
||||
AudioChunk::I32(d) => AudioChunk::I32(d.clone()),
|
||||
AudioChunk::F32(d) => AudioChunk::I32(convert_f32_to_i32(d)),
|
||||
AudioChunk::F64(d) => AudioChunk::I32(convert_f64_to_i32(d)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convertit ce chunk vers I24
|
||||
pub fn to_i24(&self) -> AudioChunk {
|
||||
match self {
|
||||
AudioChunk::I16(d) => {
|
||||
// I16 → I32 → I24
|
||||
let i32_chunk = convert_i16_to_i32(d);
|
||||
AudioChunk::I24(convert_i32_to_i24(&i32_chunk))
|
||||
}
|
||||
AudioChunk::I24(d) => AudioChunk::I24(d.clone()),
|
||||
AudioChunk::I32(d) => AudioChunk::I24(convert_i32_to_i24(d)),
|
||||
AudioChunk::F32(d) => AudioChunk::I24(convert_f32_to_i24(d)),
|
||||
AudioChunk::F64(d) => AudioChunk::I24(convert_f64_to_i24(d)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convertit ce chunk vers i16
|
||||
pub fn to_i16(&self) -> AudioChunk {
|
||||
match self {
|
||||
AudioChunk::I16(d) => AudioChunk::I16(d.clone()),
|
||||
AudioChunk::I24(d) => {
|
||||
// I24 → I32 → I16
|
||||
let i32_chunk = convert_i24_to_i32(d);
|
||||
AudioChunk::I16(convert_i32_to_i16(&i32_chunk))
|
||||
}
|
||||
AudioChunk::I32(d) => AudioChunk::I16(convert_i32_to_i16(d)),
|
||||
AudioChunk::F32(d) => AudioChunk::I16(convert_f32_to_i16(d)),
|
||||
AudioChunk::F64(d) => AudioChunk::I16(convert_f64_to_i16(d)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Implémentations des traits From/Into
|
||||
// ============================================================================
|
||||
|
||||
// ---------- From<Arc<AudioChunkData<T>>> pour AudioChunk ----------
|
||||
|
||||
impl From<Arc<AudioChunkData<i16>>> for AudioChunk {
|
||||
fn from(data: Arc<AudioChunkData<i16>>) -> Self {
|
||||
AudioChunk::I16(data)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Arc<AudioChunkData<I24>>> for AudioChunk {
|
||||
fn from(data: Arc<AudioChunkData<I24>>) -> Self {
|
||||
AudioChunk::I24(data)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Arc<AudioChunkData<i32>>> for AudioChunk {
|
||||
fn from(data: Arc<AudioChunkData<i32>>) -> Self {
|
||||
AudioChunk::I32(data)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Arc<AudioChunkData<f32>>> for AudioChunk {
|
||||
fn from(data: Arc<AudioChunkData<f32>>) -> Self {
|
||||
AudioChunk::F32(data)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Arc<AudioChunkData<f64>>> for AudioChunk {
|
||||
fn from(data: Arc<AudioChunkData<f64>>) -> Self {
|
||||
AudioChunk::F64(data)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- From entre AudioChunkData types (sans BitDepth requis) ----------
|
||||
|
||||
// I16 conversions
|
||||
impl From<&AudioChunkData<i16>> for Arc<AudioChunkData<i32>> {
|
||||
fn from(chunk: &AudioChunkData<i16>) -> Self {
|
||||
convert_i16_to_i32(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&AudioChunkData<i16>> for Arc<AudioChunkData<f32>> {
|
||||
fn from(chunk: &AudioChunkData<i16>) -> Self {
|
||||
convert_i16_to_f32(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&AudioChunkData<i16>> for Arc<AudioChunkData<f64>> {
|
||||
fn from(chunk: &AudioChunkData<i16>) -> Self {
|
||||
convert_i16_to_f64(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
// I24 conversions
|
||||
impl From<&AudioChunkData<I24>> for Arc<AudioChunkData<i32>> {
|
||||
fn from(chunk: &AudioChunkData<I24>) -> Self {
|
||||
convert_i24_to_i32(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&AudioChunkData<I24>> for Arc<AudioChunkData<f32>> {
|
||||
fn from(chunk: &AudioChunkData<I24>) -> Self {
|
||||
convert_i24_to_f32(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&AudioChunkData<I24>> for Arc<AudioChunkData<f64>> {
|
||||
fn from(chunk: &AudioChunkData<I24>) -> Self {
|
||||
convert_i24_to_f64(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
// I32 conversions vers types int (downsampling)
|
||||
|
||||
impl From<&AudioChunkData<i32>> for Arc<AudioChunkData<i16>> {
|
||||
fn from(chunk: &AudioChunkData<i32>) -> Self {
|
||||
convert_i32_to_i16(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&AudioChunkData<i32>> for Arc<AudioChunkData<I24>> {
|
||||
fn from(chunk: &AudioChunkData<i32>) -> Self {
|
||||
convert_i32_to_i24(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
// I32 conversions vers float (normalisation par 2^31)
|
||||
impl From<&AudioChunkData<i32>> for Arc<AudioChunkData<f32>> {
|
||||
fn from(chunk: &AudioChunkData<i32>) -> Self {
|
||||
convert_i32_to_f32(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&AudioChunkData<i32>> for Arc<AudioChunkData<f64>> {
|
||||
fn from(chunk: &AudioChunkData<i32>) -> Self {
|
||||
convert_i32_to_f64(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
// F32 conversions
|
||||
impl From<&AudioChunkData<f32>> for Arc<AudioChunkData<f64>> {
|
||||
fn from(chunk: &AudioChunkData<f32>) -> Self {
|
||||
convert_f32_to_f64(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&AudioChunkData<f32>> for Arc<AudioChunkData<i16>> {
|
||||
fn from(chunk: &AudioChunkData<f32>) -> Self {
|
||||
convert_f32_to_i16(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&AudioChunkData<f32>> for Arc<AudioChunkData<I24>> {
|
||||
fn from(chunk: &AudioChunkData<f32>) -> Self {
|
||||
convert_f32_to_i24(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&AudioChunkData<f32>> for Arc<AudioChunkData<i32>> {
|
||||
fn from(chunk: &AudioChunkData<f32>) -> Self {
|
||||
convert_f32_to_i32(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
// F64 conversions
|
||||
impl From<&AudioChunkData<f64>> for Arc<AudioChunkData<f32>> {
|
||||
fn from(chunk: &AudioChunkData<f64>) -> Self {
|
||||
convert_f64_to_f32(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&AudioChunkData<f64>> for Arc<AudioChunkData<i16>> {
|
||||
fn from(chunk: &AudioChunkData<f64>) -> Self {
|
||||
convert_f64_to_i16(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&AudioChunkData<f64>> for Arc<AudioChunkData<I24>> {
|
||||
fn from(chunk: &AudioChunkData<f64>) -> Self {
|
||||
convert_f64_to_i24(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&AudioChunkData<f64>> for Arc<AudioChunkData<i32>> {
|
||||
fn from(chunk: &AudioChunkData<f64>) -> Self {
|
||||
convert_f64_to_i32(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_i32_to_f32_roundtrip() {
|
||||
let stereo = vec![[1_000_000_000i32, 2_000_000_000i32]; 100];
|
||||
let chunk_i32 = AudioChunkData::new(stereo.clone(), 48_000, 0.0);
|
||||
|
||||
let chunk_f32 = convert_i32_to_f32(&chunk_i32);
|
||||
let chunk_back = convert_f32_to_i32(&chunk_f32);
|
||||
|
||||
// Vérifier que les valeurs sont proches (tolérance d'arrondi)
|
||||
// Note: Pour I32 on utilise toute la plage ±2^31
|
||||
for (orig, back) in stereo.iter().zip(chunk_back.get_frames().iter()) {
|
||||
assert!((orig[0] - back[0]).abs() <= 100); // Tolérance plus élevée pour 32-bit
|
||||
assert!((orig[1] - back[1]).abs() <= 100);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_f32_to_f64_roundtrip() {
|
||||
let stereo = vec![[0.5f32, -0.25f32]; 100];
|
||||
let chunk_f32 = AudioChunkData::new(stereo.clone(), 48_000, 0.0);
|
||||
|
||||
let chunk_f64 = convert_f32_to_f64(&chunk_f32);
|
||||
let chunk_back = convert_f64_to_f32(&chunk_f64);
|
||||
|
||||
// Vérifier égalité exacte (pas de perte de précision significative)
|
||||
for (orig, back) in stereo.iter().zip(chunk_back.get_frames().iter()) {
|
||||
assert!((orig[0] - back[0]).abs() < 1e-6);
|
||||
assert!((orig[1] - back[1]).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_i16_to_i32_upsampling() {
|
||||
let stereo = vec![[16_000i16, -8_000i16]; 10];
|
||||
let chunk_i16 = AudioChunkData::new(stereo.clone(), 48_000, 0.0);
|
||||
|
||||
let chunk_i32 = convert_i16_to_i32(&chunk_i16);
|
||||
|
||||
// Vérifier que les valeurs sont correctement upsamplées (shift de 16 bits)
|
||||
for (orig, result) in stereo.iter().zip(chunk_i32.get_frames().iter()) {
|
||||
assert_eq!(result[0], (orig[0] as i32) << 16);
|
||||
assert_eq!(result[1], (orig[1] as i32) << 16);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_i32_to_i16_downsampling() {
|
||||
let stereo = vec![[1_000_000i32 << 16, -500_000i32 << 16]; 10];
|
||||
let chunk_i32 = AudioChunkData::new(stereo.clone(), 48_000, 0.0);
|
||||
|
||||
let chunk_i16 = convert_i32_to_i16(&chunk_i32);
|
||||
|
||||
// Vérifier que les valeurs sont correctement downsamplées
|
||||
for (orig, result) in stereo.iter().zip(chunk_i16.get_frames().iter()) {
|
||||
assert_eq!(result[0], (orig[0] >> 16) as i16);
|
||||
assert_eq!(result[1], (orig[1] >> 16) as i16);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_i24_conversions() {
|
||||
let stereo = vec![[I24::new(1_000_000).unwrap(), I24::new(-500_000).unwrap()]; 10];
|
||||
let chunk_i24 = AudioChunkData::new(stereo.clone(), 48_000, 0.0);
|
||||
|
||||
// I24 → F32 → I24
|
||||
let chunk_f32 = convert_i24_to_f32(&chunk_i24);
|
||||
let chunk_back = convert_f32_to_i24(&chunk_f32);
|
||||
|
||||
for (orig, back) in stereo.iter().zip(chunk_back.get_frames().iter()) {
|
||||
assert!((orig[0].as_i32() - back[0].as_i32()).abs() <= 1);
|
||||
assert!((orig[1].as_i32() - back[1].as_i32()).abs() <= 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_audio_chunk_enum_conversions() {
|
||||
// Créer un chunk I32
|
||||
let stereo = vec![[1_000_000_000i32, -500_000_000i32]; 100];
|
||||
let chunk_data = AudioChunkData::new(stereo, 48_000, 0.0);
|
||||
let chunk = AudioChunk::I32(chunk_data);
|
||||
|
||||
// Convertir vers F32 (I32 utilise plage complète ±2^31)
|
||||
let chunk_f32 = chunk.to_f32();
|
||||
assert_eq!(chunk_f32.type_name(), "f32");
|
||||
|
||||
// Convertir vers I24
|
||||
let chunk_i24 = chunk.to_i24();
|
||||
assert_eq!(chunk_i24.type_name(), "I24");
|
||||
|
||||
// Convertir vers I16
|
||||
let chunk_i16 = chunk.to_i16();
|
||||
assert_eq!(chunk_i16.type_name(), "i16");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_trait_audio_chunk() {
|
||||
// Test From<Arc<AudioChunkData<T>>> pour AudioChunk
|
||||
let stereo_f32 = vec![[0.5f32, -0.25f32]; 100];
|
||||
let chunk_data = AudioChunkData::new(stereo_f32, 48_000, 0.0);
|
||||
|
||||
// Utiliser From/Into
|
||||
let chunk: AudioChunk = chunk_data.into();
|
||||
assert_eq!(chunk.type_name(), "f32");
|
||||
assert_eq!(chunk.len(), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_trait_conversions() {
|
||||
// Test From entre AudioChunkData types
|
||||
let stereo_i16 = vec![[16_000i16, -8_000i16]; 50];
|
||||
let chunk_i16 = AudioChunkData::new(stereo_i16, 48_000, 0.0);
|
||||
|
||||
// I16 → I32 via From
|
||||
let chunk_i32: Arc<AudioChunkData<i32>> = (&*chunk_i16).into();
|
||||
assert_eq!(chunk_i32.len(), 50);
|
||||
|
||||
// I16 → F32 via From
|
||||
let chunk_f32: Arc<AudioChunkData<f32>> = (&*chunk_i16).into();
|
||||
assert_eq!(chunk_f32.len(), 50);
|
||||
|
||||
// I16 → F64 via From
|
||||
let chunk_f64: Arc<AudioChunkData<f64>> = (&*chunk_i16).into();
|
||||
assert_eq!(chunk_f64.len(), 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_trait_i24() {
|
||||
// Test conversions I24 via From
|
||||
let stereo_i24 = vec![[I24::new(1_000_000).unwrap(), I24::new(-500_000).unwrap()]; 50];
|
||||
let chunk_i24 = AudioChunkData::new(stereo_i24, 48_000, 0.0);
|
||||
|
||||
// I24 → I32 via From
|
||||
let chunk_i32: Arc<AudioChunkData<i32>> = (&*chunk_i24).into();
|
||||
assert_eq!(chunk_i32.len(), 50);
|
||||
|
||||
// I24 → F32 via From
|
||||
let chunk_f32: Arc<AudioChunkData<f32>> = (&*chunk_i24).into();
|
||||
assert_eq!(chunk_f32.len(), 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_trait_float_conversions() {
|
||||
// Test conversions float via From
|
||||
let stereo_f32 = vec![[0.5f32, -0.25f32]; 50];
|
||||
let chunk_f32 = AudioChunkData::new(stereo_f32, 48_000, 0.0);
|
||||
|
||||
// F32 → F64 via From
|
||||
let chunk_f64: Arc<AudioChunkData<f64>> = (&*chunk_f32).into();
|
||||
assert_eq!(chunk_f64.len(), 50);
|
||||
|
||||
// F32 → I16 via From
|
||||
let chunk_i16: Arc<AudioChunkData<i16>> = (&*chunk_f32).into();
|
||||
assert_eq!(chunk_i16.len(), 50);
|
||||
|
||||
// F32 → I24 via From
|
||||
let chunk_i24: Arc<AudioChunkData<I24>> = (&*chunk_f32).into();
|
||||
assert_eq!(chunk_i24.len(), 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_trait_roundtrip() {
|
||||
// Test round-trip I24 → F32 → I24 via From
|
||||
let original = vec![[I24::new(1_000_000).unwrap(), I24::new(-500_000).unwrap()]; 10];
|
||||
let chunk_i24 = AudioChunkData::new(original.clone(), 48_000, 0.0);
|
||||
|
||||
// I24 → F32 via From
|
||||
let chunk_f32: Arc<AudioChunkData<f32>> = (&*chunk_i24).into();
|
||||
|
||||
// F32 → I24 via From
|
||||
let chunk_back: Arc<AudioChunkData<I24>> = (&*chunk_f32).into();
|
||||
|
||||
// Vérifier la précision
|
||||
for (orig, back) in original.iter().zip(chunk_back.get_frames().iter()) {
|
||||
assert!((orig[0].as_i32() - back[0].as_i32()).abs() <= 1);
|
||||
assert!((orig[1].as_i32() - back[1].as_i32()).abs() <= 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_trait_i32_conversions() {
|
||||
// Test conversions I32 via From (maintenant disponibles!)
|
||||
let stereo_i32 = vec![[1_000_000_000i32, -500_000_000i32]; 50];
|
||||
let chunk_i32 = AudioChunkData::new(stereo_i32, 48_000, 0.0);
|
||||
|
||||
// I32 → F32 via From (normalisation par 2^31)
|
||||
let chunk_f32: Arc<AudioChunkData<f32>> = (&*chunk_i32).into();
|
||||
assert_eq!(chunk_f32.len(), 50);
|
||||
|
||||
// I32 → F64 via From
|
||||
let chunk_f64: Arc<AudioChunkData<f64>> = (&*chunk_i32).into();
|
||||
assert_eq!(chunk_f64.len(), 50);
|
||||
|
||||
// F32 → I32 via From (quantization vers 2^31)
|
||||
let chunk_back_i32: Arc<AudioChunkData<i32>> = (&*chunk_f32).into();
|
||||
assert_eq!(chunk_back_i32.len(), 50);
|
||||
}
|
||||
}
|
||||
111
pmoaudio/src/dsp/depth.rs
Executable file
111
pmoaudio/src/dsp/depth.rs
Executable file
@@ -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::<i32, LANES>::splat(shift);
|
||||
|
||||
// On traite 8 frames stéréo à la fois
|
||||
let (chunks, remainder) = data.as_chunks_mut::<LANES>();
|
||||
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::<i32, LANES>::from_array(l) << shift_vec;
|
||||
let vr = Simd::<i32, LANES>::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::<i32, LANES>::splat(shift);
|
||||
let maxv = Simd::<i32, LANES>::splat(((1i64 << (dest_bits - 1)) - 1) as i32);
|
||||
let minv = Simd::<i32, LANES>::splat((-(1i64 << (dest_bits - 1))) as i32);
|
||||
|
||||
let (chunks, remainder) = data.as_chunks_mut::<LANES>();
|
||||
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::<i32, LANES>::from_array(l);
|
||||
let vr = Simd::<i32, LANES>::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;
|
||||
}
|
||||
}
|
||||
94
pmoaudio/src/dsp/gain_16bits.rs
Executable file
94
pmoaudio/src/dsp/gain_16bits.rs
Executable file
@@ -0,0 +1,94 @@
|
||||
/// Applique un gain (en dB) sur des échantillons stéréo interleavés `[L,R]`
|
||||
/// codés sur 16 bits signés.
|
||||
pub fn apply_gain_stereo_i16(samples: &mut [[i16; 2]], gain_db: f64) {
|
||||
let gain = 10f64.powf(gain_db / 20.0);
|
||||
let g_q15 = (gain * (1u32 << 15) as f64).round() as i16;
|
||||
|
||||
#[cfg(all(target_arch = "aarch64", target_feature = "neon"))]
|
||||
unsafe {
|
||||
apply_gain_stereo_i16_neon(samples, g_q15);
|
||||
return;
|
||||
}
|
||||
#[cfg(all(target_arch = "x86_64", target_feature = "avx2"))]
|
||||
unsafe {
|
||||
apply_gain_stereo_i16_avx2(samples, g_q15);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback scalaire
|
||||
#[cfg(not(any(
|
||||
all(target_arch = "aarch64", target_feature = "neon"),
|
||||
all(target_arch = "x86_64", target_feature = "avx2")
|
||||
)))]
|
||||
{
|
||||
apply_gain_stereo_i16_scalar(samples, g_q15);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(
|
||||
all(target_arch = "aarch64", target_feature = "neon"),
|
||||
all(target_arch = "x86_64", target_feature = "avx2")
|
||||
)))]
|
||||
#[inline(always)]
|
||||
fn apply_gain_stereo_i16_scalar(samples: &mut [[i16; 2]], g_q15: i16) {
|
||||
for frame in samples.iter_mut() {
|
||||
// L
|
||||
let prod_l = (frame[0] as i32 * g_q15 as i32 + (1 << 14)) >> 15;
|
||||
frame[0] = prod_l.clamp(i16::MIN as i32, i16::MAX as i32) as i16;
|
||||
|
||||
// R
|
||||
let prod_r = (frame[1] as i32 * g_q15 as i32 + (1 << 14)) >> 15;
|
||||
frame[1] = prod_r.clamp(i16::MIN as i32, i16::MAX as i32) as i16;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(target_arch = "aarch64", target_feature = "neon"))]
|
||||
#[inline(always)]
|
||||
unsafe fn apply_gain_stereo_i16_neon(samples: &mut [[i16; 2]], g_q15: i16) {
|
||||
use core::arch::aarch64::*;
|
||||
let gvec = vdupq_n_s16(g_q15);
|
||||
let mut i = 0;
|
||||
let n = samples.len() * 2;
|
||||
let ptr = samples.as_mut_ptr() as *mut i16;
|
||||
|
||||
while i + 8 <= n {
|
||||
let v = vld1q_s16(ptr.add(i));
|
||||
let res = vqdmulhq_s16(v, gvec); // Q15 multiply high
|
||||
vst1q_s16(ptr.add(i), res);
|
||||
i += 8;
|
||||
}
|
||||
|
||||
// reste scalaire
|
||||
let slice = std::slice::from_raw_parts_mut(ptr.add(i), n - i);
|
||||
apply_gain_i16_scalar(slice, g_q15);
|
||||
}
|
||||
|
||||
#[cfg(all(target_arch = "x86_64", target_feature = "avx2"))]
|
||||
#[inline(always)]
|
||||
unsafe fn apply_gain_stereo_i16_avx2(samples: &mut [[i16; 2]], g_q15: i16) {
|
||||
use core::arch::x86_64::*;
|
||||
let g = _mm256_set1_epi16(g_q15 as i16);
|
||||
let mut i = 0;
|
||||
let n = samples.len() * 2;
|
||||
let ptr = samples.as_mut_ptr() as *mut i16;
|
||||
|
||||
while i + 16 <= n {
|
||||
let x = _mm256_loadu_si256(ptr.add(i) as *const __m256i);
|
||||
let hi = _mm256_mulhi_epi16(x, g);
|
||||
_mm256_storeu_si256(ptr.add(i) as *mut __m256i, hi);
|
||||
i += 16;
|
||||
}
|
||||
|
||||
// reste scalaire
|
||||
let slice = std::slice::from_raw_parts_mut(ptr.add(i), n - i);
|
||||
apply_gain_i16_scalar(slice, g_q15);
|
||||
}
|
||||
|
||||
/// version mono utilisée pour le reste scalaire
|
||||
#[inline(always)]
|
||||
fn apply_gain_i16_scalar(samples: &mut [i16], g_q15: i16) {
|
||||
for s in samples.iter_mut() {
|
||||
let prod = (*s as i32 * g_q15 as i32 + (1 << 14)) >> 15;
|
||||
*s = prod.clamp(i16::MIN as i32, i16::MAX as i32) as i16;
|
||||
}
|
||||
}
|
||||
99
pmoaudio/src/dsp/gain_24bits.rs
Executable file
99
pmoaudio/src/dsp/gain_24bits.rs
Executable file
@@ -0,0 +1,99 @@
|
||||
use crate::I24;
|
||||
|
||||
/// Applique un gain (en dB) sur des échantillons stéréo interleavés `[L,R]`
|
||||
/// codés sur 24 bits signés (`I24`).
|
||||
pub fn apply_gain_stereo_i24(samples: &mut [[I24; 2]], gain_db: f64) {
|
||||
let gain = 10f64.powf(gain_db / 20.0);
|
||||
// Q23 scaling
|
||||
let g_q23 = (gain * (1u64 << 23) as f64).round() as i32;
|
||||
|
||||
#[cfg(all(target_arch = "aarch64", target_feature = "neon"))]
|
||||
unsafe {
|
||||
apply_gain_stereo_i24_neon(samples, g_q23);
|
||||
return;
|
||||
}
|
||||
#[cfg(all(target_arch = "x86_64", target_feature = "avx2"))]
|
||||
unsafe {
|
||||
apply_gain_stereo_i24_avx2(samples, g_q23);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback scalaire
|
||||
#[cfg(not(any(
|
||||
all(target_arch = "aarch64", target_feature = "neon"),
|
||||
all(target_arch = "x86_64", target_feature = "avx2")
|
||||
)))]
|
||||
{
|
||||
apply_gain_stereo_i24_scalar(samples, g_q23);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(
|
||||
all(target_arch = "aarch64", target_feature = "neon"),
|
||||
all(target_arch = "x86_64", target_feature = "avx2")
|
||||
)))]
|
||||
#[inline(always)]
|
||||
fn apply_gain_stereo_i24_scalar(samples: &mut [[I24; 2]], g_q23: i32) {
|
||||
for frame in samples.iter_mut() {
|
||||
// L
|
||||
let prod_l = (frame[0].as_i32() as i64 * g_q23 as i64 + (1 << 22)) >> 23;
|
||||
let clamped_l = prod_l.clamp(I24::MIN_VALUE as i64, I24::MAX_VALUE as i64) as i32;
|
||||
frame[0] = I24::new_clamped(clamped_l);
|
||||
|
||||
// R
|
||||
let prod_r = (frame[1].as_i32() as i64 * g_q23 as i64 + (1 << 22)) >> 23;
|
||||
let clamped_r = prod_r.clamp(I24::MIN_VALUE as i64, I24::MAX_VALUE as i64) as i32;
|
||||
frame[1] = I24::new_clamped(clamped_r);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(target_arch = "aarch64", target_feature = "neon"))]
|
||||
#[inline(always)]
|
||||
unsafe fn apply_gain_stereo_i24_neon(samples: &mut [[I24; 2]], g_q23: i32) {
|
||||
use core::arch::aarch64::*;
|
||||
let gvec = vdupq_n_s32(g_q23);
|
||||
let mut i = 0;
|
||||
let n = samples.len() * 2;
|
||||
let ptr = samples.as_mut_ptr() as *mut i32;
|
||||
|
||||
while i + 4 <= n {
|
||||
let v = vld1q_s32(ptr.add(i));
|
||||
let res = vqdmulhq_s32(v, gvec); // Q23 multiply high
|
||||
vst1q_s32(ptr.add(i), res);
|
||||
i += 4;
|
||||
}
|
||||
|
||||
// reste scalaire
|
||||
let slice = std::slice::from_raw_parts_mut(ptr.add(i), n - i);
|
||||
apply_gain_i24_scalar(slice, g_q23);
|
||||
}
|
||||
|
||||
#[cfg(all(target_arch = "x86_64", target_feature = "avx2"))]
|
||||
#[inline(always)]
|
||||
unsafe fn apply_gain_stereo_i24_avx2(samples: &mut [[I24; 2]], g_q23: i32) {
|
||||
use core::arch::x86_64::*;
|
||||
let g = _mm256_set1_epi32(g_q23);
|
||||
let mut i = 0;
|
||||
let n = samples.len() * 2;
|
||||
let ptr = samples.as_mut_ptr() as *mut i32;
|
||||
|
||||
while i + 8 <= n {
|
||||
let x = _mm256_loadu_si256(ptr.add(i) as *const __m256i);
|
||||
let hi = _mm256_mulhi_epi32(x, g);
|
||||
_mm256_storeu_si256(ptr.add(i) as *mut __m256i, hi);
|
||||
i += 8;
|
||||
}
|
||||
|
||||
// reste scalaire
|
||||
let slice = std::slice::from_raw_parts_mut(ptr.add(i), n - i);
|
||||
apply_gain_i24_scalar(slice, g_q23);
|
||||
}
|
||||
|
||||
/// Version mono utilisée pour le reste scalaire.
|
||||
#[inline(always)]
|
||||
fn apply_gain_i24_scalar(samples: &mut [i32], g_q23: i32) {
|
||||
for s in samples.iter_mut() {
|
||||
let prod = (*s as i64 * g_q23 as i64 + (1 << 22)) >> 23;
|
||||
*s = prod.clamp(I24::MIN_VALUE as i64, I24::MAX_VALUE as i64) as i32;
|
||||
}
|
||||
}
|
||||
93
pmoaudio/src/dsp/gain_32bits.rs
Executable file
93
pmoaudio/src/dsp/gain_32bits.rs
Executable file
@@ -0,0 +1,93 @@
|
||||
/// Applique un gain (en dB) sur des échantillons stéréo interleavés `[L,R]`.
|
||||
pub fn apply_gain_stereo_i32(samples: &mut [[i32; 2]], gain_db: f64) {
|
||||
let gain = 10f64.powf(gain_db / 20.0);
|
||||
let g_q31 = (gain * (1u64 << 31) as f64).round() as i32;
|
||||
|
||||
#[cfg(all(target_arch = "aarch64", target_feature = "neon"))]
|
||||
unsafe {
|
||||
apply_gain_stereo_i32_neon(samples, g_q31);
|
||||
return;
|
||||
}
|
||||
#[cfg(all(target_arch = "x86_64", target_feature = "avx2"))]
|
||||
unsafe {
|
||||
apply_gain_stereo_i32_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_i32_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_i32_scalar(samples: &mut [[i32; 2]], g_q31: i32) {
|
||||
for frame in samples.iter_mut() {
|
||||
// L
|
||||
let prod_l = (frame[0] as i64 * g_q31 as i64 + (1 << 30)) >> 31;
|
||||
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_i32_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_i32_scalar(slice, g_q31);
|
||||
}
|
||||
|
||||
#[cfg(all(target_arch = "x86_64", target_feature = "avx2"))]
|
||||
#[inline(always)]
|
||||
unsafe fn apply_gain_stereo_i32_avx2(samples: &mut [[i32; 2]], g_q31: i32) {
|
||||
use core::arch::x86_64::*;
|
||||
let g = _mm256_set1_epi32(g_q31);
|
||||
let mut i = 0;
|
||||
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_i32_scalar(slice, g_q31);
|
||||
}
|
||||
|
||||
/// version mono utilisée pour le reste scalaire
|
||||
#[inline(always)]
|
||||
fn apply_gain_i32_scalar(samples: &mut [i32], g_q31: i32) {
|
||||
for s in samples.iter_mut() {
|
||||
let prod = (*s as i64 * g_q31 as i64 + (1 << 30)) >> 31;
|
||||
*s = prod.clamp(i32::MIN as i64, i32::MAX as i64) as i32;
|
||||
}
|
||||
}
|
||||
489
pmoaudio/src/dsp/int_float.rs
Executable file
489
pmoaudio/src/dsp/int_float.rs
Executable file
@@ -0,0 +1,489 @@
|
||||
use crate::BitDepth;
|
||||
use bytemuck::{cast_slice, cast_slice_mut};
|
||||
|
||||
#[cfg(feature = "simd")]
|
||||
use std::simd::num::{SimdFloat, SimdInt};
|
||||
#[cfg(feature = "simd")]
|
||||
use std::simd::{Simd, StdFloat};
|
||||
|
||||
/* ====================== CŒURS CANONIQUES EN AoS ====================== */
|
||||
|
||||
// i32 L/R -> [[f32;2]] - version interne avec constante compile-time
|
||||
#[cfg(feature = "simd")]
|
||||
fn i32_stereo_to_pairs_f32_inner(
|
||||
left: &[i32],
|
||||
right: &[i32],
|
||||
out_pairs: &mut [[f32; 2]],
|
||||
max_value: f32,
|
||||
) {
|
||||
debug_assert_eq!(left.len(), right.len());
|
||||
debug_assert_eq!(out_pairs.len(), left.len());
|
||||
|
||||
const LANES: usize = 8;
|
||||
type Vf32 = Simd<f32, LANES>;
|
||||
type Vi32 = Simd<i32, LANES>;
|
||||
|
||||
let scale = Vf32::splat(1.0 / max_value);
|
||||
|
||||
let (l_chunks, l_tail) = left.as_chunks::<LANES>();
|
||||
let (r_chunks, r_tail) = right.as_chunks::<LANES>();
|
||||
let (o_chunks, o_tail) = out_pairs.as_chunks_mut::<LANES>();
|
||||
|
||||
for (k, o) in o_chunks.iter_mut().enumerate() {
|
||||
let l = Vi32::from_slice(&l_chunks[k]).cast::<f32>() * scale;
|
||||
let r = Vi32::from_slice(&r_chunks[k]).cast::<f32>() * scale;
|
||||
|
||||
for j in 0..LANES {
|
||||
// AoS direct
|
||||
unsafe {
|
||||
*o.get_unchecked_mut(j) = [l[j], r[j]];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let scale_scalar = 1.0 / max_value;
|
||||
for (dst, (&l, &r)) in o_tail.iter_mut().zip(l_tail.iter().zip(r_tail.iter())) {
|
||||
dst[0] = l as f32 * scale_scalar;
|
||||
dst[1] = r as f32 * scale_scalar;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "simd"))]
|
||||
fn i32_stereo_to_pairs_f32_inner(
|
||||
left: &[i32],
|
||||
right: &[i32],
|
||||
out_pairs: &mut [[f32; 2]],
|
||||
max_value: f32,
|
||||
) {
|
||||
debug_assert_eq!(left.len(), right.len());
|
||||
debug_assert_eq!(out_pairs.len(), left.len());
|
||||
|
||||
let scale = 1.0 / max_value;
|
||||
for ((out, &l), &r) in out_pairs.iter_mut().zip(left).zip(right) {
|
||||
out[0] = l as f32 * scale;
|
||||
out[1] = r as f32 * scale;
|
||||
}
|
||||
}
|
||||
|
||||
/// Convertit deux canaux i32 (L/R) en pairs f32 normalisées [-1.0, 1.0]
|
||||
pub fn i32_stereo_to_pairs_f32(
|
||||
left: &[i32],
|
||||
right: &[i32],
|
||||
out_pairs: &mut [[f32; 2]],
|
||||
bit_depth: BitDepth,
|
||||
) {
|
||||
i32_stereo_to_pairs_f32_inner(left, right, out_pairs, bit_depth.max_value());
|
||||
}
|
||||
|
||||
// [[f32;2]] -> i32 L/R - version interne
|
||||
#[cfg(feature = "simd")]
|
||||
fn pairs_f32_to_i32_stereo_inner(
|
||||
input_pairs: &[[f32; 2]],
|
||||
left: &mut [i32],
|
||||
right: &mut [i32],
|
||||
max_value: f32,
|
||||
) {
|
||||
debug_assert_eq!(input_pairs.len(), left.len());
|
||||
debug_assert_eq!(input_pairs.len(), right.len());
|
||||
|
||||
const LANES: usize = 8;
|
||||
type Vf32 = Simd<f32, LANES>;
|
||||
|
||||
let vmin = -max_value;
|
||||
let vmax_clamp = max_value - 1.0; // évite l'overflow après round→cast
|
||||
let vscale = Vf32::splat(max_value);
|
||||
let vminv = Vf32::splat(vmin);
|
||||
let vmaxv = Vf32::splat(vmax_clamp);
|
||||
|
||||
let (in_chunks, in_tail) = input_pairs.as_chunks::<LANES>();
|
||||
let (l_chunks, l_tail) = left.as_chunks_mut::<LANES>();
|
||||
let (r_chunks, r_tail) = right.as_chunks_mut::<LANES>();
|
||||
|
||||
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::<i32>().copy_to_slice(&mut l_chunks[k]);
|
||||
rq.cast::<i32>().copy_to_slice(&mut r_chunks[k]);
|
||||
}
|
||||
|
||||
for (j, (l, r)) in in_tail.iter().zip(l_tail.iter_mut().zip(r_tail.iter_mut())) {
|
||||
let lx = (j[0] * max_value).clamp(vmin, vmax_clamp).round();
|
||||
let rx = (j[1] * max_value).clamp(vmin, vmax_clamp).round();
|
||||
*l = lx as i32;
|
||||
*r = rx as i32;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "simd"))]
|
||||
fn pairs_f32_to_i32_stereo_inner(
|
||||
input_pairs: &[[f32; 2]],
|
||||
left: &mut [i32],
|
||||
right: &mut [i32],
|
||||
max_value: f32,
|
||||
) {
|
||||
debug_assert_eq!(input_pairs.len(), left.len());
|
||||
debug_assert_eq!(input_pairs.len(), right.len());
|
||||
|
||||
let vmin = -max_value;
|
||||
let vmax_clamp = max_value - 1.0;
|
||||
for (i, pair) in input_pairs.iter().enumerate() {
|
||||
let lx = (pair[0] * max_value).clamp(vmin, vmax_clamp).round();
|
||||
let rx = (pair[1] * max_value).clamp(vmin, vmax_clamp).round();
|
||||
left[i] = lx as i32;
|
||||
right[i] = rx as i32;
|
||||
}
|
||||
}
|
||||
|
||||
/// Convertit pairs f32 normalisées [-1.0, 1.0] en deux canaux i32 (L/R)
|
||||
pub fn pairs_f32_to_i32_stereo(
|
||||
input_pairs: &[[f32; 2]],
|
||||
left: &mut [i32],
|
||||
right: &mut [i32],
|
||||
bit_depth: BitDepth,
|
||||
) {
|
||||
pairs_f32_to_i32_stereo_inner(input_pairs, left, right, bit_depth.max_value());
|
||||
}
|
||||
|
||||
/* ====================== WRAPPERS INTERLEAVÉS ====================== */
|
||||
|
||||
/// Convertit deux canaux i32 (L/R) en buffer f32 interleaved normalisé [-1.0, 1.0]
|
||||
pub fn i32_stereo_to_interleaved_f32(
|
||||
left: &[i32],
|
||||
right: &[i32],
|
||||
out_interleaved: &mut [f32],
|
||||
bit_depth: BitDepth,
|
||||
) {
|
||||
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, bit_depth);
|
||||
}
|
||||
|
||||
/// Convertit buffer f32 interleaved normalisé [-1.0, 1.0] en deux canaux i32 (L/R)
|
||||
pub fn interleaved_f32_to_i32_stereo(
|
||||
input_interleaved: &[f32],
|
||||
left: &mut [i32],
|
||||
right: &mut [i32],
|
||||
bit_depth: BitDepth,
|
||||
) {
|
||||
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, bit_depth);
|
||||
}
|
||||
|
||||
/* ====================== CONVERSIONS I16 ↔ F32 SIMD ====================== */
|
||||
|
||||
/// Convertit deux canaux i16 (L/R) en pairs f32 normalisées [-1.0, 1.0]
|
||||
#[cfg(feature = "simd")]
|
||||
fn i16_stereo_to_pairs_f32_inner(
|
||||
left: &[i16],
|
||||
right: &[i16],
|
||||
out_pairs: &mut [[f32; 2]],
|
||||
max_value: f32,
|
||||
) {
|
||||
debug_assert_eq!(left.len(), right.len());
|
||||
debug_assert_eq!(out_pairs.len(), left.len());
|
||||
|
||||
const LANES: usize = 8;
|
||||
type Vf32 = Simd<f32, LANES>;
|
||||
type Vi32 = Simd<i32, LANES>;
|
||||
|
||||
let scale = Vf32::splat(1.0 / max_value);
|
||||
|
||||
let (l_chunks, l_tail) = left.as_chunks::<LANES>();
|
||||
let (r_chunks, r_tail) = right.as_chunks::<LANES>();
|
||||
let (o_chunks, o_tail) = out_pairs.as_chunks_mut::<LANES>();
|
||||
|
||||
for (k, o) in o_chunks.iter_mut().enumerate() {
|
||||
// Charger i16, caster en i32 puis en f32
|
||||
let l_arr: [i32; LANES] = std::array::from_fn(|i| l_chunks[k][i] as i32);
|
||||
let r_arr: [i32; LANES] = std::array::from_fn(|i| r_chunks[k][i] as i32);
|
||||
|
||||
let l = Vi32::from_array(l_arr).cast::<f32>() * scale;
|
||||
let r = Vi32::from_array(r_arr).cast::<f32>() * scale;
|
||||
|
||||
for j in 0..LANES {
|
||||
unsafe {
|
||||
*o.get_unchecked_mut(j) = [l[j], r[j]];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let scale_scalar = 1.0 / max_value;
|
||||
for (dst, (&l, &r)) in o_tail.iter_mut().zip(l_tail.iter().zip(r_tail.iter())) {
|
||||
dst[0] = l as f32 * scale_scalar;
|
||||
dst[1] = r as f32 * scale_scalar;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "simd"))]
|
||||
fn i16_stereo_to_pairs_f32_inner(
|
||||
left: &[i16],
|
||||
right: &[i16],
|
||||
out_pairs: &mut [[f32; 2]],
|
||||
max_value: f32,
|
||||
) {
|
||||
debug_assert_eq!(left.len(), right.len());
|
||||
debug_assert_eq!(out_pairs.len(), left.len());
|
||||
|
||||
let scale = 1.0 / max_value;
|
||||
for ((out, &l), &r) in out_pairs.iter_mut().zip(left).zip(right) {
|
||||
out[0] = l as f32 * scale;
|
||||
out[1] = r as f32 * scale;
|
||||
}
|
||||
}
|
||||
|
||||
/// Convertit deux canaux i16 (L/R) en pairs f32 normalisées [-1.0, 1.0]
|
||||
pub fn i16_stereo_to_pairs_f32(
|
||||
left: &[i16],
|
||||
right: &[i16],
|
||||
out_pairs: &mut [[f32; 2]],
|
||||
) {
|
||||
i16_stereo_to_pairs_f32_inner(left, right, out_pairs, 32768.0);
|
||||
}
|
||||
|
||||
/// Convertit pairs f32 normalisées [-1.0, 1.0] en deux canaux i16 (L/R)
|
||||
#[cfg(feature = "simd")]
|
||||
fn pairs_f32_to_i16_stereo_inner(
|
||||
input_pairs: &[[f32; 2]],
|
||||
left: &mut [i16],
|
||||
right: &mut [i16],
|
||||
max_value: f32,
|
||||
) {
|
||||
debug_assert_eq!(input_pairs.len(), left.len());
|
||||
debug_assert_eq!(input_pairs.len(), right.len());
|
||||
|
||||
const LANES: usize = 8;
|
||||
type Vf32 = Simd<f32, LANES>;
|
||||
|
||||
let vmin = -max_value;
|
||||
let vmax_clamp = max_value - 1.0;
|
||||
let vscale = Vf32::splat(max_value);
|
||||
let vminv = Vf32::splat(vmin);
|
||||
let vmaxv = Vf32::splat(vmax_clamp);
|
||||
|
||||
let (in_chunks, in_tail) = input_pairs.as_chunks::<LANES>();
|
||||
let (l_chunks, l_tail) = left.as_chunks_mut::<LANES>();
|
||||
let (r_chunks, r_tail) = right.as_chunks_mut::<LANES>();
|
||||
|
||||
for (k, blk) in in_chunks.iter().enumerate() {
|
||||
let mut l_arr = [0.0f32; LANES];
|
||||
let mut r_arr = [0.0f32; LANES];
|
||||
for j in 0..LANES {
|
||||
let p = blk[j];
|
||||
l_arr[j] = p[0];
|
||||
r_arr[j] = p[1];
|
||||
}
|
||||
|
||||
let lq = (Vf32::from_array(l_arr) * vscale)
|
||||
.simd_clamp(vminv, vmaxv)
|
||||
.round()
|
||||
.cast::<i32>();
|
||||
let rq = (Vf32::from_array(r_arr) * vscale)
|
||||
.simd_clamp(vminv, vmaxv)
|
||||
.round()
|
||||
.cast::<i32>();
|
||||
|
||||
for j in 0..LANES {
|
||||
l_chunks[k][j] = lq[j] as i16;
|
||||
r_chunks[k][j] = rq[j] as i16;
|
||||
}
|
||||
}
|
||||
|
||||
for (j, (l, r)) in in_tail.iter().zip(l_tail.iter_mut().zip(r_tail.iter_mut())) {
|
||||
let lx = (j[0] * max_value).clamp(vmin, vmax_clamp).round();
|
||||
let rx = (j[1] * max_value).clamp(vmin, vmax_clamp).round();
|
||||
*l = lx as i16;
|
||||
*r = rx as i16;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "simd"))]
|
||||
fn pairs_f32_to_i16_stereo_inner(
|
||||
input_pairs: &[[f32; 2]],
|
||||
left: &mut [i16],
|
||||
right: &mut [i16],
|
||||
max_value: f32,
|
||||
) {
|
||||
debug_assert_eq!(input_pairs.len(), left.len());
|
||||
debug_assert_eq!(input_pairs.len(), right.len());
|
||||
|
||||
let vmin = -max_value;
|
||||
let vmax_clamp = max_value - 1.0;
|
||||
for (i, pair) in input_pairs.iter().enumerate() {
|
||||
let lx = (pair[0] * max_value).clamp(vmin, vmax_clamp).round();
|
||||
let rx = (pair[1] * max_value).clamp(vmin, vmax_clamp).round();
|
||||
left[i] = lx as i16;
|
||||
right[i] = rx as i16;
|
||||
}
|
||||
}
|
||||
|
||||
/// Convertit pairs f32 normalisées [-1.0, 1.0] en deux canaux i16 (L/R)
|
||||
pub fn pairs_f32_to_i16_stereo(
|
||||
input_pairs: &[[f32; 2]],
|
||||
left: &mut [i16],
|
||||
right: &mut [i16],
|
||||
) {
|
||||
pairs_f32_to_i16_stereo_inner(input_pairs, left, right, 32768.0);
|
||||
}
|
||||
|
||||
/* ====================== CONVERSIONS I24 ↔ F32 SIMD ====================== */
|
||||
|
||||
/// Convertit deux canaux i32 (contenant des valeurs I24) en pairs f32 normalisées
|
||||
#[cfg(feature = "simd")]
|
||||
fn i24_as_i32_stereo_to_pairs_f32_inner(
|
||||
left: &[i32],
|
||||
right: &[i32],
|
||||
out_pairs: &mut [[f32; 2]],
|
||||
max_value: f32,
|
||||
) {
|
||||
debug_assert_eq!(left.len(), right.len());
|
||||
debug_assert_eq!(out_pairs.len(), left.len());
|
||||
|
||||
const LANES: usize = 8;
|
||||
type Vf32 = Simd<f32, LANES>;
|
||||
type Vi32 = Simd<i32, LANES>;
|
||||
|
||||
let scale = Vf32::splat(1.0 / max_value);
|
||||
|
||||
let (l_chunks, l_tail) = left.as_chunks::<LANES>();
|
||||
let (r_chunks, r_tail) = right.as_chunks::<LANES>();
|
||||
let (o_chunks, o_tail) = out_pairs.as_chunks_mut::<LANES>();
|
||||
|
||||
for (k, o) in o_chunks.iter_mut().enumerate() {
|
||||
let l = Vi32::from_slice(&l_chunks[k]).cast::<f32>() * scale;
|
||||
let r = Vi32::from_slice(&r_chunks[k]).cast::<f32>() * scale;
|
||||
|
||||
for j in 0..LANES {
|
||||
unsafe {
|
||||
*o.get_unchecked_mut(j) = [l[j], r[j]];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let scale_scalar = 1.0 / max_value;
|
||||
for (dst, (&l, &r)) in o_tail.iter_mut().zip(l_tail.iter().zip(r_tail.iter())) {
|
||||
dst[0] = l as f32 * scale_scalar;
|
||||
dst[1] = r as f32 * scale_scalar;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "simd"))]
|
||||
fn i24_as_i32_stereo_to_pairs_f32_inner(
|
||||
left: &[i32],
|
||||
right: &[i32],
|
||||
out_pairs: &mut [[f32; 2]],
|
||||
max_value: f32,
|
||||
) {
|
||||
debug_assert_eq!(left.len(), right.len());
|
||||
debug_assert_eq!(out_pairs.len(), left.len());
|
||||
|
||||
let scale = 1.0 / max_value;
|
||||
for ((out, &l), &r) in out_pairs.iter_mut().zip(left).zip(right) {
|
||||
out[0] = l as f32 * scale;
|
||||
out[1] = r as f32 * scale;
|
||||
}
|
||||
}
|
||||
|
||||
/// Convertit deux canaux i32 (contenant des valeurs I24) en pairs f32 normalisées
|
||||
pub fn i24_as_i32_stereo_to_pairs_f32(
|
||||
left: &[i32],
|
||||
right: &[i32],
|
||||
out_pairs: &mut [[f32; 2]],
|
||||
) {
|
||||
i24_as_i32_stereo_to_pairs_f32_inner(left, right, out_pairs, 8388608.0);
|
||||
}
|
||||
|
||||
/// Convertit pairs f32 normalisées en deux canaux i32 (valeurs I24 range)
|
||||
#[cfg(feature = "simd")]
|
||||
fn pairs_f32_to_i24_as_i32_stereo_inner(
|
||||
input_pairs: &[[f32; 2]],
|
||||
left: &mut [i32],
|
||||
right: &mut [i32],
|
||||
max_value: f32,
|
||||
) {
|
||||
debug_assert_eq!(input_pairs.len(), left.len());
|
||||
debug_assert_eq!(input_pairs.len(), right.len());
|
||||
|
||||
const LANES: usize = 8;
|
||||
type Vf32 = Simd<f32, LANES>;
|
||||
|
||||
let vmin = -max_value;
|
||||
let vmax_clamp = max_value - 1.0;
|
||||
let vscale = Vf32::splat(max_value);
|
||||
let vminv = Vf32::splat(vmin);
|
||||
let vmaxv = Vf32::splat(vmax_clamp);
|
||||
|
||||
let (in_chunks, in_tail) = input_pairs.as_chunks::<LANES>();
|
||||
let (l_chunks, l_tail) = left.as_chunks_mut::<LANES>();
|
||||
let (r_chunks, r_tail) = right.as_chunks_mut::<LANES>();
|
||||
|
||||
for (k, blk) in in_chunks.iter().enumerate() {
|
||||
let mut l_arr = [0.0f32; LANES];
|
||||
let mut r_arr = [0.0f32; LANES];
|
||||
for j in 0..LANES {
|
||||
let p = blk[j];
|
||||
l_arr[j] = p[0];
|
||||
r_arr[j] = p[1];
|
||||
}
|
||||
|
||||
let lq = (Vf32::from_array(l_arr) * vscale)
|
||||
.simd_clamp(vminv, vmaxv)
|
||||
.round();
|
||||
let rq = (Vf32::from_array(r_arr) * vscale)
|
||||
.simd_clamp(vminv, vmaxv)
|
||||
.round();
|
||||
|
||||
lq.cast::<i32>().copy_to_slice(&mut l_chunks[k]);
|
||||
rq.cast::<i32>().copy_to_slice(&mut r_chunks[k]);
|
||||
}
|
||||
|
||||
for (j, (l, r)) in in_tail.iter().zip(l_tail.iter_mut().zip(r_tail.iter_mut())) {
|
||||
let lx = (j[0] * max_value).clamp(vmin, vmax_clamp).round();
|
||||
let rx = (j[1] * max_value).clamp(vmin, vmax_clamp).round();
|
||||
*l = lx as i32;
|
||||
*r = rx as i32;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "simd"))]
|
||||
fn pairs_f32_to_i24_as_i32_stereo_inner(
|
||||
input_pairs: &[[f32; 2]],
|
||||
left: &mut [i32],
|
||||
right: &mut [i32],
|
||||
max_value: f32,
|
||||
) {
|
||||
debug_assert_eq!(input_pairs.len(), left.len());
|
||||
debug_assert_eq!(input_pairs.len(), right.len());
|
||||
|
||||
let vmin = -max_value;
|
||||
let vmax_clamp = max_value - 1.0;
|
||||
for (i, pair) in input_pairs.iter().enumerate() {
|
||||
let lx = (pair[0] * max_value).clamp(vmin, vmax_clamp).round();
|
||||
let rx = (pair[1] * max_value).clamp(vmin, vmax_clamp).round();
|
||||
left[i] = lx as i32;
|
||||
right[i] = rx as i32;
|
||||
}
|
||||
}
|
||||
|
||||
/// Convertit pairs f32 normalisées en deux canaux i32 (valeurs I24 range)
|
||||
pub fn pairs_f32_to_i24_as_i32_stereo(
|
||||
input_pairs: &[[f32; 2]],
|
||||
left: &mut [i32],
|
||||
right: &mut [i32],
|
||||
) {
|
||||
pairs_f32_to_i24_as_i32_stereo_inner(input_pairs, left, right, 8388608.0);
|
||||
}
|
||||
21
pmoaudio/src/dsp/mod.rs
Executable file
21
pmoaudio/src/dsp/mod.rs
Executable file
@@ -0,0 +1,21 @@
|
||||
//! Module DSP pour les conversions et traitements audio optimisés (SIMD)
|
||||
|
||||
pub mod depth;
|
||||
pub mod gain_16bits;
|
||||
pub mod gain_24bits;
|
||||
pub mod gain_32bits;
|
||||
pub mod int_float;
|
||||
pub mod resampling;
|
||||
|
||||
pub use depth::bitdepth_change_stereo;
|
||||
pub use gain_16bits::apply_gain_stereo_i16;
|
||||
pub use gain_24bits::apply_gain_stereo_i24;
|
||||
pub use gain_32bits::apply_gain_stereo_i32;
|
||||
|
||||
pub use int_float::{
|
||||
i16_stereo_to_pairs_f32, i24_as_i32_stereo_to_pairs_f32, i32_stereo_to_interleaved_f32,
|
||||
i32_stereo_to_pairs_f32, interleaved_f32_to_i32_stereo, pairs_f32_to_i16_stereo,
|
||||
pairs_f32_to_i24_as_i32_stereo, pairs_f32_to_i32_stereo,
|
||||
};
|
||||
|
||||
pub use resampling::resampling;
|
||||
75
pmoaudio/src/dsp/resampling.rs
Executable file
75
pmoaudio/src/dsp/resampling.rs
Executable file
@@ -0,0 +1,75 @@
|
||||
use soxr::format::Stereo;
|
||||
use soxr::params::{QualityRecipe, QualitySpec, RuntimeSpec};
|
||||
use soxr::Soxr;
|
||||
|
||||
use crate::dsp::{i32_stereo_to_pairs_f32, pairs_f32_to_i32_stereo};
|
||||
use crate::BitDepth;
|
||||
|
||||
// Type d'erreur simple pour resampling
|
||||
#[derive(Debug)]
|
||||
pub struct ResamplingError(pub String);
|
||||
|
||||
impl std::fmt::Display for ResamplingError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "Resampling error: {}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ResamplingError {}
|
||||
|
||||
pub struct Resampler {
|
||||
source_hz: f64,
|
||||
dest_hz: f64,
|
||||
bit_depth: BitDepth,
|
||||
soxr: Soxr<Stereo<f32>>,
|
||||
}
|
||||
|
||||
pub fn build_resampler(
|
||||
source_hz: u32,
|
||||
dest_hz: u32,
|
||||
bit_depth: BitDepth,
|
||||
) -> Result<Resampler, ResamplingError> {
|
||||
let qrecipe = match bit_depth {
|
||||
BitDepth::B8 => QualityRecipe::Medium,
|
||||
BitDepth::B16 => QualityRecipe::high(), // High pour 16-bit
|
||||
BitDepth::B24 => QualityRecipe::very_high(), // VeryHigh pour 24-bit
|
||||
BitDepth::B32 => QualityRecipe::very_high(), // VeryHigh pour 32-bit
|
||||
};
|
||||
|
||||
let quality = QualitySpec::new(qrecipe); // Phase response linear, no steep filter
|
||||
let rt = RuntimeSpec::default();
|
||||
|
||||
let soxr = Soxr::<Stereo<f32>>::new_with_params(source_hz as f64, dest_hz as f64, quality, rt)
|
||||
.map_err(|e| ResamplingError(e.to_string()))?;
|
||||
|
||||
Ok(Resampler {
|
||||
source_hz: source_hz as f64,
|
||||
dest_hz: dest_hz as f64,
|
||||
bit_depth,
|
||||
soxr,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn resampling(left: &[i32], right: &[i32], resampler: &mut Resampler) -> (Vec<i32>, Vec<i32>) {
|
||||
if left.len() != right.len() {
|
||||
panic!("Left and right channels must have the same length");
|
||||
}
|
||||
|
||||
// Convertir i32 → f32 normalisé
|
||||
let mut input = vec![[0.0f32; 2]; left.len()];
|
||||
i32_stereo_to_pairs_f32(left, right, &mut input, resampler.bit_depth);
|
||||
|
||||
// Resampling
|
||||
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();
|
||||
|
||||
// Convertir f32 normalisé → i32
|
||||
let mut oleft = vec![0i32; output.len()];
|
||||
let mut oright = vec![0i32; output.len()];
|
||||
pairs_f32_to_i32_stereo(&output, &mut oleft, &mut oright, resampler.bit_depth);
|
||||
|
||||
(oleft, oright)
|
||||
}
|
||||
0
pmoaudio/src/events.rs
Normal file → Executable file
0
pmoaudio/src/events.rs
Normal file → Executable file
201
pmoaudio/src/lib.rs
Normal file → Executable file
201
pmoaudio/src/lib.rs
Normal file → Executable file
@@ -1,89 +1,132 @@
|
||||
//! 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<Vec<f32>>`.
|
||||
//!
|
||||
//! # 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;
|
||||
mod audio_segment;
|
||||
pub mod conversions;
|
||||
pub mod events;
|
||||
mod nodes;
|
||||
pub mod nodes;
|
||||
pub mod pipeline;
|
||||
mod sample_types;
|
||||
mod sync_marker;
|
||||
pub mod type_constraints;
|
||||
#[macro_use]
|
||||
mod macros;
|
||||
|
||||
pub mod bit_depth;
|
||||
pub mod dsp;
|
||||
|
||||
pub use audio_segment::{AudioSegment, _AudioSegment};
|
||||
pub use sync_marker::SyncMarker;
|
||||
|
||||
pub use audio_chunk::{
|
||||
gain_db_from_linear, gain_linear_from_db, AudioChunk, AudioChunkData, AudioFloatChunk,
|
||||
AudioIntegerChunk,
|
||||
};
|
||||
pub use bit_depth::{Bit16, Bit24, Bit32, Bit8, BitDepth};
|
||||
pub use sample_types::{Sample, I24};
|
||||
pub use type_constraints::{
|
||||
check_compatibility, SampleType, TypeCategory, TypeMismatch, TypeRequirement,
|
||||
};
|
||||
|
||||
pub use audio_chunk::AudioChunk;
|
||||
pub use events::{
|
||||
AudioDataEvent, EventPublisher, EventReceiver, NodeEvent, NodeListener, SourceNameUpdateEvent,
|
||||
VolumeChangeEvent,
|
||||
};
|
||||
|
||||
// Export du trait de pipeline
|
||||
pub use pipeline::AudioPipelineNode;
|
||||
|
||||
// Exports publics des nodes
|
||||
pub use nodes::{
|
||||
converter_nodes::{ToF32Node, ToF64Node, ToI16Node, ToI24Node, ToI32Node},
|
||||
file_source::FileSource,
|
||||
flac_file_sink::{FlacFileSink, FlacFileSinkStats},
|
||||
http_source::HttpSource,
|
||||
AudioError, AudioNode, TypedAudioNode,
|
||||
};
|
||||
|
||||
// Nodes temporairement désactivés
|
||||
/*
|
||||
pub use nodes::{
|
||||
buffer_node::BufferNode,
|
||||
chromecast_sink::{ChromecastConfig, ChromecastSink, ChromecastStats, StreamEncoding},
|
||||
@@ -95,5 +138,5 @@ pub use nodes::{
|
||||
source_node::SourceNode,
|
||||
timer_node::{TimerHandle, TimerNode},
|
||||
volume_node::{HardwareVolumeNode, VolumeHandle, VolumeNode},
|
||||
AudioError, AudioNode, MultiSubscriberNode, SingleSubscriberNode,
|
||||
};
|
||||
*/
|
||||
|
||||
283
pmoaudio/src/macros.rs
Executable file
283
pmoaudio/src/macros.rs
Executable file
@@ -0,0 +1,283 @@
|
||||
/// Macros pour simplifier la manipulation des AudioChunk et AudioSegment
|
||||
|
||||
/// Extrait les données typées d'un AudioChunk
|
||||
///
|
||||
/// # Exemples
|
||||
/// ```
|
||||
/// use pmoaudio::{AudioChunk, AudioChunkData, extract_chunk_data};
|
||||
///
|
||||
/// fn process_i32(chunk: &AudioChunk) {
|
||||
/// if let Some(data) = extract_chunk_data!(chunk, I32) {
|
||||
/// println!("I32 chunk with {} frames", data.len());
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
#[macro_export]
|
||||
macro_rules! extract_chunk_data {
|
||||
($chunk:expr, I16) => {
|
||||
match $chunk {
|
||||
$crate::AudioChunk::I16(data) => Some(data),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
($chunk:expr, I24) => {
|
||||
match $chunk {
|
||||
$crate::AudioChunk::I24(data) => Some(data),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
($chunk:expr, I32) => {
|
||||
match $chunk {
|
||||
$crate::AudioChunk::I32(data) => Some(data),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
($chunk:expr, F32) => {
|
||||
match $chunk {
|
||||
$crate::AudioChunk::F32(data) => Some(data),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
($chunk:expr, F64) => {
|
||||
match $chunk {
|
||||
$crate::AudioChunk::F64(data) => Some(data),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Match sur le type d'un AudioChunk avec exécution de code pour chaque cas
|
||||
///
|
||||
/// # Exemples
|
||||
/// ```
|
||||
/// use pmoaudio::{AudioChunk, match_chunk};
|
||||
///
|
||||
/// fn print_chunk_info(chunk: &AudioChunk) {
|
||||
/// match_chunk!(chunk, data => {
|
||||
/// println!("Chunk type: {}, frames: {}", chunk.type_name(), data.len());
|
||||
/// });
|
||||
/// }
|
||||
/// ```
|
||||
#[macro_export]
|
||||
macro_rules! match_chunk {
|
||||
($chunk:expr, $data:ident => $body:expr) => {
|
||||
match $chunk {
|
||||
$crate::AudioChunk::I16($data) => $body,
|
||||
$crate::AudioChunk::I24($data) => $body,
|
||||
$crate::AudioChunk::I32($data) => $body,
|
||||
$crate::AudioChunk::F32($data) => $body,
|
||||
$crate::AudioChunk::F64($data) => $body,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Map sur un AudioChunk - transforme les données et retourne un nouveau AudioChunk du même type
|
||||
///
|
||||
/// # Exemples
|
||||
/// ```
|
||||
/// use pmoaudio::{AudioChunk, map_chunk};
|
||||
///
|
||||
/// fn add_gain_db(chunk: &AudioChunk, gain_db: f64) -> AudioChunk {
|
||||
/// map_chunk!(chunk, data => {
|
||||
/// data.set_gain_db(data.gain_db() + gain_db)
|
||||
/// })
|
||||
/// }
|
||||
/// ```
|
||||
#[macro_export]
|
||||
macro_rules! map_chunk {
|
||||
($chunk:expr, $data:ident => $transform:expr) => {
|
||||
match $chunk {
|
||||
$crate::AudioChunk::I16($data) => $crate::AudioChunk::I16($transform),
|
||||
$crate::AudioChunk::I24($data) => $crate::AudioChunk::I24($transform),
|
||||
$crate::AudioChunk::I32($data) => $crate::AudioChunk::I32($transform),
|
||||
$crate::AudioChunk::F32($data) => $crate::AudioChunk::F32($transform),
|
||||
$crate::AudioChunk::F64($data) => $crate::AudioChunk::F64($transform),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Prédicat sur le type d'un AudioChunk
|
||||
///
|
||||
/// # Exemples
|
||||
/// ```
|
||||
/// use pmoaudio::{AudioChunk, is_chunk_type};
|
||||
///
|
||||
/// fn process_only_i32(chunk: &AudioChunk) {
|
||||
/// if is_chunk_type!(chunk, I32) {
|
||||
/// println!("Processing I32 chunk");
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
#[macro_export]
|
||||
macro_rules! is_chunk_type {
|
||||
($chunk:expr, I16) => {
|
||||
matches!($chunk, $crate::AudioChunk::I16(_))
|
||||
};
|
||||
($chunk:expr, I24) => {
|
||||
matches!($chunk, $crate::AudioChunk::I24(_))
|
||||
};
|
||||
($chunk:expr, I32) => {
|
||||
matches!($chunk, $crate::AudioChunk::I32(_))
|
||||
};
|
||||
($chunk:expr, F32) => {
|
||||
matches!($chunk, $crate::AudioChunk::F32(_))
|
||||
};
|
||||
($chunk:expr, F64) => {
|
||||
matches!($chunk, $crate::AudioChunk::F64(_))
|
||||
};
|
||||
}
|
||||
|
||||
/// Extrait un AudioChunk d'un AudioSegment
|
||||
///
|
||||
/// # Exemples
|
||||
/// ```
|
||||
/// use pmoaudio::{AudioSegment, extract_audio_chunk};
|
||||
///
|
||||
/// fn get_chunk(segment: &AudioSegment) -> Option<&Arc<AudioChunk>> {
|
||||
/// extract_audio_chunk!(segment)
|
||||
/// }
|
||||
/// ```
|
||||
#[macro_export]
|
||||
macro_rules! extract_audio_chunk {
|
||||
($segment:expr) => {
|
||||
match &$segment.segment {
|
||||
$crate::_AudioSegment::Chunk(chunk) => Some(chunk),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Extrait un SyncMarker d'un AudioSegment
|
||||
///
|
||||
/// # Exemples
|
||||
/// ```
|
||||
/// use pmoaudio::{AudioSegment, extract_sync_marker};
|
||||
///
|
||||
/// fn get_marker(segment: &AudioSegment) -> Option<&Arc<SyncMarker>> {
|
||||
/// extract_sync_marker!(segment)
|
||||
/// }
|
||||
/// ```
|
||||
#[macro_export]
|
||||
macro_rules! extract_sync_marker {
|
||||
($segment:expr) => {
|
||||
match &$segment.segment {
|
||||
$crate::_AudioSegment::Sync(marker) => Some(marker),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Match sur le contenu d'un AudioSegment
|
||||
///
|
||||
/// # Exemples
|
||||
/// ```
|
||||
/// use pmoaudio::{AudioSegment, match_segment};
|
||||
///
|
||||
/// fn process_segment(segment: &AudioSegment) {
|
||||
/// match_segment!(segment,
|
||||
/// chunk => println!("Audio chunk: {}", chunk.type_name()),
|
||||
/// marker => println!("Sync marker")
|
||||
/// );
|
||||
/// }
|
||||
/// ```
|
||||
#[macro_export]
|
||||
macro_rules! match_segment {
|
||||
($segment:expr, $chunk_name:ident => $chunk_body:expr, $marker_name:ident => $marker_body:expr) => {
|
||||
match &$segment.segment {
|
||||
$crate::_AudioSegment::Chunk($chunk_name) => $chunk_body,
|
||||
$crate::_AudioSegment::Sync($marker_name) => $marker_body,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{AudioChunk, AudioChunkData, AudioSegment, BitDepth};
|
||||
|
||||
#[test]
|
||||
fn test_extract_chunk_data() {
|
||||
let data = AudioChunkData::new(vec![[100i32, 200i32]], 44100, 0.0);
|
||||
let chunk = AudioChunk::I32(data.clone());
|
||||
|
||||
// Test extraction réussie
|
||||
assert!(extract_chunk_data!(&chunk, I32).is_some());
|
||||
assert!(extract_chunk_data!(&chunk, F32).is_none());
|
||||
|
||||
// Test avec F32
|
||||
let f32_chunk = AudioChunk::F32(AudioChunkData::new(vec![[0.5f32, -0.5f32]], 44100, 0.0));
|
||||
assert!(extract_chunk_data!(&f32_chunk, F32).is_some());
|
||||
assert!(extract_chunk_data!(&f32_chunk, I32).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_match_chunk() {
|
||||
let chunk = AudioChunk::I32(AudioChunkData::new(vec![[100i32, 200i32]], 44100, 0.0));
|
||||
|
||||
let len = match_chunk!(&chunk, data => data.len());
|
||||
assert_eq!(len, 1);
|
||||
|
||||
let sample_rate = match_chunk!(&chunk, data => data.get_sample_rate());
|
||||
assert_eq!(sample_rate, 44100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_map_chunk() {
|
||||
let chunk = AudioChunk::I32(AudioChunkData::new(vec![[100i32, 200i32]], 44100, 0.0));
|
||||
|
||||
let modified = map_chunk!(&chunk, data => data.set_gain_db(6.0));
|
||||
|
||||
match_chunk!(&modified, data => {
|
||||
assert_eq!(data.get_gain_db(), 6.0);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_chunk_type() {
|
||||
let i32_chunk = AudioChunk::I32(AudioChunkData::new(vec![[100i32, 200i32]], 44100, 0.0));
|
||||
let f32_chunk = AudioChunk::F32(AudioChunkData::new(vec![[0.5f32, -0.5f32]], 44100, 0.0));
|
||||
|
||||
assert!(is_chunk_type!(&i32_chunk, I32));
|
||||
assert!(!is_chunk_type!(&i32_chunk, F32));
|
||||
assert!(is_chunk_type!(&f32_chunk, F32));
|
||||
assert!(!is_chunk_type!(&f32_chunk, I32));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_audio_chunk() {
|
||||
let segment = AudioSegment::new_chunk(0, 0.0, vec![[100i32, 200i32]], 44100, BitDepth::B32);
|
||||
|
||||
assert!(extract_audio_chunk!(&*segment).is_some());
|
||||
|
||||
let sync_segment = AudioSegment::new_hearbeat(1, 1.0);
|
||||
assert!(extract_audio_chunk!(&*sync_segment).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_sync_marker() {
|
||||
let segment = AudioSegment::new_hearbeat(1, 1.0);
|
||||
assert!(extract_sync_marker!(&*segment).is_some());
|
||||
|
||||
let audio_segment =
|
||||
AudioSegment::new_chunk(0, 0.0, vec![[100i32, 200i32]], 44100, BitDepth::B32);
|
||||
assert!(extract_sync_marker!(&*audio_segment).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_match_segment() {
|
||||
let audio_segment =
|
||||
AudioSegment::new_chunk(0, 0.0, vec![[100i32, 200i32]], 44100, BitDepth::B32);
|
||||
|
||||
let result = match_segment!(&*audio_segment,
|
||||
chunk => format!("audio: {}", chunk.type_name()),
|
||||
_marker => "sync".to_string()
|
||||
);
|
||||
assert_eq!(result, "audio: i32");
|
||||
|
||||
let sync_segment = AudioSegment::new_hearbeat(1, 1.0);
|
||||
let result = match_segment!(&*sync_segment,
|
||||
_chunk => "audio".to_string(),
|
||||
_marker => "sync".to_string()
|
||||
);
|
||||
assert_eq!(result, "sync");
|
||||
}
|
||||
}
|
||||
269
pmoaudio/src/nodes/converter_nodes.rs
Executable file
269
pmoaudio/src/nodes/converter_nodes.rs
Executable file
@@ -0,0 +1,269 @@
|
||||
//! Nodes de conversion de type pour AudioChunk
|
||||
//!
|
||||
//! Ces nodes permettent de convertir les chunks audio d'un type vers un autre
|
||||
//! (I16, I24, I32, F32, F64). Toutes les conversions utilisent les fonctions
|
||||
//! DSP optimisées SIMD du module `crate::conversions`.
|
||||
//!
|
||||
//! Le designer de pipeline doit insérer manuellement ces nodes pour gérer
|
||||
//! les incompatibilités de type entre producers et consumers.
|
||||
//!
|
||||
//! # Nouvelle Architecture
|
||||
//!
|
||||
//! Les converters utilisent maintenant `Node<ConverterLogic<F>>` où F est
|
||||
//! une fonction de conversion. Cela simplifie drastiquement le code (de ~130
|
||||
//! lignes par converter à ~20 lignes de logique pure).
|
||||
|
||||
use crate::{
|
||||
nodes::AudioError,
|
||||
pipeline::{Node, NodeLogic},
|
||||
AudioChunk, AudioPipelineNode, AudioSegment,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
/// Logique de conversion générique
|
||||
///
|
||||
/// Cette struct contient la logique pure de conversion d'un type vers un autre.
|
||||
/// Elle reçoit des segments, convertit les chunks audio, et relay les syncmarkers.
|
||||
pub struct ConverterLogic<F> {
|
||||
convert_fn: F,
|
||||
}
|
||||
|
||||
impl<F> ConverterLogic<F>
|
||||
where
|
||||
F: Fn(&AudioChunk) -> AudioChunk + Send + 'static,
|
||||
{
|
||||
pub fn new(convert_fn: F) -> Self {
|
||||
Self { convert_fn }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl<F> NodeLogic for ConverterLogic<F>
|
||||
where
|
||||
F: Fn(&AudioChunk) -> AudioChunk + Send + 'static,
|
||||
{
|
||||
async fn process(
|
||||
&mut self,
|
||||
input: Option<mpsc::Receiver<Arc<AudioSegment>>>,
|
||||
output: Vec<mpsc::Sender<Arc<AudioSegment>>>,
|
||||
stop_token: CancellationToken,
|
||||
) -> Result<(), AudioError> {
|
||||
let mut rx = input.expect("Converter must have input");
|
||||
tracing::debug!("ConverterLogic::process started, {} children", output.len());
|
||||
|
||||
loop {
|
||||
let segment = tokio::select! {
|
||||
_ = stop_token.cancelled() => {
|
||||
tracing::debug!("ConverterLogic cancelled");
|
||||
break;
|
||||
}
|
||||
|
||||
result = rx.recv() => {
|
||||
match result {
|
||||
Some(seg) => seg,
|
||||
None => {
|
||||
tracing::debug!("ConverterLogic received EOF");
|
||||
break; // EOF
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Convertir si c'est un chunk audio, sinon passer tel quel
|
||||
let output_segment = if segment.is_audio_chunk() {
|
||||
if let Some(chunk) = segment.as_chunk() {
|
||||
let converted_chunk = (self.convert_fn)(chunk);
|
||||
|
||||
// Debug: afficher le type du chunk converti (seulement pour le premier)
|
||||
if segment.order == 0 {
|
||||
let chunk_type = match &converted_chunk {
|
||||
crate::AudioChunk::I16(_) => "I16",
|
||||
crate::AudioChunk::I24(_) => "I24",
|
||||
crate::AudioChunk::I32(_) => "I32",
|
||||
crate::AudioChunk::F32(_) => "F32",
|
||||
crate::AudioChunk::F64(_) => "F64",
|
||||
};
|
||||
tracing::debug!("ConverterLogic: converted chunk type = {}", chunk_type);
|
||||
}
|
||||
|
||||
Arc::new(AudioSegment {
|
||||
order: segment.order,
|
||||
timestamp_sec: segment.timestamp_sec,
|
||||
segment: crate::_AudioSegment::Chunk(Arc::new(converted_chunk)),
|
||||
})
|
||||
} else {
|
||||
segment
|
||||
}
|
||||
} else {
|
||||
segment
|
||||
};
|
||||
|
||||
// Envoyer à tous les enfants
|
||||
for tx in &output {
|
||||
tx.send(output_segment.clone())
|
||||
.await
|
||||
.map_err(|_| AudioError::ChildDied)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Converters spécifiques - Fonctions factory simplifiées
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Node de conversion vers I16 (16-bit signed integer)
|
||||
pub struct ToI16Node;
|
||||
|
||||
impl ToI16Node {
|
||||
pub fn new() -> Box<dyn AudioPipelineNode> {
|
||||
Self::with_channel_size(16)
|
||||
}
|
||||
|
||||
pub fn with_channel_size(channel_size: usize) -> Box<dyn AudioPipelineNode> {
|
||||
let logic = ConverterLogic::new(|chunk: &AudioChunk| chunk.to_i16());
|
||||
Box::new(Node::new_with_input(logic, channel_size))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ToI16Node {
|
||||
fn default() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
/// Node de conversion vers I24 (24-bit signed integer)
|
||||
pub struct ToI24Node;
|
||||
|
||||
impl ToI24Node {
|
||||
pub fn new() -> Box<dyn AudioPipelineNode> {
|
||||
Self::with_channel_size(16)
|
||||
}
|
||||
|
||||
pub fn with_channel_size(channel_size: usize) -> Box<dyn AudioPipelineNode> {
|
||||
let logic = ConverterLogic::new(|chunk: &AudioChunk| chunk.to_i24());
|
||||
Box::new(Node::new_with_input(logic, channel_size))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ToI24Node {
|
||||
fn default() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
/// Node de conversion vers I32 (32-bit signed integer)
|
||||
pub struct ToI32Node;
|
||||
|
||||
impl ToI32Node {
|
||||
pub fn new() -> Box<dyn AudioPipelineNode> {
|
||||
Self::with_channel_size(16)
|
||||
}
|
||||
|
||||
pub fn with_channel_size(channel_size: usize) -> Box<dyn AudioPipelineNode> {
|
||||
let logic = ConverterLogic::new(|chunk: &AudioChunk| chunk.to_i32());
|
||||
Box::new(Node::new_with_input(logic, channel_size))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ToI32Node {
|
||||
fn default() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
/// Node de conversion vers F32 (32-bit floating point)
|
||||
pub struct ToF32Node;
|
||||
|
||||
impl ToF32Node {
|
||||
pub fn new() -> Box<dyn AudioPipelineNode> {
|
||||
Self::with_channel_size(16)
|
||||
}
|
||||
|
||||
pub fn with_channel_size(channel_size: usize) -> Box<dyn AudioPipelineNode> {
|
||||
let logic = ConverterLogic::new(|chunk: &AudioChunk| chunk.to_f32());
|
||||
Box::new(Node::new_with_input(logic, channel_size))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ToF32Node {
|
||||
fn default() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
/// Node de conversion vers F64 (64-bit floating point)
|
||||
pub struct ToF64Node;
|
||||
|
||||
impl ToF64Node {
|
||||
pub fn new() -> Box<dyn AudioPipelineNode> {
|
||||
Self::with_channel_size(16)
|
||||
}
|
||||
|
||||
pub fn with_channel_size(channel_size: usize) -> Box<dyn AudioPipelineNode> {
|
||||
let logic = ConverterLogic::new(|chunk: &AudioChunk| chunk.to_f64());
|
||||
Box::new(Node::new_with_input(logic, channel_size))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ToF64Node {
|
||||
fn default() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{AudioChunk, AudioChunkData};
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_converter_logic() {
|
||||
// Test unitaire de la logique pure
|
||||
let mut logic = ConverterLogic::new(|chunk: &AudioChunk| chunk.to_f32());
|
||||
|
||||
let (input_tx, input_rx) = mpsc::channel(10);
|
||||
let (output_tx, mut output_rx) = mpsc::channel(10);
|
||||
let stop_token = CancellationToken::new();
|
||||
|
||||
// Créer un chunk de test
|
||||
let test_chunk = AudioChunk::I16(AudioChunkData::new(
|
||||
vec![[100, 200], [300, 400]],
|
||||
48000,
|
||||
0.0,
|
||||
));
|
||||
|
||||
// Créer le segment directement
|
||||
let segment = Arc::new(AudioSegment {
|
||||
order: 0,
|
||||
timestamp_sec: 0.0,
|
||||
segment: crate::_AudioSegment::Chunk(Arc::new(test_chunk)),
|
||||
});
|
||||
|
||||
// Envoyer le segment
|
||||
input_tx.send(segment).await.unwrap();
|
||||
drop(input_tx); // EOF
|
||||
|
||||
// Lancer le traitement
|
||||
tokio::spawn(async move {
|
||||
logic
|
||||
.process(Some(input_rx), vec![output_tx], stop_token)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
// Vérifier le résultat
|
||||
let result = output_rx.recv().await.unwrap();
|
||||
assert!(result.is_audio_chunk());
|
||||
|
||||
if let Some(chunk) = result.as_chunk() {
|
||||
assert!(matches!(chunk.as_ref(), AudioChunk::F32(_)));
|
||||
} else {
|
||||
panic!("Expected audio chunk");
|
||||
}
|
||||
}
|
||||
}
|
||||
552
pmoaudio/src/nodes/file_source.rs
Executable file
552
pmoaudio/src/nodes/file_source.rs
Executable file
@@ -0,0 +1,552 @@
|
||||
use crate::{
|
||||
nodes::{AudioError, TypedAudioNode, DEFAULT_CHUNK_DURATION_MS},
|
||||
pipeline::{AudioPipelineNode, Node, NodeLogic},
|
||||
type_constraints::TypeRequirement,
|
||||
AudioChunk, AudioChunkData, AudioSegment, I24,
|
||||
};
|
||||
use pmoflac::{decode_audio_stream, AudioFileMetadata, StreamInfo};
|
||||
use pmometadata::{MemoryTrackMetadata, TrackMetadata};
|
||||
use std::{path::PathBuf, sync::Arc, time::Duration};
|
||||
use tokio::{fs::File, io::AsyncReadExt, sync::mpsc};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// NOUVELLE ARCHITECTURE - FileSourceLogic
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Logique pure de lecture de fichier audio
|
||||
///
|
||||
/// Contient seulement la logique de décodage et d'envoi des segments,
|
||||
/// sans la plomberie d'orchestration (gérée par Node<FileSourceLogic>).
|
||||
pub struct FileSourceLogic {
|
||||
path: PathBuf,
|
||||
chunk_frames: usize,
|
||||
}
|
||||
|
||||
impl FileSourceLogic {
|
||||
pub fn new<P: Into<PathBuf>>(path: P, chunk_frames: usize) -> Self {
|
||||
Self {
|
||||
path: path.into(),
|
||||
chunk_frames,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl NodeLogic for FileSourceLogic {
|
||||
async fn process(
|
||||
&mut self,
|
||||
_input: Option<mpsc::Receiver<Arc<AudioSegment>>>,
|
||||
output: Vec<mpsc::Sender<Arc<AudioSegment>>>,
|
||||
stop_token: CancellationToken,
|
||||
) -> Result<(), AudioError> {
|
||||
tracing::debug!("FileSourceLogic::process started, path={:?}, {} children", self.path, output.len());
|
||||
|
||||
// Macro helper pour envoyer à tous les enfants
|
||||
macro_rules! send_to_children {
|
||||
($segment:expr) => {
|
||||
for tx in &output {
|
||||
tx.send($segment.clone())
|
||||
.await
|
||||
.map_err(|_| AudioError::ChildDied)?;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Ouvrir le fichier
|
||||
let file = File::open(&self.path).await.map_err(|e| {
|
||||
AudioError::IoError(format!("Failed to open {:?}: {}", self.path, e))
|
||||
})?;
|
||||
|
||||
// Décoder le flux audio
|
||||
let mut stream = decode_audio_stream(file)
|
||||
.await
|
||||
.map_err(|e| AudioError::ProcessingError(format!("Decode error: {}", e)))?;
|
||||
let stream_info = stream.info().clone();
|
||||
|
||||
validate_stream(&stream_info)?;
|
||||
|
||||
// Calculer la taille des chunks si non spécifiée (0 = auto)
|
||||
let chunk_frames = if self.chunk_frames == 0 {
|
||||
let frames =
|
||||
(stream_info.sample_rate as f64 * DEFAULT_CHUNK_DURATION_MS / 1000.0) as usize;
|
||||
frames.next_power_of_two().max(256)
|
||||
} else {
|
||||
self.chunk_frames.max(1)
|
||||
};
|
||||
|
||||
// Émettre TopZeroSync
|
||||
let top_zero = AudioSegment::new_top_zero_sync();
|
||||
send_to_children!(top_zero);
|
||||
|
||||
// Extraire et émettre les métadonnées du fichier
|
||||
if let Ok(file_metadata) = AudioFileMetadata::from_file(&self.path) {
|
||||
let mut metadata = MemoryTrackMetadata::new();
|
||||
if let Some(title) = file_metadata.title {
|
||||
let _ = metadata.set_title(Some(title)).await;
|
||||
}
|
||||
if let Some(artist) = file_metadata.artist {
|
||||
let _ = metadata.set_artist(Some(artist)).await;
|
||||
}
|
||||
if let Some(album) = file_metadata.album {
|
||||
let _ = metadata.set_album(Some(album)).await;
|
||||
}
|
||||
if let Some(year) = file_metadata.year {
|
||||
let _ = metadata.set_year(Some(year)).await;
|
||||
}
|
||||
if let Some(duration_secs) = file_metadata.duration_secs {
|
||||
let _ = metadata
|
||||
.set_duration(Some(Duration::from_secs(duration_secs)))
|
||||
.await;
|
||||
}
|
||||
|
||||
let track_boundary = AudioSegment::new_track_boundary(
|
||||
0,
|
||||
0.0,
|
||||
Arc::new(tokio::sync::RwLock::new(metadata)),
|
||||
);
|
||||
send_to_children!(track_boundary);
|
||||
}
|
||||
|
||||
// Préparer la lecture des chunks audio
|
||||
let frame_bytes = stream_info.bytes_per_sample() * stream_info.channels as usize;
|
||||
let chunk_byte_len = chunk_frames * frame_bytes;
|
||||
let mut pending = Vec::new();
|
||||
let mut read_buf = vec![0u8; frame_bytes * 512.max(chunk_frames)];
|
||||
let mut chunk_index = 0u64;
|
||||
let mut total_frames = 0u64;
|
||||
|
||||
// Lire et émettre les chunks audio
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = stop_token.cancelled() => {
|
||||
tracing::info!("FileSourceLogic: stop requested");
|
||||
break;
|
||||
}
|
||||
|
||||
read_result = stream.read(&mut read_buf) => {
|
||||
// Remplir le buffer
|
||||
if pending.len() < chunk_byte_len {
|
||||
let read = read_result.map_err(|e| {
|
||||
AudioError::IoError(format!("I/O error while decoding: {}", e))
|
||||
})?;
|
||||
if read == 0 && pending.is_empty() {
|
||||
break;
|
||||
}
|
||||
if read > 0 {
|
||||
pending.extend_from_slice(&read_buf[..read]);
|
||||
}
|
||||
}
|
||||
|
||||
if pending.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
// Extraire un chunk
|
||||
let frames_in_pending = pending.len() / frame_bytes;
|
||||
let frames_to_emit = frames_in_pending.min(chunk_frames);
|
||||
if frames_to_emit == 0 {
|
||||
break;
|
||||
}
|
||||
let take_bytes = frames_to_emit * frame_bytes;
|
||||
let chunk_bytes = pending.drain(..take_bytes).collect::<Vec<u8>>();
|
||||
|
||||
// Calculer le timestamp
|
||||
let timestamp_sec = total_frames as f64 / stream_info.sample_rate as f64;
|
||||
|
||||
// Créer et envoyer le segment audio
|
||||
let segment = bytes_to_segment(
|
||||
&chunk_bytes,
|
||||
&stream_info,
|
||||
frames_to_emit,
|
||||
chunk_index,
|
||||
timestamp_sec,
|
||||
)?;
|
||||
|
||||
send_to_children!(segment);
|
||||
|
||||
chunk_index += 1;
|
||||
total_frames += frames_to_emit as u64;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Traiter le reste éventuel (moins qu'un chunk complet)
|
||||
if !pending.is_empty() {
|
||||
let frames = pending.len() / frame_bytes;
|
||||
if frames > 0 {
|
||||
let timestamp_sec = total_frames as f64 / stream_info.sample_rate as f64;
|
||||
let segment =
|
||||
bytes_to_segment(&pending, &stream_info, frames, chunk_index, timestamp_sec)?;
|
||||
send_to_children!(segment);
|
||||
total_frames += frames as u64;
|
||||
chunk_index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Émettre EndOfStream
|
||||
let final_timestamp = total_frames as f64 / stream_info.sample_rate as f64;
|
||||
let eos = AudioSegment::new_end_of_stream(chunk_index, final_timestamp);
|
||||
send_to_children!(eos);
|
||||
|
||||
// Attendre la fin du décodage
|
||||
stream
|
||||
.wait()
|
||||
.await
|
||||
.map_err(|e| AudioError::ProcessingError(format!("Decode task failed: {}", e)))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// WRAPPER FileSource - Délègue à Node<FileSourceLogic>
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// FileSource - Lit un fichier audio et publie des `AudioSegment`
|
||||
///
|
||||
/// Cette source utilise `pmoflac` pour décoder le fichier (FLAC/MP3/OGG/WAV/AIFF)
|
||||
/// puis transforme les échantillons PCM en `AudioSegment` stéréo avec le type approprié
|
||||
/// (I16, I24, ou I32) selon la profondeur de bit du fichier source.
|
||||
///
|
||||
/// Le node émet trois types de syncmarkers :
|
||||
/// - `TopZeroSync` au début du flux
|
||||
/// - `TrackBoundary` avec les métadonnées du fichier
|
||||
/// - `EndOfStream` à la fin du flux
|
||||
///
|
||||
/// # Architecture
|
||||
///
|
||||
/// Utilise la nouvelle architecture avec `Node<FileSourceLogic>` pour séparer
|
||||
/// la logique métier (décodage) de la plomberie (spawning, monitoring).
|
||||
pub struct FileSource {
|
||||
inner: Node<FileSourceLogic>,
|
||||
}
|
||||
|
||||
impl FileSource {
|
||||
/// Crée une nouvelle source de fichier avec calcul automatique de la taille des chunks.
|
||||
///
|
||||
/// La taille des chunks sera calculée automatiquement pour obtenir environ 50ms
|
||||
/// de latence par chunk, en fonction du sample rate du fichier.
|
||||
///
|
||||
/// * `path` - chemin du fichier audio à lire
|
||||
pub fn new<P: Into<PathBuf>>(path: P) -> Self {
|
||||
Self::with_chunk_size(path, 0) // 0 = auto-calculer
|
||||
}
|
||||
|
||||
/// Crée une nouvelle source de fichier avec une taille de chunk spécifique.
|
||||
///
|
||||
/// * `path` - chemin du fichier audio à lire
|
||||
/// * `chunk_frames` - nombre d'échantillons par canal par chunk (0 = auto)
|
||||
pub fn with_chunk_size<P: Into<PathBuf>>(path: P, chunk_frames: usize) -> Self {
|
||||
let logic = FileSourceLogic::new(path, chunk_frames);
|
||||
Self {
|
||||
inner: Node::new_source(logic),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl AudioPipelineNode for FileSource {
|
||||
fn get_tx(&self) -> Option<mpsc::Sender<Arc<AudioSegment>>> {
|
||||
self.inner.get_tx()
|
||||
}
|
||||
|
||||
fn register(&mut self, child: Box<dyn AudioPipelineNode>) {
|
||||
self.inner.register(child)
|
||||
}
|
||||
|
||||
async fn run(
|
||||
self: Box<Self>,
|
||||
stop_token: CancellationToken,
|
||||
) -> Result<(), AudioError> {
|
||||
Box::new(self.inner).run(stop_token).await
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_stream(info: &StreamInfo) -> Result<(), AudioError> {
|
||||
if !(1..=2).contains(&info.channels) {
|
||||
return Err(AudioError::ProcessingError(format!(
|
||||
"Unsupported channel count: {}",
|
||||
info.channels
|
||||
)));
|
||||
}
|
||||
match info.bits_per_sample {
|
||||
8 | 16 | 24 | 32 => Ok(()),
|
||||
other => Err(AudioError::ProcessingError(format!(
|
||||
"Unsupported bit depth: {}",
|
||||
other
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convertit des bytes PCM en AudioSegment avec le type approprié
|
||||
fn bytes_to_segment(
|
||||
chunk_bytes: &[u8],
|
||||
info: &StreamInfo,
|
||||
frames: usize,
|
||||
order: u64,
|
||||
timestamp_sec: f64,
|
||||
) -> Result<Arc<AudioSegment>, AudioError> {
|
||||
let bytes_per_sample = info.bytes_per_sample();
|
||||
let channels = info.channels as usize;
|
||||
let frame_bytes = bytes_per_sample * channels;
|
||||
|
||||
// Créer le chunk du bon type selon la profondeur de bit
|
||||
let chunk = match info.bits_per_sample {
|
||||
16 => {
|
||||
// Type I16
|
||||
let mut stereo = Vec::with_capacity(frames);
|
||||
for frame_idx in 0..frames {
|
||||
let base = frame_idx * frame_bytes;
|
||||
let l = i16::from_le_bytes(
|
||||
chunk_bytes[base..base + bytes_per_sample]
|
||||
.try_into()
|
||||
.unwrap(),
|
||||
);
|
||||
let r = if channels == 1 {
|
||||
l
|
||||
} else {
|
||||
i16::from_le_bytes(
|
||||
chunk_bytes[base + bytes_per_sample..base + 2 * bytes_per_sample]
|
||||
.try_into()
|
||||
.unwrap(),
|
||||
)
|
||||
};
|
||||
stereo.push([l, r]);
|
||||
}
|
||||
let chunk_data = AudioChunkData::new(stereo, info.sample_rate, 0.0);
|
||||
AudioChunk::I16(chunk_data)
|
||||
}
|
||||
24 => {
|
||||
// Type I24
|
||||
let mut stereo = Vec::with_capacity(frames);
|
||||
for frame_idx in 0..frames {
|
||||
let base = frame_idx * frame_bytes;
|
||||
let l_i32 = {
|
||||
let mut buf = [0u8; 4];
|
||||
buf[..3].copy_from_slice(&chunk_bytes[base..base + 3]);
|
||||
// Sign extend
|
||||
if chunk_bytes[base + 2] & 0x80 != 0 {
|
||||
buf[3] = 0xFF;
|
||||
}
|
||||
i32::from_le_bytes(buf)
|
||||
};
|
||||
let l = I24::new(l_i32).ok_or_else(|| {
|
||||
AudioError::ProcessingError(format!("Invalid I24 value: {}", l_i32))
|
||||
})?;
|
||||
|
||||
let r = if channels == 1 {
|
||||
l
|
||||
} else {
|
||||
let r_i32 = {
|
||||
let mut buf = [0u8; 4];
|
||||
buf[..3].copy_from_slice(
|
||||
&chunk_bytes[base + bytes_per_sample..base + bytes_per_sample + 3],
|
||||
);
|
||||
// Sign extend
|
||||
if chunk_bytes[base + bytes_per_sample + 2] & 0x80 != 0 {
|
||||
buf[3] = 0xFF;
|
||||
}
|
||||
i32::from_le_bytes(buf)
|
||||
};
|
||||
I24::new(r_i32).ok_or_else(|| {
|
||||
AudioError::ProcessingError(format!("Invalid I24 value: {}", r_i32))
|
||||
})?
|
||||
};
|
||||
stereo.push([l, r]);
|
||||
}
|
||||
let chunk_data = AudioChunkData::new(stereo, info.sample_rate, 0.0);
|
||||
AudioChunk::I24(chunk_data)
|
||||
}
|
||||
32 => {
|
||||
// Type I32
|
||||
let mut stereo = Vec::with_capacity(frames);
|
||||
for frame_idx in 0..frames {
|
||||
let base = frame_idx * frame_bytes;
|
||||
let l = i32::from_le_bytes(
|
||||
chunk_bytes[base..base + bytes_per_sample]
|
||||
.try_into()
|
||||
.unwrap(),
|
||||
);
|
||||
let r = if channels == 1 {
|
||||
l
|
||||
} else {
|
||||
i32::from_le_bytes(
|
||||
chunk_bytes[base + bytes_per_sample..base + 2 * bytes_per_sample]
|
||||
.try_into()
|
||||
.unwrap(),
|
||||
)
|
||||
};
|
||||
stereo.push([l, r]);
|
||||
}
|
||||
let chunk_data = AudioChunkData::new(stereo, info.sample_rate, 0.0);
|
||||
AudioChunk::I32(chunk_data)
|
||||
}
|
||||
other => {
|
||||
return Err(AudioError::ProcessingError(format!(
|
||||
"Unsupported bit depth: {}",
|
||||
other
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
// Créer le segment audio
|
||||
Ok(Arc::new(AudioSegment {
|
||||
order,
|
||||
timestamp_sec,
|
||||
segment: crate::_AudioSegment::Chunk(Arc::new(chunk)),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
impl TypedAudioNode for FileSource {
|
||||
fn input_type(&self) -> Option<TypeRequirement> {
|
||||
// FileSource est une source, elle ne consomme pas d'audio
|
||||
None
|
||||
}
|
||||
|
||||
fn output_type(&self) -> Option<TypeRequirement> {
|
||||
// FileSource peut produire n'importe quel type entier (I16, I24, I32)
|
||||
// selon la profondeur de bit du fichier source
|
||||
Some(TypeRequirement::any_integer())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pmoflac::{encode_flac_stream, EncoderOptions, PcmFormat};
|
||||
use std::io::Cursor;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_file_source_decodes_flac() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let flac_path = temp_dir.path().join("test.flac");
|
||||
|
||||
let sample_rate = 48_000;
|
||||
let frames = 256;
|
||||
let mut pcm = Vec::with_capacity(frames * 4);
|
||||
for i in 0..frames {
|
||||
let sample = ((i % 32) as f32 / 31.0 * 2.0 - 1.0) * 0.5; // simple ramp
|
||||
let sample_i16 = (sample * 32767.0) as i16;
|
||||
pcm.extend_from_slice(&sample_i16.to_le_bytes());
|
||||
pcm.extend_from_slice(&sample_i16.to_le_bytes());
|
||||
}
|
||||
|
||||
let format = PcmFormat {
|
||||
sample_rate,
|
||||
channels: 2,
|
||||
bits_per_sample: 16,
|
||||
};
|
||||
|
||||
let mut flac_stream =
|
||||
encode_flac_stream(Cursor::new(pcm.clone()), format, EncoderOptions::default())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut file = File::create(&flac_path).await.expect("create flac file");
|
||||
tokio::io::copy(&mut flac_stream, &mut file)
|
||||
.await
|
||||
.expect("write flac");
|
||||
file.flush().await.expect("flush file");
|
||||
flac_stream.wait().await.unwrap();
|
||||
|
||||
// Créer un collecteur simple qui transmet les segments à un channel de test
|
||||
struct TestCollectorNode {
|
||||
tx: mpsc::Sender<Arc<AudioSegment>>,
|
||||
rx: mpsc::Receiver<Arc<AudioSegment>>,
|
||||
test_tx: mpsc::Sender<Arc<AudioSegment>>,
|
||||
}
|
||||
|
||||
impl TestCollectorNode {
|
||||
fn new(test_tx: mpsc::Sender<Arc<AudioSegment>>) -> Self {
|
||||
let (tx, rx) = mpsc::channel(16);
|
||||
Self {
|
||||
tx,
|
||||
rx,
|
||||
test_tx,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl AudioPipelineNode for TestCollectorNode {
|
||||
fn get_tx(&self) -> Option<mpsc::Sender<Arc<AudioSegment>>> {
|
||||
Some(self.tx.clone())
|
||||
}
|
||||
|
||||
fn register(&mut self, _child: Box<dyn AudioPipelineNode>) {
|
||||
panic!("TestCollectorNode is a terminal node");
|
||||
}
|
||||
|
||||
async fn run(
|
||||
mut self: Box<Self>,
|
||||
stop_token: CancellationToken,
|
||||
) -> Result<(), AudioError> {
|
||||
// Transférer tous les segments au test
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = stop_token.cancelled() => {
|
||||
break;
|
||||
}
|
||||
segment = self.rx.recv() => {
|
||||
match segment {
|
||||
Some(seg) => {
|
||||
if self.test_tx.send(seg).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
let (test_tx, mut rx) = mpsc::channel(1024);
|
||||
let mut source = FileSource::with_chunk_size(&flac_path, 64);
|
||||
let collector = TestCollectorNode::new(test_tx);
|
||||
source.register(Box::new(collector));
|
||||
|
||||
tokio::spawn(async move {
|
||||
let token = CancellationToken::new();
|
||||
Box::new(source).run(token).await.unwrap();
|
||||
});
|
||||
|
||||
let mut received_frames = 0usize;
|
||||
let mut received_syncmarkers = 0usize;
|
||||
let mut seen_top_zero = false;
|
||||
let mut seen_eos = false;
|
||||
|
||||
while let Some(segment) = rx.recv().await {
|
||||
if segment.is_audio_chunk() {
|
||||
if let Some(chunk) = segment.as_chunk() {
|
||||
received_frames += chunk.len();
|
||||
assert_eq!(chunk.sample_rate(), sample_rate);
|
||||
}
|
||||
} else {
|
||||
received_syncmarkers += 1;
|
||||
if let Some(marker) = segment.as_sync_marker() {
|
||||
match **marker {
|
||||
crate::SyncMarker::TopZeroSync => seen_top_zero = true,
|
||||
crate::SyncMarker::EndOfStream => seen_eos = true,
|
||||
crate::SyncMarker::TrackBoundary { .. } => {}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Vérifier que tous les frames ont été reçus
|
||||
assert_eq!(received_frames, frames);
|
||||
// Vérifier qu'on a bien reçu des syncmarkers
|
||||
assert!(received_syncmarkers >= 2); // Au moins TopZeroSync et EndOfStream
|
||||
assert!(seen_top_zero, "Should have received TopZeroSync");
|
||||
assert!(seen_eos, "Should have received EndOfStream");
|
||||
}
|
||||
}
|
||||
806
pmoaudio/src/nodes/flac_file_sink.rs
Executable file
806
pmoaudio/src/nodes/flac_file_sink.rs
Executable file
@@ -0,0 +1,806 @@
|
||||
use crate::{
|
||||
nodes::{AudioError, TypedAudioNode, DEFAULT_CHANNEL_SIZE},
|
||||
pipeline::{Node, NodeLogic},
|
||||
type_constraints::TypeRequirement,
|
||||
AudioChunk, AudioPipelineNode, AudioSegment, SyncMarker,
|
||||
};
|
||||
use pmoflac::{encode_flac_stream, EncoderOptions, PcmFormat};
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
path::{Path, PathBuf},
|
||||
pin::Pin,
|
||||
sync::Arc,
|
||||
task::{Context, Poll},
|
||||
};
|
||||
use tokio::{
|
||||
fs::File,
|
||||
io::{self, AsyncRead, AsyncWriteExt, ReadBuf},
|
||||
sync::mpsc,
|
||||
};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
/// Sink qui encode les `AudioSegment` reçus au format FLAC.
|
||||
///
|
||||
/// Ce sink :
|
||||
/// - Filtre les chunks audio et ignore les autres syncmarkers (sauf TrackBoundary et EndOfStream)
|
||||
/// - Crée un nouveau fichier FLAC pour chaque TrackBoundary rencontré
|
||||
/// - Adapte automatiquement l'encodage FLAC selon la profondeur de bit du chunk (8/16/24/32-bit)
|
||||
/// - Termine l'encodage proprement quand il reçoit EndOfStream
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// FlacFileSinkLogic - Logique métier pure
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Signal retourné par pump_segments indiquant pourquoi l'encodage s'est arrêté.
|
||||
enum StopReason {
|
||||
TrackBoundary(Arc<tokio::sync::RwLock<dyn pmometadata::TrackMetadata>>),
|
||||
EndOfStream,
|
||||
ChannelClosed,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// Logique pure d'encodage FLAC
|
||||
pub struct FlacFileSinkLogic {
|
||||
base_path: PathBuf,
|
||||
encoder_options: EncoderOptions,
|
||||
pcm_buffer_capacity: usize,
|
||||
}
|
||||
|
||||
impl FlacFileSinkLogic {
|
||||
pub fn new<P: Into<PathBuf>>(
|
||||
base_path: P,
|
||||
encoder_options: EncoderOptions,
|
||||
pcm_buffer_capacity: usize,
|
||||
) -> Self {
|
||||
Self {
|
||||
base_path: base_path.into(),
|
||||
encoder_options,
|
||||
pcm_buffer_capacity,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl NodeLogic for FlacFileSinkLogic {
|
||||
async fn process(
|
||||
&mut self,
|
||||
input: Option<mpsc::Receiver<Arc<AudioSegment>>>,
|
||||
_output: Vec<mpsc::Sender<Arc<AudioSegment>>>,
|
||||
stop_token: CancellationToken,
|
||||
) -> Result<(), AudioError> {
|
||||
let mut rx = input.expect("FlacFileSink must have input");
|
||||
let mut track_number = 0;
|
||||
|
||||
tracing::debug!("FlacFileSinkLogic::process started, base_path={:?}", self.base_path);
|
||||
|
||||
loop {
|
||||
// Vérifier si l'arrêt a été demandé
|
||||
if stop_token.is_cancelled() {
|
||||
tracing::debug!("FlacFileSinkLogic cancelled");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Attendre le premier chunk audio pour cette track, en capturant les métadonnées du TrackBoundary
|
||||
let (first_segment, track_metadata) = match wait_for_first_audio_chunk_with_metadata(&mut rx, &stop_token).await {
|
||||
Ok(result) => result,
|
||||
Err(_) => {
|
||||
// Plus d'audio disponible ou arrêt demandé
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
// Extraire les informations du premier chunk
|
||||
let first_chunk = first_segment.as_chunk().unwrap();
|
||||
let sample_rate = first_chunk.sample_rate();
|
||||
let bits_per_sample = get_chunk_bit_depth(first_chunk);
|
||||
|
||||
tracing::debug!(
|
||||
"FlacFileSinkLogic: encoding track {} with {}bit @ {}Hz",
|
||||
track_number, bits_per_sample, sample_rate
|
||||
);
|
||||
|
||||
let format = PcmFormat {
|
||||
sample_rate,
|
||||
channels: 2,
|
||||
bits_per_sample,
|
||||
};
|
||||
if let Err(err) = format.validate() {
|
||||
return Err(AudioError::ProcessingError(format!(
|
||||
"Invalid PCM format: {}",
|
||||
err
|
||||
)));
|
||||
}
|
||||
|
||||
// Générer le chemin du fichier pour cette track
|
||||
let track_path = generate_track_path(&self.base_path, track_number);
|
||||
|
||||
// Créer le pipeline d'encodage pour cette track
|
||||
let (pcm_tx, pcm_rx) = mpsc::channel::<Vec<u8>>(self.pcm_buffer_capacity);
|
||||
|
||||
// Préparer les options d'encodage avec les métadonnées du TrackBoundary
|
||||
let mut options_with_metadata = self.encoder_options.clone();
|
||||
options_with_metadata.metadata = track_metadata;
|
||||
|
||||
// Créer l'encoder et le fichier
|
||||
let reader = ByteStreamReader::new(pcm_rx);
|
||||
let mut flac_stream = encode_flac_stream(reader, format, options_with_metadata)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AudioError::ProcessingError(format!("FLAC encode init failed: {}", e))
|
||||
})?;
|
||||
|
||||
let mut output = File::create(&track_path).await.map_err(|e| {
|
||||
AudioError::ProcessingError(format!("Failed to create {:?}: {}", track_path, e))
|
||||
})?;
|
||||
|
||||
// Exécuter pump et copy en parallèle avec tokio::select! en boucle
|
||||
let pump_future =
|
||||
pump_track_segments(first_segment, &mut rx, pcm_tx, bits_per_sample, sample_rate, &stop_token);
|
||||
let copy_future = async {
|
||||
let copy_result = tokio::io::copy(&mut flac_stream, &mut output).await;
|
||||
let flush_result = output.flush().await;
|
||||
let wait_result = flac_stream.wait().await;
|
||||
|
||||
copy_result.map_err(|e| {
|
||||
AudioError::ProcessingError(format!("FLAC write failed: {}", e))
|
||||
})?;
|
||||
flush_result
|
||||
.map_err(|e| AudioError::ProcessingError(format!("Failed to flush: {}", e)))?;
|
||||
wait_result
|
||||
.map_err(|e| AudioError::ProcessingError(format!("Encoder failed: {}", e)))?;
|
||||
Ok::<_, AudioError>(())
|
||||
};
|
||||
|
||||
// Attendre les deux tâches en parallèle
|
||||
let (copy_result, pump_result) = tokio::join!(copy_future, pump_future);
|
||||
copy_result?;
|
||||
let stop_reason = pump_result?;
|
||||
|
||||
// Vérifier le stop_reason pour savoir si on continue
|
||||
match stop_reason {
|
||||
StopReason::TrackBoundary(_metadata) => {
|
||||
// Continuer avec la prochaine track
|
||||
track_number += 1;
|
||||
continue;
|
||||
}
|
||||
StopReason::EndOfStream | StopReason::ChannelClosed | StopReason::Cancelled => {
|
||||
// Fin de l'encodage
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// FlacFileSink - Wrapper utilisant Node<FlacFileSinkLogic>
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
pub struct FlacFileSink {
|
||||
inner: Node<FlacFileSinkLogic>,
|
||||
}
|
||||
|
||||
impl FlacFileSink {
|
||||
/// Crée un sink FLAC avec les options par défaut (compression 5, buffer de 16 segments).
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `base_path` - Chemin de base pour les fichiers FLAC. Si des TrackBoundary sont reçus,
|
||||
/// des fichiers seront créés avec des suffixes (_01, _02, etc.)
|
||||
pub fn new<P: Into<PathBuf>>(base_path: P) -> Self {
|
||||
Self::with_channel_size(base_path, DEFAULT_CHANNEL_SIZE)
|
||||
}
|
||||
|
||||
/// Crée un sink FLAC avec une taille de buffer MPSC personnalisée.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `base_path` - Chemin de base pour les fichiers FLAC
|
||||
/// * `channel_size` - Taille du buffer MPSC (nombre de segments en attente avant backpressure)
|
||||
pub fn with_channel_size<P: Into<PathBuf>>(
|
||||
base_path: P,
|
||||
channel_size: usize,
|
||||
) -> Self {
|
||||
Self::with_config(base_path, channel_size, EncoderOptions::default())
|
||||
}
|
||||
|
||||
/// Crée un sink FLAC avec une configuration complète.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `base_path` - Chemin de base pour les fichiers FLAC
|
||||
/// * `channel_size` - Taille du buffer MPSC
|
||||
/// * `encoder_options` - Options d'encodage FLAC (compression, etc.)
|
||||
pub fn with_config<P: Into<PathBuf>>(
|
||||
base_path: P,
|
||||
channel_size: usize,
|
||||
encoder_options: EncoderOptions,
|
||||
) -> Self {
|
||||
let logic = FlacFileSinkLogic::new(base_path, encoder_options, 8);
|
||||
Self {
|
||||
inner: Node::new_with_input(logic, channel_size),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Génère le chemin de fichier pour une track donnée.
|
||||
/// - track 0 → base_path.flac
|
||||
/// - track 1 → base_path_01.flac
|
||||
/// - track 2 → base_path_02.flac, etc.
|
||||
fn generate_track_path(base_path: &Path, track_number: usize) -> PathBuf {
|
||||
if track_number == 0 {
|
||||
base_path.to_path_buf()
|
||||
} else {
|
||||
let stem = base_path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("output");
|
||||
let extension = base_path
|
||||
.extension()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("flac");
|
||||
let parent = base_path.parent().unwrap_or(Path::new("."));
|
||||
parent.join(format!("{}_{:02}.{}", stem, track_number, extension))
|
||||
}
|
||||
}
|
||||
|
||||
/// Attend et retourne le premier chunk audio avec les métadonnées du TrackBoundary si présent.
|
||||
/// Retourne une erreur si EndOfStream est reçu avant tout audio ou si l'arrêt est demandé.
|
||||
async fn wait_for_first_audio_chunk_with_metadata(
|
||||
rx: &mut mpsc::Receiver<Arc<AudioSegment>>,
|
||||
stop_token: &CancellationToken,
|
||||
) -> Result<(Arc<AudioSegment>, Option<Arc<tokio::sync::RwLock<dyn pmometadata::TrackMetadata>>>), AudioError> {
|
||||
let mut track_metadata: Option<Arc<tokio::sync::RwLock<dyn pmometadata::TrackMetadata>>> = None;
|
||||
|
||||
loop {
|
||||
let segment = tokio::select! {
|
||||
result = rx.recv() => {
|
||||
result.ok_or_else(|| AudioError::ProcessingError("No audio data received".into()))?
|
||||
}
|
||||
_ = stop_token.cancelled() => {
|
||||
return Err(AudioError::ProcessingError("Cancelled".into()));
|
||||
}
|
||||
};
|
||||
|
||||
match &segment.segment {
|
||||
crate::_AudioSegment::Chunk(chunk) => {
|
||||
if chunk.len() == 0 {
|
||||
return Err(AudioError::ProcessingError("Received empty chunk".into()));
|
||||
}
|
||||
return Ok((segment, track_metadata));
|
||||
}
|
||||
crate::_AudioSegment::Sync(marker) => {
|
||||
match **marker {
|
||||
SyncMarker::TrackBoundary { ref metadata, .. } => {
|
||||
// Capturer les métadonnées du TrackBoundary
|
||||
track_metadata = Some(metadata.clone());
|
||||
continue;
|
||||
}
|
||||
SyncMarker::EndOfStream => {
|
||||
return Err(AudioError::ProcessingError(
|
||||
"EndOfStream received before any audio".into(),
|
||||
));
|
||||
}
|
||||
_ => {
|
||||
// Ignorer TopZeroSync, Heartbeat, etc.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pompe les segments pour une seule track (s'arrête au TrackBoundary).
|
||||
async fn pump_track_segments(
|
||||
first_segment: Arc<AudioSegment>,
|
||||
rx: &mut mpsc::Receiver<Arc<AudioSegment>>,
|
||||
pcm_tx: mpsc::Sender<Vec<u8>>,
|
||||
bits_per_sample: u8,
|
||||
expected_rate: u32,
|
||||
stop_token: &CancellationToken,
|
||||
) -> Result<StopReason, AudioError> {
|
||||
// Traiter le premier segment
|
||||
if let Some(chunk) = first_segment.as_chunk() {
|
||||
let pcm_bytes = chunk_to_pcm_bytes(chunk, bits_per_sample)?;
|
||||
if !pcm_bytes.is_empty() {
|
||||
pcm_tx
|
||||
.send(pcm_bytes)
|
||||
.await
|
||||
.map_err(|_| AudioError::SendError)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Boucle sur les segments suivants
|
||||
loop {
|
||||
let segment = tokio::select! {
|
||||
result = rx.recv() => {
|
||||
match result {
|
||||
Some(seg) => seg,
|
||||
None => {
|
||||
drop(pcm_tx);
|
||||
return Ok(StopReason::ChannelClosed);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = stop_token.cancelled() => {
|
||||
drop(pcm_tx);
|
||||
return Ok(StopReason::Cancelled);
|
||||
}
|
||||
};
|
||||
|
||||
match &segment.segment {
|
||||
crate::_AudioSegment::Chunk(chunk) => {
|
||||
// Vérifier la cohérence du sample rate
|
||||
if chunk.sample_rate() != expected_rate {
|
||||
return Err(AudioError::ProcessingError(format!(
|
||||
"FlacFileSink: inconsistent sample rate ({} vs {})",
|
||||
chunk.sample_rate(),
|
||||
expected_rate
|
||||
)));
|
||||
}
|
||||
|
||||
let pcm_bytes = chunk_to_pcm_bytes(chunk, bits_per_sample)?;
|
||||
if pcm_bytes.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
pcm_tx
|
||||
.send(pcm_bytes)
|
||||
.await
|
||||
.map_err(|_| AudioError::SendError)?;
|
||||
}
|
||||
crate::_AudioSegment::Sync(marker) => {
|
||||
match &**marker {
|
||||
SyncMarker::TrackBoundary { metadata, .. } => {
|
||||
drop(pcm_tx); // Fermer le channel PCM
|
||||
return Ok(StopReason::TrackBoundary(metadata.clone()));
|
||||
}
|
||||
SyncMarker::EndOfStream => {
|
||||
drop(pcm_tx); // Fermer le channel PCM
|
||||
return Ok(StopReason::EndOfStream);
|
||||
}
|
||||
_ => {} // Ignorer les autres syncmarkers
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Détermine la profondeur de bit d'un chunk audio
|
||||
fn get_chunk_bit_depth(chunk: &AudioChunk) -> u8 {
|
||||
match chunk {
|
||||
AudioChunk::I16(_) => 16,
|
||||
AudioChunk::I24(_) => 24,
|
||||
AudioChunk::I32(_) => 32,
|
||||
AudioChunk::F32(_) => 32, // Les flottants seront convertis en 32-bit
|
||||
AudioChunk::F64(_) => 32, // Les flottants seront convertis en 32-bit
|
||||
}
|
||||
}
|
||||
|
||||
/// Convertit un chunk audio en bytes PCM avec la profondeur de bit spécifiée
|
||||
fn chunk_to_pcm_bytes(chunk: &AudioChunk, bits_per_sample: u8) -> Result<Vec<u8>, AudioError> {
|
||||
// Vérifier que le chunk est de type entier
|
||||
match chunk {
|
||||
AudioChunk::F32(_) | AudioChunk::F64(_) => {
|
||||
return Err(AudioError::ProcessingError(
|
||||
"FlacFileSink only supports integer audio chunks (I16, I24, I32)".into(),
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let len = chunk.len();
|
||||
let bytes_per_frame = (bits_per_sample / 8) as usize * 2; // 2 channels
|
||||
let mut bytes = Vec::with_capacity(len * bytes_per_frame);
|
||||
|
||||
// Convertir selon le type du chunk
|
||||
match (chunk, bits_per_sample) {
|
||||
// I16 source
|
||||
(AudioChunk::I16(data), 16) => {
|
||||
for frame in data.get_frames() {
|
||||
bytes.extend_from_slice(&frame[0].to_le_bytes());
|
||||
bytes.extend_from_slice(&frame[1].to_le_bytes());
|
||||
}
|
||||
}
|
||||
(AudioChunk::I16(data), 24) => {
|
||||
for frame in data.get_frames() {
|
||||
let left = (frame[0] as i32) << 8;
|
||||
let right = (frame[1] as i32) << 8;
|
||||
bytes.extend_from_slice(&left.to_le_bytes()[..3]);
|
||||
bytes.extend_from_slice(&right.to_le_bytes()[..3]);
|
||||
}
|
||||
}
|
||||
(AudioChunk::I16(data), 32) => {
|
||||
for frame in data.get_frames() {
|
||||
let left = (frame[0] as i32) << 16;
|
||||
let right = (frame[1] as i32) << 16;
|
||||
bytes.extend_from_slice(&left.to_le_bytes());
|
||||
bytes.extend_from_slice(&right.to_le_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
// I24 source
|
||||
(AudioChunk::I24(data), 16) => {
|
||||
for frame in data.get_frames() {
|
||||
let left = (frame[0].as_i32() >> 8) as i16;
|
||||
let right = (frame[1].as_i32() >> 8) as i16;
|
||||
bytes.extend_from_slice(&left.to_le_bytes());
|
||||
bytes.extend_from_slice(&right.to_le_bytes());
|
||||
}
|
||||
}
|
||||
(AudioChunk::I24(data), 24) => {
|
||||
for frame in data.get_frames() {
|
||||
bytes.extend_from_slice(&frame[0].as_i32().to_le_bytes()[..3]);
|
||||
bytes.extend_from_slice(&frame[1].as_i32().to_le_bytes()[..3]);
|
||||
}
|
||||
}
|
||||
(AudioChunk::I24(data), 32) => {
|
||||
for frame in data.get_frames() {
|
||||
let left = frame[0].as_i32() << 8;
|
||||
let right = frame[1].as_i32() << 8;
|
||||
bytes.extend_from_slice(&left.to_le_bytes());
|
||||
bytes.extend_from_slice(&right.to_le_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
// I32 source
|
||||
(AudioChunk::I32(data), 16) => {
|
||||
for frame in data.get_frames() {
|
||||
let left = (frame[0] >> 16) as i16;
|
||||
let right = (frame[1] >> 16) as i16;
|
||||
bytes.extend_from_slice(&left.to_le_bytes());
|
||||
bytes.extend_from_slice(&right.to_le_bytes());
|
||||
}
|
||||
}
|
||||
(AudioChunk::I32(data), 24) => {
|
||||
for frame in data.get_frames() {
|
||||
let left = frame[0] >> 8;
|
||||
let right = frame[1] >> 8;
|
||||
bytes.extend_from_slice(&left.to_le_bytes()[..3]);
|
||||
bytes.extend_from_slice(&right.to_le_bytes()[..3]);
|
||||
}
|
||||
}
|
||||
(AudioChunk::I32(data), 32) => {
|
||||
for frame in data.get_frames() {
|
||||
bytes.extend_from_slice(&frame[0].to_le_bytes());
|
||||
bytes.extend_from_slice(&frame[1].to_le_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
_ => {
|
||||
return Err(AudioError::ProcessingError(format!(
|
||||
"Unsupported bits_per_sample: {}",
|
||||
bits_per_sample
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
struct ByteStreamReader {
|
||||
rx: mpsc::Receiver<Vec<u8>>,
|
||||
buffer: VecDeque<u8>,
|
||||
finished: bool,
|
||||
}
|
||||
|
||||
impl ByteStreamReader {
|
||||
fn new(rx: mpsc::Receiver<Vec<u8>>) -> 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<io::Result<()>> {
|
||||
loop {
|
||||
if !self.buffer.is_empty() {
|
||||
let to_copy = self.buffer.len().min(buf.remaining());
|
||||
if to_copy == 0 {
|
||||
return Poll::Ready(Ok(()));
|
||||
}
|
||||
|
||||
// VecDeque::make_contiguous pour copier efficacement
|
||||
let slice = self.buffer.make_contiguous();
|
||||
buf.put_slice(&slice[..to_copy]);
|
||||
self.buffer.drain(..to_copy);
|
||||
return Poll::Ready(Ok(()));
|
||||
}
|
||||
|
||||
if self.finished {
|
||||
return Poll::Ready(Ok(()));
|
||||
}
|
||||
|
||||
match Pin::new(&mut self.rx).poll_recv(cx) {
|
||||
Poll::Ready(Some(bytes)) => {
|
||||
if bytes.is_empty() {
|
||||
continue;
|
||||
}
|
||||
self.buffer.extend(bytes);
|
||||
}
|
||||
Poll::Ready(None) => {
|
||||
self.finished = true;
|
||||
return Poll::Ready(Ok(()));
|
||||
}
|
||||
Poll::Pending => return Poll::Pending,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Statistiques pour une track individuelle.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TrackStats {
|
||||
pub path: PathBuf,
|
||||
pub track_number: usize,
|
||||
pub chunks_received: u64,
|
||||
pub total_samples: u64,
|
||||
pub total_duration_sec: f64,
|
||||
}
|
||||
|
||||
/// Statistiques produites par le `FlacFileSink`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FlacFileSinkStats {
|
||||
pub tracks: Vec<TrackStats>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl AudioPipelineNode for FlacFileSink {
|
||||
fn get_tx(&self) -> Option<mpsc::Sender<Arc<AudioSegment>>> {
|
||||
self.inner.get_tx()
|
||||
}
|
||||
|
||||
fn register(&mut self, _child: Box<dyn AudioPipelineNode>) {
|
||||
panic!("FlacFileSink is a terminal node and cannot have children");
|
||||
}
|
||||
|
||||
async fn run(
|
||||
self: Box<Self>,
|
||||
stop_token: CancellationToken,
|
||||
) -> Result<(), AudioError> {
|
||||
Box::new(self.inner).run(stop_token).await
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedAudioNode for FlacFileSink {
|
||||
fn input_type(&self) -> Option<TypeRequirement> {
|
||||
// FlacFileSink accepte n'importe quel type entier (I16, I24, I32)
|
||||
// mais rejette les chunks flottants
|
||||
Some(TypeRequirement::any_integer())
|
||||
}
|
||||
|
||||
fn output_type(&self) -> Option<TypeRequirement> {
|
||||
// FlacFileSink est un sink, il ne produit pas d'audio
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pmoflac::{decode_flac_stream, AudioFileMetadata};
|
||||
use pmometadata::{MemoryTrackMetadata, TrackMetadata};
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_flac_file_sink_writes_metadata() {
|
||||
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let output_path = temp_dir.path().join("output_with_metadata.flac");
|
||||
|
||||
let sample_rate = 44_100;
|
||||
let frames = 256;
|
||||
|
||||
// Créer le sink
|
||||
let sink = FlacFileSink::with_channel_size(&output_path, 16);
|
||||
let tx = sink.get_tx().unwrap();
|
||||
let stop_token = CancellationToken::new();
|
||||
let sink_handle = tokio::spawn(async move {
|
||||
Box::new(sink).run(stop_token).await.unwrap()
|
||||
});
|
||||
|
||||
// Envoyer des segments avec métadonnées
|
||||
tokio::spawn(async move {
|
||||
// TopZeroSync
|
||||
tx.send(crate::AudioSegment::new_top_zero_sync())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// TrackBoundary avec métadonnées
|
||||
let mut metadata = MemoryTrackMetadata::new();
|
||||
metadata.set_title(Some("Test Track Title".to_string())).await.unwrap();
|
||||
metadata.set_artist(Some("Test Artist".to_string())).await.unwrap();
|
||||
metadata.set_album(Some("Test Album".to_string())).await.unwrap();
|
||||
metadata.set_year(Some(2024)).await.unwrap();
|
||||
|
||||
let track_boundary =
|
||||
crate::AudioSegment::new_track_boundary(0, 0.0, std::sync::Arc::new(tokio::sync::RwLock::new(metadata)));
|
||||
tx.send(track_boundary).await.unwrap();
|
||||
|
||||
// Générer et envoyer des chunks audio
|
||||
let chunk_frames = 64;
|
||||
let mut order = 0u64;
|
||||
let mut total_frames = 0u64;
|
||||
|
||||
for chunk_start in (0..frames).step_by(chunk_frames) {
|
||||
let chunk_len = (frames - chunk_start).min(chunk_frames);
|
||||
let mut stereo = Vec::with_capacity(chunk_len);
|
||||
|
||||
for i in 0..chunk_len {
|
||||
let frame_idx = chunk_start + i;
|
||||
let sample = ((frame_idx % 32) as f32 / 31.0 * 2.0 - 1.0) * 0.5;
|
||||
let sample_i16 = (sample * 32767.0) as i16;
|
||||
stereo.push([sample_i16, sample_i16]);
|
||||
}
|
||||
|
||||
let timestamp = total_frames as f64 / sample_rate as f64;
|
||||
let chunk_data = crate::AudioChunkData::new(stereo, sample_rate, 0.0);
|
||||
let chunk = crate::AudioChunk::I16(chunk_data);
|
||||
let segment = crate::AudioSegment {
|
||||
order,
|
||||
timestamp_sec: timestamp,
|
||||
segment: crate::_AudioSegment::Chunk(std::sync::Arc::new(chunk)),
|
||||
};
|
||||
|
||||
tx.send(std::sync::Arc::new(segment)).await.unwrap();
|
||||
total_frames += chunk_len as u64;
|
||||
order += 1;
|
||||
}
|
||||
|
||||
// EndOfStream
|
||||
let final_timestamp = total_frames as f64 / sample_rate as f64;
|
||||
tx.send(crate::AudioSegment::new_end_of_stream(
|
||||
order,
|
||||
final_timestamp,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
drop(tx);
|
||||
});
|
||||
|
||||
sink_handle.await.unwrap();
|
||||
|
||||
// Vérifier que le fichier a été créé et contient les métadonnées
|
||||
assert!(output_path.exists(), "Output file should exist");
|
||||
|
||||
// Lire les métadonnées du fichier FLAC généré
|
||||
let file_metadata = AudioFileMetadata::from_file(&output_path).unwrap();
|
||||
|
||||
// Vérifier que les métadonnées ont été correctement écrites
|
||||
assert_eq!(file_metadata.title, Some("Test Track Title".to_string()));
|
||||
assert_eq!(file_metadata.artist, Some("Test Artist".to_string()));
|
||||
assert_eq!(file_metadata.album, Some("Test Album".to_string()));
|
||||
assert_eq!(file_metadata.year, Some(2024));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_flac_file_sink_writes_audio() {
|
||||
use pmoflac::{encode_flac_stream, EncoderOptions, PcmFormat};
|
||||
use std::io::Cursor;
|
||||
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let input_path = temp_dir.path().join("input.flac");
|
||||
let output_path = temp_dir.path().join("output.flac");
|
||||
|
||||
// Créer un petit fichier FLAC de test (comme dans file_source test)
|
||||
let sample_rate = 44_100;
|
||||
let frames = 512;
|
||||
let mut pcm = Vec::with_capacity(frames * 4);
|
||||
for i in 0..frames {
|
||||
let sample = ((i % 32) as f32 / 31.0 * 2.0 - 1.0) * 0.5;
|
||||
let sample_i16 = (sample * 32767.0) as i16;
|
||||
pcm.extend_from_slice(&sample_i16.to_le_bytes());
|
||||
pcm.extend_from_slice(&sample_i16.to_le_bytes());
|
||||
}
|
||||
|
||||
let format = PcmFormat {
|
||||
sample_rate,
|
||||
channels: 2,
|
||||
bits_per_sample: 16,
|
||||
};
|
||||
|
||||
let mut flac_stream =
|
||||
encode_flac_stream(Cursor::new(pcm.clone()), format, EncoderOptions::default())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut input_file = File::create(&input_path).await.unwrap();
|
||||
tokio::io::copy(&mut flac_stream, &mut input_file)
|
||||
.await
|
||||
.unwrap();
|
||||
input_file.flush().await.unwrap();
|
||||
flac_stream.wait().await.unwrap();
|
||||
|
||||
// Maintenant utiliser FlacFileSink pour réécrire le fichier
|
||||
let sink = FlacFileSink::with_channel_size(&output_path, 16);
|
||||
let tx = sink.get_tx().unwrap();
|
||||
let stop_token = CancellationToken::new();
|
||||
let sink_handle = tokio::spawn(async move {
|
||||
Box::new(sink).run(stop_token).await.unwrap()
|
||||
});
|
||||
|
||||
// Lire le fichier input et envoyer les segments au sink
|
||||
tokio::spawn(async move {
|
||||
let source_file = File::open(&input_path).await.unwrap();
|
||||
let mut decode_stream = pmoflac::decode_audio_stream(source_file).await.unwrap();
|
||||
let info = decode_stream.info().clone();
|
||||
|
||||
// TopZeroSync
|
||||
tx.send(crate::AudioSegment::new_top_zero_sync())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Lire et envoyer les chunks
|
||||
let mut buffer = vec![0u8; info.bytes_per_sample() * info.channels as usize * 256];
|
||||
let mut total_frames = 0u64;
|
||||
let mut order = 0u64;
|
||||
|
||||
loop {
|
||||
let read = decode_stream.read(&mut buffer).await.unwrap();
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
let chunk_frames = read / (info.bytes_per_sample() * info.channels as usize);
|
||||
let timestamp = total_frames as f64 / info.sample_rate as f64;
|
||||
|
||||
// Créer un segment I16
|
||||
let mut stereo = Vec::with_capacity(chunk_frames);
|
||||
for i in 0..chunk_frames {
|
||||
let offset = i * info.bytes_per_sample() * info.channels as usize;
|
||||
let l = i16::from_le_bytes([buffer[offset], buffer[offset + 1]]);
|
||||
let r = i16::from_le_bytes([buffer[offset + 2], buffer[offset + 3]]);
|
||||
stereo.push([l, r]);
|
||||
}
|
||||
|
||||
let chunk_data = crate::AudioChunkData::new(stereo, info.sample_rate, 0.0);
|
||||
let chunk = crate::AudioChunk::I16(chunk_data);
|
||||
let segment = crate::AudioSegment {
|
||||
order,
|
||||
timestamp_sec: timestamp,
|
||||
segment: crate::_AudioSegment::Chunk(std::sync::Arc::new(chunk)),
|
||||
};
|
||||
|
||||
tx.send(std::sync::Arc::new(segment)).await.unwrap();
|
||||
total_frames += chunk_frames as u64;
|
||||
order += 1;
|
||||
}
|
||||
|
||||
// EndOfStream
|
||||
let final_timestamp = total_frames as f64 / info.sample_rate as f64;
|
||||
tx.send(crate::AudioSegment::new_end_of_stream(
|
||||
order,
|
||||
final_timestamp,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
drop(tx);
|
||||
decode_stream.wait().await.unwrap();
|
||||
});
|
||||
|
||||
sink_handle.await.unwrap();
|
||||
|
||||
// Vérifier que le fichier de sortie est valide
|
||||
let file = File::open(&output_path).await.unwrap();
|
||||
let mut stream = decode_flac_stream(file).await.unwrap();
|
||||
let info = stream.info().clone();
|
||||
assert_eq!(info.channels, 2);
|
||||
assert_eq!(info.sample_rate, sample_rate);
|
||||
assert_eq!(info.bits_per_sample, 16);
|
||||
|
||||
let mut decoded = Vec::new();
|
||||
stream.read_to_end(&mut decoded).await.unwrap();
|
||||
stream.wait().await.unwrap();
|
||||
assert!(decoded.len() > 0);
|
||||
}
|
||||
}
|
||||
903
pmoaudio/src/nodes/http_source.rs
Executable file
903
pmoaudio/src/nodes/http_source.rs
Executable file
@@ -0,0 +1,903 @@
|
||||
use crate::{
|
||||
nodes::{AudioError, TypedAudioNode, DEFAULT_CHUNK_DURATION_MS},
|
||||
pipeline::{Node, NodeLogic},
|
||||
type_constraints::TypeRequirement,
|
||||
AudioChunk, AudioChunkData, AudioPipelineNode, AudioSegment, I24,
|
||||
};
|
||||
use futures_util::StreamExt;
|
||||
use pmoflac::{decode_audio_stream, StreamInfo};
|
||||
use pmometadata::{MemoryTrackMetadata, TrackMetadata};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_util::{io::StreamReader, sync::CancellationToken};
|
||||
|
||||
/// HttpSource - Récupère un fichier audio via HTTP et publie des `AudioSegment`
|
||||
///
|
||||
/// Cette source télécharge un fichier audio depuis une URL HTTP/HTTPS,
|
||||
/// utilise `pmoflac` pour le décoder (FLAC/MP3/OGG/WAV/AIFF) puis transforme
|
||||
/// les échantillons PCM en `AudioSegment` stéréo avec le type approprié.
|
||||
///
|
||||
/// Le node émet trois types de syncmarkers :
|
||||
/// - `TopZeroSync` au début du flux
|
||||
/// - `TrackBoundary` avec les métadonnées extraites des headers HTTP
|
||||
/// - `EndOfStream` à la fin du flux
|
||||
///
|
||||
/// # Métadonnées HTTP
|
||||
///
|
||||
/// Les métadonnées suivantes sont extraites des headers HTTP lorsqu'elles sont disponibles:
|
||||
/// - `icy-name`: nom du stream (Icecast/Shoutcast) → utilisé comme titre
|
||||
/// - `icy-url`: URL du stream source
|
||||
/// - `content-type`: type MIME du contenu (ex: audio/flac, audio/mpeg)
|
||||
///
|
||||
/// Si aucun header `icy-name` n'est présent, le nom du fichier est extrait de l'URL
|
||||
/// et utilisé comme titre.
|
||||
///
|
||||
/// # Exemples
|
||||
///
|
||||
/// ## Lecture d'un fichier FLAC distant
|
||||
///
|
||||
/// ```no_run
|
||||
/// use pmoaudio::HttpSource;
|
||||
/// use tokio::sync::mpsc;
|
||||
///
|
||||
/// #[tokio::main]
|
||||
/// async fn main() {
|
||||
/// let mut source = HttpSource::new("http://example.com/audio.flac");
|
||||
/// let (tx, mut rx) = mpsc::channel(16);
|
||||
/// source.add_subscriber(tx);
|
||||
///
|
||||
/// // Lancer la lecture dans une tâche séparée
|
||||
/// tokio::spawn(async move {
|
||||
/// source.run().await.unwrap();
|
||||
/// });
|
||||
///
|
||||
/// // Recevoir et traiter les segments audio
|
||||
/// while let Some(segment) = rx.recv().await {
|
||||
/// if segment.is_audio_chunk() {
|
||||
/// println!("Chunk reçu à {}s", segment.timestamp_sec);
|
||||
/// }
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// ## Stream Icecast/Shoutcast
|
||||
///
|
||||
/// ```no_run
|
||||
/// use pmoaudio::HttpSource;
|
||||
/// use tokio::sync::mpsc;
|
||||
///
|
||||
/// #[tokio::main]
|
||||
/// async fn main() {
|
||||
/// // Les métadonnées icy-name seront extraites automatiquement
|
||||
/// let mut source = HttpSource::new("http://stream.example.com:8000/stream");
|
||||
/// let (tx, rx) = mpsc::channel(32);
|
||||
/// source.add_subscriber(tx);
|
||||
///
|
||||
/// tokio::spawn(async move {
|
||||
/// source.run().await.unwrap();
|
||||
/// });
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// # Gestion des erreurs
|
||||
///
|
||||
/// La méthode `run()` peut retourner les erreurs suivantes:
|
||||
/// - `AudioError::ProcessingError`: échec de connexion HTTP, status code non-200,
|
||||
/// erreur de décodage audio, ou format non supporté
|
||||
///
|
||||
/// # Performance
|
||||
///
|
||||
/// - Le téléchargement et le décodage sont effectués en streaming
|
||||
/// - Pas de buffering complet du fichier en mémoire
|
||||
/// - La taille des chunks audio est calculée automatiquement pour ~50ms de latence
|
||||
/// - Compatible avec les streams infinis (radios web, etc.)
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// HttpSourceLogic - Logique métier pure
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Logique pure de lecture HTTP et décodage audio
|
||||
pub struct HttpSourceLogic {
|
||||
url: String,
|
||||
chunk_frames: usize,
|
||||
}
|
||||
|
||||
impl HttpSourceLogic {
|
||||
pub fn new<S: Into<String>>(url: S, chunk_frames: usize) -> Self {
|
||||
Self {
|
||||
url: url.into(),
|
||||
chunk_frames,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_url(&self) -> String {
|
||||
self.url.clone()
|
||||
}
|
||||
|
||||
pub fn get_chunc_frames(&self) -> usize {
|
||||
self.chunk_frames
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl NodeLogic for HttpSourceLogic {
|
||||
async fn process(
|
||||
&mut self,
|
||||
_input: Option<mpsc::Receiver<Arc<AudioSegment>>>,
|
||||
output: Vec<mpsc::Sender<Arc<AudioSegment>>>,
|
||||
stop_token: CancellationToken,
|
||||
) -> Result<(), AudioError> {
|
||||
macro_rules! send_to_children {
|
||||
($segment:expr) => {
|
||||
for tx in &output {
|
||||
tx.send($segment.clone())
|
||||
.await
|
||||
.map_err(|_| AudioError::ChildDied)?;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Effectuer la requête HTTP
|
||||
let response = reqwest::get(&self.url)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AudioError::ProcessingError(format!("HTTP request failed for {}: {}", self.url, e))
|
||||
})?;
|
||||
|
||||
// Vérifier le status
|
||||
if !response.status().is_success() {
|
||||
return Err(AudioError::ProcessingError(format!(
|
||||
"HTTP request returned status {}: {}",
|
||||
response.status(),
|
||||
self.url
|
||||
)));
|
||||
}
|
||||
|
||||
// Extraire les métadonnées depuis les headers HTTP
|
||||
let metadata = extract_metadata_from_headers(&response, &self.url).await;
|
||||
|
||||
// Convertir le stream de bytes en AsyncRead
|
||||
let bytes_stream = response.bytes_stream();
|
||||
let stream_reader = StreamReader::new(bytes_stream.map(|result| {
|
||||
result.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))
|
||||
}));
|
||||
|
||||
// Décoder le flux audio
|
||||
let mut stream = decode_audio_stream(stream_reader)
|
||||
.await
|
||||
.map_err(|e| AudioError::ProcessingError(format!("Decode error: {}", e)))?;
|
||||
let stream_info = stream.info().clone();
|
||||
|
||||
validate_stream(&stream_info)?;
|
||||
|
||||
// Calculer la taille des chunks si non spécifiée (0 = auto)
|
||||
let chunk_frames_final = if self.chunk_frames == 0 {
|
||||
let frames =
|
||||
(stream_info.sample_rate as f64 * DEFAULT_CHUNK_DURATION_MS / 1000.0) as usize;
|
||||
frames.next_power_of_two().max(256)
|
||||
} else {
|
||||
self.chunk_frames.max(1)
|
||||
};
|
||||
|
||||
// Émettre TopZeroSync
|
||||
send_to_children!(AudioSegment::new_top_zero_sync());
|
||||
|
||||
// Émettre TrackBoundary avec les métadonnées HTTP
|
||||
let track_boundary = AudioSegment::new_track_boundary(
|
||||
0,
|
||||
0.0,
|
||||
Arc::new(tokio::sync::RwLock::new(metadata)),
|
||||
);
|
||||
send_to_children!(track_boundary);
|
||||
|
||||
// Préparer la lecture des chunks audio
|
||||
let frame_bytes = stream_info.bytes_per_sample() * stream_info.channels as usize;
|
||||
let chunk_byte_len = chunk_frames_final * frame_bytes;
|
||||
let mut pending = Vec::new();
|
||||
let mut read_buf = vec![0u8; frame_bytes * 512.max(chunk_frames_final)];
|
||||
let mut chunk_index = 0u64;
|
||||
let mut total_frames = 0u64;
|
||||
|
||||
// Lire et émettre les chunks audio
|
||||
loop {
|
||||
// Vérifier l'arrêt
|
||||
if stop_token.is_cancelled() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Remplir le buffer
|
||||
if pending.len() < chunk_byte_len {
|
||||
use tokio::io::AsyncReadExt;
|
||||
let read = tokio::select! {
|
||||
result = stream.read(&mut read_buf) => {
|
||||
result.map_err(|e| {
|
||||
AudioError::ProcessingError(format!("I/O error while decoding: {}", e))
|
||||
})?
|
||||
}
|
||||
_ = stop_token.cancelled() => {
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
pending.extend_from_slice(&read_buf[..read]);
|
||||
}
|
||||
|
||||
if pending.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
// Extraire un chunk
|
||||
let frames_in_pending = pending.len() / frame_bytes;
|
||||
let frames_to_emit = frames_in_pending.min(chunk_frames_final);
|
||||
let take_bytes = frames_to_emit * frame_bytes;
|
||||
let chunk_bytes = pending.drain(..take_bytes).collect::<Vec<u8>>();
|
||||
|
||||
// Calculer le timestamp
|
||||
let timestamp_sec = total_frames as f64 / stream_info.sample_rate as f64;
|
||||
|
||||
// Créer le segment audio
|
||||
let segment = bytes_to_segment(
|
||||
&chunk_bytes,
|
||||
&stream_info,
|
||||
frames_to_emit,
|
||||
chunk_index,
|
||||
timestamp_sec,
|
||||
)?;
|
||||
|
||||
send_to_children!(segment);
|
||||
|
||||
chunk_index += 1;
|
||||
total_frames += frames_to_emit as u64;
|
||||
}
|
||||
|
||||
// Traiter le reste éventuel
|
||||
if !pending.is_empty() {
|
||||
let frames = pending.len() / frame_bytes;
|
||||
if frames > 0 {
|
||||
let timestamp_sec = total_frames as f64 / stream_info.sample_rate as f64;
|
||||
let segment =
|
||||
bytes_to_segment(&pending, &stream_info, frames, chunk_index, timestamp_sec)?;
|
||||
send_to_children!(segment);
|
||||
total_frames += frames as u64;
|
||||
chunk_index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Émettre EndOfStream
|
||||
let final_timestamp = total_frames as f64 / stream_info.sample_rate as f64;
|
||||
let eos = AudioSegment::new_end_of_stream(chunk_index, final_timestamp);
|
||||
send_to_children!(eos);
|
||||
|
||||
// Attendre la fin du décodage
|
||||
stream
|
||||
.wait()
|
||||
.await
|
||||
.map_err(|e| AudioError::ProcessingError(format!("Decode task failed: {}", e)))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// HttpSource - Wrapper utilisant Node<HttpSourceLogic>
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
pub struct HttpSource {
|
||||
inner: Node<HttpSourceLogic>,
|
||||
}
|
||||
|
||||
impl HttpSource {
|
||||
/// Crée une nouvelle source HTTP avec calcul automatique de la taille des chunks.
|
||||
///
|
||||
/// La taille des chunks sera calculée automatiquement pour obtenir environ 50ms
|
||||
/// de latence par chunk, en fonction du sample rate du fichier distant.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `url` - URL HTTP ou HTTPS du fichier audio à télécharger
|
||||
///
|
||||
/// # Exemples
|
||||
///
|
||||
/// ```no_run
|
||||
/// use pmoaudio::HttpSource;
|
||||
///
|
||||
/// let source = HttpSource::new("http://example.com/music.flac");
|
||||
/// ```
|
||||
pub fn new<S: Into<String>>(url: S) -> Self {
|
||||
Self::with_chunk_size(url, 0)
|
||||
}
|
||||
|
||||
/// Crée une nouvelle source HTTP avec une taille de chunk spécifique.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `url` - URL HTTP ou HTTPS du fichier audio à télécharger
|
||||
/// * `chunk_frames` - nombre d'échantillons par canal par chunk (0 = auto-calcul)
|
||||
///
|
||||
/// # Exemples
|
||||
///
|
||||
/// ```no_run
|
||||
/// use pmoaudio::HttpSource;
|
||||
///
|
||||
/// // Utiliser des chunks de 2048 frames
|
||||
/// let source = HttpSource::with_chunk_size("http://example.com/music.mp3", 2048);
|
||||
/// ```
|
||||
pub fn with_chunk_size<S: Into<String>>(url: S, chunk_frames: usize) -> Self {
|
||||
let logic = HttpSourceLogic::new(url.into(), chunk_frames);
|
||||
Self {
|
||||
inner: Node::new_source(logic),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_url(&self) -> String {
|
||||
self.inner.logic().get_url()
|
||||
}
|
||||
|
||||
pub fn get_chunc_frames(&self) -> usize {
|
||||
self.inner.logic().get_chunc_frames()
|
||||
}
|
||||
}
|
||||
|
||||
/// Extrait les métadonnées disponibles depuis les headers HTTP
|
||||
async fn extract_metadata_from_headers(
|
||||
response: &reqwest::Response,
|
||||
url: &str,
|
||||
) -> MemoryTrackMetadata {
|
||||
let mut metadata = MemoryTrackMetadata::new();
|
||||
let headers = response.headers();
|
||||
|
||||
// Icecast/Shoutcast stream name
|
||||
if let Some(name) = headers.get("icy-name").and_then(|v| v.to_str().ok()) {
|
||||
let _ = metadata.set_title(Some(name.to_string())).await;
|
||||
}
|
||||
|
||||
// Icecast/Shoutcast stream URL (peut être utilisé comme source)
|
||||
if let Some(stream_url) = headers.get("icy-url").and_then(|v| v.to_str().ok()) {
|
||||
// On pourrait stocker ça dans un champ custom si nécessaire
|
||||
eprintln!("Stream URL: {}", stream_url);
|
||||
}
|
||||
|
||||
// Content-Type pour déterminer le format
|
||||
if let Some(content_type) = headers.get("content-type").and_then(|v| v.to_str().ok()) {
|
||||
eprintln!("Content-Type: {}", content_type);
|
||||
// On pourrait utiliser ça pour valider le format attendu
|
||||
}
|
||||
|
||||
// Si aucune métadonnée spécifique n'est trouvée, utiliser l'URL comme titre
|
||||
if metadata.get_title().await.ok().flatten().is_none() {
|
||||
// Extraire le nom du fichier depuis l'URL
|
||||
if let Some(filename) = url.rsplit('/').next() {
|
||||
if !filename.is_empty() {
|
||||
let _ = metadata.set_title(Some(filename.to_string())).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
metadata
|
||||
}
|
||||
|
||||
fn validate_stream(info: &StreamInfo) -> Result<(), AudioError> {
|
||||
if !(1..=2).contains(&info.channels) {
|
||||
return Err(AudioError::ProcessingError(format!(
|
||||
"Unsupported channel count: {}",
|
||||
info.channels
|
||||
)));
|
||||
}
|
||||
match info.bits_per_sample {
|
||||
8 | 16 | 24 | 32 => Ok(()),
|
||||
other => Err(AudioError::ProcessingError(format!(
|
||||
"Unsupported bit depth: {}",
|
||||
other
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convertit des bytes PCM en AudioSegment avec le type approprié
|
||||
fn bytes_to_segment(
|
||||
chunk_bytes: &[u8],
|
||||
info: &StreamInfo,
|
||||
frames: usize,
|
||||
order: u64,
|
||||
timestamp_sec: f64,
|
||||
) -> Result<Arc<AudioSegment>, AudioError> {
|
||||
let bytes_per_sample = info.bytes_per_sample();
|
||||
let channels = info.channels as usize;
|
||||
let frame_bytes = bytes_per_sample * channels;
|
||||
|
||||
// Créer le chunk du bon type selon la profondeur de bit
|
||||
let chunk = match info.bits_per_sample {
|
||||
16 => {
|
||||
// Type I16
|
||||
let mut stereo = Vec::with_capacity(frames);
|
||||
for frame_idx in 0..frames {
|
||||
let base = frame_idx * frame_bytes;
|
||||
let l = i16::from_le_bytes(
|
||||
chunk_bytes[base..base + bytes_per_sample]
|
||||
.try_into()
|
||||
.unwrap(),
|
||||
);
|
||||
let r = if channels == 1 {
|
||||
l
|
||||
} else {
|
||||
i16::from_le_bytes(
|
||||
chunk_bytes[base + bytes_per_sample..base + 2 * bytes_per_sample]
|
||||
.try_into()
|
||||
.unwrap(),
|
||||
)
|
||||
};
|
||||
stereo.push([l, r]);
|
||||
}
|
||||
let chunk_data = AudioChunkData::new(stereo, info.sample_rate, 0.0);
|
||||
AudioChunk::I16(chunk_data)
|
||||
}
|
||||
24 => {
|
||||
// Type I24
|
||||
let mut stereo = Vec::with_capacity(frames);
|
||||
for frame_idx in 0..frames {
|
||||
let base = frame_idx * frame_bytes;
|
||||
let l_i32 = {
|
||||
let mut buf = [0u8; 4];
|
||||
buf[..3].copy_from_slice(&chunk_bytes[base..base + 3]);
|
||||
// Sign extend
|
||||
if chunk_bytes[base + 2] & 0x80 != 0 {
|
||||
buf[3] = 0xFF;
|
||||
}
|
||||
i32::from_le_bytes(buf)
|
||||
};
|
||||
let l = I24::new(l_i32).ok_or_else(|| {
|
||||
AudioError::ProcessingError(format!("Invalid I24 value: {}", l_i32))
|
||||
})?;
|
||||
|
||||
let r = if channels == 1 {
|
||||
l
|
||||
} else {
|
||||
let r_i32 = {
|
||||
let mut buf = [0u8; 4];
|
||||
buf[..3].copy_from_slice(
|
||||
&chunk_bytes[base + bytes_per_sample..base + bytes_per_sample + 3],
|
||||
);
|
||||
// Sign extend
|
||||
if chunk_bytes[base + bytes_per_sample + 2] & 0x80 != 0 {
|
||||
buf[3] = 0xFF;
|
||||
}
|
||||
i32::from_le_bytes(buf)
|
||||
};
|
||||
I24::new(r_i32).ok_or_else(|| {
|
||||
AudioError::ProcessingError(format!("Invalid I24 value: {}", r_i32))
|
||||
})?
|
||||
};
|
||||
stereo.push([l, r]);
|
||||
}
|
||||
let chunk_data = AudioChunkData::new(stereo, info.sample_rate, 0.0);
|
||||
AudioChunk::I24(chunk_data)
|
||||
}
|
||||
32 => {
|
||||
// Type I32
|
||||
let mut stereo = Vec::with_capacity(frames);
|
||||
for frame_idx in 0..frames {
|
||||
let base = frame_idx * frame_bytes;
|
||||
let l = i32::from_le_bytes(
|
||||
chunk_bytes[base..base + bytes_per_sample]
|
||||
.try_into()
|
||||
.unwrap(),
|
||||
);
|
||||
let r = if channels == 1 {
|
||||
l
|
||||
} else {
|
||||
i32::from_le_bytes(
|
||||
chunk_bytes[base + bytes_per_sample..base + 2 * bytes_per_sample]
|
||||
.try_into()
|
||||
.unwrap(),
|
||||
)
|
||||
};
|
||||
stereo.push([l, r]);
|
||||
}
|
||||
let chunk_data = AudioChunkData::new(stereo, info.sample_rate, 0.0);
|
||||
AudioChunk::I32(chunk_data)
|
||||
}
|
||||
other => {
|
||||
return Err(AudioError::ProcessingError(format!(
|
||||
"Unsupported bit depth: {}",
|
||||
other
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
// Créer le segment audio
|
||||
Ok(Arc::new(AudioSegment {
|
||||
order,
|
||||
timestamp_sec,
|
||||
segment: crate::_AudioSegment::Chunk(Arc::new(chunk)),
|
||||
}))
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl AudioPipelineNode for HttpSource {
|
||||
fn get_tx(&self) -> Option<mpsc::Sender<Arc<AudioSegment>>> {
|
||||
self.inner.get_tx()
|
||||
}
|
||||
|
||||
fn register(&mut self, child: Box<dyn AudioPipelineNode>) {
|
||||
self.inner.register(child)
|
||||
}
|
||||
|
||||
async fn run(
|
||||
self: Box<Self>,
|
||||
stop_token: CancellationToken,
|
||||
) -> Result<(), AudioError> {
|
||||
Box::new(self.inner).run(stop_token).await
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedAudioNode for HttpSource {
|
||||
fn input_type(&self) -> Option<TypeRequirement> {
|
||||
// HttpSource est une source, elle ne consomme pas d'audio
|
||||
None
|
||||
}
|
||||
|
||||
fn output_type(&self) -> Option<TypeRequirement> {
|
||||
// HttpSource peut produire n'importe quel type entier (I16, I24, I32)
|
||||
// selon la profondeur de bit du fichier source
|
||||
Some(TypeRequirement::any_integer())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pmoflac::{encode_flac_stream, EncoderOptions, PcmFormat};
|
||||
use std::io::Cursor;
|
||||
use tokio::sync::mpsc;
|
||||
use wiremock::{
|
||||
matchers::{method, path},
|
||||
Mock, MockServer, ResponseTemplate,
|
||||
};
|
||||
|
||||
/// Nœud de test qui collecte tous les segments et les envoie à un channel de test
|
||||
struct TestCollectorNode {
|
||||
input_tx: mpsc::Sender<Arc<AudioSegment>>,
|
||||
input_rx: mpsc::Receiver<Arc<AudioSegment>>,
|
||||
output_tx: mpsc::Sender<Arc<AudioSegment>>,
|
||||
}
|
||||
|
||||
impl TestCollectorNode {
|
||||
fn new(output_tx: mpsc::Sender<Arc<AudioSegment>>) -> Self {
|
||||
let (input_tx, input_rx) = mpsc::channel(16);
|
||||
Self {
|
||||
input_tx,
|
||||
input_rx,
|
||||
output_tx,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl AudioPipelineNode for TestCollectorNode {
|
||||
fn get_tx(&self) -> Option<mpsc::Sender<Arc<AudioSegment>>> {
|
||||
Some(self.input_tx.clone())
|
||||
}
|
||||
|
||||
fn register(&mut self, _child: Box<dyn AudioPipelineNode>) {
|
||||
panic!("TestCollectorNode is a sink and cannot have children");
|
||||
}
|
||||
|
||||
async fn run(
|
||||
mut self: Box<Self>,
|
||||
_stop_token: CancellationToken,
|
||||
) -> Result<(), AudioError> {
|
||||
while let Some(segment) = self.input_rx.recv().await {
|
||||
if self.output_tx.send(segment).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Test de création basique de HttpSource
|
||||
#[test]
|
||||
fn test_http_source_creation() {
|
||||
let source = HttpSource::new("http://example.com/audio.flac");
|
||||
assert_eq!(source.get_url(), "http://example.com/audio.flac");
|
||||
assert_eq!(source.get_chunc_frames(), 0);
|
||||
}
|
||||
|
||||
/// Test de création avec taille de chunk personnalisée
|
||||
#[test]
|
||||
fn test_http_source_with_chunk_size() {
|
||||
let source = HttpSource::with_chunk_size("http://example.com/audio.mp3", 1024);
|
||||
assert_eq!(source.get_url(), "http://example.com/audio.mp3");
|
||||
assert_eq!(source.get_chunc_frames(), 1024);
|
||||
}
|
||||
|
||||
/// Test de téléchargement et décodage d'un fichier FLAC via HTTP
|
||||
#[tokio::test]
|
||||
async fn test_http_source_downloads_and_decodes_flac() {
|
||||
// Créer un serveur HTTP mock
|
||||
let mock_server = MockServer::start().await;
|
||||
|
||||
// Générer un petit fichier FLAC de test
|
||||
let sample_rate = 48_000;
|
||||
let frames = 256;
|
||||
let mut pcm = Vec::with_capacity(frames * 4);
|
||||
for i in 0..frames {
|
||||
let sample = ((i % 32) as f32 / 31.0 * 2.0 - 1.0) * 0.5;
|
||||
let sample_i16 = (sample * 32767.0) as i16;
|
||||
pcm.extend_from_slice(&sample_i16.to_le_bytes());
|
||||
pcm.extend_from_slice(&sample_i16.to_le_bytes());
|
||||
}
|
||||
|
||||
let format = PcmFormat {
|
||||
sample_rate,
|
||||
channels: 2,
|
||||
bits_per_sample: 16,
|
||||
};
|
||||
|
||||
let mut flac_stream =
|
||||
encode_flac_stream(Cursor::new(pcm.clone()), format, EncoderOptions::default())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Lire le FLAC encodé dans un buffer
|
||||
let mut flac_data = Vec::new();
|
||||
tokio::io::copy(&mut flac_stream, &mut flac_data)
|
||||
.await
|
||||
.unwrap();
|
||||
flac_stream.wait().await.unwrap();
|
||||
|
||||
// Configurer le mock pour servir le fichier FLAC
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/test.flac"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_bytes(flac_data)
|
||||
.insert_header("content-type", "audio/flac"),
|
||||
)
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
// Créer la source HTTP pointant vers le mock
|
||||
let url = format!("{}/test.flac", mock_server.uri());
|
||||
let mut source = HttpSource::with_chunk_size(&url, 64);
|
||||
|
||||
// Créer un noeud collecteur pour recevoir les segments
|
||||
let (tx, mut rx) = mpsc::channel(16);
|
||||
let collector = TestCollectorNode::new(tx);
|
||||
|
||||
// Construire le pipeline
|
||||
source.register(Box::new(collector));
|
||||
|
||||
// Lancer le pipeline
|
||||
let stop_token = CancellationToken::new();
|
||||
tokio::spawn(async move {
|
||||
Box::new(source).run(stop_token).await.unwrap();
|
||||
});
|
||||
|
||||
// Vérifier les segments reçus
|
||||
let mut received_frames = 0usize;
|
||||
let mut seen_top_zero = false;
|
||||
let mut seen_track_boundary = false;
|
||||
let mut seen_eos = false;
|
||||
|
||||
while let Some(segment) = rx.recv().await {
|
||||
if segment.is_audio_chunk() {
|
||||
if let Some(chunk) = segment.as_chunk() {
|
||||
received_frames += chunk.len();
|
||||
assert_eq!(chunk.sample_rate(), sample_rate);
|
||||
}
|
||||
} else if let Some(marker) = segment.as_sync_marker() {
|
||||
match **marker {
|
||||
crate::SyncMarker::TopZeroSync => seen_top_zero = true,
|
||||
crate::SyncMarker::TrackBoundary { .. } => seen_track_boundary = true,
|
||||
crate::SyncMarker::EndOfStream => seen_eos = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Vérifications
|
||||
assert_eq!(received_frames, frames, "Tous les frames doivent être reçus");
|
||||
assert!(seen_top_zero, "TopZeroSync doit être émis");
|
||||
assert!(seen_track_boundary, "TrackBoundary doit être émis");
|
||||
assert!(seen_eos, "EndOfStream doit être émis");
|
||||
}
|
||||
|
||||
/// Test de l'extraction des métadonnées depuis les headers HTTP
|
||||
#[tokio::test]
|
||||
async fn test_http_source_extracts_icy_metadata() {
|
||||
let mock_server = MockServer::start().await;
|
||||
|
||||
// Créer un fichier FLAC minimal
|
||||
let sample_rate = 48_000;
|
||||
let frames = 128;
|
||||
let mut pcm = Vec::with_capacity(frames * 4);
|
||||
for i in 0..frames {
|
||||
let sample_i16 = ((i % 100) as i16) * 100;
|
||||
pcm.extend_from_slice(&sample_i16.to_le_bytes());
|
||||
pcm.extend_from_slice(&sample_i16.to_le_bytes());
|
||||
}
|
||||
|
||||
let format = PcmFormat {
|
||||
sample_rate,
|
||||
channels: 2,
|
||||
bits_per_sample: 16,
|
||||
};
|
||||
|
||||
let mut flac_stream = encode_flac_stream(Cursor::new(pcm), format, EncoderOptions::default())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut flac_data = Vec::new();
|
||||
tokio::io::copy(&mut flac_stream, &mut flac_data)
|
||||
.await
|
||||
.unwrap();
|
||||
flac_stream.wait().await.unwrap();
|
||||
|
||||
// Configurer le mock avec headers Icecast
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/stream"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_bytes(flac_data)
|
||||
.insert_header("content-type", "audio/flac")
|
||||
.insert_header("icy-name", "Test Radio Stream")
|
||||
.insert_header("icy-url", "http://example.com/radio"),
|
||||
)
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let url = format!("{}/stream", mock_server.uri());
|
||||
let mut source = HttpSource::new(&url);
|
||||
let (tx, mut rx) = mpsc::channel(16);
|
||||
let collector = TestCollectorNode::new(tx);
|
||||
source.register(Box::new(collector));
|
||||
|
||||
let stop_token = CancellationToken::new();
|
||||
tokio::spawn(async move {
|
||||
Box::new(source).run(stop_token).await.unwrap();
|
||||
});
|
||||
|
||||
// Chercher le TrackBoundary pour vérifier les métadonnées
|
||||
let mut found_metadata = false;
|
||||
while let Some(segment) = rx.recv().await {
|
||||
if let Some(marker) = segment.as_sync_marker() {
|
||||
if let crate::SyncMarker::TrackBoundary { metadata, .. } = &**marker {
|
||||
// Vérifier que le titre extrait est "Test Radio Stream"
|
||||
if let Some(title) = metadata.read().await.get_title().await.ok().flatten() {
|
||||
assert_eq!(title, "Test Radio Stream");
|
||||
found_metadata = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(found_metadata, "Les métadonnées ICY doivent être extraites");
|
||||
}
|
||||
|
||||
/// Test du comportement en cas d'erreur HTTP 404
|
||||
#[tokio::test]
|
||||
async fn test_http_source_handles_404_error() {
|
||||
let mock_server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/notfound.flac"))
|
||||
.respond_with(ResponseTemplate::new(404))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let url = format!("{}/notfound.flac", mock_server.uri());
|
||||
let mut source = HttpSource::new(&url);
|
||||
let (tx, _rx) = mpsc::channel(16);
|
||||
let collector = TestCollectorNode::new(tx);
|
||||
source.register(Box::new(collector));
|
||||
|
||||
let stop_token = CancellationToken::new();
|
||||
let result = Box::new(source).run(stop_token).await;
|
||||
assert!(result.is_err(), "Doit retourner une erreur pour HTTP 404");
|
||||
|
||||
if let Err(AudioError::ProcessingError(msg)) = result {
|
||||
assert!(msg.contains("404"), "Le message d'erreur doit mentionner le code 404");
|
||||
} else {
|
||||
panic!("Le type d'erreur doit être ProcessingError");
|
||||
}
|
||||
}
|
||||
|
||||
/// Test du comportement avec un format audio invalide
|
||||
#[tokio::test]
|
||||
async fn test_http_source_handles_invalid_audio_format() {
|
||||
let mock_server = MockServer::start().await;
|
||||
|
||||
// Envoyer des données invalides (pas un fichier audio)
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/invalid.flac"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_bytes(b"This is not a valid audio file")
|
||||
.insert_header("content-type", "audio/flac"),
|
||||
)
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let url = format!("{}/invalid.flac", mock_server.uri());
|
||||
let mut source = HttpSource::new(&url);
|
||||
let (tx, _rx) = mpsc::channel(16);
|
||||
let collector = TestCollectorNode::new(tx);
|
||||
source.register(Box::new(collector));
|
||||
|
||||
let stop_token = CancellationToken::new();
|
||||
let result = Box::new(source).run(stop_token).await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"Doit retourner une erreur pour un format invalide"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test de l'extraction du nom de fichier depuis l'URL quand pas de header icy-name
|
||||
#[tokio::test]
|
||||
async fn test_http_source_uses_filename_as_title() {
|
||||
let mock_server = MockServer::start().await;
|
||||
|
||||
let sample_rate = 48_000;
|
||||
let frames = 128;
|
||||
let mut pcm = Vec::with_capacity(frames * 4);
|
||||
for i in 0..frames {
|
||||
let sample_i16 = (i % 100) as i16;
|
||||
pcm.extend_from_slice(&sample_i16.to_le_bytes());
|
||||
pcm.extend_from_slice(&sample_i16.to_le_bytes());
|
||||
}
|
||||
|
||||
let format = PcmFormat {
|
||||
sample_rate,
|
||||
channels: 2,
|
||||
bits_per_sample: 16,
|
||||
};
|
||||
|
||||
let mut flac_stream = encode_flac_stream(Cursor::new(pcm), format, EncoderOptions::default())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut flac_data = Vec::new();
|
||||
tokio::io::copy(&mut flac_stream, &mut flac_data)
|
||||
.await
|
||||
.unwrap();
|
||||
flac_stream.wait().await.unwrap();
|
||||
|
||||
// Sans header icy-name
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/my-song.flac"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_bytes(flac_data)
|
||||
.insert_header("content-type", "audio/flac"),
|
||||
)
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let url = format!("{}/my-song.flac", mock_server.uri());
|
||||
let mut source = HttpSource::new(&url);
|
||||
let (tx, mut rx) = mpsc::channel(16);
|
||||
let collector = TestCollectorNode::new(tx);
|
||||
source.register(Box::new(collector));
|
||||
|
||||
let stop_token = CancellationToken::new();
|
||||
tokio::spawn(async move {
|
||||
Box::new(source).run(stop_token).await.unwrap();
|
||||
});
|
||||
|
||||
let mut found_title = false;
|
||||
while let Some(segment) = rx.recv().await {
|
||||
if let Some(marker) = segment.as_sync_marker() {
|
||||
if let crate::SyncMarker::TrackBoundary { metadata, .. } = &**marker {
|
||||
if let Some(title) = metadata.read().await.get_title().await.ok().flatten() {
|
||||
assert_eq!(title, "my-song.flac");
|
||||
found_title = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(found_title, "Le nom du fichier doit être utilisé comme titre");
|
||||
}
|
||||
}
|
||||
157
pmoaudio/src/nodes/mod.rs
Normal file → Executable file
157
pmoaudio/src/nodes/mod.rs
Normal file → Executable file
@@ -3,10 +3,29 @@
|
||||
//! Ce module contient tous les types de nodes disponibles pour construire
|
||||
//! un pipeline audio, ainsi que les traits et structures de support.
|
||||
|
||||
use crate::AudioChunk;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::type_constraints::{TypeMismatch, TypeRequirement};
|
||||
use crate::AudioSegment;
|
||||
|
||||
/// Taille par défaut du buffer de channel MPSC pour les nodes
|
||||
/// Cette valeur détermine combien de segments audio peuvent être mis en attente
|
||||
/// avant que le producteur soit bloqué (backpressure).
|
||||
pub const DEFAULT_CHANNEL_SIZE: usize = 16;
|
||||
|
||||
/// Durée par défaut des chunks audio en millisecondes
|
||||
/// Cette valeur détermine la latence de traitement et le compromis efficacité/réactivité.
|
||||
/// 50ms offre un bon équilibre pour la plupart des applications de lecture audio.
|
||||
pub const DEFAULT_CHUNK_DURATION_MS: f64 = 50.0;
|
||||
|
||||
// Modules actifs
|
||||
pub mod converter_nodes;
|
||||
pub mod file_source;
|
||||
pub mod flac_file_sink;
|
||||
pub mod http_source;
|
||||
|
||||
// Modules temporairement désactivés
|
||||
/*
|
||||
pub mod buffer_node;
|
||||
pub mod chromecast_sink;
|
||||
pub mod decoder_node;
|
||||
@@ -17,6 +36,7 @@ pub mod sink_node;
|
||||
pub mod source_node;
|
||||
pub mod timer_node;
|
||||
pub mod volume_node;
|
||||
*/
|
||||
|
||||
/// Trait de base pour tous les nodes audio
|
||||
///
|
||||
@@ -29,97 +49,62 @@ pub trait AudioNode: Send + Sync {
|
||||
/// # Erreurs
|
||||
///
|
||||
/// Retourne `AudioError::SendError` si l'envoi échoue
|
||||
async fn push(&mut self, chunk: Arc<AudioChunk>) -> Result<(), AudioError>;
|
||||
async fn push(&mut self, chunk: Arc<AudioSegment>) -> Result<(), AudioError>;
|
||||
|
||||
/// Ferme le node proprement
|
||||
async fn close(&mut self);
|
||||
}
|
||||
|
||||
/// Node avec un seul abonné (pas de clone inutile)
|
||||
/// Trait pour les nodes qui déclarent leurs types acceptés/produits
|
||||
///
|
||||
/// Optimisé pour les cas où un node n'a qu'un seul destinataire.
|
||||
/// Le Arc du chunk est simplement transféré sans clonage supplémentaire.
|
||||
/// Ce trait permet de vérifier la compatibilité des types entre nodes
|
||||
/// avant de les connecter dans un pipeline.
|
||||
///
|
||||
/// # Exemples
|
||||
///
|
||||
/// ```no_run
|
||||
/// use pmoaudio::{FileSource, FlacFileSink, TypedAudioNode};
|
||||
/// use pmoaudio::type_constraints::check_compatibility;
|
||||
///
|
||||
/// // Vérifier la compatibilité avant de connecter
|
||||
/// let source = FileSource::new("input.flac");
|
||||
/// let (sink, tx) = FlacFileSink::new("output.flac");
|
||||
///
|
||||
/// let source_output = source.output_type();
|
||||
/// let sink_input = sink.input_type();
|
||||
///
|
||||
/// match check_compatibility(&source_output, &sink_input) {
|
||||
/// Ok(()) => println!("Types compatibles!"),
|
||||
/// Err(e) => eprintln!("Types incompatibles: {}", e),
|
||||
/// }
|
||||
/// ```
|
||||
/// use pmoaudio::SingleSubscriberNode;
|
||||
/// use tokio::sync::mpsc;
|
||||
///
|
||||
/// let (tx, rx) = mpsc::channel(10);
|
||||
/// let node = SingleSubscriberNode::new(tx);
|
||||
/// ```
|
||||
pub struct SingleSubscriberNode {
|
||||
tx: mpsc::Sender<Arc<AudioChunk>>,
|
||||
}
|
||||
pub trait TypedAudioNode {
|
||||
/// Retourne les types que ce node peut accepter en entrée
|
||||
///
|
||||
/// Pour les sources (qui ne consomment rien), retourne `None`.
|
||||
fn input_type(&self) -> Option<TypeRequirement>;
|
||||
|
||||
impl SingleSubscriberNode {
|
||||
pub fn new(tx: mpsc::Sender<Arc<AudioChunk>>) -> Self {
|
||||
Self { tx }
|
||||
}
|
||||
/// Retourne les types que ce node peut produire en sortie
|
||||
///
|
||||
/// Pour les sinks (qui ne produisent rien), retourne `None`.
|
||||
fn output_type(&self) -> Option<TypeRequirement>;
|
||||
|
||||
pub async fn push(&self, chunk: Arc<AudioChunk>) -> Result<(), AudioError> {
|
||||
self.tx.send(chunk).await.map_err(|_| AudioError::SendError)
|
||||
/// Vérifie si ce node peut accepter les chunks d'un producer donné
|
||||
///
|
||||
/// # Erreurs
|
||||
///
|
||||
/// Retourne `AudioError::TypeMismatch` si les types sont incompatibles
|
||||
fn can_accept_from(&self, producer: &dyn TypedAudioNode) -> Result<(), AudioError> {
|
||||
match (producer.output_type(), self.input_type()) {
|
||||
(Some(prod), Some(cons)) => crate::type_constraints::check_compatibility(&prod, &cons)
|
||||
.map_err(|e| AudioError::TypeMismatch(e)),
|
||||
(None, Some(_)) => Err(AudioError::TypeMismatch(TypeMismatch {
|
||||
producer: TypeRequirement::any(), // Placeholder
|
||||
consumer: self.input_type().unwrap(),
|
||||
incompatible_type: None,
|
||||
})),
|
||||
_ => Ok(()), // Si pas de contrainte, toujours compatible
|
||||
}
|
||||
}
|
||||
|
||||
/// Node avec plusieurs abonnés (partage le même Arc)
|
||||
///
|
||||
/// Permet de broadcaster un chunk à plusieurs destinations.
|
||||
/// Tous les abonnés reçoivent le même `Arc<AudioChunk>`, donc pas de copie
|
||||
/// des données audio - seul le compteur de référence Arc est incrémenté.
|
||||
///
|
||||
/// # Exemples
|
||||
///
|
||||
/// ```
|
||||
/// use pmoaudio::MultiSubscriberNode;
|
||||
/// use tokio::sync::mpsc;
|
||||
///
|
||||
/// let mut node = MultiSubscriberNode::new();
|
||||
/// let (tx1, rx1) = mpsc::channel(10);
|
||||
/// let (tx2, rx2) = mpsc::channel(10);
|
||||
///
|
||||
/// node.add_subscriber(tx1);
|
||||
/// node.add_subscriber(tx2);
|
||||
/// // Les deux abonnés recevront les mêmes chunks
|
||||
/// ```
|
||||
pub struct MultiSubscriberNode {
|
||||
subscribers: Vec<mpsc::Sender<Arc<AudioChunk>>>,
|
||||
}
|
||||
|
||||
impl MultiSubscriberNode {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
subscribers: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_subscriber(&mut self, tx: mpsc::Sender<Arc<AudioChunk>>) {
|
||||
self.subscribers.push(tx);
|
||||
}
|
||||
|
||||
pub async fn push(&self, chunk: Arc<AudioChunk>) -> Result<(), AudioError> {
|
||||
for tx in &self.subscribers {
|
||||
// On partage le même Arc avec tous les abonnés
|
||||
tx.send(chunk.clone())
|
||||
.await
|
||||
.map_err(|_| AudioError::SendError)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn try_push(&self, chunk: Arc<AudioChunk>) -> Result<(), AudioError> {
|
||||
for tx in &self.subscribers {
|
||||
// try_send non-bloquant, ignore si saturé
|
||||
let _ = tx.try_send(chunk.clone());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MultiSubscriberNode {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +117,14 @@ pub enum AudioError {
|
||||
ReceiveError,
|
||||
/// Erreur de traitement avec message descriptif
|
||||
ProcessingError(String),
|
||||
/// Incompatibilité de types entre nodes
|
||||
TypeMismatch(TypeMismatch),
|
||||
/// Un nœud enfant s'est terminé prématurément (anormal dans un pipeline descendant)
|
||||
ChildFinished,
|
||||
/// Un nœud enfant est mort (channel fermé pendant un send)
|
||||
ChildDied,
|
||||
/// Erreur d'I/O (fichier, réseau, etc.)
|
||||
IoError(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AudioError {
|
||||
@@ -140,6 +133,10 @@ impl std::fmt::Display for AudioError {
|
||||
AudioError::SendError => write!(f, "Failed to send audio chunk"),
|
||||
AudioError::ReceiveError => write!(f, "Failed to receive audio chunk"),
|
||||
AudioError::ProcessingError(msg) => write!(f, "Processing error: {}", msg),
|
||||
AudioError::TypeMismatch(tm) => write!(f, "{}", tm),
|
||||
AudioError::ChildFinished => write!(f, "Child node finished prematurely"),
|
||||
AudioError::ChildDied => write!(f, "Child node died unexpectedly"),
|
||||
AudioError::IoError(msg) => write!(f, "I/O error: {}", msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
718
pmoaudio/src/pipeline.rs
Executable file
718
pmoaudio/src/pipeline.rs
Executable file
@@ -0,0 +1,718 @@
|
||||
//! Architecture de pipeline audio avec propagation automatique du run et gestion d'arrêt
|
||||
//!
|
||||
//! Ce module définit le trait `AudioPipelineNode` qui permet de construire des arbres
|
||||
//! de traitement audio avec :
|
||||
//! - Démarrage automatique de tous les enfants lors du run de la tête
|
||||
//! - Arrêt coordonné sur EOF ou erreur
|
||||
//! - Propagation bidirectionnelle sans boucle infinie
|
||||
//!
|
||||
//! # Architecture
|
||||
//!
|
||||
//! Les pipelines forment des **arbres** (pas de DAG) où :
|
||||
//! - Les sources n'ont pas d'input (get_tx retourne None)
|
||||
//! - Les sinks n'ont pas d'enfants (register panic)
|
||||
//! - Les convertisseurs ont à la fois un input et des enfants
|
||||
//!
|
||||
//! # Mécanisme d'arrêt
|
||||
//!
|
||||
//! - **Descendant** : `stop_token.cancel()` propage l'arrêt vers les fils
|
||||
//! - **Montant** : Le retour de `run()` informe le parent
|
||||
//! - **Détection** : Un enfant mort → parent voit `send().is_err()` ou `await handle`
|
||||
//!
|
||||
//! # Exemple
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use pmoaudio::{FileSource, AudioPipelineNode};
|
||||
//! use pmoaudio::nodes::FlacFileSink;
|
||||
//! use tokio_util::sync::CancellationToken;
|
||||
//!
|
||||
//! # async fn example() -> Result<(), pmoaudio::nodes::AudioError> {
|
||||
//! // Construire le pipeline
|
||||
//! let mut source = FileSource::new("input.flac");
|
||||
//! let sink = FlacFileSink::new("output.flac");
|
||||
//!
|
||||
//! source.register(Box::new(sink));
|
||||
//!
|
||||
//! // Lancer avec contrôle d'arrêt
|
||||
//! let stop_token = CancellationToken::new();
|
||||
//! Box::new(source).run(stop_token).await?;
|
||||
//! # Ok(())
|
||||
//! # }
|
||||
//! ```
|
||||
|
||||
use crate::{nodes::AudioError, AudioSegment};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
/// Trait pour les nœuds d'un pipeline audio
|
||||
///
|
||||
/// Permet la construction d'arbres de traitement avec:
|
||||
/// - Démarrage automatique de tous les enfants
|
||||
/// - Arrêt coordonné sur EOF ou erreur
|
||||
/// - Propagation bidirectionnelle sans boucle
|
||||
#[async_trait::async_trait]
|
||||
pub trait AudioPipelineNode: Send + 'static {
|
||||
/// Retourne un clone du sender pour recevoir des segments
|
||||
///
|
||||
/// # Retourne
|
||||
///
|
||||
/// - `Some(tx)` pour les nœuds qui ont un input (sinks, convertisseurs)
|
||||
/// - `None` pour les sources qui génèrent des données
|
||||
///
|
||||
/// Le sender retourné est un clone, permettant au parent de l'extraire
|
||||
/// avant de consommer le nœud dans `run()`.
|
||||
fn get_tx(&self) -> Option<mpsc::Sender<Arc<AudioSegment>>>;
|
||||
|
||||
/// Enregistre un nœud enfant dans l'arbre
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `child` - Le nœud enfant à enregistrer
|
||||
///
|
||||
/// # Comportement
|
||||
///
|
||||
/// - Pour les sources et convertisseurs : enregistre l'enfant et clone son tx
|
||||
/// - Pour les sinks : panic (nœuds terminaux)
|
||||
///
|
||||
/// Le parent extrait le tx via `child.get_tx()` avant de stocker le child.
|
||||
fn register(&mut self, child: Box<dyn AudioPipelineNode>);
|
||||
|
||||
/// Lance le nœud et tous ses enfants
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `stop_token` - Token d'arrêt partagé pour coordination
|
||||
///
|
||||
/// # Comportement
|
||||
///
|
||||
/// 1. **Spawn enfants** : Tous les enfants sont spawned avant le traitement
|
||||
/// 2. **Traitement** : Le nœud fait son travail (lecture, conversion, écriture)
|
||||
/// 3. **Détection** : Si un enfant meurt, le parent le détecte via send().is_err()
|
||||
/// 4. **Arrêt** : Sur EOF/erreur, appel de `stop_token.cancel()` pour les enfants
|
||||
/// 5. **Attente** : Attend que tous les enfants se terminent
|
||||
/// 6. **Retour** : Retourne pour informer le parent (propagation montante)
|
||||
///
|
||||
/// # Propagation d'erreur
|
||||
///
|
||||
/// - Erreur du nœud → propagée vers les enfants (cancel) puis vers le parent (return)
|
||||
/// - Erreur d'un enfant → détectée à l'await du handle, propagée vers le parent
|
||||
///
|
||||
/// # Arrêt sans boucle
|
||||
///
|
||||
/// - Un seul `cancel()` par nœud (en sortant de la boucle de travail)
|
||||
/// - L'enfant ne cancel JAMAIS le parent
|
||||
/// - `cancel()` est idempotent (pas de problème si appelé plusieurs fois)
|
||||
async fn run(
|
||||
self: Box<Self>,
|
||||
stop_token: CancellationToken,
|
||||
) -> Result<(), AudioError>;
|
||||
|
||||
/// Lance le pipeline en arrière-plan et retourne un handle de contrôle
|
||||
///
|
||||
/// Cette méthode est recommandée pour la plupart des cas d'usage.
|
||||
/// Elle spawn le pipeline dans une tâche Tokio et retourne immédiatement
|
||||
/// un `PipelineHandle` permettant de contrôler et surveiller l'exécution.
|
||||
///
|
||||
/// # Retour
|
||||
///
|
||||
/// Un `PipelineHandle` qui permet de :
|
||||
/// - Arrêter le pipeline avec `stop()`
|
||||
/// - Attendre sa complétion avec `wait()`
|
||||
/// - Vérifier son état avec `is_finished()`
|
||||
///
|
||||
/// # Exemple
|
||||
///
|
||||
/// ```no_run
|
||||
/// use pmoaudio::{FileSource, AudioPipelineNode};
|
||||
/// use pmoaudio::nodes::FlacFileSink;
|
||||
///
|
||||
/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// let mut source = FileSource::new("input.flac");
|
||||
/// source.register(Box::new(FlacFileSink::new("output.flac")));
|
||||
///
|
||||
/// // Lancer le pipeline
|
||||
/// let handle = Box::new(source).start();
|
||||
///
|
||||
/// // Faire autre chose...
|
||||
/// tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
|
||||
///
|
||||
/// // Arrêter et attendre
|
||||
/// handle.stop(None);
|
||||
/// handle.wait().await?;
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
fn start(self: Box<Self>) -> PipelineHandle {
|
||||
let stop_token = CancellationToken::new();
|
||||
let token_for_task = stop_token.clone();
|
||||
|
||||
let join_handle = tokio::spawn(async move {
|
||||
self.run(token_for_task).await
|
||||
});
|
||||
|
||||
PipelineHandle {
|
||||
stop_token,
|
||||
join_handle,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// NOUVELLE ARCHITECTURE - Séparation plomberie/logique métier
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Raison de l'arrêt d'un nœud
|
||||
///
|
||||
/// Passé à la méthode `cleanup()` pour permettre au nœud d'adapter
|
||||
/// son comportement de nettoyage selon la cause de l'arrêt.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum StopReason {
|
||||
/// Fin normale - toutes les données ont été traitées (EOF)
|
||||
Completed,
|
||||
|
||||
/// Cancel explicite demandé via CancellationToken
|
||||
Cancelled,
|
||||
|
||||
/// Un nœud enfant s'est terminé prématurément
|
||||
/// (dans un pipeline descendant, ceci est anormal)
|
||||
ChildFinished,
|
||||
|
||||
/// Une erreur s'est produite (dans ce nœud ou un enfant)
|
||||
Error(AudioError),
|
||||
}
|
||||
|
||||
/// Trait définissant la logique métier pure d'un nœud
|
||||
///
|
||||
/// Ce trait sépare la logique de traitement spécifique au nœud (ce qu'il **fait**)
|
||||
/// de la plomberie d'orchestration (spawning, monitoring, cleanup).
|
||||
///
|
||||
/// # Responsabilités
|
||||
///
|
||||
/// - Recevoir des données via `input` (None pour les sources)
|
||||
/// - Traiter les données selon la logique du nœud
|
||||
/// - Envoyer les résultats via `output`
|
||||
/// - Surveiller `stop_token` pour arrêt rapide
|
||||
/// - Optionnellement : cleanup contextualisé via `cleanup()`
|
||||
///
|
||||
/// # Exemple
|
||||
///
|
||||
/// ```no_run
|
||||
/// use pmoaudio::pipeline::NodeLogic;
|
||||
/// use pmoaudio::nodes::AudioError;
|
||||
/// use tokio_util::sync::CancellationToken;
|
||||
///
|
||||
/// struct MyProcessorLogic {
|
||||
/// // Configuration du nœud
|
||||
/// }
|
||||
///
|
||||
/// #[async_trait::async_trait]
|
||||
/// impl NodeLogic for MyProcessorLogic {
|
||||
/// async fn process(
|
||||
/// &mut self,
|
||||
/// input: Option<mpsc::Receiver<Arc<AudioSegment>>>,
|
||||
/// output: Vec<mpsc::Sender<Arc<AudioSegment>>>,
|
||||
/// stop_token: CancellationToken,
|
||||
/// ) -> Result<(), AudioError> {
|
||||
/// let mut rx = input.expect("Processor needs input");
|
||||
///
|
||||
/// loop {
|
||||
/// tokio::select! {
|
||||
/// _ = stop_token.cancelled() => break,
|
||||
///
|
||||
/// segment = rx.recv() => {
|
||||
/// match segment {
|
||||
/// Some(data) => {
|
||||
/// // Traiter les données
|
||||
/// let processed = self.do_processing(data)?;
|
||||
///
|
||||
/// // Envoyer aux enfants
|
||||
/// for tx in &output {
|
||||
/// tx.send(processed.clone()).await
|
||||
/// .map_err(|_| AudioError::ChildDied)?;
|
||||
/// }
|
||||
/// }
|
||||
/// None => break, // EOF
|
||||
/// }
|
||||
/// }
|
||||
/// }
|
||||
/// }
|
||||
///
|
||||
/// Ok(())
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
#[async_trait::async_trait]
|
||||
pub trait NodeLogic: Send + 'static {
|
||||
/// Logique de traitement du nœud
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `input` - Receiver pour les données entrantes (None pour les sources)
|
||||
/// * `output` - Liste des senders vers les nœuds enfants
|
||||
/// * `stop_token` - Token pour détecter les demandes d'arrêt
|
||||
///
|
||||
/// # Retour
|
||||
///
|
||||
/// - `Ok(())` : Arrêt propre (EOF, cancelled)
|
||||
/// - `Err(...)` : Erreur de traitement
|
||||
///
|
||||
/// # Comportement attendu
|
||||
///
|
||||
/// - Surveiller `stop_token.cancelled()` dans la boucle principale
|
||||
/// - Sortir proprement sur EOF (input.recv() → None)
|
||||
/// - Gérer les erreurs de send (enfant mort) selon la politique du nœud
|
||||
async fn process(
|
||||
&mut self,
|
||||
input: Option<mpsc::Receiver<Arc<AudioSegment>>>,
|
||||
output: Vec<mpsc::Sender<Arc<AudioSegment>>>,
|
||||
stop_token: CancellationToken,
|
||||
) -> Result<(), AudioError>;
|
||||
|
||||
/// Cleanup appelé automatiquement après l'arrêt du nœud
|
||||
///
|
||||
/// Cette méthode permet au nœud de faire du nettoyage contextualisé
|
||||
/// selon la raison de l'arrêt (fichiers incomplets, ressources, etc.)
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `reason` - La raison de l'arrêt du nœud
|
||||
///
|
||||
/// # Implémentation par défaut
|
||||
///
|
||||
/// Ne fait rien. Seulement les nœuds qui nécessitent un cleanup
|
||||
/// (ex: Sinks) doivent implémenter cette méthode.
|
||||
///
|
||||
/// # Exemple
|
||||
///
|
||||
/// ```no_run
|
||||
/// async fn cleanup(&mut self, reason: StopReason) -> Result<(), AudioError> {
|
||||
/// match reason {
|
||||
/// StopReason::Completed => {
|
||||
/// // Finaliser le fichier proprement
|
||||
/// self.flush_and_close().await?;
|
||||
/// }
|
||||
/// StopReason::Error(_) => {
|
||||
/// // Supprimer le fichier incomplet
|
||||
/// self.delete_incomplete_file().await?;
|
||||
/// }
|
||||
/// _ => {
|
||||
/// // Autre cas selon politique
|
||||
/// }
|
||||
/// }
|
||||
/// Ok(())
|
||||
/// }
|
||||
/// ```
|
||||
async fn cleanup(&mut self, _reason: StopReason) -> Result<(), AudioError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle pour contrôler un pipeline en cours d'exécution
|
||||
///
|
||||
/// Retourné par la méthode `start()`, ce handle permet de :
|
||||
/// - Arrêter le pipeline explicitement
|
||||
/// - Attendre sa complétion
|
||||
/// - Vérifier s'il est toujours en cours
|
||||
///
|
||||
/// # Exemple
|
||||
///
|
||||
/// ```no_run
|
||||
/// use pmoaudio::{FileSource, AudioPipelineNode};
|
||||
///
|
||||
/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// let mut source = FileSource::new("input.flac");
|
||||
/// // ... register children ...
|
||||
///
|
||||
/// let handle = Box::new(source).start();
|
||||
///
|
||||
/// // Faire autre chose...
|
||||
/// tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
|
||||
///
|
||||
/// // Arrêter le pipeline
|
||||
/// handle.stop(None);
|
||||
///
|
||||
/// // Attendre la fin
|
||||
/// handle.wait().await?;
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
pub struct PipelineHandle {
|
||||
stop_token: CancellationToken,
|
||||
join_handle: JoinHandle<Result<(), AudioError>>,
|
||||
}
|
||||
|
||||
impl PipelineHandle {
|
||||
/// Demande l'arrêt du pipeline
|
||||
///
|
||||
/// Cette méthode est non-bloquante. Pour attendre la fin effective,
|
||||
/// utiliser `wait()` ou `stop_and_wait()`.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `reason` - Raison optionnelle de l'arrêt (pour logging/debugging)
|
||||
pub fn stop(&self, reason: Option<AudioError>) {
|
||||
if let Some(err) = reason {
|
||||
tracing::info!("Pipeline stop requested with error: {}", err);
|
||||
} else {
|
||||
tracing::info!("Pipeline stop requested");
|
||||
}
|
||||
self.stop_token.cancel();
|
||||
}
|
||||
|
||||
/// Attendre la complétion du pipeline
|
||||
///
|
||||
/// Bloque jusqu'à ce que le pipeline se termine (normalement ou par erreur).
|
||||
///
|
||||
/// # Retour
|
||||
///
|
||||
/// Le résultat du nœud racine :
|
||||
/// - `Ok(())` : Pipeline terminé avec succès
|
||||
/// - `Err(...)` : Erreur survenue dans le pipeline
|
||||
pub async fn wait(self) -> Result<(), AudioError> {
|
||||
match self.join_handle.await {
|
||||
Ok(result) => result,
|
||||
Err(e) if e.is_panic() => Err(AudioError::ProcessingError(
|
||||
format!("Pipeline task panicked: {}", e)
|
||||
)),
|
||||
Err(e) => Err(AudioError::ProcessingError(
|
||||
format!("Pipeline task cancelled: {}", e)
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie si le pipeline est toujours en cours d'exécution
|
||||
pub fn is_finished(&self) -> bool {
|
||||
self.join_handle.is_finished()
|
||||
}
|
||||
|
||||
/// Arrête le pipeline et attend sa complétion
|
||||
///
|
||||
/// Équivalent à `stop()` suivi de `wait()`.
|
||||
pub async fn stop_and_wait(self, reason: Option<AudioError>) -> Result<(), AudioError> {
|
||||
self.stop(reason);
|
||||
self.wait().await
|
||||
}
|
||||
|
||||
/// Obtient une copie du token d'arrêt
|
||||
///
|
||||
/// Pour cas d'usage avancés nécessitant une intégration
|
||||
/// avec d'autres systèmes utilisant CancellationToken.
|
||||
pub fn cancellation_token(&self) -> CancellationToken {
|
||||
self.stop_token.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrapper générique qui implémente l'orchestration d'un nœud
|
||||
///
|
||||
/// Cette struct encapsule n'importe quelle logique métier (implémentant `NodeLogic`)
|
||||
/// et fournit l'implémentation standard du trait `AudioPipelineNode` avec :
|
||||
/// - Spawning automatique des enfants
|
||||
/// - Monitoring des enfants pour détection d'arrêt prématuré
|
||||
/// - Cleanup coordonné avec propagation de cancel
|
||||
/// - Appel automatique de `cleanup()` selon le contexte
|
||||
///
|
||||
/// # Type Parameters
|
||||
///
|
||||
/// * `L` - Le type implémentant `NodeLogic`, qui contient la logique spécifique du nœud
|
||||
///
|
||||
/// # Exemple
|
||||
///
|
||||
/// ```no_run
|
||||
/// use pmoaudio::pipeline::{Node, NodeLogic};
|
||||
///
|
||||
/// struct MyLogic { /* ... */ }
|
||||
/// impl NodeLogic for MyLogic { /* ... */ }
|
||||
///
|
||||
/// // Créer un nœud avec cette logique
|
||||
/// let node = Node::new(MyLogic { /* ... */ });
|
||||
/// ```
|
||||
pub struct Node<L: NodeLogic> {
|
||||
/// La logique métier du nœud
|
||||
logic: L,
|
||||
|
||||
/// Receiver pour les données entrantes (None pour les sources)
|
||||
rx: Option<mpsc::Receiver<Arc<AudioSegment>>>,
|
||||
|
||||
/// Sender pour les données entrantes (pour clonage via get_tx)
|
||||
tx: Option<mpsc::Sender<Arc<AudioSegment>>>,
|
||||
|
||||
/// Liste des nœuds enfants
|
||||
children: Vec<Box<dyn AudioPipelineNode>>,
|
||||
|
||||
/// Liste des senders vers les enfants
|
||||
child_txs: Vec<mpsc::Sender<Arc<AudioSegment>>>,
|
||||
}
|
||||
|
||||
impl<L: NodeLogic> Node<L> {
|
||||
/// Crée un nouveau nœud source (sans input)
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `logic` - La logique métier du nœud
|
||||
pub fn new_source(logic: L) -> Self {
|
||||
Self {
|
||||
logic,
|
||||
rx: None,
|
||||
tx: None,
|
||||
children: Vec::new(),
|
||||
child_txs: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Crée un nouveau nœud avec input (converter ou sink)
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `logic` - La logique métier du nœud
|
||||
/// * `buffer_size` - Taille du buffer du channel d'input
|
||||
pub fn new_with_input(logic: L, buffer_size: usize) -> Self {
|
||||
let (tx, rx) = mpsc::channel(buffer_size);
|
||||
Self {
|
||||
logic,
|
||||
rx: Some(rx),
|
||||
tx: Some(tx),
|
||||
children: Vec::new(),
|
||||
child_txs: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Retourne une référence vers la logique métier du nœud
|
||||
pub fn logic(&self) -> &L {
|
||||
&self.logic
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl<L: NodeLogic> AudioPipelineNode for Node<L> {
|
||||
fn get_tx(&self) -> Option<mpsc::Sender<Arc<AudioSegment>>> {
|
||||
self.tx.clone()
|
||||
}
|
||||
|
||||
fn register(&mut self, child: Box<dyn AudioPipelineNode>) {
|
||||
if let Some(tx) = child.get_tx() {
|
||||
self.child_txs.push(tx);
|
||||
}
|
||||
self.children.push(child);
|
||||
}
|
||||
|
||||
async fn run(
|
||||
mut self: Box<Self>,
|
||||
stop_token: CancellationToken,
|
||||
) -> Result<(), AudioError> {
|
||||
let Node {
|
||||
mut logic,
|
||||
rx,
|
||||
children,
|
||||
child_txs,
|
||||
..
|
||||
} = *self;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// PHASE 1: SPAWNER TOUS LES ENFANTS
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
let mut child_handles = Vec::new();
|
||||
for child in children {
|
||||
let child_token = stop_token.child_token();
|
||||
let handle = tokio::spawn(async move {
|
||||
child.run(child_token).await
|
||||
});
|
||||
child_handles.push(handle);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// PHASE 2: MONITORER LES ENFANTS EN PARALLÈLE
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
// Task qui surveille tous les enfants
|
||||
// Si pas d'enfants, retourne None pour indiquer qu'il n'y a rien à surveiller
|
||||
let mut child_monitor = if child_handles.is_empty() {
|
||||
tracing::debug!("No children to monitor (terminal node)");
|
||||
None
|
||||
} else {
|
||||
let handles = child_handles;
|
||||
let num_handles = handles.len();
|
||||
tracing::debug!("Child monitor starting with {} handles", num_handles);
|
||||
Some(tokio::spawn(async move {
|
||||
let mut has_error = false;
|
||||
let mut first_error = None;
|
||||
|
||||
for handle in handles {
|
||||
match handle.await {
|
||||
Ok(Ok(())) => {
|
||||
// Un enfant s'est terminé proprement
|
||||
// C'est normal dans un pipeline linéaire
|
||||
tracing::debug!("Child finished successfully");
|
||||
continue;
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
// Un enfant a eu une erreur
|
||||
tracing::warn!("Child error: {}", e);
|
||||
if !has_error {
|
||||
first_error = Some(e);
|
||||
has_error = true;
|
||||
}
|
||||
// Continue à surveiller les autres enfants
|
||||
}
|
||||
Err(e) => {
|
||||
// Un enfant a paniqué
|
||||
tracing::error!("Child panicked: {}", e);
|
||||
if !has_error {
|
||||
first_error = Some(AudioError::ProcessingError(
|
||||
format!("Child task panicked: {}", e)
|
||||
));
|
||||
has_error = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Retourner le résultat
|
||||
if let Some(err) = first_error {
|
||||
Err(err)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}))
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// PHASE 3: EXÉCUTER LA LOGIQUE MÉTIER EN RACE AVEC LE MONITORING
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
let (stop_reason, process_result, child_monitor_consumed) = if let Some(monitor) = &mut child_monitor {
|
||||
// Il y a des enfants à surveiller
|
||||
tokio::select! {
|
||||
// Cancel externe demandé
|
||||
_ = stop_token.cancelled() => {
|
||||
tracing::debug!("Node cancelled via stop_token");
|
||||
(StopReason::Cancelled, Ok(()), false)
|
||||
}
|
||||
|
||||
// Monitoring des enfants - retourne quand tous sont terminés ou sur erreur
|
||||
child_result = monitor => {
|
||||
match child_result {
|
||||
Ok(Ok(())) => {
|
||||
// Tous les enfants terminés avec succès
|
||||
// Le parent devrait aussi terminer bientôt
|
||||
tracing::debug!("All children finished successfully");
|
||||
(StopReason::Completed, Ok(()), true)
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
// Un enfant a eu une erreur - arrêter immédiatement
|
||||
tracing::warn!("Child error: {}", e);
|
||||
(StopReason::Error(e.clone()), Err(e), true)
|
||||
}
|
||||
Err(e) => {
|
||||
// Le monitor task a paniqué
|
||||
let error = AudioError::ProcessingError(
|
||||
format!("Child monitor panicked: {}", e)
|
||||
);
|
||||
(StopReason::Error(error.clone()), Err(error), true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Logique métier du nœud
|
||||
process_result = logic.process(rx, child_txs.clone(), stop_token.clone()) => {
|
||||
match process_result {
|
||||
Ok(()) => {
|
||||
tracing::debug!("Node process completed successfully");
|
||||
(StopReason::Completed, Ok(()), false)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Node process error: {}", e);
|
||||
(StopReason::Error(e.clone()), Err(e), false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Pas d'enfants (nœud terminal) - juste exécuter la logique
|
||||
tokio::select! {
|
||||
// Cancel externe demandé
|
||||
_ = stop_token.cancelled() => {
|
||||
tracing::debug!("Node cancelled via stop_token");
|
||||
(StopReason::Cancelled, Ok(()), true) // true car pas de monitor à attendre
|
||||
}
|
||||
|
||||
// Logique métier du nœud
|
||||
process_result = logic.process(rx, child_txs.clone(), stop_token.clone()) => {
|
||||
match process_result {
|
||||
Ok(()) => {
|
||||
tracing::debug!("Node process completed successfully (terminal)");
|
||||
(StopReason::Completed, Ok(()), true) // true car pas de monitor
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Node process error: {}", e);
|
||||
(StopReason::Error(e.clone()), Err(e), true) // true car pas de monitor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// PHASE 4: CLEANUP COORDONNÉ
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
// 4.1 Fermer les channels pour signaler EOF aux enfants
|
||||
// Ceci permet aux enfants de finir de traiter les données restantes
|
||||
drop(child_txs);
|
||||
|
||||
// 4.2 Cancel pour arrêt d'urgence seulement en cas d'erreur ou d'annulation
|
||||
// Si le nœud s'est terminé normalement, on laisse les enfants finir tranquillement
|
||||
match &stop_reason {
|
||||
StopReason::Completed => {
|
||||
// Fin normale - les enfants vont se terminer naturellement après avoir traité les données
|
||||
tracing::debug!("Node completed, letting children finish naturally");
|
||||
}
|
||||
StopReason::Cancelled | StopReason::ChildFinished | StopReason::Error(_) => {
|
||||
// Erreur ou annulation - forcer l'arrêt des enfants
|
||||
tracing::debug!("Cancelling children due to: {:?}", stop_reason);
|
||||
stop_token.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
// 4.3 Attendre que les enfants finissent (si child_monitor n'a pas été consommé dans le select!)
|
||||
if !child_monitor_consumed {
|
||||
if let Some(monitor) = child_monitor {
|
||||
tracing::debug!("Waiting for children to finish...");
|
||||
match monitor.await {
|
||||
Ok(Ok(())) => {
|
||||
tracing::debug!("All children finished successfully");
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
tracing::warn!("Child error during cleanup: {}", e);
|
||||
// Si on n'avait pas d'erreur avant, propager celle-ci
|
||||
if process_result.is_ok() {
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Child monitor panicked during cleanup: {}", e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tracing::debug!("No children to wait for (terminal node)");
|
||||
}
|
||||
}
|
||||
|
||||
// 4.4 Cleanup du nœud (contextualisé selon la raison)
|
||||
if let Err(cleanup_err) = logic.cleanup(stop_reason).await {
|
||||
tracing::error!("Cleanup failed: {}", cleanup_err);
|
||||
// Si le cleanup échoue, propager cette erreur si process_result était Ok
|
||||
if process_result.is_ok() {
|
||||
return Err(cleanup_err);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// PHASE 5: RETOURNER LE RÉSULTAT
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
process_result
|
||||
}
|
||||
}
|
||||
329
pmoaudio/src/sample_types.rs
Executable file
329
pmoaudio/src/sample_types.rs
Executable file
@@ -0,0 +1,329 @@
|
||||
//! Types de samples audio et trait de conversion générique
|
||||
|
||||
use std::fmt;
|
||||
|
||||
/// Trait pour tous les types de samples audio supportés
|
||||
///
|
||||
/// Ce trait permet d'écrire du code générique sur différents types de samples
|
||||
/// (entiers 8/16/24/32 bits et flottants 32/64 bits).
|
||||
pub trait Sample: Copy + Clone + Send + Sync + 'static + fmt::Debug {
|
||||
/// Nom du type pour le débogage
|
||||
const NAME: &'static str;
|
||||
|
||||
/// Valeur minimale du type
|
||||
const MIN: Self;
|
||||
|
||||
/// Valeur maximale du type
|
||||
const MAX: Self;
|
||||
|
||||
/// Valeur zéro
|
||||
const ZERO: Self;
|
||||
|
||||
/// Convertit le sample en f64 normalisé dans [-1.0, 1.0]
|
||||
fn to_f64(self) -> f64;
|
||||
|
||||
/// Crée un sample depuis un f64 normalisé dans [-1.0, 1.0]
|
||||
fn from_f64(value: f64) -> Self;
|
||||
|
||||
/// Convertit le sample en f32 normalisé dans [-1.0, 1.0]
|
||||
fn to_f32(self) -> f32 {
|
||||
self.to_f64() as f32
|
||||
}
|
||||
|
||||
/// Crée un sample depuis un f32 normalisé dans [-1.0, 1.0]
|
||||
fn from_f32(value: f32) -> Self {
|
||||
Self::from_f64(value as f64)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Type I24 : Échantillon audio 24-bit stocké dans un i32
|
||||
// ============================================================================
|
||||
|
||||
/// Échantillon audio 24-bit signé, stocké dans un i32
|
||||
///
|
||||
/// Représente un sample audio de 24 bits de résolution effective,
|
||||
/// stocké sur 32 bits pour l'alignement et les performances.
|
||||
///
|
||||
/// Plage valide : [-8_388_608, 8_388_607] (±2^23)
|
||||
///
|
||||
/// # Exemples
|
||||
///
|
||||
/// ```
|
||||
/// use pmoaudio::I24;
|
||||
///
|
||||
/// let sample = I24::new(1_000_000).unwrap();
|
||||
/// assert_eq!(sample.as_i32(), 1_000_000);
|
||||
///
|
||||
/// // Hors plage : erreur
|
||||
/// assert!(I24::new(10_000_000).is_none());
|
||||
/// ```
|
||||
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct I24(i32);
|
||||
|
||||
impl I24 {
|
||||
/// Valeur minimale : -2^23
|
||||
pub const MIN_VALUE: i32 = -8_388_608;
|
||||
|
||||
/// Valeur maximale : 2^23 - 1
|
||||
pub const MAX_VALUE: i32 = 8_388_607;
|
||||
|
||||
/// Valeur zéro
|
||||
pub const ZERO: I24 = I24(0);
|
||||
|
||||
/// Valeur minimale
|
||||
pub const MIN: I24 = I24(Self::MIN_VALUE);
|
||||
|
||||
/// Valeur maximale
|
||||
pub const MAX: I24 = I24(Self::MAX_VALUE);
|
||||
|
||||
/// Crée un nouveau I24 depuis un i32, en vérifiant la plage valide
|
||||
///
|
||||
/// # Exemples
|
||||
///
|
||||
/// ```
|
||||
/// use pmoaudio::I24;
|
||||
///
|
||||
/// assert!(I24::new(0).is_some());
|
||||
/// assert!(I24::new(8_388_607).is_some());
|
||||
/// assert!(I24::new(-8_388_608).is_some());
|
||||
/// assert!(I24::new(10_000_000).is_none()); // Hors plage
|
||||
/// ```
|
||||
#[inline]
|
||||
pub const fn new(value: i32) -> Option<Self> {
|
||||
if value >= Self::MIN_VALUE && value <= Self::MAX_VALUE {
|
||||
Some(I24(value))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Crée un nouveau I24 depuis un i32, en clampant à la plage valide
|
||||
///
|
||||
/// # Exemples
|
||||
///
|
||||
/// ```
|
||||
/// use pmoaudio::I24;
|
||||
///
|
||||
/// assert_eq!(I24::new_clamped(10_000_000).as_i32(), 8_388_607);
|
||||
/// assert_eq!(I24::new_clamped(-10_000_000).as_i32(), -8_388_608);
|
||||
/// ```
|
||||
#[inline]
|
||||
pub const fn new_clamped(value: i32) -> Self {
|
||||
let clamped = if value < Self::MIN_VALUE {
|
||||
Self::MIN_VALUE
|
||||
} else if value > Self::MAX_VALUE {
|
||||
Self::MAX_VALUE
|
||||
} else {
|
||||
value
|
||||
};
|
||||
I24(clamped)
|
||||
}
|
||||
|
||||
/// Crée un nouveau I24 depuis un i32 sans vérification
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// Le caller doit garantir que `value` est dans [-8_388_608, 8_388_607]
|
||||
#[inline]
|
||||
pub const unsafe fn new_unchecked(value: i32) -> Self {
|
||||
I24(value)
|
||||
}
|
||||
|
||||
/// Retourne la valeur i32 interne
|
||||
#[inline]
|
||||
pub const fn as_i32(self) -> i32 {
|
||||
self.0
|
||||
}
|
||||
|
||||
/// Retourne la valeur i32 interne (alias pour compatibilité)
|
||||
#[inline]
|
||||
pub const fn get(self) -> i32 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for I24 {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "I24({})", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for I24 {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<I24> for i32 {
|
||||
#[inline]
|
||||
fn from(i24: I24) -> i32 {
|
||||
i24.0
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<i32> for I24 {
|
||||
type Error = &'static str;
|
||||
|
||||
#[inline]
|
||||
fn try_from(value: i32) -> Result<Self, Self::Error> {
|
||||
I24::new(value).ok_or("i32 value out of I24 range")
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Implémentations du trait Sample pour tous les types
|
||||
// ============================================================================
|
||||
|
||||
impl Sample for i16 {
|
||||
const NAME: &'static str = "i16";
|
||||
const MIN: Self = i16::MIN;
|
||||
const MAX: Self = i16::MAX;
|
||||
const ZERO: Self = 0;
|
||||
|
||||
#[inline]
|
||||
fn to_f64(self) -> f64 {
|
||||
self as f64 / 32_768.0
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn from_f64(value: f64) -> Self {
|
||||
(value * 32_767.0).clamp(-32_768.0, 32_767.0).round() as i16
|
||||
}
|
||||
}
|
||||
|
||||
impl Sample for I24 {
|
||||
const NAME: &'static str = "I24";
|
||||
const MIN: Self = I24::MIN;
|
||||
const MAX: Self = I24::MAX;
|
||||
const ZERO: Self = I24::ZERO;
|
||||
|
||||
#[inline]
|
||||
fn to_f64(self) -> f64 {
|
||||
self.0 as f64 / 8_388_608.0
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn from_f64(value: f64) -> Self {
|
||||
let scaled = (value * 8_388_607.0)
|
||||
.clamp(-8_388_608.0, 8_388_607.0)
|
||||
.round() as i32;
|
||||
I24(scaled)
|
||||
}
|
||||
}
|
||||
|
||||
impl Sample for i32 {
|
||||
const NAME: &'static str = "i32";
|
||||
const MIN: Self = i32::MIN;
|
||||
const MAX: Self = i32::MAX;
|
||||
const ZERO: Self = 0;
|
||||
|
||||
#[inline]
|
||||
fn to_f64(self) -> f64 {
|
||||
self as f64 / 2_147_483_648.0
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn from_f64(value: f64) -> Self {
|
||||
(value * 2_147_483_647.0)
|
||||
.clamp(-2_147_483_648.0, 2_147_483_647.0)
|
||||
.round() as i32
|
||||
}
|
||||
}
|
||||
|
||||
impl Sample for f32 {
|
||||
const NAME: &'static str = "f32";
|
||||
const MIN: Self = -1.0;
|
||||
const MAX: Self = 1.0;
|
||||
const ZERO: Self = 0.0;
|
||||
|
||||
#[inline]
|
||||
fn to_f64(self) -> f64 {
|
||||
self as f64
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn from_f64(value: f64) -> Self {
|
||||
value as f32
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn to_f32(self) -> f32 {
|
||||
self
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn from_f32(value: f32) -> Self {
|
||||
value
|
||||
}
|
||||
}
|
||||
|
||||
impl Sample for f64 {
|
||||
const NAME: &'static str = "f64";
|
||||
const MIN: Self = -1.0;
|
||||
const MAX: Self = 1.0;
|
||||
const ZERO: Self = 0.0;
|
||||
|
||||
#[inline]
|
||||
fn to_f64(self) -> f64 {
|
||||
self
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn from_f64(value: f64) -> Self {
|
||||
value
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_i24_creation() {
|
||||
assert_eq!(I24::new(0).unwrap().as_i32(), 0);
|
||||
assert_eq!(I24::new(8_388_607).unwrap().as_i32(), 8_388_607);
|
||||
assert_eq!(I24::new(-8_388_608).unwrap().as_i32(), -8_388_608);
|
||||
|
||||
assert!(I24::new(8_388_608).is_none());
|
||||
assert!(I24::new(-8_388_609).is_none());
|
||||
assert!(I24::new(10_000_000).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_i24_clamped() {
|
||||
assert_eq!(I24::new_clamped(10_000_000).as_i32(), 8_388_607);
|
||||
assert_eq!(I24::new_clamped(-10_000_000).as_i32(), -8_388_608);
|
||||
assert_eq!(I24::new_clamped(1_000_000).as_i32(), 1_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sample_trait_i24() {
|
||||
let sample = I24::new(4_194_303).unwrap(); // ~0.5 en normalized
|
||||
let normalized = sample.to_f64();
|
||||
assert!((normalized - 0.5).abs() < 0.001);
|
||||
|
||||
let back = I24::from_f64(0.5);
|
||||
assert!((back.as_i32() - 4_194_303).abs() <= 1); // Tolérance d'arrondi
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sample_trait_roundtrip_i16() {
|
||||
let original: i16 = 16_000;
|
||||
let normalized = original.to_f64();
|
||||
let back = i16::from_f64(normalized);
|
||||
assert!((back - original).abs() <= 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sample_trait_roundtrip_f32() {
|
||||
let original: f32 = 0.75;
|
||||
let normalized = original.to_f64();
|
||||
let back = f32::from_f64(normalized);
|
||||
assert!((back - original).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
14
pmoaudio/src/sync_marker.rs
Executable file
14
pmoaudio/src/sync_marker.rs
Executable file
@@ -0,0 +1,14 @@
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use pmometadata::TrackMetadata;
|
||||
|
||||
pub enum SyncMarker {
|
||||
TrackBoundary { metadata: Arc<RwLock<dyn TrackMetadata>> },
|
||||
StreamMetadata { key: String, value: String },
|
||||
TopZeroSync,
|
||||
Heartbeat,
|
||||
EndOfStream,
|
||||
Error(String),
|
||||
// autres cas à venir…
|
||||
}
|
||||
385
pmoaudio/src/type_constraints.rs
Executable file
385
pmoaudio/src/type_constraints.rs
Executable file
@@ -0,0 +1,385 @@
|
||||
//! Système de contraintes de types pour les nodes audio
|
||||
//!
|
||||
//! Ce module définit les types et structures permettant de vérifier la compatibilité
|
||||
//! entre les producers et consumers de chunks audio dans un pipeline.
|
||||
|
||||
use crate::AudioChunk;
|
||||
use std::fmt;
|
||||
|
||||
/// Type d'échantillon supporté
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum SampleType {
|
||||
/// Entier 16-bit
|
||||
I16,
|
||||
/// Entier 24-bit (I24)
|
||||
I24,
|
||||
/// Entier 32-bit
|
||||
I32,
|
||||
/// Flottant 32-bit
|
||||
F32,
|
||||
/// Flottant 64-bit
|
||||
F64,
|
||||
}
|
||||
|
||||
impl SampleType {
|
||||
/// Vérifie si le type est un entier
|
||||
pub fn is_integer(&self) -> bool {
|
||||
matches!(self, SampleType::I16 | SampleType::I24 | SampleType::I32)
|
||||
}
|
||||
|
||||
/// Vérifie si le type est un flottant
|
||||
pub fn is_float(&self) -> bool {
|
||||
matches!(self, SampleType::F32 | SampleType::F64)
|
||||
}
|
||||
|
||||
/// Retourne la profondeur de bit
|
||||
pub fn bit_depth(&self) -> u8 {
|
||||
match self {
|
||||
SampleType::I16 => 16,
|
||||
SampleType::I24 => 24,
|
||||
SampleType::I32 | SampleType::F32 => 32,
|
||||
SampleType::F64 => 64,
|
||||
}
|
||||
}
|
||||
|
||||
/// Extrait le type d'un AudioChunk
|
||||
pub fn from_audio_chunk(chunk: &AudioChunk) -> Self {
|
||||
match chunk {
|
||||
AudioChunk::I16(_) => SampleType::I16,
|
||||
AudioChunk::I24(_) => SampleType::I24,
|
||||
AudioChunk::I32(_) => SampleType::I32,
|
||||
AudioChunk::F32(_) => SampleType::F32,
|
||||
AudioChunk::F64(_) => SampleType::F64,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for SampleType {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
SampleType::I16 => write!(f, "I16"),
|
||||
SampleType::I24 => write!(f, "I24"),
|
||||
SampleType::I32 => write!(f, "I32"),
|
||||
SampleType::F32 => write!(f, "F32"),
|
||||
SampleType::F64 => write!(f, "F64"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Catégorie de type acceptée
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum TypeCategory {
|
||||
/// N'importe quel type entier (I16, I24, I32)
|
||||
AnyInteger,
|
||||
/// N'importe quel type flottant (F32, F64)
|
||||
AnyFloat,
|
||||
/// Un type spécifique uniquement
|
||||
Specific(SampleType),
|
||||
/// N'importe quel type (entier ou flottant)
|
||||
Any,
|
||||
}
|
||||
|
||||
impl TypeCategory {
|
||||
/// Vérifie si cette catégorie accepte le type donné
|
||||
pub fn accepts(&self, sample_type: SampleType) -> bool {
|
||||
match self {
|
||||
TypeCategory::AnyInteger => sample_type.is_integer(),
|
||||
TypeCategory::AnyFloat => sample_type.is_float(),
|
||||
TypeCategory::Specific(t) => *t == sample_type,
|
||||
TypeCategory::Any => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Retourne tous les types possibles pour cette catégorie
|
||||
pub fn possible_types(&self) -> Vec<SampleType> {
|
||||
match self {
|
||||
TypeCategory::AnyInteger => vec![SampleType::I16, SampleType::I24, SampleType::I32],
|
||||
TypeCategory::AnyFloat => vec![SampleType::F32, SampleType::F64],
|
||||
TypeCategory::Specific(t) => vec![*t],
|
||||
TypeCategory::Any => vec![
|
||||
SampleType::I16,
|
||||
SampleType::I24,
|
||||
SampleType::I32,
|
||||
SampleType::F32,
|
||||
SampleType::F64,
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for TypeCategory {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
TypeCategory::AnyInteger => write!(f, "AnyInteger (I16|I24|I32)"),
|
||||
TypeCategory::AnyFloat => write!(f, "AnyFloat (F32|F64)"),
|
||||
TypeCategory::Specific(t) => write!(f, "{}", t),
|
||||
TypeCategory::Any => write!(f, "Any"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Contrainte de type pour un node
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TypeRequirement {
|
||||
/// Catégorie de type acceptée
|
||||
pub category: TypeCategory,
|
||||
/// Types spécifiques acceptés (pour contraintes plus fines)
|
||||
/// Si vide, utilise category.possible_types()
|
||||
pub accepted_types: Vec<SampleType>,
|
||||
}
|
||||
|
||||
impl TypeRequirement {
|
||||
/// Crée une contrainte pour n'importe quel type
|
||||
pub fn any() -> Self {
|
||||
Self {
|
||||
category: TypeCategory::Any,
|
||||
accepted_types: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// Crée une contrainte pour n'importe quel entier
|
||||
pub fn any_integer() -> Self {
|
||||
Self {
|
||||
category: TypeCategory::AnyInteger,
|
||||
accepted_types: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// Crée une contrainte pour n'importe quel flottant
|
||||
pub fn any_float() -> Self {
|
||||
Self {
|
||||
category: TypeCategory::AnyFloat,
|
||||
accepted_types: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// Crée une contrainte pour un type spécifique
|
||||
pub fn specific(sample_type: SampleType) -> Self {
|
||||
Self {
|
||||
category: TypeCategory::Specific(sample_type),
|
||||
accepted_types: vec![sample_type],
|
||||
}
|
||||
}
|
||||
|
||||
/// Crée une contrainte avec une liste explicite de types acceptés
|
||||
pub fn from_list(types: Vec<SampleType>) -> Self {
|
||||
// Déterminer la catégorie la plus appropriée
|
||||
let all_integer = types.iter().all(|t| t.is_integer());
|
||||
let all_float = types.iter().all(|t| t.is_float());
|
||||
|
||||
let category = if types.len() == 1 {
|
||||
TypeCategory::Specific(types[0])
|
||||
} else if all_integer && types.len() == 3 {
|
||||
TypeCategory::AnyInteger
|
||||
} else if all_float && types.len() == 2 {
|
||||
TypeCategory::AnyFloat
|
||||
} else if types.len() == 5 {
|
||||
TypeCategory::Any
|
||||
} else {
|
||||
// Catégorie personnalisée - on garde la liste explicite
|
||||
TypeCategory::Any
|
||||
};
|
||||
|
||||
Self {
|
||||
category,
|
||||
accepted_types: types,
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie si cette contrainte accepte le type donné
|
||||
pub fn accepts(&self, sample_type: SampleType) -> bool {
|
||||
if !self.accepted_types.is_empty() {
|
||||
// Si une liste explicite est fournie, utiliser celle-ci
|
||||
self.accepted_types.contains(&sample_type)
|
||||
} else {
|
||||
// Sinon, utiliser la catégorie
|
||||
self.category.accepts(sample_type)
|
||||
}
|
||||
}
|
||||
|
||||
/// Retourne tous les types acceptés par cette contrainte
|
||||
pub fn get_accepted_types(&self) -> Vec<SampleType> {
|
||||
if !self.accepted_types.is_empty() {
|
||||
self.accepted_types.clone()
|
||||
} else {
|
||||
self.category.possible_types()
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie si cette contrainte est plus restrictive qu'une autre
|
||||
pub fn is_more_restrictive_than(&self, other: &TypeRequirement) -> bool {
|
||||
let my_types = self.get_accepted_types();
|
||||
let other_types = other.get_accepted_types();
|
||||
|
||||
// Je suis plus restrictif si tous mes types sont dans other_types
|
||||
// et que j'en ai moins
|
||||
my_types.iter().all(|t| other_types.contains(t)) && my_types.len() < other_types.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for TypeRequirement {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
if !self.accepted_types.is_empty() && self.accepted_types.len() < 5 {
|
||||
write!(
|
||||
f,
|
||||
"{}",
|
||||
self.accepted_types
|
||||
.iter()
|
||||
.map(|t| t.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("|")
|
||||
)
|
||||
} else {
|
||||
write!(f, "{}", self.category)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie la compatibilité entre un producer et un consumer
|
||||
///
|
||||
/// # Règles de compatibilité :
|
||||
///
|
||||
/// 1. Si le producer produit un type spécifique et que le consumer l'accepte → compatible
|
||||
/// 2. Si le producer peut produire plusieurs types (ex: AnyInteger) et que le consumer
|
||||
/// accepte un type spécifique (ex: I24), le producer POURRAIT produire un type
|
||||
/// incompatible → **incompatible** (nécessite conversion explicite)
|
||||
/// 3. Si le producer produit un type spécifique et que le consumer accepte une catégorie
|
||||
/// contenant ce type → compatible
|
||||
///
|
||||
/// # Exemples :
|
||||
///
|
||||
/// - Producer(Specific(I24)) + Consumer(AnyInteger) → Compatible ✓
|
||||
/// - Producer(AnyInteger) + Consumer(Specific(I24)) → Incompatible ✗ (producer peut produire I16)
|
||||
/// - Producer(Specific(I24)) + Consumer(Specific(I24)) → Compatible ✓
|
||||
/// - Producer(AnyInteger) + Consumer(AnyInteger) → Compatible ✓
|
||||
pub fn check_compatibility(
|
||||
producer: &TypeRequirement,
|
||||
consumer: &TypeRequirement,
|
||||
) -> Result<(), TypeMismatch> {
|
||||
let producer_types = producer.get_accepted_types();
|
||||
let consumer_types = consumer.get_accepted_types();
|
||||
|
||||
// Vérifier si tous les types que le producer peut produire sont acceptés par le consumer
|
||||
for prod_type in &producer_types {
|
||||
if !consumer_types.contains(prod_type) {
|
||||
return Err(TypeMismatch {
|
||||
producer: producer.clone(),
|
||||
consumer: consumer.clone(),
|
||||
incompatible_type: Some(*prod_type),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Erreur de compatibilité de types
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TypeMismatch {
|
||||
/// Type requirement du producer
|
||||
pub producer: TypeRequirement,
|
||||
/// Type requirement du consumer
|
||||
pub consumer: TypeRequirement,
|
||||
/// Type spécifique incompatible (si identifié)
|
||||
pub incompatible_type: Option<SampleType>,
|
||||
}
|
||||
|
||||
impl fmt::Display for TypeMismatch {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"Type mismatch: producer produces {} but consumer only accepts {}",
|
||||
self.producer, self.consumer
|
||||
)?;
|
||||
if let Some(incomp_type) = self.incompatible_type {
|
||||
write!(f, " (incompatible type: {})", incomp_type)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for TypeMismatch {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_sample_type_is_integer() {
|
||||
assert!(SampleType::I16.is_integer());
|
||||
assert!(SampleType::I24.is_integer());
|
||||
assert!(SampleType::I32.is_integer());
|
||||
assert!(!SampleType::F32.is_integer());
|
||||
assert!(!SampleType::F64.is_integer());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sample_type_is_float() {
|
||||
assert!(!SampleType::I16.is_float());
|
||||
assert!(SampleType::F32.is_float());
|
||||
assert!(SampleType::F64.is_float());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_type_category_accepts() {
|
||||
let any_int = TypeCategory::AnyInteger;
|
||||
assert!(any_int.accepts(SampleType::I16));
|
||||
assert!(any_int.accepts(SampleType::I24));
|
||||
assert!(any_int.accepts(SampleType::I32));
|
||||
assert!(!any_int.accepts(SampleType::F32));
|
||||
|
||||
let specific = TypeCategory::Specific(SampleType::I24);
|
||||
assert!(!specific.accepts(SampleType::I16));
|
||||
assert!(specific.accepts(SampleType::I24));
|
||||
assert!(!specific.accepts(SampleType::I32));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compatibility_specific_to_category() {
|
||||
// Producer(Specific(I24)) + Consumer(AnyInteger) → Compatible
|
||||
let producer = TypeRequirement::specific(SampleType::I24);
|
||||
let consumer = TypeRequirement::any_integer();
|
||||
assert!(check_compatibility(&producer, &consumer).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compatibility_category_to_specific() {
|
||||
// Producer(AnyInteger) + Consumer(Specific(I24)) → Incompatible
|
||||
let producer = TypeRequirement::any_integer();
|
||||
let consumer = TypeRequirement::specific(SampleType::I24);
|
||||
assert!(check_compatibility(&producer, &consumer).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compatibility_same_specific() {
|
||||
// Producer(Specific(I24)) + Consumer(Specific(I24)) → Compatible
|
||||
let producer = TypeRequirement::specific(SampleType::I24);
|
||||
let consumer = TypeRequirement::specific(SampleType::I24);
|
||||
assert!(check_compatibility(&producer, &consumer).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compatibility_same_category() {
|
||||
// Producer(AnyInteger) + Consumer(AnyInteger) → Compatible
|
||||
let producer = TypeRequirement::any_integer();
|
||||
let consumer = TypeRequirement::any_integer();
|
||||
assert!(check_compatibility(&producer, &consumer).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compatibility_integer_to_float() {
|
||||
// Producer(AnyInteger) + Consumer(AnyFloat) → Incompatible
|
||||
let producer = TypeRequirement::any_integer();
|
||||
let consumer = TypeRequirement::any_float();
|
||||
assert!(check_compatibility(&producer, &consumer).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_type_requirement_from_list() {
|
||||
let req = TypeRequirement::from_list(vec![SampleType::I24, SampleType::I32]);
|
||||
assert!(req.accepts(SampleType::I24));
|
||||
assert!(req.accepts(SampleType::I32));
|
||||
assert!(!req.accepts(SampleType::I16));
|
||||
assert!(!req.accepts(SampleType::F32));
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,20 @@
|
||||
//! Tests d'intégration pour le pipeline audio complet
|
||||
//!
|
||||
//! NOTE: Ces tests utilisent l'ancienne API (BufferNode, DecoderNode, etc.)
|
||||
//! qui a été temporairement désactivée. Ils doivent être réécrits pour
|
||||
//! utiliser la nouvelle architecture de pipeline (FileSource, HttpSource, FlacFileSink, etc.)
|
||||
|
||||
use pmoaudio::{BufferNode, DecoderNode, DspNode, SinkNode, SourceNode, TimerNode};
|
||||
// Désactivé temporairement - ancienne API non disponible
|
||||
/*
|
||||
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);
|
||||
@@ -159,3 +166,4 @@ async fn test_arc_sharing() {
|
||||
assert_eq!(stats2.chunks_received, 5);
|
||||
assert_eq!(stats1.total_samples, stats2.total_samples);
|
||||
}
|
||||
*/
|
||||
|
||||
@@ -10,6 +10,10 @@ pmocache = { path = "../pmocache" }
|
||||
# DIDL-Lite pour UPnP
|
||||
pmodidl = { path = "../pmodidl" }
|
||||
|
||||
# Streaming FLAC asynchrone
|
||||
pmoflac = { path = "../pmoflac" }
|
||||
pmometadata = { path = "../pmometadata" }
|
||||
|
||||
# Base de données
|
||||
rusqlite = { version = "0.37", features = ["bundled"] }
|
||||
chrono = "0.4"
|
||||
@@ -17,20 +21,17 @@ chrono = "0.4"
|
||||
# Métadonnées audio
|
||||
lofty = "0.22"
|
||||
|
||||
# Encodage/décodage audio
|
||||
symphonia = { version = "0.5", features = ["all"] }
|
||||
claxon = "0.4" # Décodeur FLAC
|
||||
flacenc = "0.4" # Encodeur FLAC
|
||||
futures-util = "0.3" # Pour le streaming
|
||||
|
||||
# HTTP client
|
||||
reqwest = { version = "0.12", features = ["blocking"] }
|
||||
# Outils de streaming
|
||||
futures-util = "0.3"
|
||||
bytes = "1.0"
|
||||
|
||||
# Utilitaires
|
||||
anyhow = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
quick-xml = { version = "0.37", features = ["serialize"] }
|
||||
paste = "1.0"
|
||||
async-trait = "0.1"
|
||||
|
||||
# Async
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
@@ -45,7 +46,9 @@ tracing = "0.1.41"
|
||||
|
||||
[dev-dependencies]
|
||||
tracing-subscriber = "0.3"
|
||||
tempfile = "3"
|
||||
|
||||
[features]
|
||||
default = ["pmoserver"]
|
||||
pmoserver = ["dep:pmoserver", "dep:pmoconfig", "dep:axum", "dep:utoipa", "pmocache/pmoserver", "pmocache/openapi"]
|
||||
pmoconfig = ["dep:pmoconfig", "pmocache/pmoconfig"]
|
||||
pmoserver = ["pmoconfig", "dep:pmoserver", "dep:axum", "dep:utoipa", "pmocache/pmoserver", "pmocache/openapi"]
|
||||
|
||||
@@ -21,7 +21,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
let pk = cache::add_with_metadata_extraction(&cache, test_url, Some("test")).await?;
|
||||
|
||||
println!("\nPK: {}", pk);
|
||||
let file_path = cache.file_path(&pk);
|
||||
let file_path = cache.get_file_path(&pk);
|
||||
println!("File path: {}", file_path.display());
|
||||
|
||||
// Vérifier le format
|
||||
|
||||
69
pmoaudiocache/examples/test_progressive_streaming.rs
Normal file
69
pmoaudiocache/examples/test_progressive_streaming.rs
Normal file
@@ -0,0 +1,69 @@
|
||||
//! Test du streaming progressif avec le nouveau transformer
|
||||
//!
|
||||
//! Cet exemple démontre comment les fichiers deviennent disponibles
|
||||
//! progressivement pendant le téléchargement avec le nouveau système.
|
||||
|
||||
use pmoaudiocache::cache;
|
||||
use std::time::Instant;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
// Initialiser le logging
|
||||
tracing_subscriber::fmt()
|
||||
.with_max_level(tracing::Level::DEBUG)
|
||||
.init();
|
||||
|
||||
println!("=== Test du cache audio avec streaming progressif ===\n");
|
||||
|
||||
// Créer un cache temporaire
|
||||
let cache_dir = "/tmp/test_streaming_cache";
|
||||
let _ = std::fs::remove_dir_all(cache_dir);
|
||||
let cache = cache::new_cache(cache_dir, 10)?;
|
||||
|
||||
println!("Cache créé dans: {}\n", cache_dir);
|
||||
|
||||
// URL d'un fichier FLAC pour tester le streaming complet
|
||||
// Pour tester, vous pouvez utiliser votre propre URL ou un fichier local
|
||||
let test_url = std::env::var("TEST_AUDIO_URL")
|
||||
.unwrap_or_else(|_| "https://www.kozco.com/tech/piano2-CoolEdit.flac".to_string());
|
||||
|
||||
println!("Test avec URL: {}\n", test_url);
|
||||
|
||||
// Démarrer le téléchargement et la conversion
|
||||
println!("🚀 Démarrage du téléchargement et de la conversion...");
|
||||
let start = Instant::now();
|
||||
|
||||
// Ajouter avec extraction de métadonnées
|
||||
let pk = cache::add_with_metadata_extraction(&cache, &test_url, None).await?;
|
||||
|
||||
let total_time = start.elapsed();
|
||||
println!(" ✓ Ajouté au cache avec pk: {}", pk);
|
||||
println!(" ✓ Temps total: {:?}", total_time);
|
||||
|
||||
// Vérifier que le fichier est bien accessible
|
||||
println!("\n🔍 Vérification du fichier:");
|
||||
let file_path = cache.get(&pk).await?;
|
||||
let file_size = tokio::fs::metadata(&file_path).await?.len();
|
||||
println!(" • Chemin: {:?}", file_path);
|
||||
println!(" • Taille: {} bytes", file_size);
|
||||
|
||||
// Extraire et afficher les métadonnées
|
||||
println!("\n📋 Métadonnées extraites:");
|
||||
match cache::get_metadata(&cache, &pk) {
|
||||
Ok(metadata) => {
|
||||
println!(" • Titre: {:?}", metadata.title);
|
||||
println!(" • Artiste: {:?}", metadata.artist);
|
||||
println!(" • Album: {:?}", metadata.album);
|
||||
println!(" • Durée: {:?} secondes", metadata.duration_secs);
|
||||
println!(" • Sample rate: {:?} Hz", metadata.sample_rate);
|
||||
println!(" • Channels: {:?}", metadata.channels);
|
||||
}
|
||||
Err(e) => {
|
||||
println!(" ⚠️ Métadonnées non disponibles: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
println!("\n✨ Test terminé avec succès !");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -33,7 +33,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
println!("✓ Fichier converti avec succès!");
|
||||
println!(" Clé primaire: {}", pk);
|
||||
|
||||
let file_path = cache.file_path(&pk);
|
||||
let file_path = cache.get_file_path(&pk);
|
||||
println!(" Chemin: {}", file_path.display());
|
||||
|
||||
if let Ok(metadata) = std::fs::metadata(&file_path) {
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
//! des métadonnées en JSON dans la base de données.
|
||||
|
||||
use anyhow::Result;
|
||||
use pmocache::{CacheConfig, StreamTransformer};
|
||||
use pmocache::CacheConfig;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Configuration pour le cache audio
|
||||
@@ -16,10 +17,6 @@ impl CacheConfig for AudioConfig {
|
||||
"flac"
|
||||
}
|
||||
|
||||
fn table_name() -> &'static str {
|
||||
"audio_tracks"
|
||||
}
|
||||
|
||||
fn cache_type() -> &'static str {
|
||||
"flac"
|
||||
}
|
||||
@@ -36,240 +33,6 @@ impl CacheConfig for AudioConfig {
|
||||
/// Type alias pour le cache audio avec conversion FLAC
|
||||
pub type Cache = pmocache::Cache<AudioConfig>;
|
||||
|
||||
/// Créateur de transformer FLAC
|
||||
///
|
||||
/// Convertit automatiquement tout fichier audio téléchargé en format FLAC
|
||||
/// en traitant les données au vol, sans tout charger en mémoire.
|
||||
///
|
||||
/// # Workflow
|
||||
///
|
||||
/// 1. Télécharger les bytes par chunks depuis le stream HTTP
|
||||
/// 2. Buffer temporaire pour accumuler les données nécessaires à Symphonia
|
||||
/// 3. Décoder l'audio en PCM via Symphonia
|
||||
/// 4. Encoder le PCM en FLAC progressivement via flacenc
|
||||
/// 5. Écrire les frames FLAC directement dans le fichier
|
||||
/// 6. Mettre à jour la progression après chaque chunk
|
||||
///
|
||||
/// Note: Bien que nous utilisions un buffer temporaire, celui-ci est géré
|
||||
/// de manière efficace et les données FLAC sont écrites au fur et à mesure.
|
||||
fn create_flac_transformer() -> StreamTransformer {
|
||||
Box::new(|input, mut file, progress| {
|
||||
Box::pin(async move {
|
||||
use futures_util::StreamExt;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
// 1. Collecter tous les bytes du stream
|
||||
// Note: Symphonia nécessite un MediaSource avec Read + Seek,
|
||||
// ce qui n'est pas compatible avec un vrai streaming HTTP.
|
||||
// Nous devons donc bufferiser les données.
|
||||
let mut buffer = Vec::new();
|
||||
let mut stream = input.into_byte_stream();
|
||||
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|e| format!("Stream error: {}", e))?;
|
||||
buffer.extend_from_slice(&chunk);
|
||||
}
|
||||
|
||||
tracing::debug!(
|
||||
"Downloaded {} bytes total, starting FLAC conversion",
|
||||
buffer.len()
|
||||
);
|
||||
|
||||
// 2. Si c'est déjà du FLAC, on l'écrit directement
|
||||
if buffer.len() >= 4 && &buffer[0..4] == b"fLaC" {
|
||||
tracing::debug!("Input is already FLAC, writing directly");
|
||||
file.write_all(&buffer).await.map_err(|e| e.to_string())?;
|
||||
file.flush().await.map_err(|e| e.to_string())?;
|
||||
progress(buffer.len() as u64);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
tracing::debug!("Converting to FLAC with Symphonia + flacenc");
|
||||
|
||||
// 3. Décoder l'audio avec Symphonia
|
||||
let (samples, channels, sample_rate, bits_per_sample) = {
|
||||
use std::io::Cursor;
|
||||
use symphonia::core::audio::SampleBuffer;
|
||||
use symphonia::core::codecs::{DecoderOptions, CODEC_TYPE_NULL};
|
||||
use symphonia::core::errors::Error as SymphoniaError;
|
||||
use symphonia::core::formats::FormatOptions;
|
||||
use symphonia::core::io::MediaSourceStream;
|
||||
use symphonia::core::meta::MetadataOptions;
|
||||
use symphonia::core::probe::Hint;
|
||||
|
||||
let cursor = Cursor::new(buffer);
|
||||
let mss = MediaSourceStream::new(Box::new(cursor), Default::default());
|
||||
|
||||
let hint = Hint::new();
|
||||
let probed = symphonia::default::get_probe()
|
||||
.format(
|
||||
&hint,
|
||||
mss,
|
||||
&FormatOptions::default(),
|
||||
&MetadataOptions::default(),
|
||||
)
|
||||
.map_err(|e| format!("Failed to probe format: {}", e))?;
|
||||
|
||||
let mut format = probed.format;
|
||||
|
||||
let track = format
|
||||
.tracks()
|
||||
.iter()
|
||||
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL)
|
||||
.ok_or_else(|| "No audio track found".to_string())?;
|
||||
|
||||
let mut decoder = symphonia::default::get_codecs()
|
||||
.make(&track.codec_params, &DecoderOptions::default())
|
||||
.map_err(|e| format!("Failed to create decoder: {}", e))?;
|
||||
|
||||
let channels = track
|
||||
.codec_params
|
||||
.channels
|
||||
.ok_or_else(|| "No channel info".to_string())?
|
||||
.count();
|
||||
|
||||
let sample_rate = track
|
||||
.codec_params
|
||||
.sample_rate
|
||||
.ok_or_else(|| "No sample rate info".to_string())?;
|
||||
|
||||
let bits_per_sample = track.codec_params.bits_per_sample.unwrap_or(16);
|
||||
|
||||
let mut samples_i32 = Vec::new();
|
||||
let track_id = track.id;
|
||||
|
||||
// Décoder tous les packets
|
||||
loop {
|
||||
let packet = match format.next_packet() {
|
||||
Ok(packet) => packet,
|
||||
Err(SymphoniaError::ResetRequired) => {
|
||||
decoder.reset();
|
||||
continue;
|
||||
}
|
||||
Err(SymphoniaError::IoError(e))
|
||||
if e.kind() == std::io::ErrorKind::UnexpectedEof =>
|
||||
{
|
||||
break;
|
||||
}
|
||||
Err(e) => return Err(format!("Decode error: {}", e)),
|
||||
};
|
||||
|
||||
if packet.track_id() != track_id {
|
||||
continue;
|
||||
}
|
||||
|
||||
match decoder.decode(&packet) {
|
||||
Ok(decoded) => {
|
||||
let spec = *decoded.spec();
|
||||
let duration = decoded.capacity() as u64;
|
||||
|
||||
// Convertir en i32 pour flacenc
|
||||
// Note: Symphonia retourne des samples i32, nous devons les convertir
|
||||
// en fonction du bits_per_sample réel
|
||||
let mut sample_buf = SampleBuffer::<i32>::new(duration, spec);
|
||||
sample_buf.copy_interleaved_ref(decoded);
|
||||
samples_i32.extend_from_slice(sample_buf.samples());
|
||||
}
|
||||
Err(SymphoniaError::DecodeError(_)) => continue,
|
||||
Err(e) => return Err(format!("Decode error: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
if samples_i32.is_empty() {
|
||||
return Err("No samples decoded".to_string());
|
||||
}
|
||||
|
||||
tracing::debug!(
|
||||
"Decoded {} samples (i32), {} channels, {} Hz, {} bits",
|
||||
samples_i32.len(),
|
||||
channels,
|
||||
sample_rate,
|
||||
bits_per_sample
|
||||
);
|
||||
|
||||
// Normaliser les samples i32 vers la plage appropriée pour flacenc
|
||||
// Symphonia retourne des samples i32 en pleine échelle (32 bits),
|
||||
// nous devons les normaliser selon le bits_per_sample réel
|
||||
let (normalized_samples, target_bits): (Vec<i32>, u32) = match bits_per_sample {
|
||||
0..=16 => {
|
||||
// Pour 16 bits ou moins, normaliser vers la plage i16
|
||||
tracing::debug!("Normalizing to 16-bit");
|
||||
let samples = samples_i32.iter().map(|&s| (s >> 16) as i32).collect();
|
||||
(samples, 16)
|
||||
}
|
||||
17..=24 => {
|
||||
// Pour 17-24 bits, normaliser vers la plage 24-bit
|
||||
tracing::debug!("Normalizing to 24-bit");
|
||||
let samples = samples_i32.iter().map(|&s| (s >> 8) as i32).collect();
|
||||
(samples, 24)
|
||||
}
|
||||
_ => {
|
||||
// Pour 25-32 bits, garder la pleine échelle i32
|
||||
tracing::debug!("Keeping 32-bit");
|
||||
(samples_i32, 32)
|
||||
}
|
||||
};
|
||||
|
||||
(normalized_samples, channels, sample_rate, target_bits)
|
||||
};
|
||||
|
||||
tracing::debug!(
|
||||
"Encoding to FLAC: {} samples, {} channels, {} Hz, {} bits",
|
||||
samples.len(),
|
||||
channels,
|
||||
sample_rate,
|
||||
bits_per_sample
|
||||
);
|
||||
|
||||
// 4. Encoder en FLAC avec flacenc
|
||||
// Note: L'encodage FLAC est une opération bloquante/CPU-intensive,
|
||||
// donc nous l'exécutons dans un thread bloquant pour ne pas bloquer le runtime Tokio
|
||||
let flac_data = tokio::task::spawn_blocking(move || {
|
||||
use flacenc::bitsink::ByteSink;
|
||||
use flacenc::component::BitRepr;
|
||||
use flacenc::error::Verify;
|
||||
|
||||
let config = flacenc::config::Encoder::default()
|
||||
.into_verified()
|
||||
.map_err(|e| format!("FLAC config error: {:?}", e))?;
|
||||
|
||||
let source = flacenc::source::MemSource::from_samples(
|
||||
&samples,
|
||||
channels,
|
||||
bits_per_sample as usize,
|
||||
sample_rate as usize,
|
||||
);
|
||||
|
||||
let flac_stream =
|
||||
flacenc::encode_with_fixed_block_size(&config, source, config.block_size)
|
||||
.map_err(|e| format!("FLAC encode error: {:?}", e))?;
|
||||
|
||||
let mut sink = ByteSink::new();
|
||||
flac_stream
|
||||
.write(&mut sink)
|
||||
.map_err(|e| format!("FLAC write error: {:?}", e))?;
|
||||
|
||||
Ok::<Vec<u8>, String>(sink.into_inner())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Spawn blocking error: {}", e))??;
|
||||
|
||||
tracing::debug!("FLAC encoding complete: {} bytes", flac_data.len());
|
||||
|
||||
// 5. Écrire le fichier FLAC
|
||||
file.write_all(&flac_data)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
file.flush().await.map_err(|e| e.to_string())?;
|
||||
|
||||
// 6. Mettre à jour la progression finale
|
||||
progress(flac_data.len() as u64);
|
||||
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Crée un cache audio avec conversion FLAC automatique
|
||||
///
|
||||
/// # Arguments
|
||||
@@ -289,7 +52,7 @@ fn create_flac_transformer() -> StreamTransformer {
|
||||
/// let cache = cache::new_cache("./audio_cache", 1000).unwrap();
|
||||
/// ```
|
||||
pub fn new_cache(dir: &str, limit: usize) -> Result<Cache> {
|
||||
let transformer_factory = Arc::new(|| create_flac_transformer());
|
||||
let transformer_factory = Arc::new(|| crate::streaming::create_streaming_flac_transformer());
|
||||
Cache::with_transformer(dir, limit, Some(transformer_factory))
|
||||
}
|
||||
|
||||
@@ -315,10 +78,10 @@ pub fn new_cache(dir: &str, limit: usize) -> Result<Cache> {
|
||||
/// use pmoaudiocache::cache;
|
||||
///
|
||||
/// # async fn example() -> anyhow::Result<()> {
|
||||
/// let cache = cache::new_cache("./audio_cache", 1000, "http://localhost:8080")?;
|
||||
/// let cache = cache::new_cache("./audio_cache", 1000)?;
|
||||
/// let pk = cache::add_with_metadata_extraction(
|
||||
/// &cache,
|
||||
/// "http://example.com/track.flac",
|
||||
/// "https://example.com/track.flac",
|
||||
/// Some("artist:album")
|
||||
/// ).await?;
|
||||
/// # Ok(())
|
||||
@@ -336,19 +99,26 @@ pub async fn add_with_metadata_extraction(
|
||||
cache.wait_until_finished(&pk).await?;
|
||||
|
||||
// Lire le fichier FLAC pour extraire les métadonnées
|
||||
let file_path = cache.file_path(&pk);
|
||||
let file_path = cache.get_file_path(&pk);
|
||||
let flac_bytes = tokio::fs::read(&file_path).await?;
|
||||
|
||||
// Extraire les métadonnées
|
||||
let metadata = crate::metadata::AudioMetadata::from_bytes(&flac_bytes)?;
|
||||
|
||||
// Sérialiser en JSON
|
||||
let metadata_json = serde_json::to_string(&metadata)?;
|
||||
let mut metadata = crate::metadata::AudioMetadata::from_bytes(&flac_bytes)?;
|
||||
|
||||
if let Some(transform) = cache.transform_metadata(&pk).await {
|
||||
if let Some(mode) = transform.mode {
|
||||
metadata.conversion = Some(crate::metadata::AudioConversionInfo {
|
||||
mode,
|
||||
source_codec: transform.input_codec,
|
||||
});
|
||||
}
|
||||
}
|
||||
let metadata_json: Value = serde_json::to_value(&metadata)
|
||||
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
|
||||
// Stocker dans la DB
|
||||
cache
|
||||
.db
|
||||
.update_metadata(&pk, &metadata_json)
|
||||
.set_metadata(&pk, &metadata_json)
|
||||
.map_err(|e| anyhow::anyhow!("Database error: {}", e))?;
|
||||
|
||||
// Mettre à jour la collection si les métadonnées en fournissent une
|
||||
@@ -356,8 +126,9 @@ pub async fn add_with_metadata_extraction(
|
||||
if let Some(auto_collection) = metadata.collection_key() {
|
||||
cache
|
||||
.db
|
||||
.add(&pk, url, Some(&auto_collection))
|
||||
.add(&pk, None, Some(&auto_collection))
|
||||
.map_err(|e| anyhow::anyhow!("Database error: {}", e))?;
|
||||
cache.db.set_origin_url(&pk, url)?;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
106
pmoaudiocache/src/config_ext.rs
Normal file
106
pmoaudiocache/src/config_ext.rs
Normal file
@@ -0,0 +1,106 @@
|
||||
//! Extension pour intégrer le cache audio dans pmoconfig
|
||||
//!
|
||||
//! Ce module fournit le trait `AudioCacheConfigExt` qui permet d'ajouter facilement
|
||||
//! des méthodes de gestion du cache audio à pmoconfig::Config.
|
||||
|
||||
use anyhow::Result;
|
||||
use pmocache::CacheConfigExt;
|
||||
use pmoconfig::Config;
|
||||
use std::sync::Arc;
|
||||
|
||||
const DEFAULT_AUDIO_CACHE_DIR: &str = "cache_audio";
|
||||
const DEFAULT_AUDIO_CACHE_SIZE: usize = 500;
|
||||
|
||||
/// Trait d'extension pour gérer le cache audio dans pmoconfig
|
||||
///
|
||||
/// Ce trait étend `pmoconfig::Config` avec des méthodes spécifiques
|
||||
/// au cache audio avec conversion FLAC.
|
||||
///
|
||||
/// # Exemple
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// use pmoconfig::get_config;
|
||||
/// use pmoaudiocache::AudioCacheConfigExt;
|
||||
///
|
||||
/// let config = get_config();
|
||||
/// let cache = config.create_audio_cache()?;
|
||||
///
|
||||
/// // Utiliser le cache
|
||||
/// let pk = cache.add_from_url("http://example.com/track.mp3", Some("album:123")).await?;
|
||||
/// ```
|
||||
pub trait AudioCacheConfigExt {
|
||||
/// Récupère le répertoire du cache audio
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Le chemin absolu du répertoire du cache audio (default: "cache_audio")
|
||||
fn get_audiocache_dir(&self) -> Result<String>;
|
||||
|
||||
/// Définit le répertoire du cache audio
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `directory` - Chemin du répertoire (absolu ou relatif au config_dir)
|
||||
fn set_audiocache_dir(&self, directory: String) -> Result<()>;
|
||||
|
||||
/// Récupère la taille maximale du cache audio
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Le nombre maximal de pistes audio dans le cache (default: 500)
|
||||
fn get_audiocache_size(&self) -> Result<usize>;
|
||||
|
||||
/// Définit la taille maximale du cache audio
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `size` - Nombre maximal de pistes audio
|
||||
fn set_audiocache_size(&self, size: usize) -> Result<()>;
|
||||
|
||||
/// Crée une instance du cache audio configurée avec conversion FLAC
|
||||
///
|
||||
/// Cette méthode factory crée un cache audio en utilisant les paramètres
|
||||
/// de configuration (répertoire et taille) et active la conversion FLAC
|
||||
/// automatique pour tous les fichiers audio téléchargés.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Une instance Arc du cache audio configuré
|
||||
///
|
||||
/// # Exemple
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// use pmoconfig::get_config;
|
||||
/// use pmoaudiocache::AudioCacheConfigExt;
|
||||
///
|
||||
/// let config = get_config();
|
||||
/// let cache = config.create_audio_cache()?;
|
||||
///
|
||||
/// // Le cache est prêt à être utilisé avec conversion FLAC automatique
|
||||
/// ```
|
||||
fn create_audio_cache(&self) -> Result<Arc<crate::Cache>>;
|
||||
}
|
||||
|
||||
impl AudioCacheConfigExt for Config {
|
||||
fn get_audiocache_dir(&self) -> Result<String> {
|
||||
self.get_cache_dir("audio_cache", DEFAULT_AUDIO_CACHE_DIR)
|
||||
}
|
||||
|
||||
fn set_audiocache_dir(&self, directory: String) -> Result<()> {
|
||||
self.set_cache_dir("audio_cache", directory)
|
||||
}
|
||||
|
||||
fn get_audiocache_size(&self) -> Result<usize> {
|
||||
self.get_cache_size("audio_cache", DEFAULT_AUDIO_CACHE_SIZE)
|
||||
}
|
||||
|
||||
fn set_audiocache_size(&self, size: usize) -> Result<()> {
|
||||
self.set_cache_size("audio_cache", size)
|
||||
}
|
||||
|
||||
fn create_audio_cache(&self) -> Result<Arc<crate::Cache>> {
|
||||
let dir = self.get_audiocache_dir()?;
|
||||
let size = self.get_audiocache_size()?;
|
||||
Ok(Arc::new(crate::cache::new_cache(&dir, size)?))
|
||||
}
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
//! Module de conversion audio en FLAC
|
||||
//!
|
||||
//! Ce module gère la conversion de divers formats audio vers FLAC
|
||||
//! pour standardiser le stockage dans le cache.
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use std::io::Cursor;
|
||||
use symphonia::core::audio::SampleBuffer;
|
||||
use symphonia::core::codecs::{DecoderOptions, CODEC_TYPE_NULL};
|
||||
use symphonia::core::errors::Error as SymphoniaError;
|
||||
use symphonia::core::formats::FormatOptions;
|
||||
use symphonia::core::io::MediaSourceStream;
|
||||
use symphonia::core::meta::MetadataOptions;
|
||||
use symphonia::core::probe::Hint;
|
||||
|
||||
/// Convertit des données audio en FLAC
|
||||
///
|
||||
/// Cette fonction accepte n'importe quel format audio supporté par Symphonia
|
||||
/// et le convertit en FLAC pour un stockage standardisé.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `data` - Données audio brutes (n'importe quel format)
|
||||
/// * `extension` - Extension du fichier source (optionnel, aide à la détection)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Données audio au format FLAC
|
||||
///
|
||||
/// # Exemple
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// use pmoaudiocache::flac::convert_to_flac;
|
||||
///
|
||||
/// let mp3_data = std::fs::read("track.mp3").unwrap();
|
||||
/// let flac_data = convert_to_flac(&mp3_data, Some("mp3")).unwrap();
|
||||
/// ```
|
||||
pub fn convert_to_flac(data: &[u8], extension: Option<&str>) -> Result<Vec<u8>> {
|
||||
// Si c'est déjà du FLAC, on le retourne tel quel
|
||||
if is_flac(data) {
|
||||
return Ok(data.to_vec());
|
||||
}
|
||||
|
||||
// Créer un MediaSource depuis les données (en clonant pour avoir 'static)
|
||||
let data_owned = data.to_vec();
|
||||
let cursor = Cursor::new(data_owned);
|
||||
let mss = MediaSourceStream::new(Box::new(cursor), Default::default());
|
||||
|
||||
// Créer un hint si on a l'extension
|
||||
let mut hint = Hint::new();
|
||||
if let Some(ext) = extension {
|
||||
hint.with_extension(ext);
|
||||
}
|
||||
|
||||
// Prober le format
|
||||
let probed = symphonia::default::get_probe()
|
||||
.format(
|
||||
&hint,
|
||||
mss,
|
||||
&FormatOptions::default(),
|
||||
&MetadataOptions::default(),
|
||||
)
|
||||
.map_err(|e| anyhow!("Impossible de détecter le format audio: {}", e))?;
|
||||
|
||||
let mut format = probed.format;
|
||||
|
||||
// Obtenir le premier track audio
|
||||
let track = format
|
||||
.tracks()
|
||||
.iter()
|
||||
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL)
|
||||
.ok_or_else(|| anyhow!("Aucune piste audio trouvée"))?;
|
||||
|
||||
// Créer un décodeur
|
||||
let mut decoder = symphonia::default::get_codecs()
|
||||
.make(&track.codec_params, &DecoderOptions::default())
|
||||
.map_err(|e| anyhow!("Impossible de créer le décodeur: {}", e))?;
|
||||
|
||||
// Buffer pour stocker les samples décodés
|
||||
let mut samples = Vec::new();
|
||||
let track_id = track.id;
|
||||
|
||||
// Décoder tous les packets
|
||||
loop {
|
||||
let packet = match format.next_packet() {
|
||||
Ok(packet) => packet,
|
||||
Err(SymphoniaError::ResetRequired) => {
|
||||
// Reset du décodeur requis
|
||||
decoder.reset();
|
||||
continue;
|
||||
}
|
||||
Err(SymphoniaError::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
|
||||
break;
|
||||
}
|
||||
Err(e) => return Err(anyhow!("Erreur lors de la lecture: {}", e)),
|
||||
};
|
||||
|
||||
// Ignorer les packets qui ne sont pas de notre track
|
||||
if packet.track_id() != track_id {
|
||||
continue;
|
||||
}
|
||||
|
||||
match decoder.decode(&packet) {
|
||||
Ok(decoded) => {
|
||||
// Convertir les samples en format standard
|
||||
let spec = *decoded.spec();
|
||||
let duration = decoded.capacity() as u64;
|
||||
|
||||
let mut sample_buf = SampleBuffer::<i16>::new(duration, spec);
|
||||
sample_buf.copy_interleaved_ref(decoded);
|
||||
|
||||
samples.extend_from_slice(sample_buf.samples());
|
||||
}
|
||||
Err(SymphoniaError::DecodeError(_)) => continue,
|
||||
Err(e) => return Err(anyhow!("Erreur de décodage: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
if samples.is_empty() {
|
||||
return Err(anyhow!("Aucun sample décodé"));
|
||||
}
|
||||
|
||||
// Note: Pour l'encodage FLAC, on aurait besoin d'une bibliothèque comme
|
||||
// `flacenc` qui n'existe pas encore en Rust. Pour l'instant, on stocke
|
||||
// les données telles quelles si c'est déjà du FLAC, sinon on retourne
|
||||
// les données originales avec un warning.
|
||||
|
||||
// TODO: Implémenter l'encodage FLAC quand une bibliothèque sera disponible
|
||||
tracing::warn!("Encodage FLAC non implémenté, stockage du format original");
|
||||
Ok(data.to_vec())
|
||||
}
|
||||
|
||||
/// Vérifie si les données sont déjà au format FLAC
|
||||
fn is_flac(data: &[u8]) -> bool {
|
||||
data.len() >= 4 && &data[0..4] == b"fLaC"
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_is_flac() {
|
||||
let flac_header = b"fLaC\x00\x00\x00\x22";
|
||||
assert!(is_flac(flac_header));
|
||||
|
||||
let not_flac = b"RIFF\x00\x00\x00\x00";
|
||||
assert!(!is_flac(not_flac));
|
||||
}
|
||||
}
|
||||
161
pmoaudiocache/src/lib.rs
Normal file → Executable file
161
pmoaudiocache/src/lib.rs
Normal file → Executable file
@@ -1,145 +1,101 @@
|
||||
//! # pmoaudiocache - Cache de pistes audio pour PMOMusic
|
||||
//! # pmoaudiocache – Cache de pistes audio pour PMOMusic
|
||||
//!
|
||||
//! Cette crate fournit un système de cache pour les pistes audio avec conversion
|
||||
//! automatique en FLAC et extraction des métadonnées.
|
||||
//! `pmoaudiocache` s'appuie sur [`pmocache`] pour fournir un cache spécialisé
|
||||
//! dans les fichiers audio. Il assure la conversion transparente au format FLAC,
|
||||
//! l'extraction des métadonnées et la mise à disposition d'outils pour les exposer.
|
||||
//!
|
||||
//! ## Vue d'ensemble
|
||||
//! ## Fonctionnalités
|
||||
//!
|
||||
//! `pmoaudiocache` étend `pmocache` pour gérer spécifiquement les fichiers audio :
|
||||
//! - **Téléchargement asynchrone** via le système de download de `pmocache`
|
||||
//! - **Conversion automatique en FLAC** lors du téléchargement (via transformer)
|
||||
//! - **Extraction et stockage des métadonnées** en JSON dans la base de données
|
||||
//! - **Gestion de collections** basées sur artiste/album
|
||||
//! - **Streaming progressif** automatique (via `pmocache`)
|
||||
//! - **API REST complète** fournie par `pmocache`
|
||||
//! - conversion automatique des entrées en FLAC grâce à un `StreamTransformer` ;
|
||||
//! - extraction des tags (artiste, album, titre, etc.) via [`metadata::AudioMetadata`] ;
|
||||
//! - stockage des métadonnées dans la table `metadata` de `pmocache::DB` ;
|
||||
//! - helpers pour renseigner les collections à partir des tags ;
|
||||
//! - intégration optionnelle avec `pmoserver` (routes REST + diffusion de fichiers).
|
||||
//!
|
||||
//! ## Architecture
|
||||
//!
|
||||
//! Cette crate est une spécialisation minimale de `pmocache` :
|
||||
//! - Configuration via `AudioConfig`
|
||||
//! - Transformer FLAC pour la conversion automatique
|
||||
//! - Helpers pour l'extraction et la lecture des métadonnées
|
||||
//!
|
||||
//! Tout le reste (DB, API REST, streaming) est fourni par `pmocache`.
|
||||
//!
|
||||
//! ## Utilisation
|
||||
//!
|
||||
//! ### Exemple basique
|
||||
//! ## Exemple rapide
|
||||
//!
|
||||
//! ```rust,no_run
|
||||
//! use pmoaudiocache::cache;
|
||||
//!
|
||||
//! #[tokio::main]
|
||||
//! async fn main() -> anyhow::Result<()> {
|
||||
//! // Créer le cache
|
||||
//! let cache = cache::new_cache("./audio_cache", 1000, "http://localhost:8080")?;
|
||||
//! let cache = cache::new_cache("./audio_cache", 500)?;
|
||||
//!
|
||||
//! // Ajouter une piste avec extraction des métadonnées
|
||||
//! // Télécharge la piste, déclenche la conversion FLAC et stocke les métadonnées.
|
||||
//! let pk = cache::add_with_metadata_extraction(
|
||||
//! &cache,
|
||||
//! "http://example.com/track.flac",
|
||||
//! None // collection auto-détectée depuis métadonnées
|
||||
//! "https://example.com/track.mp3",
|
||||
//! None,
|
||||
//! ).await?;
|
||||
//!
|
||||
//! // Lire les métadonnées
|
||||
//! // Lecture des métadonnées extraites
|
||||
//! let metadata = cache::get_metadata(&cache, &pk)?;
|
||||
//! println!("{} - {}",
|
||||
//! metadata.artist.as_deref().unwrap_or("Unknown"),
|
||||
//! metadata.title.as_deref().unwrap_or("Unknown")
|
||||
//! println!(
|
||||
//! "Titre: {}",
|
||||
//! metadata.title.as_deref().unwrap_or("Inconnu")
|
||||
//! );
|
||||
//!
|
||||
//! // Le fichier FLAC est disponible immédiatement après le download
|
||||
//! let file_path = cache.get(&pk).await?;
|
||||
//! println!("FLAC file: {:?}", file_path);
|
||||
//! // Accès au fichier FLAC converti
|
||||
//! let flac_path = cache.get(&pk).await?;
|
||||
//! println!("Fichier converti: {flac_path:?}");
|
||||
//!
|
||||
//! Ok(())
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! ### Utilisation avec pmoserver
|
||||
//! ## Intégration serveur (feature `pmoserver`)
|
||||
//!
|
||||
//! ```rust,no_run
|
||||
//! use pmoaudiocache::AudioCacheExt;
|
||||
//! use pmoserver::ServerBuilder;
|
||||
//! Lorsque la feature `pmoserver` est activée, [`AudioCacheExt`] permet
|
||||
//! d'enregistrer automatiquement les routes suivantes :
|
||||
//!
|
||||
//! #[tokio::main]
|
||||
//! async fn main() -> anyhow::Result<()> {
|
||||
//! let mut server = ServerBuilder::new_configured().build();
|
||||
//! - `GET /audio/tracks/{pk}` : téléchargement/stream du FLAC original ;
|
||||
//! - `GET /audio/tracks/{pk}/{qualifier}` : variantes (ex: `orig`) ;
|
||||
//! - `GET /api/audio` / `POST /api/audio` / `DELETE /api/audio` : API REST générique ;
|
||||
//! - `GET /api/audio/{pk}/status` : suivi de téléchargement ;
|
||||
//! - endpoints OpenAPI/Swagger lorsqu'`openapi` est activée.
|
||||
//!
|
||||
//! // Initialiser le cache audio avec configuration automatique
|
||||
//! server.init_audio_cache_configured().await?;
|
||||
//! ## Métadonnées gérées
|
||||
//!
|
||||
//! server.start().await;
|
||||
//! server.wait().await;
|
||||
//! Ok(())
|
||||
//! }
|
||||
//! ```
|
||||
//! Le module [`metadata`] extrait notamment :
|
||||
//! - titre, artiste, album, genre ;
|
||||
//! - numéros de piste/disque et totaux associés ;
|
||||
//! - année, durée, bitrate, sample rate, nombre de canaux.
|
||||
//!
|
||||
//! ## API HTTP (avec feature "pmoserver")
|
||||
//! En l'absence d'artiste/album, aucune collection automatique n'est créée.
|
||||
//!
|
||||
//! Lorsque la feature `pmoserver` est activée, les routes suivantes sont disponibles :
|
||||
//! ## Modules
|
||||
//!
|
||||
//! ### Routes de fichiers
|
||||
//! - `GET /audio/tracks/{pk}` - Stream du fichier FLAC original
|
||||
//! - `GET /audio/tracks/{pk}/orig` - Alias pour l'original
|
||||
//! - [`cache`] : instanciation du cache et helpers de téléchargement ;
|
||||
//! - [`metadata`] : extraction/structure des métadonnées audio ;
|
||||
//! - [`config_ext`] *(feature `pmoconfig`)* : dérivation de la configuration depuis `pmoconfig`;
|
||||
//! - [`openapi`] *(feature `pmoserver`)* : documentation des routes REST.
|
||||
//!
|
||||
//! ### API REST
|
||||
//! - `GET /api/audio` - Liste toutes les pistes
|
||||
//! - `POST /api/audio` - Ajoute une piste depuis une URL
|
||||
//! - `GET /api/audio/{pk}` - Informations complètes d'une piste
|
||||
//! - `DELETE /api/audio/{pk}` - Supprime une piste
|
||||
//! - `GET /api/audio/{pk}/status` - Statut du téléchargement
|
||||
//! - `POST /api/audio/consolidate` - Consolide le cache
|
||||
//! - `DELETE /api/audio` - Purge tout le cache
|
||||
//! ## Crates voisines
|
||||
//!
|
||||
//! ## Métadonnées supportées
|
||||
//!
|
||||
//! Les métadonnées suivantes sont extraites automatiquement :
|
||||
//! - Titre, artiste, album
|
||||
//! - Année, genre
|
||||
//! - Numéro de piste/disque
|
||||
//! - Durée, taux d'échantillonnage, bitrate
|
||||
//! - Nombre de canaux
|
||||
//!
|
||||
//! ## Format des collections
|
||||
//!
|
||||
//! Les collections sont identifiées par une clé au format `"artist:album"`, avec :
|
||||
//! - Conversion en minuscules
|
||||
//! - Remplacement des espaces par des underscores
|
||||
//! - Exemple : `"Pink Floyd - Wish You Were Here"` → `"pink_floyd:wish_you_were_here"`
|
||||
//!
|
||||
//! ## Différences avec l'ancienne version
|
||||
//!
|
||||
//! Cette version refactorisée de `pmoaudiocache` :
|
||||
//! - ✅ **Supprime le champ `conversion_status`** : le système `Download` de `pmocache` gère déjà l'état asynchrone
|
||||
//! - ✅ **Utilise `pmocache::DB`** : plus de DB personnalisée, les métadonnées sont en JSON
|
||||
//! - ✅ **API REST générique** : fournie par `pmocache`, plus de code custom
|
||||
//! - ✅ **Code réduit de 52%** : de ~1681 lignes à ~800 lignes
|
||||
//! - ✅ **Streaming progressif** : automatique via `pmocache`
|
||||
//! - ✅ **Politique LRU optimisée** : nouvel index composite dans `pmocache`
|
||||
//!
|
||||
//! ## Dépendances principales
|
||||
//!
|
||||
//! - `pmocache` : Cache générique avec download asynchrone
|
||||
//! - `lofty` : Extraction de métadonnées audio
|
||||
//! - `tokio` : Runtime asynchrone
|
||||
//!
|
||||
//! ## Voir aussi
|
||||
//!
|
||||
//! - [`pmocache`] : Cache générique
|
||||
//! - [`pmocovers`] : Cache d'images (architecture similaire)
|
||||
//! - [`pmoserver`] : Serveur HTTP
|
||||
//! - [`pmocache`] : fondation générique ;
|
||||
//! - [`pmocovers`] : spécialisation images (architecture similaire) ;
|
||||
//! - [`pmoserver`] : serveur HTTP optionnel.
|
||||
|
||||
pub mod cache;
|
||||
pub mod flac;
|
||||
pub mod metadata;
|
||||
pub mod metadata_ext;
|
||||
pub mod streaming;
|
||||
pub mod track_metadata;
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
pub mod openapi;
|
||||
|
||||
#[cfg(feature = "pmoconfig")]
|
||||
pub mod config_ext;
|
||||
|
||||
// Re-exports principaux
|
||||
pub use cache::{add_with_metadata_extraction, get_metadata, new_cache, AudioConfig, Cache};
|
||||
pub use metadata::AudioMetadata;
|
||||
pub use metadata_ext::{AudioMetadataExt, AudioTrackMetadataExt};
|
||||
pub use track_metadata::AudioCacheTrackMetadata;
|
||||
|
||||
#[cfg(feature = "pmoconfig")]
|
||||
pub use config_ext::AudioCacheConfigExt;
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
pub use openapi::ApiDoc;
|
||||
@@ -207,9 +163,10 @@ impl AudioCacheExt for pmoserver::Server {
|
||||
}
|
||||
|
||||
async fn init_audio_cache_configured(&mut self) -> anyhow::Result<Arc<Cache>> {
|
||||
use crate::AudioCacheConfigExt;
|
||||
let config = pmoconfig::get_config();
|
||||
let cache_dir = config.get_audio_cache_dir()?;
|
||||
let limit = config.get_audio_cache_size()?;
|
||||
let cache_dir = config.get_audiocache_dir()?;
|
||||
let limit = config.get_audiocache_size()?;
|
||||
self.init_audio_cache(&cache_dir, limit).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,9 +68,67 @@ pub struct AudioMetadata {
|
||||
/// Bitrate moyen (kbps)
|
||||
#[cfg_attr(feature = "pmoserver", schema(example = 1411))]
|
||||
pub bitrate: Option<u32>,
|
||||
|
||||
/// Informations sur la conversion appliquée lors de l'ingestion
|
||||
#[cfg_attr(feature = "pmoserver", schema(example = json!({"mode":"transcode","source_codec":"mp3"})))]
|
||||
pub conversion: Option<AudioConversionInfo>,
|
||||
}
|
||||
|
||||
/// Informations sur le processus de conversion
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "pmoserver", derive(ToSchema))]
|
||||
pub struct AudioConversionInfo {
|
||||
/// Mode de conversion (ex: "passthrough", "transcode")
|
||||
#[cfg_attr(feature = "pmoserver", schema(example = "transcode"))]
|
||||
pub mode: String,
|
||||
|
||||
/// Codec source détecté
|
||||
#[cfg_attr(feature = "pmoserver", schema(example = "mp3"))]
|
||||
pub source_codec: Option<String>,
|
||||
}
|
||||
|
||||
impl AudioMetadata {
|
||||
/// Extrait les métadonnées depuis un fichier audio taggé
|
||||
///
|
||||
/// Fonction interne commune pour extraire les métadonnées depuis un TaggedFile
|
||||
fn from_tagged_file(tagged_file: lofty::file::TaggedFile) -> Self {
|
||||
let properties = tagged_file.properties();
|
||||
let tag = tagged_file
|
||||
.primary_tag()
|
||||
.or_else(|| tagged_file.first_tag());
|
||||
|
||||
let mut metadata = Self {
|
||||
title: None,
|
||||
artist: None,
|
||||
album: None,
|
||||
year: None,
|
||||
track_number: None,
|
||||
track_total: None,
|
||||
disc_number: None,
|
||||
disc_total: None,
|
||||
genre: None,
|
||||
duration_secs: Some(properties.duration().as_secs()),
|
||||
sample_rate: properties.sample_rate(),
|
||||
channels: properties.channels(),
|
||||
bitrate: properties.audio_bitrate(),
|
||||
conversion: None,
|
||||
};
|
||||
|
||||
if let Some(tag) = tag {
|
||||
metadata.title = tag.title().map(|s| s.to_string());
|
||||
metadata.artist = tag.artist().map(|s| s.to_string());
|
||||
metadata.album = tag.album().map(|s| s.to_string());
|
||||
metadata.year = tag.year();
|
||||
metadata.track_number = tag.track();
|
||||
metadata.track_total = tag.track_total();
|
||||
metadata.disc_number = tag.disk();
|
||||
metadata.disc_total = tag.disk_total();
|
||||
metadata.genre = tag.genre().map(|s| s.to_string());
|
||||
}
|
||||
|
||||
metadata
|
||||
}
|
||||
|
||||
/// Extrait les métadonnées d'un fichier audio
|
||||
///
|
||||
/// # Arguments
|
||||
@@ -88,41 +146,7 @@ impl AudioMetadata {
|
||||
/// ```
|
||||
pub fn from_file(path: &Path) -> Result<Self> {
|
||||
let tagged_file = Probe::open(path)?.options(ParseOptions::new()).read()?;
|
||||
|
||||
let properties = tagged_file.properties();
|
||||
let tag = tagged_file
|
||||
.primary_tag()
|
||||
.or_else(|| tagged_file.first_tag());
|
||||
|
||||
let mut metadata = Self {
|
||||
title: None,
|
||||
artist: None,
|
||||
album: None,
|
||||
year: None,
|
||||
track_number: None,
|
||||
track_total: None,
|
||||
disc_number: None,
|
||||
disc_total: None,
|
||||
genre: None,
|
||||
duration_secs: Some(properties.duration().as_secs()),
|
||||
sample_rate: properties.sample_rate(),
|
||||
channels: properties.channels(),
|
||||
bitrate: properties.audio_bitrate(),
|
||||
};
|
||||
|
||||
if let Some(tag) = tag {
|
||||
metadata.title = tag.title().map(|s| s.to_string());
|
||||
metadata.artist = tag.artist().map(|s| s.to_string());
|
||||
metadata.album = tag.album().map(|s| s.to_string());
|
||||
metadata.year = tag.year();
|
||||
metadata.track_number = tag.track();
|
||||
metadata.track_total = tag.track_total();
|
||||
metadata.disc_number = tag.disk();
|
||||
metadata.disc_total = tag.disk_total();
|
||||
metadata.genre = tag.genre().map(|s| s.to_string());
|
||||
}
|
||||
|
||||
Ok(metadata)
|
||||
Ok(Self::from_tagged_file(tagged_file))
|
||||
}
|
||||
|
||||
/// Crée des métadonnées depuis des données brutes audio
|
||||
@@ -136,41 +160,7 @@ impl AudioMetadata {
|
||||
.guess_file_type()?
|
||||
.options(ParseOptions::new())
|
||||
.read()?;
|
||||
|
||||
let properties = tagged_file.properties();
|
||||
let tag = tagged_file
|
||||
.primary_tag()
|
||||
.or_else(|| tagged_file.first_tag());
|
||||
|
||||
let mut metadata = Self {
|
||||
title: None,
|
||||
artist: None,
|
||||
album: None,
|
||||
year: None,
|
||||
track_number: None,
|
||||
track_total: None,
|
||||
disc_number: None,
|
||||
disc_total: None,
|
||||
genre: None,
|
||||
duration_secs: Some(properties.duration().as_secs()),
|
||||
sample_rate: properties.sample_rate(),
|
||||
channels: properties.channels(),
|
||||
bitrate: properties.audio_bitrate(),
|
||||
};
|
||||
|
||||
if let Some(tag) = tag {
|
||||
metadata.title = tag.title().map(|s| s.to_string());
|
||||
metadata.artist = tag.artist().map(|s| s.to_string());
|
||||
metadata.album = tag.album().map(|s| s.to_string());
|
||||
metadata.year = tag.year();
|
||||
metadata.track_number = tag.track();
|
||||
metadata.track_total = tag.track_total();
|
||||
metadata.disc_number = tag.disk();
|
||||
metadata.disc_total = tag.disk_total();
|
||||
metadata.genre = tag.genre().map(|s| s.to_string());
|
||||
}
|
||||
|
||||
Ok(metadata)
|
||||
Ok(Self::from_tagged_file(tagged_file))
|
||||
}
|
||||
|
||||
/// Génère une clé de collection basée sur l'artiste et l'album
|
||||
@@ -187,6 +177,82 @@ impl AudioMetadata {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Retourne la durée formatée pour DIDL-Lite (H:MM:SS)
|
||||
///
|
||||
/// # Exemple
|
||||
///
|
||||
/// ```
|
||||
/// use pmoaudiocache::AudioMetadata;
|
||||
///
|
||||
/// let metadata = AudioMetadata {
|
||||
/// duration_secs: Some(3665),
|
||||
/// ..Default::default()
|
||||
/// };
|
||||
///
|
||||
/// assert_eq!(metadata.duration_formatted(), Some("1:01:05".to_string()));
|
||||
/// ```
|
||||
pub fn duration_formatted(&self) -> Option<String> {
|
||||
self.duration_secs.map(|d| {
|
||||
let hours = d / 3600;
|
||||
let minutes = (d % 3600) / 60;
|
||||
let seconds = d % 60;
|
||||
format!("{}:{:02}:{:02}", hours, minutes, seconds)
|
||||
})
|
||||
}
|
||||
|
||||
/// Convertit les métadonnées en Resource DIDL-Lite
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `url` - URL de la ressource audio
|
||||
///
|
||||
/// # Exemple
|
||||
///
|
||||
/// ```no_run
|
||||
/// use pmoaudiocache::AudioMetadata;
|
||||
///
|
||||
/// let metadata = AudioMetadata {
|
||||
/// duration_secs: Some(180),
|
||||
/// sample_rate: Some(44100),
|
||||
/// channels: Some(2),
|
||||
/// ..Default::default()
|
||||
/// };
|
||||
///
|
||||
/// let resource = metadata.to_didl_resource("http://localhost:8080/audio/tracks/abc123".into());
|
||||
/// assert_eq!(resource.url, "http://localhost:8080/audio/tracks/abc123");
|
||||
/// ```
|
||||
pub fn to_didl_resource(&self, url: String) -> pmodidl::Resource {
|
||||
pmodidl::Resource {
|
||||
protocol_info: "http-get:*:audio/flac:*".to_string(),
|
||||
bits_per_sample: None,
|
||||
sample_frequency: self.sample_rate.map(|sr| sr.to_string()),
|
||||
nr_audio_channels: self.channels.map(|ch| ch.to_string()),
|
||||
duration: self.duration_formatted(),
|
||||
url,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AudioMetadata {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
title: None,
|
||||
artist: None,
|
||||
album: None,
|
||||
year: None,
|
||||
track_number: None,
|
||||
track_total: None,
|
||||
disc_number: None,
|
||||
disc_total: None,
|
||||
genre: None,
|
||||
duration_secs: None,
|
||||
sample_rate: None,
|
||||
channels: None,
|
||||
bitrate: None,
|
||||
conversion: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -209,6 +275,7 @@ mod tests {
|
||||
sample_rate: Some(44100),
|
||||
channels: Some(2),
|
||||
bitrate: Some(1411),
|
||||
conversion: None,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
@@ -233,6 +300,7 @@ mod tests {
|
||||
sample_rate: None,
|
||||
channels: None,
|
||||
bitrate: None,
|
||||
conversion: None,
|
||||
};
|
||||
|
||||
assert_eq!(metadata.collection_key(), None);
|
||||
|
||||
50
pmoaudiocache/src/metadata_ext.rs
Executable file
50
pmoaudiocache/src/metadata_ext.rs
Executable file
@@ -0,0 +1,50 @@
|
||||
//! Extension trait pour accéder aux métadonnées audio de manière typée
|
||||
//!
|
||||
//! Ce module utilise la macro `define_metadata_properties!` de pmocache
|
||||
//! pour générer automatiquement des méthodes d'accès typées aux métadonnées audio.
|
||||
|
||||
use crate::{AudioCacheTrackMetadata, AudioConfig};
|
||||
use pmocache::define_metadata_properties;
|
||||
use pmometadata::TrackMetadata;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
// Génération automatique du trait AudioMetadataExt avec toutes les propriétés audio
|
||||
define_metadata_properties! {
|
||||
AudioMetadataExt for pmocache::Cache<AudioConfig> {
|
||||
// Métadonnées textuelles
|
||||
title: String as string,
|
||||
artist: String as string,
|
||||
album: String as string,
|
||||
album_artist: String as string,
|
||||
genre: String as string,
|
||||
composer: String as string,
|
||||
comment: String as string,
|
||||
|
||||
// Métadonnées numériques (année, numéros de piste)
|
||||
year: i64 as i64,
|
||||
track_number: i64 as i64,
|
||||
disc_number: i64 as i64,
|
||||
total_tracks: i64 as i64,
|
||||
total_discs: i64 as i64,
|
||||
|
||||
// Métadonnées techniques audio
|
||||
duration_secs: i64 as i64,
|
||||
sample_rate: i64 as i64,
|
||||
bitrate: i64 as i64,
|
||||
channels: i64 as i64,
|
||||
bit_depth: i64 as i64,
|
||||
}
|
||||
}
|
||||
|
||||
/// Fournit un accès direct à une implémentation `TrackMetadata` basée sur le cache.
|
||||
pub trait AudioTrackMetadataExt {
|
||||
fn track_metadata(&self, pk: impl Into<String>) -> Arc<RwLock<dyn TrackMetadata>>;
|
||||
}
|
||||
|
||||
impl AudioTrackMetadataExt for Arc<pmocache::Cache<AudioConfig>> {
|
||||
fn track_metadata(&self, pk: impl Into<String>) -> Arc<RwLock<dyn TrackMetadata>> {
|
||||
let metadata = AudioCacheTrackMetadata::new(self.clone(), pk);
|
||||
Arc::new(RwLock::new(metadata))
|
||||
}
|
||||
}
|
||||
161
pmoaudiocache/src/streaming.rs
Normal file
161
pmoaudiocache/src/streaming.rs
Normal file
@@ -0,0 +1,161 @@
|
||||
//! Audio streaming transformer built on pmoflac.
|
||||
//!
|
||||
//! This module wires the generic `pmocache` download pipeline with the new
|
||||
//! streaming transcode helper provided by `pmoflac`. Any supported codec
|
||||
//! (FLAC, MP3, OGG/Vorbis, Opus, WAV, AIFF) is converted to FLAC on the fly,
|
||||
//! while native FLAC input is forwarded byte-for-byte without re-encoding.
|
||||
|
||||
use bytes::Bytes;
|
||||
use pmocache::download::TransformMetadata;
|
||||
use pmocache::StreamTransformer;
|
||||
use pmoflac::{transcode_to_flac_stream, AudioCodec, TranscodeOptions};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
/// Creates the transformer consumed by the audio cache.
|
||||
pub fn create_streaming_flac_transformer() -> StreamTransformer {
|
||||
Box::new(|input, mut file, context| {
|
||||
Box::pin(async move {
|
||||
let byte_stream = input.into_byte_stream();
|
||||
let reader = StreamToAsyncRead::new(byte_stream);
|
||||
|
||||
let transcode = transcode_to_flac_stream(reader, TranscodeOptions::default())
|
||||
.await
|
||||
.map_err(|e| format!("Audio transcode error: {}", e))?;
|
||||
|
||||
let codec = transcode.input_codec();
|
||||
let info = transcode.input_stream_info().clone();
|
||||
log_stream_info(codec, &info);
|
||||
|
||||
let mode = if transcode.is_passthrough() {
|
||||
"passthrough"
|
||||
} else {
|
||||
"transcode"
|
||||
};
|
||||
|
||||
context
|
||||
.set_metadata(TransformMetadata {
|
||||
mode: Some(mode.to_string()),
|
||||
input_codec: Some(codec_to_string(codec)),
|
||||
details: None,
|
||||
})
|
||||
.await;
|
||||
|
||||
let mut flac_stream = transcode.into_stream();
|
||||
let mut buffer = vec![0u8; 64 * 1024];
|
||||
let mut total_written = 0u64;
|
||||
|
||||
loop {
|
||||
let read = flac_stream
|
||||
.read(&mut buffer)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read FLAC data: {}", e))?;
|
||||
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
file.write_all(&buffer[..read])
|
||||
.await
|
||||
.map_err(|e| format!("Failed to write FLAC file: {}", e))?;
|
||||
|
||||
total_written += read as u64;
|
||||
context.report_progress(total_written);
|
||||
}
|
||||
|
||||
file.flush()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to flush FLAC file: {}", e))?;
|
||||
|
||||
flac_stream
|
||||
.wait()
|
||||
.await
|
||||
.map_err(|e| format!("FLAC encoder error: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn log_stream_info(codec: AudioCodec, info: &pmoflac::StreamInfo) {
|
||||
tracing::debug!(
|
||||
"Detected codec {:?}: {} Hz, {} channels, {} bits/sample (passthrough={})",
|
||||
codec,
|
||||
info.sample_rate,
|
||||
info.channels,
|
||||
info.bits_per_sample,
|
||||
codec == AudioCodec::Flac
|
||||
);
|
||||
}
|
||||
|
||||
fn codec_to_string(codec: AudioCodec) -> String {
|
||||
match codec {
|
||||
AudioCodec::Flac => "flac",
|
||||
AudioCodec::Mp3 => "mp3",
|
||||
AudioCodec::OggVorbis => "ogg_vorbis",
|
||||
AudioCodec::OggOpus => "ogg_opus",
|
||||
AudioCodec::Wav => "wav",
|
||||
AudioCodec::Aiff => "aiff",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Adapter exposing a byte stream as `AsyncRead`.
|
||||
struct StreamToAsyncRead {
|
||||
stream: futures_util::stream::BoxStream<'static, Result<Bytes, String>>,
|
||||
current_chunk: Option<Bytes>,
|
||||
offset: usize,
|
||||
}
|
||||
|
||||
impl StreamToAsyncRead {
|
||||
fn new(
|
||||
stream: std::pin::Pin<Box<dyn futures_util::Stream<Item = Result<Bytes, String>> + Send>>,
|
||||
) -> Self {
|
||||
use futures_util::StreamExt;
|
||||
Self {
|
||||
stream: stream.boxed(),
|
||||
current_chunk: None,
|
||||
offset: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl tokio::io::AsyncRead for StreamToAsyncRead {
|
||||
fn poll_read(
|
||||
mut self: std::pin::Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
buf: &mut tokio::io::ReadBuf<'_>,
|
||||
) -> std::task::Poll<std::io::Result<()>> {
|
||||
use futures_util::StreamExt;
|
||||
use std::task::Poll;
|
||||
|
||||
loop {
|
||||
if let Some(chunk) = &self.current_chunk {
|
||||
if self.offset < chunk.len() {
|
||||
let available = chunk.len() - self.offset;
|
||||
let to_copy = available.min(buf.remaining());
|
||||
buf.put_slice(&chunk[self.offset..self.offset + to_copy]);
|
||||
self.offset += to_copy;
|
||||
return Poll::Ready(Ok(()));
|
||||
}
|
||||
self.current_chunk = None;
|
||||
self.offset = 0;
|
||||
}
|
||||
|
||||
match self.stream.poll_next_unpin(cx) {
|
||||
Poll::Ready(Some(Ok(chunk))) => {
|
||||
if chunk.is_empty() {
|
||||
continue;
|
||||
}
|
||||
self.current_chunk = Some(chunk);
|
||||
}
|
||||
Poll::Ready(Some(Err(e))) => {
|
||||
return Poll::Ready(Err(std::io::Error::new(std::io::ErrorKind::Other, e)));
|
||||
}
|
||||
Poll::Ready(None) => return Poll::Ready(Ok(())),
|
||||
Poll::Pending => return Poll::Pending,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Unpin for StreamToAsyncRead {}
|
||||
322
pmoaudiocache/src/track_metadata.rs
Normal file
322
pmoaudiocache/src/track_metadata.rs
Normal file
@@ -0,0 +1,322 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use pmometadata::{MetadataError, MetadataResult, TrackMetadata};
|
||||
use serde_json::{Number, Value};
|
||||
|
||||
fn map_db_err(err: rusqlite::Error) -> MetadataError {
|
||||
MetadataError::Backend(err.to_string())
|
||||
}
|
||||
|
||||
pub struct AudioCacheTrackMetadata {
|
||||
cache: Arc<crate::Cache>,
|
||||
pk: String,
|
||||
}
|
||||
|
||||
impl AudioCacheTrackMetadata {
|
||||
pub fn new(cache: Arc<crate::Cache>, pk: impl Into<String>) -> Self {
|
||||
Self {
|
||||
cache,
|
||||
pk: pk.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_raw(&self, key: &str) -> Result<Option<Value>, MetadataError> {
|
||||
self.cache
|
||||
.db
|
||||
.get_a_metadata(&self.pk, key)
|
||||
.map_err(map_db_err)
|
||||
}
|
||||
|
||||
fn write_raw(&self, key: &str, value: Value) -> Result<(), MetadataError> {
|
||||
self.cache
|
||||
.db
|
||||
.set_a_metadata(&self.pk, key, value)
|
||||
.map_err(map_db_err)
|
||||
}
|
||||
|
||||
fn read_string(&self, key: &str) -> Result<Option<String>, MetadataError> {
|
||||
match self.read_raw(key)? {
|
||||
Some(Value::String(s)) => Ok(Some(s)),
|
||||
Some(Value::Null) | None => Ok(None),
|
||||
Some(other) => Err(MetadataError::Backend(format!(
|
||||
"metadata {key} for {} is not a string ({other})",
|
||||
self.pk
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_string(&self, key: &str, value: Option<String>) -> Result<(), MetadataError> {
|
||||
let json = value.map(Value::String).unwrap_or(Value::Null);
|
||||
self.write_raw(key, json)
|
||||
}
|
||||
|
||||
fn read_number(&self, key: &str) -> Result<Option<Number>, MetadataError> {
|
||||
match self.read_raw(key)? {
|
||||
Some(Value::Number(n)) => Ok(Some(n)),
|
||||
Some(Value::Null) | None => Ok(None),
|
||||
Some(other) => Err(MetadataError::Backend(format!(
|
||||
"metadata {key} for {} is not a number ({other})",
|
||||
self.pk
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_number(&self, key: &str, value: Option<i64>) -> Result<(), MetadataError> {
|
||||
let json = match value {
|
||||
Some(n) => Value::Number(Number::from(n)),
|
||||
None => Value::Null,
|
||||
};
|
||||
self.write_raw(key, json)
|
||||
}
|
||||
|
||||
fn write_u64(&self, key: &str, value: Option<u64>) -> Result<(), MetadataError> {
|
||||
let json = match value {
|
||||
Some(n) => Value::Number(Number::from(n)),
|
||||
None => Value::Null,
|
||||
};
|
||||
self.write_raw(key, json)
|
||||
}
|
||||
|
||||
fn write_f64(&self, key: &str, value: Option<f64>) -> Result<(), MetadataError> {
|
||||
let json = match value {
|
||||
Some(v) => Number::from_f64(v)
|
||||
.map(Value::Number)
|
||||
.ok_or_else(|| MetadataError::Backend(format!("invalid float for {key}")))?,
|
||||
None => Value::Null,
|
||||
};
|
||||
self.write_raw(key, json)
|
||||
}
|
||||
|
||||
fn read_duration(&self) -> Result<Option<Duration>, MetadataError> {
|
||||
match self.read_number("duration_secs")? {
|
||||
Some(n) => match n.as_u64() {
|
||||
Some(secs) => Ok(Some(Duration::from_secs(secs))),
|
||||
None => Err(MetadataError::Backend(format!(
|
||||
"duration_secs for {} out of range",
|
||||
self.pk
|
||||
))),
|
||||
},
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_duration(&self, value: Option<Duration>) -> Result<(), MetadataError> {
|
||||
self.write_u64("duration_secs", value.map(|d| d.as_secs()))
|
||||
}
|
||||
|
||||
fn read_timestamp(&self) -> Result<Option<SystemTime>, MetadataError> {
|
||||
match self.read_number("updated_at")? {
|
||||
Some(n) => match n.as_u64() {
|
||||
Some(secs) => Ok(Some(UNIX_EPOCH + Duration::from_secs(secs))),
|
||||
None => Err(MetadataError::Backend(format!(
|
||||
"updated_at for {} out of range",
|
||||
self.pk
|
||||
))),
|
||||
},
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_timestamp(&self, when: SystemTime) -> Result<(), MetadataError> {
|
||||
let secs = when
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_err(|e| MetadataError::Backend(e.to_string()))?
|
||||
.as_secs();
|
||||
self.write_u64("updated_at", Some(secs))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl TrackMetadata for AudioCacheTrackMetadata {
|
||||
async fn get_title(&self) -> MetadataResult<String> {
|
||||
Ok(self.read_string("title")?)
|
||||
}
|
||||
|
||||
async fn set_title(&mut self, value: Option<String>) -> MetadataResult<()> {
|
||||
self.write_string("title", value)?;
|
||||
let _ = self.touch().await?;
|
||||
Ok(Some(()))
|
||||
}
|
||||
|
||||
async fn get_artist(&self) -> MetadataResult<String> {
|
||||
Ok(self.read_string("artist")?)
|
||||
}
|
||||
|
||||
async fn set_artist(&mut self, value: Option<String>) -> MetadataResult<()> {
|
||||
self.write_string("artist", value)?;
|
||||
let _ = self.touch().await?;
|
||||
Ok(Some(()))
|
||||
}
|
||||
|
||||
async fn get_album(&self) -> MetadataResult<String> {
|
||||
Ok(self.read_string("album")?)
|
||||
}
|
||||
|
||||
async fn set_album(&mut self, value: Option<String>) -> MetadataResult<()> {
|
||||
self.write_string("album", value)?;
|
||||
let _ = self.touch().await?;
|
||||
Ok(Some(()))
|
||||
}
|
||||
|
||||
async fn get_year(&self) -> MetadataResult<u32> {
|
||||
Ok(match self.read_number("year")? {
|
||||
Some(n) => n
|
||||
.as_i64()
|
||||
.and_then(|v| u32::try_from(v).ok())
|
||||
.map(Some)
|
||||
.unwrap_or(None),
|
||||
None => None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn set_year(&mut self, value: Option<u32>) -> MetadataResult<()> {
|
||||
self.write_number("year", value.map(|v| v as i64))?;
|
||||
let _ = self.touch().await?;
|
||||
Ok(Some(()))
|
||||
}
|
||||
|
||||
async fn get_duration(&self) -> MetadataResult<Duration> {
|
||||
Ok(self.read_duration()?)
|
||||
}
|
||||
|
||||
async fn set_duration(&mut self, value: Option<Duration>) -> MetadataResult<()> {
|
||||
self.write_duration(value)?;
|
||||
let _ = self.touch().await?;
|
||||
Ok(Some(()))
|
||||
}
|
||||
|
||||
async fn get_track_id(&self) -> MetadataResult<String> {
|
||||
Ok(self.read_string("track_id")?)
|
||||
}
|
||||
|
||||
async fn set_track_id(&mut self, value: Option<String>) -> MetadataResult<()> {
|
||||
self.write_string("track_id", value)?;
|
||||
let _ = self.touch().await?;
|
||||
Ok(Some(()))
|
||||
}
|
||||
|
||||
async fn get_channel_id(&self) -> MetadataResult<String> {
|
||||
Ok(self.read_string("channel_id")?)
|
||||
}
|
||||
|
||||
async fn set_channel_id(&mut self, value: Option<String>) -> MetadataResult<()> {
|
||||
self.write_string("channel_id", value)?;
|
||||
let _ = self.touch().await?;
|
||||
Ok(Some(()))
|
||||
}
|
||||
|
||||
async fn get_event(&self) -> MetadataResult<String> {
|
||||
Ok(self.read_string("event")?)
|
||||
}
|
||||
|
||||
async fn set_event(&mut self, value: Option<String>) -> MetadataResult<()> {
|
||||
self.write_string("event", value)?;
|
||||
let _ = self.touch().await?;
|
||||
Ok(Some(()))
|
||||
}
|
||||
|
||||
async fn get_rating(&self) -> MetadataResult<f32> {
|
||||
Ok(match self.read_number("rating")? {
|
||||
Some(n) => n.as_f64().map(|v| v as f32),
|
||||
None => None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn set_rating(&mut self, value: Option<f32>) -> MetadataResult<()> {
|
||||
self.write_f64("rating", value.map(|v| v as f64))?;
|
||||
let _ = self.touch().await?;
|
||||
Ok(Some(()))
|
||||
}
|
||||
|
||||
async fn get_cover_url(&self) -> MetadataResult<String> {
|
||||
Ok(self.read_string("cover_url")?)
|
||||
}
|
||||
|
||||
async fn set_cover_url(&mut self, value: Option<String>) -> MetadataResult<()> {
|
||||
self.write_string("cover_url", value)?;
|
||||
let _ = self.touch().await?;
|
||||
Ok(Some(()))
|
||||
}
|
||||
|
||||
async fn get_cover_pk(&self) -> MetadataResult<String> {
|
||||
Ok(self.read_string("cover_pk")?)
|
||||
}
|
||||
|
||||
async fn set_cover_pk(&mut self, value: Option<String>) -> MetadataResult<()> {
|
||||
self.write_string("cover_pk", value)?;
|
||||
let _ = self.touch().await?;
|
||||
Ok(Some(()))
|
||||
}
|
||||
|
||||
async fn get_updated_at(&self) -> MetadataResult<SystemTime> {
|
||||
Ok(self.read_timestamp()?)
|
||||
}
|
||||
|
||||
async fn touch(&mut self) -> MetadataResult<()> {
|
||||
self.write_timestamp(SystemTime::now())?;
|
||||
Ok(Some(()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cache::new_cache;
|
||||
use crate::metadata_ext::AudioTrackMetadataExt;
|
||||
use std::sync::Arc;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn roundtrip_metadata() {
|
||||
let dir = tempdir().unwrap();
|
||||
let cache = Arc::new(new_cache(dir.path().to_str().unwrap(), 4).unwrap());
|
||||
let pk = "track-test";
|
||||
cache.db.add(pk, None, None).unwrap();
|
||||
|
||||
let track = cache.track_metadata(pk);
|
||||
|
||||
{
|
||||
let mut meta = track.write().await;
|
||||
|
||||
meta.set_title(Some("Title".into())).await.unwrap();
|
||||
meta.set_artist(Some("Artist".into())).await.unwrap();
|
||||
meta.set_album(Some("Album".into())).await.unwrap();
|
||||
meta.set_year(Some(2024)).await.unwrap();
|
||||
meta.set_duration(Some(Duration::from_secs(90)))
|
||||
.await
|
||||
.unwrap();
|
||||
meta.set_track_id(Some("trk".into())).await.unwrap();
|
||||
meta.set_channel_id(Some("chn".into())).await.unwrap();
|
||||
meta.set_event(Some("event".into())).await.unwrap();
|
||||
meta.set_rating(Some(4.5)).await.unwrap();
|
||||
meta.set_cover_url(Some("http://cover".into()))
|
||||
.await
|
||||
.unwrap();
|
||||
meta.set_cover_pk(Some("cover123".into())).await.unwrap();
|
||||
}
|
||||
{
|
||||
let meta = track.read().await;
|
||||
|
||||
|
||||
assert_eq!(meta.get_title().await.unwrap(), Some("Title".into()));
|
||||
assert_eq!(meta.get_artist().await.unwrap(), Some("Artist".into()));
|
||||
assert_eq!(meta.get_album().await.unwrap(), Some("Album".into()));
|
||||
assert_eq!(meta.get_year().await.unwrap(), Some(2024));
|
||||
assert_eq!(
|
||||
meta.get_duration().await.unwrap(),
|
||||
Some(Duration::from_secs(90))
|
||||
);
|
||||
assert_eq!(meta.get_track_id().await.unwrap(), Some("trk".into()));
|
||||
assert_eq!(meta.get_channel_id().await.unwrap(), Some("chn".into()));
|
||||
assert_eq!(meta.get_event().await.unwrap(), Some("event".into()));
|
||||
assert_eq!(meta.get_rating().await.unwrap(), Some(4.5));
|
||||
assert_eq!(
|
||||
meta.get_cover_url().await.unwrap(),
|
||||
Some("http://cover".into())
|
||||
);
|
||||
assert_eq!(meta.get_cover_pk().await.unwrap(), Some("cover123".into()));
|
||||
assert!(meta.get_updated_at().await.unwrap().is_some());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,13 +13,16 @@ futures-util = "0.3"
|
||||
|
||||
# Cryptographie
|
||||
sha1 = "0.10"
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
|
||||
# Utilitaires
|
||||
anyhow = "1.0"
|
||||
chrono = "0.4"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
bytes = "1.6"
|
||||
paste = "1.0"
|
||||
|
||||
# Async
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
@@ -34,7 +37,12 @@ utoipa = { version = "5.3", optional = true }
|
||||
# Feature pour pmoserver (extension HTTP)
|
||||
axum = { version = "0.8", optional = true }
|
||||
|
||||
# Feature pour pmoconfig (extension de configuration)
|
||||
pmoconfig = { path = "../pmoconfig", optional = true }
|
||||
serde_yaml = { version = "0.9", optional = true }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
openapi = ["dep:utoipa"]
|
||||
pmoserver = ["dep:axum"]
|
||||
pmoconfig = ["dep:pmoconfig", "dep:serde_yaml"]
|
||||
|
||||
@@ -11,9 +11,7 @@ fn main() {
|
||||
println!(" dl.wait_until_finished().await?;\n");
|
||||
|
||||
println!("2. Téléchargement avec transformation:");
|
||||
println!(
|
||||
" let transformer: StreamTransformer = Box::new(|response, mut file, update_progress| {{"
|
||||
);
|
||||
println!(" let transformer: StreamTransformer = Box::new(|response, mut file, ctx| {{");
|
||||
println!(" Box::pin(async move {{");
|
||||
println!(" let mut stream = response.bytes_stream();");
|
||||
println!(" let mut total = 0u64;");
|
||||
@@ -26,7 +24,7 @@ fn main() {
|
||||
println!();
|
||||
println!(" file.write_all(&transformed).await.map_err(|e| e.to_string())?;");
|
||||
println!(" total += transformed.len() as u64;");
|
||||
println!(" update_progress(total);");
|
||||
println!(" ctx.report_progress(total);");
|
||||
println!(" }}");
|
||||
println!();
|
||||
println!(" file.flush().await.map_err(|e| e.to_string())?;");
|
||||
|
||||
@@ -19,7 +19,7 @@ fn create_gzip_transformer() -> StreamTransformer {
|
||||
unimplemented!("Cette fonction nécessite la dépendance async-compression")
|
||||
|
||||
/*
|
||||
Box::new(|response, mut file, update_progress| {
|
||||
Box::new(|response, mut file, context| {
|
||||
Box::pin(async move {
|
||||
use async_compression::tokio::write::GzipEncoder;
|
||||
|
||||
@@ -36,7 +36,7 @@ fn create_gzip_transformer() -> StreamTransformer {
|
||||
.map_err(|e| format!("Failed to write compressed data: {}", e))?;
|
||||
|
||||
total_written += chunk.len() as u64;
|
||||
update_progress(total_written);
|
||||
context.report_progress(total_written);
|
||||
}
|
||||
|
||||
encoder
|
||||
@@ -52,7 +52,7 @@ fn create_gzip_transformer() -> StreamTransformer {
|
||||
|
||||
/// Exemple de transformer qui convertit les données en majuscules (exemple simple)
|
||||
fn create_uppercase_transformer() -> StreamTransformer {
|
||||
Box::new(|input, mut file, update_progress| {
|
||||
Box::new(|input, mut file, context| {
|
||||
Box::pin(async move {
|
||||
let mut stream = input.into_byte_stream();
|
||||
let mut total_written = 0u64;
|
||||
@@ -77,7 +77,7 @@ fn create_uppercase_transformer() -> StreamTransformer {
|
||||
.map_err(|e| format!("Failed to write: {}", e))?;
|
||||
|
||||
total_written += transformed.len() as u64;
|
||||
update_progress(total_written);
|
||||
context.report_progress(total_written);
|
||||
}
|
||||
|
||||
file.flush()
|
||||
@@ -91,7 +91,7 @@ fn create_uppercase_transformer() -> StreamTransformer {
|
||||
|
||||
/// Exemple de transformer qui saute les N premiers bytes (utile pour enlever des headers)
|
||||
fn create_skip_header_transformer(skip_bytes: usize) -> StreamTransformer {
|
||||
Box::new(move |input, mut file, update_progress| {
|
||||
Box::new(move |input, mut file, context| {
|
||||
Box::pin(async move {
|
||||
let mut stream = input.into_byte_stream();
|
||||
let mut skipped = 0usize;
|
||||
@@ -118,7 +118,7 @@ fn create_skip_header_transformer(skip_bytes: usize) -> StreamTransformer {
|
||||
.map_err(|e| format!("Failed to write: {}", e))?;
|
||||
|
||||
total_written += to_write.len() as u64;
|
||||
update_progress(total_written);
|
||||
context.report_progress(total_written);
|
||||
}
|
||||
|
||||
file.flush()
|
||||
@@ -132,7 +132,7 @@ fn create_skip_header_transformer(skip_bytes: usize) -> StreamTransformer {
|
||||
|
||||
/// Exemple de transformer qui compte les lignes et ajoute des numéros
|
||||
fn create_line_number_transformer() -> StreamTransformer {
|
||||
Box::new(|input, mut file, update_progress| {
|
||||
Box::new(|input, mut file, context| {
|
||||
Box::pin(async move {
|
||||
let mut stream = input.into_byte_stream();
|
||||
let mut line_number = 1u32;
|
||||
@@ -162,7 +162,7 @@ fn create_line_number_transformer() -> StreamTransformer {
|
||||
.map_err(|e| format!("Failed to write: {}", e))?;
|
||||
|
||||
total_written += numbered_line.len() as u64 + line.len() as u64 + 1;
|
||||
update_progress(total_written);
|
||||
context.report_progress(total_written);
|
||||
|
||||
line_number += 1;
|
||||
buffer.drain(..=newline_pos);
|
||||
@@ -181,7 +181,7 @@ fn create_line_number_transformer() -> StreamTransformer {
|
||||
.map_err(|e| format!("Failed to write: {}", e))?;
|
||||
|
||||
total_written += numbered_line.len() as u64 + buffer.len() as u64;
|
||||
update_progress(total_written);
|
||||
context.report_progress(total_written);
|
||||
}
|
||||
|
||||
file.flush()
|
||||
|
||||
@@ -15,6 +15,7 @@ use axum::{
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[cfg(feature = "openapi")]
|
||||
@@ -39,6 +40,21 @@ pub struct DownloadStatus {
|
||||
pub finished: bool,
|
||||
/// Erreur éventuelle
|
||||
pub error: Option<String>,
|
||||
/// Informations sur la conversion
|
||||
pub conversion: Option<ConversionStatus>,
|
||||
}
|
||||
|
||||
/// Informations sur la conversion en cours ou réalisée
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
#[cfg_attr(feature = "openapi", derive(ToSchema))]
|
||||
pub struct ConversionStatus {
|
||||
/// Mode de conversion (ex: "passthrough", "transcode")
|
||||
#[cfg_attr(feature = "openapi", schema(example = "passthrough"))]
|
||||
pub mode: String,
|
||||
/// Codec source détecté (si disponible)
|
||||
pub input_codec: Option<String>,
|
||||
/// Informations complémentaires lisibles (optionnel)
|
||||
pub details: Option<String>,
|
||||
}
|
||||
|
||||
/// Requête pour ajouter un item au cache
|
||||
@@ -93,7 +109,7 @@ pub struct ErrorResponse {
|
||||
///
|
||||
/// Retourne la liste complète des entrées du cache triées par nombre d'accès décroissant.
|
||||
pub async fn list_items<C: CacheConfig>(State(cache): State<Arc<Cache<C>>>) -> impl IntoResponse {
|
||||
match cache.db.get_all() {
|
||||
match cache.db.get_all(true) {
|
||||
Ok(entries) => (StatusCode::OK, Json(entries)).into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
@@ -113,7 +129,7 @@ pub async fn get_item_info<C: CacheConfig>(
|
||||
State(cache): State<Arc<Cache<C>>>,
|
||||
Path(pk): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
match cache.db.get(&pk) {
|
||||
match cache.db.get(&pk, true) {
|
||||
Ok(entry) => (StatusCode::OK, Json(entry)).into_response(),
|
||||
Err(_) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
@@ -134,8 +150,9 @@ pub async fn get_download_status<C: CacheConfig>(
|
||||
State(cache): State<Arc<Cache<C>>>,
|
||||
Path(pk): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
// Vérifier que l'item existe dans la DB
|
||||
if cache.db.get(&pk).is_err() {
|
||||
let entry = match cache.db.get(&pk, false) {
|
||||
Ok(entry) => entry,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ErrorResponse {
|
||||
@@ -145,19 +162,62 @@ pub async fn get_download_status<C: CacheConfig>(
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let in_progress = cache.get_download(&pk).await.is_some();
|
||||
let current_size = cache.current_size(&pk).await;
|
||||
let transformed_size = cache.transformed_size(&pk).await;
|
||||
let expected_size = cache.expected_size(&pk).await;
|
||||
let finished = cache.is_finished(&pk).await;
|
||||
let download = cache.get_download(&pk).await;
|
||||
let file_path = cache.get_file_path(&pk);
|
||||
let file_size = if file_path.exists() {
|
||||
std::fs::metadata(&file_path).ok().map(|m| m.len())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let error = if let Some(download) = cache.get_download(&pk).await {
|
||||
let in_progress = download.is_some();
|
||||
let current_size = if let Some(download) = download.as_ref() {
|
||||
Some(download.current_size().await)
|
||||
} else {
|
||||
file_size
|
||||
};
|
||||
|
||||
let transformed_size = if let Some(download) = download.as_ref() {
|
||||
Some(download.transformed_size().await)
|
||||
} else {
|
||||
file_size
|
||||
};
|
||||
|
||||
let expected_size = if let Some(download) = download.as_ref() {
|
||||
download.expected_size().await
|
||||
} else {
|
||||
file_size
|
||||
};
|
||||
|
||||
let finished = if let Some(download) = download.as_ref() {
|
||||
download.finished().await
|
||||
} else {
|
||||
file_path.exists()
|
||||
};
|
||||
|
||||
let error = if let Some(download) = download.as_ref() {
|
||||
download.error().await
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut conversion = if let Some(download) = download.as_ref() {
|
||||
download
|
||||
.transform_metadata()
|
||||
.await
|
||||
.map(ConversionStatus::from)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if conversion.is_none() {
|
||||
if let Some(meta) = entry.metadata.as_ref() {
|
||||
conversion = conversion_from_json(meta);
|
||||
}
|
||||
}
|
||||
|
||||
let status = DownloadStatus {
|
||||
pk,
|
||||
in_progress,
|
||||
@@ -166,11 +226,28 @@ pub async fn get_download_status<C: CacheConfig>(
|
||||
expected_size,
|
||||
finished,
|
||||
error,
|
||||
conversion,
|
||||
};
|
||||
|
||||
(StatusCode::OK, Json(status)).into_response()
|
||||
}
|
||||
|
||||
impl From<crate::download::TransformMetadata> for ConversionStatus {
|
||||
fn from(value: crate::download::TransformMetadata) -> Self {
|
||||
Self {
|
||||
mode: value.mode.unwrap_or_else(|| "unknown".to_string()),
|
||||
input_codec: value.input_codec,
|
||||
details: value.details,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn conversion_from_json(value: &Value) -> Option<ConversionStatus> {
|
||||
value
|
||||
.get("conversion")
|
||||
.and_then(|conv| serde_json::from_value(conv.clone()).ok())
|
||||
}
|
||||
|
||||
/// Ajoute un item au cache depuis une URL
|
||||
///
|
||||
/// Télécharge l'item depuis l'URL fournie et l'ajoute au cache.
|
||||
@@ -222,7 +299,7 @@ pub async fn delete_item<C: CacheConfig>(
|
||||
Path(pk): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
// Vérifier que l'item existe
|
||||
if cache.db.get(&pk).is_err() {
|
||||
if cache.db.get(&pk, false).is_err() {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ErrorResponse {
|
||||
|
||||
376
pmocache/src/cache.rs
Normal file → Executable file
376
pmocache/src/cache.rs
Normal file → Executable file
@@ -3,12 +3,13 @@
|
||||
//! Ce module fournit une interface générique pour gérer un cache de fichiers
|
||||
//! avec métadonnées dans une base de données SQLite.
|
||||
|
||||
use crate::cache_trait::{pk_from_url, FileCache};
|
||||
use crate::cache_trait::FileCache;
|
||||
use crate::db::DB;
|
||||
use crate::download::{
|
||||
download_with_transformer, ingest_with_transformer, Download, StreamTransformer,
|
||||
};
|
||||
use anyhow::{anyhow, Result};
|
||||
use anyhow::{anyhow, bail, Result};
|
||||
use serde_json::{Number, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
@@ -16,23 +17,19 @@ use tokio::io::AsyncRead;
|
||||
use tokio::sync::RwLock;
|
||||
use tracing;
|
||||
|
||||
/// Trait pour définir les paramètres du cache
|
||||
/// Paramètres statiques d'un cache spécialisé.
|
||||
pub trait CacheConfig: Send + Sync {
|
||||
/// Extension des fichiers (ex: "webp", "flac")
|
||||
/// Extension des fichiers générés (ex: `"webp"`, `"flac"`).
|
||||
fn file_extension() -> &'static str;
|
||||
/// Nom de la table dans la base de données (ex: "covers", "audio")
|
||||
fn table_name() -> &'static str {
|
||||
"cached_items"
|
||||
}
|
||||
/// Type de cache (ex: "audio", "image")
|
||||
/// Type logique exposé (ex: `"audio"`, `"image"`). Sert notamment pour les routes HTTP.
|
||||
fn cache_type() -> &'static str {
|
||||
"file"
|
||||
}
|
||||
/// Cache name (ex: "covers", "audio", "cache")
|
||||
/// Nom du cache (ex: `"covers"`, `"audio"`). Utilisé pour composer les chemins d'accès.
|
||||
fn cache_name() -> &'static str {
|
||||
"cache"
|
||||
}
|
||||
/// Default param extension ("orig")
|
||||
/// Qualifier par défaut associé au fichier original (ex: `"orig"`).
|
||||
fn default_param() -> &'static str {
|
||||
"orig"
|
||||
}
|
||||
@@ -97,9 +94,10 @@ impl<C: CacheConfig> Cache<C> {
|
||||
///
|
||||
/// let transformer_factory = Arc::new(|| {
|
||||
/// // Créer un transformer qui convertit les données
|
||||
/// Box::new(|input, file, progress| {
|
||||
/// Box::new(|input, file, ctx| {
|
||||
/// Box::pin(async move {
|
||||
/// // Transformation personnalisée
|
||||
/// ctx.report_progress(0);
|
||||
/// Ok(())
|
||||
/// })
|
||||
/// }) as StreamTransformer
|
||||
@@ -118,7 +116,7 @@ impl<C: CacheConfig> Cache<C> {
|
||||
) -> Result<Self> {
|
||||
let directory = PathBuf::from(dir);
|
||||
std::fs::create_dir_all(&directory)?;
|
||||
let db = DB::init(&directory.join("cache.db"), C::table_name())?;
|
||||
let db = DB::init(&directory.join("cache.db"))?;
|
||||
|
||||
Ok(Self {
|
||||
dir: directory,
|
||||
@@ -132,8 +130,18 @@ impl<C: CacheConfig> Cache<C> {
|
||||
|
||||
/// Télécharge un fichier depuis une URL et l'ajoute au cache
|
||||
///
|
||||
/// Utilise le module download pour gérer le téléchargement asynchrone.
|
||||
/// Le download est tracké dans la map jusqu'à sa fin.
|
||||
/// Cette méthode utilise un système d'identifiants basé sur le contenu plutôt que sur l'URL.
|
||||
/// Elle télécharge les 512 premiers octets du fichier pour calculer un identifiant unique (pk),
|
||||
/// puis vérifie si le fichier est déjà en cache. Si c'est le cas, elle met à jour le timestamp
|
||||
/// et retourne rapidement. Sinon, elle lance le téléchargement complet en arrière-plan.
|
||||
///
|
||||
/// # Workflow
|
||||
///
|
||||
/// 1. Télécharge les 512 premiers octets via une requête HTTP partielle
|
||||
/// 2. Calcule le pk en hashant (SHA256) ces premiers octets
|
||||
/// 3. Vérifie si le fichier existe déjà dans le cache
|
||||
/// 4. Si oui : update timestamp et retour rapide
|
||||
/// 5. Si non : lance le téléchargement complet en background
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
@@ -142,21 +150,46 @@ impl<C: CacheConfig> Cache<C> {
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// La clé primaire (pk) du fichier dans le cache
|
||||
/// La clé primaire (pk) du fichier dans le cache, calculée à partir du contenu
|
||||
///
|
||||
/// # Note
|
||||
///
|
||||
/// Deux URLs différentes pointant vers le même contenu auront le même pk,
|
||||
/// permettant une déduplication automatique.
|
||||
pub async fn add_from_url(&self, url: &str, collection: Option<&str>) -> Result<String> {
|
||||
let pk = pk_from_url(url);
|
||||
let file_path = self.file_path(&pk);
|
||||
// 1. Télécharger les 512 premiers octets pour calculer le pk
|
||||
let header = crate::download::peek_header(url, 512)
|
||||
.await
|
||||
.map_err(|e| anyhow!("Failed to peek header: {}", e))?;
|
||||
|
||||
// Vérifier si déjà en cours de téléchargement
|
||||
{
|
||||
let downloads = self.downloads.read().await;
|
||||
if downloads.contains_key(&pk) {
|
||||
// Download déjà en cours, retourner la clé
|
||||
// 2. Calculer le pk basé sur le contenu
|
||||
let pk = crate::cache_trait::pk_from_content_header(&header);
|
||||
tracing::debug!("Computed pk {} for URL {}", pk, url);
|
||||
|
||||
// 3. Vérifier si le fichier est déjà en cache
|
||||
if self.db.get(&pk, false).is_ok() {
|
||||
let file_path = self.get_file_path(&pk);
|
||||
if file_path.exists() {
|
||||
// Déjà en cache, update timestamp et retour rapide
|
||||
tracing::debug!("File with pk {} already in cache, updating timestamp", pk);
|
||||
self.db.update_hit(&pk)?;
|
||||
return Ok(pk);
|
||||
}
|
||||
}
|
||||
|
||||
// Lancer le téléchargement avec transformer
|
||||
// 4. Vérifier si un download est déjà en cours pour ce pk
|
||||
{
|
||||
let downloads = self.downloads.read().await;
|
||||
if downloads.contains_key(&pk) {
|
||||
// Download déjà en cours pour ce contenu, retourner la clé
|
||||
tracing::debug!("Download already in progress for pk {}", pk);
|
||||
return Ok(pk);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Lancer le téléchargement complet avec transformer
|
||||
tracing::debug!("Starting full download for pk {} from URL {}", pk, url);
|
||||
let file_path = self.get_file_path(&pk);
|
||||
let transformer = self.transformer_factory.as_ref().map(|f| f());
|
||||
let download = download_with_transformer(&file_path, url, transformer);
|
||||
|
||||
@@ -167,10 +200,9 @@ impl<C: CacheConfig> Cache<C> {
|
||||
}
|
||||
|
||||
// Ajouter immédiatement à la DB
|
||||
self.db.add(&pk, url, collection)?;
|
||||
|
||||
self.db.add(&pk, None, collection)?;
|
||||
self.db.set_origin_url(&pk, url)?;
|
||||
// Appliquer la politique d'éviction LRU si nécessaire
|
||||
// Cela garantit que le cache respecte toujours la limite configurée
|
||||
if let Err(e) = self.enforce_limit().await {
|
||||
tracing::warn!("Error enforcing cache limit: {}", e);
|
||||
}
|
||||
@@ -179,9 +211,7 @@ impl<C: CacheConfig> Cache<C> {
|
||||
let downloads_clone = self.downloads.clone();
|
||||
let pk_clone = pk.clone();
|
||||
tokio::spawn(async move {
|
||||
// Attendre la fin du téléchargement
|
||||
let _ = download.wait_until_finished().await;
|
||||
// Retirer de la map
|
||||
downloads_clone.write().await.remove(&pk_clone);
|
||||
});
|
||||
|
||||
@@ -190,45 +220,93 @@ impl<C: CacheConfig> Cache<C> {
|
||||
|
||||
/// Ajoute un fichier à partir d'un flux asynchrone.
|
||||
///
|
||||
/// Le flux peut provenir de n'importe quelle source (stream HTTP custom, décodeur,
|
||||
/// extraction en mémoire, etc.). Les mêmes transformers que `add_from_url` sont
|
||||
/// appliqués.
|
||||
/// Cette méthode utilise le même système d'identifiants basé sur le contenu que `add_from_url`.
|
||||
/// Elle lit les 512 premiers octets du flux pour calculer l'identifiant, puis reconstitue
|
||||
/// le flux complet pour l'ingestion.
|
||||
///
|
||||
/// # Workflow
|
||||
///
|
||||
/// 1. Lit les 512 premiers octets du reader
|
||||
/// 2. Calcule le pk en hashant (SHA256) ces premiers octets
|
||||
/// 3. Vérifie si le fichier existe déjà dans le cache
|
||||
/// 4. Si oui : update timestamp et retour rapide
|
||||
/// 5. Si non : reconstitue le reader (header + reste) et lance l'ingestion
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `source_uri` - Identifiant logique du flux (utilisé pour générer le pk)
|
||||
/// * `source_uri` - Identifiant logique optionnel du flux (pour traçabilité dans la DB). Si None, l'origin_url ne sera pas sauvegardée.
|
||||
/// * `reader` - Flux asynchrone fournissant les données
|
||||
/// * `length` - Taille attendue (si connue)
|
||||
/// * `collection` - Collection optionnelle à laquelle appartient l'élément
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// La clé primaire (pk) du fichier dans le cache, calculée à partir du contenu
|
||||
pub async fn add_from_reader<R>(
|
||||
&self,
|
||||
source_uri: &str,
|
||||
reader: R,
|
||||
source_uri: Option<&str>,
|
||||
mut reader: R,
|
||||
length: Option<u64>,
|
||||
collection: Option<&str>,
|
||||
) -> Result<String>
|
||||
where
|
||||
R: AsyncRead + Send + Unpin + 'static,
|
||||
{
|
||||
let pk = pk_from_url(source_uri);
|
||||
let file_path = self.file_path(&pk);
|
||||
// 1. Lire les 512 premiers octets pour calculer le pk
|
||||
let header = crate::download::peek_reader_header(&mut reader, 512)
|
||||
.await
|
||||
.map_err(|e| anyhow!("Failed to peek reader header: {}", e))?;
|
||||
|
||||
{
|
||||
let downloads = self.downloads.read().await;
|
||||
if downloads.contains_key(&pk) {
|
||||
// 2. Calculer le pk basé sur le contenu
|
||||
let pk = crate::cache_trait::pk_from_content_header(&header);
|
||||
if let Some(uri) = source_uri {
|
||||
tracing::debug!("Computed pk {} for source_uri {}", pk, uri);
|
||||
} else {
|
||||
tracing::debug!("Computed pk {} from reader", pk);
|
||||
}
|
||||
|
||||
// 3. Vérifier si le fichier est déjà en cache
|
||||
if self.db.get(&pk, false).is_ok() {
|
||||
let file_path = self.get_file_path(&pk);
|
||||
if file_path.exists() {
|
||||
// Déjà en cache, update timestamp et retour rapide
|
||||
tracing::debug!("File with pk {} already in cache, updating timestamp", pk);
|
||||
self.db.update_hit(&pk)?;
|
||||
return Ok(pk);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Vérifier si un download est déjà en cours pour ce pk
|
||||
{
|
||||
let downloads = self.downloads.read().await;
|
||||
if downloads.contains_key(&pk) {
|
||||
tracing::debug!("Download already in progress for pk {}", pk);
|
||||
return Ok(pk);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Reconstituer le reader complet (header + reste)
|
||||
// Utiliser tokio::io::chain pour créer un reader composé
|
||||
use std::io::Cursor;
|
||||
use tokio::io::AsyncReadExt;
|
||||
let header_reader = Cursor::new(header);
|
||||
let full_reader = header_reader.chain(reader);
|
||||
|
||||
// 6. Lancer l'ingestion avec transformer
|
||||
tracing::debug!("Starting ingestion for pk {} from reader", pk);
|
||||
let file_path = self.get_file_path(&pk);
|
||||
let transformer = self.transformer_factory.as_ref().map(|factory| factory());
|
||||
let download = ingest_with_transformer(&file_path, reader, length, transformer);
|
||||
let download = ingest_with_transformer(&file_path, full_reader, length, transformer);
|
||||
|
||||
{
|
||||
let mut downloads = self.downloads.write().await;
|
||||
downloads.insert(pk.clone(), download.clone());
|
||||
}
|
||||
|
||||
self.db.add(&pk, source_uri, collection)?;
|
||||
self.db.add(&pk, None, collection)?;
|
||||
if let Some(uri) = source_uri {
|
||||
self.db.set_origin_url(&pk, uri)?;
|
||||
}
|
||||
|
||||
if let Err(e) = self.enforce_limit().await {
|
||||
tracing::warn!("Error enforcing cache limit: {}", e);
|
||||
@@ -246,7 +324,9 @@ impl<C: CacheConfig> Cache<C> {
|
||||
|
||||
/// Ajoute un fichier local au cache
|
||||
///
|
||||
/// Le fichier est copié dans le cache via une URL file://
|
||||
/// Cette méthode lit les 512 premiers octets du fichier local pour calculer
|
||||
/// l'identifiant basé sur le contenu, puis utilise `add_from_reader()` pour
|
||||
/// l'ingestion complète.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
@@ -255,7 +335,21 @@ impl<C: CacheConfig> Cache<C> {
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// La clé primaire (pk) du fichier dans le cache
|
||||
/// La clé primaire (pk) du fichier dans le cache, calculée à partir du contenu
|
||||
///
|
||||
/// # Exemple
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// use pmocache::{Cache, CacheConfig};
|
||||
///
|
||||
/// struct MyConfig;
|
||||
/// impl CacheConfig for MyConfig {
|
||||
/// fn file_extension() -> &'static str { "dat" }
|
||||
/// }
|
||||
///
|
||||
/// let cache = Cache::<MyConfig>::new("./cache", 1000)?;
|
||||
/// let pk = cache.add_from_file("/path/to/file.dat", None).await?;
|
||||
/// ```
|
||||
pub async fn add_from_file(&self, path: &str, collection: Option<&str>) -> Result<String> {
|
||||
let canonical_path = std::fs::canonicalize(path)?;
|
||||
let file_url = format!("file://{}", canonical_path.display());
|
||||
@@ -264,29 +358,56 @@ impl<C: CacheConfig> Cache<C> {
|
||||
.ok()
|
||||
.map(|m| m.len());
|
||||
let reader = tokio::fs::File::open(&canonical_path).await?;
|
||||
self.add_from_reader(&file_url, reader, length, collection)
|
||||
|
||||
// add_from_reader() s'occupe de lire les 512 premiers octets et de calculer le pk
|
||||
self.add_from_reader(Some(&file_url), reader, length, collection)
|
||||
.await
|
||||
}
|
||||
|
||||
/// S'assure qu'un fichier est présent dans le cache
|
||||
///
|
||||
/// Si le fichier existe déjà, retourne sa clé. Sinon, le télécharge.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `url` - URL du fichier
|
||||
/// * `collection` - Collection optionnelle à laquelle appartient le fichier
|
||||
pub async fn ensure_from_url(&self, url: &str, collection: Option<&str>) -> Result<String> {
|
||||
let pk = pk_from_url(url);
|
||||
pub async fn delete_item(&self, pk: &str) -> Result<()> {
|
||||
// Vérifie l'existence pour signaler une erreur explicite si l'entrée est absente
|
||||
self.db.get(pk, false)?;
|
||||
|
||||
if self.db.get(&pk).is_ok() {
|
||||
let file_path = self.file_path(&pk);
|
||||
if file_path.exists() {
|
||||
return Ok(pk);
|
||||
// Oublie un téléchargement en cours pour cette clé
|
||||
self.downloads.write().await.remove(pk);
|
||||
|
||||
// Supprime chaque fichier {pk}.{qualifier}.{ext} (ignorer si déjà absent)
|
||||
for path in self.get_file_paths(pk)? {
|
||||
if let Err(err) = tokio::fs::remove_file(&path).await {
|
||||
if err.kind() != std::io::ErrorKind::NotFound {
|
||||
return Err(err.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.add_from_url(url, collection).await
|
||||
// Efface l’entrée de la base (les métadonnées partent via ON DELETE CASCADE)
|
||||
self.db.delete(pk)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_collection(&self, collection: &str) -> Result<()> {
|
||||
let entries = self.db.get_by_collection(collection, false)?;
|
||||
|
||||
{
|
||||
let mut downloads = self.downloads.write().await;
|
||||
for entry in &entries {
|
||||
downloads.remove(&entry.pk);
|
||||
}
|
||||
}
|
||||
|
||||
for entry in &entries {
|
||||
for path in self.get_file_paths(&entry.pk)? {
|
||||
if let Err(err) = tokio::fs::remove_file(&path).await {
|
||||
if err.kind() != std::io::ErrorKind::NotFound {
|
||||
return Err(err.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.db.delete_collection(collection)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Récupère le chemin d'un fichier dans le cache
|
||||
@@ -295,10 +416,10 @@ impl<C: CacheConfig> Cache<C> {
|
||||
///
|
||||
/// * `pk` - Clé primaire du fichier
|
||||
pub async fn get(&self, pk: &str) -> Result<PathBuf> {
|
||||
self.db.get(pk)?;
|
||||
self.db.get(pk, false)?;
|
||||
self.db.update_hit(pk)?;
|
||||
|
||||
let file_path = self.file_path(pk);
|
||||
let file_path = self.get_file_path(pk);
|
||||
if file_path.exists() {
|
||||
Ok(file_path)
|
||||
} else {
|
||||
@@ -306,17 +427,53 @@ impl<C: CacheConfig> Cache<C> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère une métadonnée précise pour une entrée du cache.
|
||||
pub async fn get_a_metadata(&self, pk: &str, key: &str) -> Result<Option<Value>> {
|
||||
Ok(self.db.get_metadata_value(pk, key)?)
|
||||
}
|
||||
|
||||
/// Récupère une métadonnée en tant que chaîne, si disponible.
|
||||
pub async fn get_a_metadata_as_string(&self, pk: &str, key: &str) -> Result<Option<String>> {
|
||||
match self.get_a_metadata(pk, key).await? {
|
||||
Some(Value::String(s)) => Ok(Some(s)),
|
||||
Some(Value::Null) | None => Ok(None),
|
||||
Some(other) => bail!("metadata '{key}' for pk '{pk}' is not a string (found {other})"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère une métadonnée en tant que nombre JSON (`serde_json::Number`).
|
||||
pub async fn get_a_metadata_as_number(&self, pk: &str, key: &str) -> Result<Option<Number>> {
|
||||
match self.get_a_metadata(pk, key).await? {
|
||||
Some(Value::Number(n)) => Ok(Some(n)),
|
||||
Some(Value::Null) | None => Ok(None),
|
||||
Some(other) => bail!("metadata '{key}' for pk '{pk}' is not a number (found {other})"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère une métadonnée en tant que booléen.
|
||||
pub async fn get_a_metadata_as_bool(&self, pk: &str, key: &str) -> Result<Option<bool>> {
|
||||
match self.get_a_metadata(pk, key).await? {
|
||||
Some(Value::Bool(b)) => Ok(Some(b)),
|
||||
Some(Value::Null) | None => Ok(None),
|
||||
Some(other) => bail!("metadata '{key}' for pk '{pk}' is not a boolean (found {other})"),
|
||||
}
|
||||
}
|
||||
pub async fn touch(&self, pk: &str) -> Result<()> {
|
||||
self.db.update_hit(pk)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Récupère tous les fichiers d'une collection
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `collection` - Identifiant de la collection
|
||||
pub async fn get_collection(&self, collection: &str) -> Result<Vec<PathBuf>> {
|
||||
let entries = self.db.get_by_collection(collection)?;
|
||||
let entries = self.db.get_by_collection(collection, false)?;
|
||||
let mut paths = Vec::new();
|
||||
|
||||
for entry in entries {
|
||||
let path = self.file_path(&entry.pk);
|
||||
let path = self.get_file_path(&entry.pk);
|
||||
if path.exists() {
|
||||
paths.push(path);
|
||||
}
|
||||
@@ -342,20 +499,26 @@ impl<C: CacheConfig> Cache<C> {
|
||||
/// Consolide le cache en supprimant les orphelins et en re-téléchargeant les fichiers manquants
|
||||
pub async fn consolidate(&self) -> Result<()> {
|
||||
// Récupérer la liste des entrées à traiter
|
||||
let entries = self.db.get_all()?;
|
||||
let entries = self.db.get_all(false)?;
|
||||
|
||||
// Supprimer les entrées sans fichiers correspondants
|
||||
for entry in entries {
|
||||
let file_path = self.file_path(&entry.pk);
|
||||
let file_path = self.get_file_path(&entry.pk);
|
||||
|
||||
if !file_path.exists() {
|
||||
// Re-télécharger le fichier manquant
|
||||
match self
|
||||
.add_from_url(&entry.source_url, entry.collection.as_deref())
|
||||
.await
|
||||
match self.db.get_origin_url(&entry.pk)? {
|
||||
Some(url) => {
|
||||
if let Err(err) = self.add_from_url(&url, entry.collection.as_deref()).await
|
||||
{
|
||||
Ok(_) => {}
|
||||
Err(_) => {
|
||||
// Si le téléchargement échoue, supprimer l'entrée DB
|
||||
tracing::warn!(
|
||||
"Unable to redownload missing file for {}: {}",
|
||||
entry.pk,
|
||||
err
|
||||
);
|
||||
self.db.delete(&entry.pk)?;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
self.db.delete(&entry.pk)?;
|
||||
}
|
||||
}
|
||||
@@ -371,7 +534,7 @@ impl<C: CacheConfig> Cache<C> {
|
||||
// Format attendu: {pk}.{qualifier}.{EXT}
|
||||
// On extrait le pk (première partie avant le premier point)
|
||||
if let Some(pk) = file_name.split('.').next() {
|
||||
if self.db.get(pk).is_err() {
|
||||
if self.db.get(pk, false).is_err() {
|
||||
tokio::fs::remove_file(path).await?;
|
||||
}
|
||||
}
|
||||
@@ -409,7 +572,7 @@ impl<C: CacheConfig> Cache<C> {
|
||||
Some(download.current_size().await)
|
||||
} else {
|
||||
// Fichier terminé, lire la taille du fichier
|
||||
let file_path = self.file_path(pk);
|
||||
let file_path = self.get_file_path(pk);
|
||||
if file_path.exists() {
|
||||
std::fs::metadata(file_path).ok().map(|m| m.len())
|
||||
} else {
|
||||
@@ -431,7 +594,7 @@ impl<C: CacheConfig> Cache<C> {
|
||||
Some(download.transformed_size().await)
|
||||
} else {
|
||||
// Fichier terminé, lire la taille du fichier
|
||||
let file_path = self.file_path(pk);
|
||||
let file_path = self.get_file_path(pk);
|
||||
if file_path.exists() {
|
||||
std::fs::metadata(file_path).ok().map(|m| m.len())
|
||||
} else {
|
||||
@@ -454,6 +617,15 @@ impl<C: CacheConfig> Cache<C> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Retourne les métadonnées de transformation (si disponibles)
|
||||
pub async fn transform_metadata(&self, pk: &str) -> Option<crate::download::TransformMetadata> {
|
||||
if let Some(download) = self.get_download(pk).await {
|
||||
download.transform_metadata().await
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Indique si le téléchargement est terminé
|
||||
///
|
||||
/// # Arguments
|
||||
@@ -464,7 +636,7 @@ impl<C: CacheConfig> Cache<C> {
|
||||
download.finished().await
|
||||
} else {
|
||||
// Pas dans la map = terminé (ou n'existe pas)
|
||||
self.file_path(pk).exists()
|
||||
self.get_file_path(pk).exists()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -482,7 +654,7 @@ impl<C: CacheConfig> Cache<C> {
|
||||
.map_err(|e| anyhow!("Download error: {}", e))
|
||||
} else {
|
||||
// Déjà terminé ou n'existe pas
|
||||
if self.file_path(pk).exists() {
|
||||
if self.get_file_path(pk).exists() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow!("File not found"))
|
||||
@@ -503,7 +675,7 @@ impl<C: CacheConfig> Cache<C> {
|
||||
.map_err(|e| anyhow!("Download error: {}", e))
|
||||
} else {
|
||||
// Déjà terminé ou n'existe pas
|
||||
if self.file_path(pk).exists() {
|
||||
if self.get_file_path(pk).exists() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow!("File not found"))
|
||||
@@ -519,18 +691,54 @@ impl<C: CacheConfig> Cache<C> {
|
||||
/// Construit le chemin complet d'un fichier dans le cache avec le param par défaut
|
||||
///
|
||||
/// Format: `{pk}.{default_param}.{extension}`
|
||||
pub fn file_path(&self, pk: &str) -> PathBuf {
|
||||
self.file_path_with_qualifier(pk, C::default_param())
|
||||
pub fn get_file_path(&self, pk: &str) -> PathBuf {
|
||||
self.get_file_path_with_qualifier(pk, C::default_param())
|
||||
}
|
||||
|
||||
/// Construit le chemin d'un fichier dans le cache avec un qualificatif
|
||||
///
|
||||
/// Format: `{pk}.{qualifier}.{extension}`
|
||||
pub fn file_path_with_qualifier(&self, pk: &str, qualifier: &str) -> PathBuf {
|
||||
pub fn get_file_path_with_qualifier(&self, pk: &str, qualifier: &str) -> PathBuf {
|
||||
self.dir
|
||||
.join(format!("{}.{}.{}", pk, qualifier, C::file_extension()))
|
||||
}
|
||||
|
||||
/// Retourne tous les chemins de fichiers stockés pour une clé donnée,
|
||||
/// quel que soit le qualifier.
|
||||
///
|
||||
/// Format: `{pk}.*.{extension}`
|
||||
pub fn get_file_paths(&self, pk: &str) -> Result<Vec<PathBuf>> {
|
||||
let mut paths = Vec::new();
|
||||
let prefix = format!("{pk}.");
|
||||
let expected_ext = C::file_extension();
|
||||
|
||||
for entry in std::fs::read_dir(&self.dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let file_name = match entry.file_name().into_string() {
|
||||
Ok(name) => name,
|
||||
Err(_) => continue, // nom de fichier non UTF-8 : on l’ignore
|
||||
};
|
||||
|
||||
if !file_name.starts_with(&prefix) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !file_name.ends_with(expected_ext) {
|
||||
continue;
|
||||
}
|
||||
|
||||
paths.push(path);
|
||||
}
|
||||
|
||||
Ok(paths)
|
||||
}
|
||||
|
||||
/// Valide les données avant de les stocker
|
||||
/// Par défaut, accepte toutes les données
|
||||
pub fn validate_data(&self, data: &[u8]) -> Result<Vec<u8>> {
|
||||
@@ -620,10 +828,6 @@ impl<C: CacheConfig> FileCache<C> for Cache<C> {
|
||||
self.add_from_file(path, collection).await
|
||||
}
|
||||
|
||||
async fn ensure_from_url(&self, url: &str, collection: Option<&str>) -> Result<String> {
|
||||
self.ensure_from_url(url, collection).await
|
||||
}
|
||||
|
||||
async fn get(&self, pk: &str) -> Result<PathBuf> {
|
||||
self.get(pk).await
|
||||
}
|
||||
|
||||
@@ -51,11 +51,6 @@ pub trait FileCache<C: CacheConfig>: Send + Sync {
|
||||
C::file_extension()
|
||||
}
|
||||
|
||||
/// Retourne le nom de la table
|
||||
fn table_name(&self) -> &'static str {
|
||||
C::table_name()
|
||||
}
|
||||
|
||||
/// Construit le chemin complet d'un fichier dans le cache
|
||||
///
|
||||
/// Format: `{pk}.{qualificatif}.{extension}`
|
||||
@@ -116,16 +111,6 @@ pub trait FileCache<C: CacheConfig>: Send + Sync {
|
||||
/// La clé primaire (pk) du fichier dans le cache
|
||||
async fn add_from_file(&self, path: &str, collection: Option<&str>) -> Result<String>;
|
||||
|
||||
/// S'assure qu'un fichier est présent dans le cache
|
||||
///
|
||||
/// Si le fichier existe déjà, retourne sa clé. Sinon, le télécharge.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `url` - URL du fichier
|
||||
/// * `collection` - Collection optionnelle à laquelle appartient le fichier
|
||||
async fn ensure_from_url(&self, url: &str, collection: Option<&str>) -> Result<String>;
|
||||
|
||||
/// Récupère le chemin d'un fichier dans le cache
|
||||
///
|
||||
/// # Arguments
|
||||
@@ -145,14 +130,48 @@ pub trait FileCache<C: CacheConfig>: Send + Sync {
|
||||
|
||||
/// Consolide le cache en supprimant les orphelins et en re-téléchargeant les fichiers manquants
|
||||
async fn consolidate(&self) -> Result<()>;
|
||||
|
||||
/// Vérifie si une clé primaire est valide (existe en DB et fichier présent)
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `pk` - Clé primaire à vérifier
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// `true` si l'entrée existe en base de données et que le fichier est présent
|
||||
fn is_valid_pk(&self, pk: &str) -> bool {
|
||||
self.get_database().get(pk, false).is_ok() && self.file_path(pk).exists()
|
||||
}
|
||||
}
|
||||
|
||||
/// Génère une clé primaire à partir d'une URL
|
||||
/// Génère une clé primaire à partir des premiers octets d'un document
|
||||
///
|
||||
/// Utilise SHA1 pour hasher l'URL et retourne les 8 premiers octets en hexadécimal.
|
||||
pub fn pk_from_url(url: &str) -> String {
|
||||
let mut hasher = Sha1::new();
|
||||
hasher.update(url.as_bytes());
|
||||
/// Utilise SHA256 pour hasher les premiers octets du contenu et retourne les 16 premiers octets
|
||||
/// en hexadécimal (32 caractères). L'utilisation de 16 octets au lieu de 8 réduit considérablement
|
||||
/// les risques de collision.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `header` - Les premiers octets du document (typiquement 512 octets)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Une chaîne hexadécimale de 32 caractères servant de clé primaire unique
|
||||
///
|
||||
/// # Exemple
|
||||
///
|
||||
/// ```
|
||||
/// use pmocache::pk_from_content_header;
|
||||
///
|
||||
/// let data = b"Some file content...";
|
||||
/// let pk = pk_from_content_header(data);
|
||||
/// assert_eq!(pk.len(), 32); // 16 bytes = 32 hex chars
|
||||
/// ```
|
||||
pub fn pk_from_content_header(header: &[u8]) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(header);
|
||||
let result = hasher.finalize();
|
||||
hex::encode(&result[..8])
|
||||
hex::encode(&result[..16]) // 16 octets = 32 caractères hex
|
||||
}
|
||||
|
||||
225
pmocache/src/config_ext.rs
Normal file
225
pmocache/src/config_ext.rs
Normal file
@@ -0,0 +1,225 @@
|
||||
//! Extension pour intégrer la gestion des caches dans pmoconfig
|
||||
//!
|
||||
//! Ce module fournit le trait `CacheConfigExt` qui permet d'ajouter facilement
|
||||
//! des méthodes de gestion de cache générique à pmoconfig::Config.
|
||||
//!
|
||||
//! Il propose également un macro `impl_cache_config_ext!` pour simplifier
|
||||
//! l'implémentation de traits d'extension spécialisés (audio, covers, etc.).
|
||||
|
||||
use anyhow::Result;
|
||||
use pmoconfig::Config;
|
||||
use serde_yaml::{Number, Value};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Trait d'extension pour ajouter la gestion des caches à pmoconfig
|
||||
///
|
||||
/// Ce trait étend `pmoconfig::Config` avec des méthodes génériques pour gérer
|
||||
/// n'importe quel type de cache (audio, images, etc.).
|
||||
///
|
||||
/// # Exemple
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// use pmoconfig::get_config;
|
||||
/// use pmocache::{CacheConfigExt, AudioConfig};
|
||||
///
|
||||
/// let config = get_config();
|
||||
/// let cache_dir = config.get_cache_dir("audio_cache", "cache_audio")?;
|
||||
/// let cache_size = config.get_cache_size("audio_cache", 500)?;
|
||||
/// ```
|
||||
pub trait CacheConfigExt {
|
||||
/// Récupère le répertoire d'un cache
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `cache_type` - Type de cache (ex: "audio_cache", "cover_cache")
|
||||
/// * `default` - Nom de répertoire par défaut si non configuré
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Le chemin absolu du répertoire du cache
|
||||
fn get_cache_dir(&self, cache_type: &str, default: &str) -> Result<String>;
|
||||
|
||||
/// Définit le répertoire d'un cache
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `cache_type` - Type de cache (ex: "audio_cache", "cover_cache")
|
||||
/// * `directory` - Chemin du répertoire (absolu ou relatif au config_dir)
|
||||
fn set_cache_dir(&self, cache_type: &str, directory: String) -> Result<()>;
|
||||
|
||||
/// Récupère la taille maximale d'un cache
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `cache_type` - Type de cache (ex: "audio_cache", "cover_cache")
|
||||
/// * `default` - Taille par défaut si non configurée
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Le nombre maximal d'éléments dans le cache
|
||||
fn get_cache_size(&self, cache_type: &str, default: usize) -> Result<usize>;
|
||||
|
||||
/// Définit la taille maximale d'un cache
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `cache_type` - Type de cache (ex: "audio_cache", "cover_cache")
|
||||
/// * `size` - Nombre maximal d'éléments
|
||||
fn set_cache_size(&self, cache_type: &str, size: usize) -> Result<()>;
|
||||
|
||||
/// Crée une instance de cache générique configurée
|
||||
///
|
||||
/// Cette méthode factory crée un cache en utilisant les paramètres
|
||||
/// de configuration (répertoire et taille).
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `cache_type` - Type de cache (ex: "audio_cache", "cover_cache")
|
||||
/// * `default_dir` - Répertoire par défaut
|
||||
/// * `default_size` - Taille par défaut
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Une instance Arc du cache configuré
|
||||
///
|
||||
/// # Exemple
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// use pmoconfig::get_config;
|
||||
/// use pmocache::{CacheConfigExt, AudioConfig};
|
||||
///
|
||||
/// let config = get_config();
|
||||
/// let cache = config.create_cache::<AudioConfig>("audio_cache", "cache_audio", 500)?;
|
||||
/// ```
|
||||
fn create_cache<C: crate::CacheConfig>(
|
||||
&self,
|
||||
cache_type: &str,
|
||||
default_dir: &str,
|
||||
default_size: usize,
|
||||
) -> Result<Arc<crate::Cache<C>>>;
|
||||
}
|
||||
|
||||
impl CacheConfigExt for Config {
|
||||
fn get_cache_dir(&self, cache_type: &str, default: &str) -> Result<String> {
|
||||
self.get_managed_dir(&["host", cache_type, "directory"], default)
|
||||
}
|
||||
|
||||
fn set_cache_dir(&self, cache_type: &str, directory: String) -> Result<()> {
|
||||
self.set_managed_dir(&["host", cache_type, "directory"], directory)
|
||||
}
|
||||
|
||||
fn get_cache_size(&self, cache_type: &str, default: usize) -> Result<usize> {
|
||||
match self.get_value(&["host", cache_type, "size"])? {
|
||||
Value::Number(n) if n.is_i64() => Ok(n.as_i64().unwrap() as usize),
|
||||
Value::Number(n) if n.is_u64() => Ok(n.as_u64().unwrap() as usize),
|
||||
_ => Ok(default),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_cache_size(&self, cache_type: &str, size: usize) -> Result<()> {
|
||||
let n = Number::from(size);
|
||||
self.set_value(&["host", cache_type, "size"], Value::Number(n))
|
||||
}
|
||||
|
||||
fn create_cache<C: crate::CacheConfig>(
|
||||
&self,
|
||||
cache_type: &str,
|
||||
default_dir: &str,
|
||||
default_size: usize,
|
||||
) -> Result<Arc<crate::Cache<C>>> {
|
||||
let dir = self.get_cache_dir(cache_type, default_dir)?;
|
||||
let size = self.get_cache_size(cache_type, default_size)?;
|
||||
Ok(Arc::new(crate::Cache::<C>::new(&dir, size)?))
|
||||
}
|
||||
}
|
||||
|
||||
/// Macro pour simplifier l'implémentation de traits d'extension de cache spécialisés
|
||||
///
|
||||
/// Ce macro génère automatiquement un trait d'extension pour `pmoconfig::Config`
|
||||
/// avec des méthodes spécifiques à un type de cache (audio, covers, etc.).
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `trait_name` - Nom du trait à générer (ex: `AudioCacheConfigExt`)
|
||||
/// * `cache_type` - Type de cache dans la config (ex: `"audio_cache"`)
|
||||
/// * `default_dir` - Répertoire par défaut (ex: `"cache_audio"`)
|
||||
/// * `default_size` - Taille par défaut (ex: `500`)
|
||||
/// * `cache_struct` - Type du cache (ex: `crate::Cache`)
|
||||
/// * `constructor` - Expression pour construire le cache (ex: `crate::cache::new_cache(&dir, size)`)
|
||||
///
|
||||
/// # Exemple
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// use pmocache::impl_cache_config_ext;
|
||||
///
|
||||
/// impl_cache_config_ext! {
|
||||
/// AudioCacheConfigExt,
|
||||
/// "audio_cache",
|
||||
/// "cache_audio",
|
||||
/// 500,
|
||||
/// crate::Cache,
|
||||
/// |dir, size| crate::cache::new_cache(dir, size)
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// Cela génère un trait avec les méthodes :
|
||||
/// - `get_audiocache_dir()` / `set_audiocache_dir()`
|
||||
/// - `get_audiocache_size()` / `set_audiocache_size()`
|
||||
/// - `create_audio_cache()`
|
||||
#[macro_export]
|
||||
macro_rules! impl_cache_config_ext {
|
||||
(
|
||||
$trait_name:ident,
|
||||
$cache_type:expr,
|
||||
$default_dir:expr,
|
||||
$default_size:expr,
|
||||
$cache_type_struct:ty,
|
||||
$constructor:expr
|
||||
) => {
|
||||
pub trait $trait_name {
|
||||
/// Récupère le répertoire du cache
|
||||
fn get_cache_dir_ext(&self) -> anyhow::Result<String>;
|
||||
|
||||
/// Définit le répertoire du cache
|
||||
fn set_cache_dir_ext(&self, directory: String) -> anyhow::Result<()>;
|
||||
|
||||
/// Récupère la taille maximale du cache
|
||||
fn get_cache_size_ext(&self) -> anyhow::Result<usize>;
|
||||
|
||||
/// Définit la taille maximale du cache
|
||||
fn set_cache_size_ext(&self, size: usize) -> anyhow::Result<()>;
|
||||
|
||||
/// Crée une instance du cache configurée
|
||||
fn create_cache_ext(&self) -> anyhow::Result<std::sync::Arc<$cache_type_struct>>;
|
||||
}
|
||||
|
||||
impl $trait_name for pmoconfig::Config {
|
||||
fn get_cache_dir_ext(&self) -> anyhow::Result<String> {
|
||||
use $crate::CacheConfigExt;
|
||||
self.get_cache_dir($cache_type, $default_dir)
|
||||
}
|
||||
|
||||
fn set_cache_dir_ext(&self, directory: String) -> anyhow::Result<()> {
|
||||
use $crate::CacheConfigExt;
|
||||
self.set_cache_dir($cache_type, directory)
|
||||
}
|
||||
|
||||
fn get_cache_size_ext(&self) -> anyhow::Result<usize> {
|
||||
use $crate::CacheConfigExt;
|
||||
self.get_cache_size($cache_type, $default_size)
|
||||
}
|
||||
|
||||
fn set_cache_size_ext(&self, size: usize) -> anyhow::Result<()> {
|
||||
use $crate::CacheConfigExt;
|
||||
self.set_cache_size($cache_type, size)
|
||||
}
|
||||
|
||||
fn create_cache_ext(&self) -> anyhow::Result<std::sync::Arc<$cache_type_struct>> {
|
||||
let dir = self.get_cache_dir_ext()?;
|
||||
let size = self.get_cache_size_ext()?;
|
||||
let constructor = $constructor;
|
||||
Ok(std::sync::Arc::new(constructor(&dir, size)?))
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -4,10 +4,15 @@
|
||||
//! des éléments en cache, avec tracking des accès et des statistiques.
|
||||
|
||||
use chrono::Utc;
|
||||
use rusqlite::{params, Connection};
|
||||
use rusqlite::{params, Connection, Error, OptionalExtension};
|
||||
use serde::Serialize;
|
||||
use serde_json::{Map, Number, Value};
|
||||
use tracing::{trace, warn};
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
use std::str::FromStr;
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
use std::time::Instant;
|
||||
|
||||
#[cfg(feature = "openapi")]
|
||||
use utoipa::ToSchema;
|
||||
@@ -21,7 +26,7 @@ pub struct CacheEntry {
|
||||
pub pk: String,
|
||||
/// URL source de l'élément
|
||||
#[cfg_attr(feature = "openapi", schema(example = "https://example.com/resource"))]
|
||||
pub source_url: String,
|
||||
pub id: Option<String>,
|
||||
/// Collection à laquelle appartient l'élément (optionnel)
|
||||
#[cfg_attr(feature = "openapi", schema(example = "album:123"))]
|
||||
pub collection: Option<String>,
|
||||
@@ -36,7 +41,7 @@ pub struct CacheEntry {
|
||||
feature = "openapi",
|
||||
schema(example = r#"{"title":"Track","artist":"Artist"}"#)
|
||||
)]
|
||||
pub metadata_json: Option<String>,
|
||||
pub metadata: Option<Value>,
|
||||
}
|
||||
|
||||
/// Base de données SQLite pour le cache
|
||||
@@ -48,10 +53,47 @@ pub struct CacheEntry {
|
||||
#[derive(Debug)]
|
||||
pub struct DB {
|
||||
conn: Mutex<Connection>,
|
||||
table_name: String,
|
||||
}
|
||||
|
||||
struct ConnGuard<'a> {
|
||||
ctx: &'static str,
|
||||
guard: MutexGuard<'a, Connection>,
|
||||
}
|
||||
|
||||
impl<'a> Drop for ConnGuard<'a> {
|
||||
fn drop(&mut self) {
|
||||
trace!("DB mutex → released ({})", self.ctx);
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> std::ops::Deref for ConnGuard<'a> {
|
||||
type Target = Connection;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.guard
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> std::ops::DerefMut for ConnGuard<'a> {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
&mut self.guard
|
||||
}
|
||||
}
|
||||
|
||||
impl DB {
|
||||
fn lock_conn(&self, ctx: &'static str) -> ConnGuard<'_> {
|
||||
trace!("DB mutex → acquiring ({ctx})");
|
||||
let start = Instant::now();
|
||||
let guard = self.conn.lock().unwrap();
|
||||
let waited = start.elapsed();
|
||||
|
||||
trace!("DB mutex → acquired ({ctx}) in {:?}", waited);
|
||||
if waited > std::time::Duration::from_millis(50) {
|
||||
warn!("DB mutex wait >50 ms ({}): {:?}", ctx, waited);
|
||||
}
|
||||
|
||||
ConnGuard { ctx, guard }
|
||||
}
|
||||
|
||||
/// Initialise une nouvelle base de données avec une table personnalisée
|
||||
///
|
||||
/// # Arguments
|
||||
@@ -67,42 +109,55 @@ impl DB {
|
||||
///
|
||||
/// let db = DB::init(Path::new("cache.db"), "my_cache").unwrap();
|
||||
/// ```
|
||||
pub fn init(path: &Path, table_name: &str) -> Result<Self, rusqlite::Error> {
|
||||
pub fn init(path: &Path) -> Result<Self, rusqlite::Error> {
|
||||
let conn = Connection::open(path)?;
|
||||
|
||||
let create_table_sql = format!(
|
||||
"CREATE TABLE IF NOT EXISTS {} (
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS asset (
|
||||
pk TEXT PRIMARY KEY,
|
||||
source_url TEXT,
|
||||
collection TEXT,
|
||||
id TEXT,
|
||||
hits INTEGER DEFAULT 0,
|
||||
last_used TEXT,
|
||||
metadata_json TEXT
|
||||
last_used TEXT
|
||||
)",
|
||||
table_name
|
||||
);
|
||||
|
||||
conn.execute(&create_table_sql, [])?;
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS metadata (
|
||||
pk TEXT,
|
||||
key TEXT,
|
||||
value_type TEXT NOT NULL CHECK (value_type IN ('string','number','boolean','null')),
|
||||
value TEXT,
|
||||
PRIMARY KEY (pk, key),
|
||||
FOREIGN KEY (pk) REFERENCES asset (pk) ON DELETE CASCADE
|
||||
)"
|
||||
, [])?;
|
||||
|
||||
// Créer un index sur la collection pour les requêtes rapides
|
||||
let create_index_sql = format!(
|
||||
"CREATE INDEX IF NOT EXISTS idx_{}_collection ON {} (collection)",
|
||||
table_name, table_name
|
||||
);
|
||||
|
||||
conn.execute(&create_index_sql, [])?;
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_asset_collection
|
||||
ON ASSET (collection)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// Créer un index composite pour optimiser la politique LRU (get_oldest)
|
||||
let create_lru_index_sql = format!(
|
||||
"CREATE INDEX IF NOT EXISTS idx_{}_lru ON {} (last_used ASC, hits ASC)",
|
||||
table_name, table_name
|
||||
);
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_asset_lru
|
||||
ON asset (last_used ASC, hits ASC)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(&create_lru_index_sql, [])?;
|
||||
// Crée un index composite pour rendre unique les ids si défini dans une collection
|
||||
conn.execute(
|
||||
"CREATE UNIQUE INDEX
|
||||
IF NOT EXISTS asset_collection_id_unique
|
||||
ON asset (collection, id)
|
||||
WHERE id IS NOT NULL;",
|
||||
[],
|
||||
)?;
|
||||
|
||||
Ok(Self {
|
||||
conn: Mutex::new(conn),
|
||||
table_name: table_name.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -113,8 +168,13 @@ impl DB {
|
||||
/// * `pk` - Clé primaire de l'élément
|
||||
/// * `url` - URL source de l'élément
|
||||
/// * `collection` - Collection optionnelle à laquelle appartient l'élément
|
||||
pub fn add(&self, pk: &str, url: &str, collection: Option<&str>) -> rusqlite::Result<()> {
|
||||
self.add_with_metadata(pk, url, collection, None)
|
||||
pub fn add(
|
||||
&self,
|
||||
pk: &str,
|
||||
id: Option<&str>,
|
||||
collection: Option<&str>,
|
||||
) -> rusqlite::Result<()> {
|
||||
self.add_with_metadata(pk, id, collection, None)
|
||||
}
|
||||
|
||||
/// Ajoute ou met à jour une entrée avec métadonnées JSON optionnelles
|
||||
@@ -128,52 +188,345 @@ impl DB {
|
||||
pub fn add_with_metadata(
|
||||
&self,
|
||||
pk: &str,
|
||||
url: &str,
|
||||
id: Option<&str>,
|
||||
collection: Option<&str>,
|
||||
metadata_json: Option<&str>,
|
||||
metadata: Option<&Value>,
|
||||
) -> rusqlite::Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let sql = format!(
|
||||
"INSERT INTO {} (pk, source_url, collection, hits, last_used, metadata_json)
|
||||
VALUES (?1, ?2, ?3, 0, ?4, ?5)
|
||||
ON CONFLICT(pk) DO UPDATE SET
|
||||
source_url = excluded.source_url,
|
||||
collection = excluded.collection,
|
||||
last_used = excluded.last_used,
|
||||
metadata_json = excluded.metadata_json",
|
||||
self.table_name
|
||||
);
|
||||
let conn = self.lock_conn("add_with_metadata");
|
||||
|
||||
conn.execute(
|
||||
&sql,
|
||||
params![pk, url, collection, Utc::now().to_rfc3339(), metadata_json],
|
||||
"INSERT INTO asset (pk, id, collection, hits, last_used)
|
||||
VALUES (?1, ?2, ?3, 0, ?4)
|
||||
ON CONFLICT(pk) DO UPDATE SET
|
||||
id = excluded.id,
|
||||
collection = excluded.collection,
|
||||
last_used = excluded.last_used",
|
||||
params![pk, id, collection, Utc::now().to_rfc3339()],
|
||||
)?;
|
||||
|
||||
if metadata.is_some() {
|
||||
self.set_metadata(pk, metadata.unwrap())?
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remplace toutes les métadonnées associées à une entrée.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `pk` - Clé primaire de l'élément ciblé.
|
||||
/// * `metadata` - Objet JSON complet décrivant les nouvelles métadonnées.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Retourne une erreur si `metadata` n'est pas un objet JSON ou si l'écriture
|
||||
/// SQLite échoue.
|
||||
pub fn set_metadata(&self, pk: &str, metadata: &Value) -> rusqlite::Result<()> {
|
||||
let metadata_obj = metadata.as_object().ok_or_else(|| {
|
||||
Error::InvalidParameterName("metadata must be a JSON object".to_owned())
|
||||
})?;
|
||||
|
||||
let mut conn = self.lock_conn("set_metadata");
|
||||
|
||||
let tx = conn.transaction()?;
|
||||
|
||||
tx.execute("DELETE FROM metadata WHERE pk = ?1", params![pk])?;
|
||||
|
||||
for (key, value) in metadata_obj.iter() {
|
||||
let (value_type, value_text): (&str, Option<String>) = match value {
|
||||
Value::Null => ("null", None),
|
||||
Value::Bool(b) => ("boolean", Some(b.to_string())),
|
||||
Value::Number(n) => ("number", Some(n.to_string())),
|
||||
Value::String(s) => ("string", Some(s.clone())),
|
||||
Value::Array(_) | Value::Object(_) => ("string", Some(value.to_string())),
|
||||
};
|
||||
|
||||
tx.execute(
|
||||
"INSERT INTO metadata (pk, key, value_type, value) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![pk, key, value_type, value_text.as_deref()],
|
||||
)?;
|
||||
}
|
||||
|
||||
tx.commit()
|
||||
}
|
||||
|
||||
/// Insère ou met à jour une métadonnée individuelle.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `pk` - Clé primaire de l'élément concerné.
|
||||
/// * `key` - Nom de la métadonnée à enregistrer.
|
||||
/// * `value` - Valeur JSON à stocker pour cette clé.
|
||||
pub fn set_a_metadata(&self, pk: &str, key: &str, value: Value) -> rusqlite::Result<()> {
|
||||
let (value_type, value_text): (&str, Option<String>) = match value {
|
||||
Value::Null => ("null", None),
|
||||
Value::Bool(b) => ("boolean", Some(b.to_string())),
|
||||
Value::Number(n) => ("number", Some(n.to_string())),
|
||||
Value::String(s) => ("string", Some(s)),
|
||||
Value::Array(arr) => ("string", Some(Value::Array(arr).to_string())),
|
||||
Value::Object(map) => ("string", Some(Value::Object(map).to_string())),
|
||||
};
|
||||
|
||||
let conn = self.lock_conn("set_a_metadata");
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO metadata (pk, key, value_type, value)
|
||||
VALUES (?1, ?2, ?3, ?4)
|
||||
ON CONFLICT(pk, key) DO UPDATE SET
|
||||
value_type = excluded.value_type,
|
||||
value = excluded.value",
|
||||
params![pk, key, value_type, value_text.as_deref()],
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Récupère une entrée de la base de données par sa clé
|
||||
/// Alias interne pour récupérer une métadonnée individuelle.
|
||||
///
|
||||
/// Préférer `get_metadata_value` pour les appels externes.
|
||||
pub fn get_a_metadata(&self, pk: &str, key: &str) -> rusqlite::Result<Option<Value>> {
|
||||
let conn = self.lock_conn("get_a_metadata");
|
||||
|
||||
conn.query_row(
|
||||
"SELECT value_type, value FROM metadata WHERE pk = ?1 AND key = ?2",
|
||||
params![pk, key],
|
||||
|row| {
|
||||
let value_type: String = row.get(0)?;
|
||||
let raw: Option<String> = row.get(1)?;
|
||||
decode_metadata_value(key, &value_type, raw)
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
}
|
||||
|
||||
/// Récupère toutes les métadonnées d'une entrée sous forme d'objet JSON.
|
||||
///
|
||||
/// Retourne `Ok(None)` si aucune métadonnée n'est présente.
|
||||
pub fn get_metadata(&self, pk: &str) -> rusqlite::Result<Option<Value>> {
|
||||
let conn = self.lock_conn("get_metadata");
|
||||
let mut stmt = conn.prepare("SELECT key, value_type, value FROM metadata WHERE pk = ?1")?;
|
||||
|
||||
let rows = stmt.query_map([pk], |row| {
|
||||
let key: String = row.get(0)?;
|
||||
let value_type: String = row.get(1)?;
|
||||
let value: Option<String> = row.get(2)?;
|
||||
Ok((key, value_type, value))
|
||||
})?;
|
||||
|
||||
let mut metadata = Map::new();
|
||||
let mut found = false;
|
||||
|
||||
for row in rows {
|
||||
let (key, value_type, raw) = row?;
|
||||
found = true;
|
||||
|
||||
let value = match value_type.as_str() {
|
||||
"null" => Value::Null,
|
||||
"boolean" => {
|
||||
let raw = raw.as_deref().ok_or_else(|| {
|
||||
Error::InvalidParameterName(format!(
|
||||
"missing boolean metadata for key '{key}'"
|
||||
))
|
||||
})?;
|
||||
let parsed = raw.parse::<bool>().map_err(|_| {
|
||||
Error::InvalidParameterName(format!(
|
||||
"invalid boolean metadata for key '{key}'"
|
||||
))
|
||||
})?;
|
||||
Value::Bool(parsed)
|
||||
}
|
||||
"number" => {
|
||||
let raw = raw.as_deref().ok_or_else(|| {
|
||||
Error::InvalidParameterName(format!(
|
||||
"missing number metadata for key '{key}'"
|
||||
))
|
||||
})?;
|
||||
let number = Number::from_str(raw).map_err(|_| {
|
||||
Error::InvalidParameterName(format!(
|
||||
"invalid number metadata for key '{key}'"
|
||||
))
|
||||
})?;
|
||||
Value::Number(number)
|
||||
}
|
||||
"string" => Value::String(raw.unwrap_or_default()),
|
||||
other => {
|
||||
return Err(Error::InvalidParameterName(format!(
|
||||
"unknown metadata type '{other}' for key '{key}'"
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
metadata.insert(key, value);
|
||||
}
|
||||
|
||||
if found {
|
||||
Ok(Some(Value::Object(metadata)))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Enregistre l'URL d'origine liée à un élément du cache.
|
||||
pub fn set_origin_url(&self, pk: &str, origin_url: &str) -> rusqlite::Result<()> {
|
||||
self.set_a_metadata(pk, "origin_url", Value::String(origin_url.to_owned()))
|
||||
}
|
||||
|
||||
/// Récupère l'URL d'origine précédemment stockée pour un élément.
|
||||
///
|
||||
/// Retourne `Ok(None)` lorsqu'aucune URL n'a été définie.
|
||||
pub fn get_origin_url(&self, pk: &str) -> rusqlite::Result<Option<String>> {
|
||||
match self.get_metadata_value(pk, "origin_url")? {
|
||||
Some(Value::String(url)) => Ok(Some(url)),
|
||||
Some(Value::Null) | None => Ok(None),
|
||||
Some(other) => Err(Error::InvalidParameterName(format!(
|
||||
"metadata 'origin_url' must be a string, got {other}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère uniquement les métadonnées JSON d'une entrée
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `pk` - Clé primaire de l'élément à récupérer
|
||||
pub fn get(&self, pk: &str) -> rusqlite::Result<CacheEntry> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let sql = format!(
|
||||
"SELECT pk, source_url, collection, hits, last_used, metadata_json FROM {} WHERE pk = ?1",
|
||||
self.table_name
|
||||
);
|
||||
/// * `pk` - Clé primaire de l'élément
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Les métadonnées JSON si présentes, None sinon
|
||||
pub fn get_metadata_json(&self, pk: &str) -> rusqlite::Result<Option<String>> {
|
||||
Ok(self.get_metadata(pk)?.map(|value| value.to_string()))
|
||||
}
|
||||
|
||||
conn.query_row(&sql, [pk], |row| {
|
||||
/// Récupère une métadonnée individuelle, si elle existe.
|
||||
pub fn get_metadata_value(&self, pk: &str, key: &str) -> rusqlite::Result<Option<Value>> {
|
||||
let conn = self.lock_conn("get_metadata_value");
|
||||
|
||||
conn.query_row(
|
||||
"SELECT value_type, value FROM metadata WHERE pk = ?1 AND key = ?2",
|
||||
params![pk, key],
|
||||
|row| {
|
||||
let value_type: String = row.get(0)?;
|
||||
let raw: Option<String> = row.get(1)?;
|
||||
decode_metadata_value(key, &value_type, raw)
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
}
|
||||
|
||||
/// Récupère une entrée de la base de données par sa clé
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `pk` - Clé primaire de l'élément à récupérer.
|
||||
/// * `with_metadata` - Charge les métadonnées associées si `true`.
|
||||
pub fn get(&self, pk: &str, with_metadata: bool) -> rusqlite::Result<CacheEntry> {
|
||||
let mut entry = {
|
||||
let conn = self.lock_conn("get");
|
||||
conn.query_row(
|
||||
"SELECT pk, id, collection, hits, last_used \
|
||||
FROM asset \
|
||||
WHERE pk = ?1",
|
||||
[pk],
|
||||
|row| {
|
||||
Ok(CacheEntry {
|
||||
pk: row.get(0)?,
|
||||
source_url: row.get(1)?,
|
||||
id: row.get::<_, Option<String>>(1)?,
|
||||
collection: row.get(2)?,
|
||||
hits: row.get(3)?,
|
||||
last_used: row.get(4)?,
|
||||
metadata_json: row.get(5)?,
|
||||
metadata: None,
|
||||
})
|
||||
},
|
||||
)?
|
||||
};
|
||||
|
||||
if with_metadata {
|
||||
entry.metadata = self.get_metadata(&entry.pk)?;
|
||||
}
|
||||
|
||||
Ok(entry)
|
||||
}
|
||||
|
||||
/// Récupère une entrée en utilisant la paire `(collection, id)`.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `collection` - Collection dans laquelle chercher.
|
||||
/// * `id` - Identifiant logique de l'élément.
|
||||
/// * `with_metadata` - Charge les métadonnées associées si `true`.
|
||||
pub fn get_from_id(
|
||||
&self,
|
||||
collection: &str,
|
||||
id: &str,
|
||||
with_metadata: bool,
|
||||
) -> rusqlite::Result<CacheEntry> {
|
||||
let mut entry = {
|
||||
let conn = self.lock_conn("get_from_id");
|
||||
conn.query_row(
|
||||
"SELECT pk, id, collection, hits, last_used \
|
||||
FROM asset \
|
||||
WHERE collection = ?1 AND id = ?2",
|
||||
params![collection, id],
|
||||
|row| {
|
||||
Ok(CacheEntry {
|
||||
pk: row.get(0)?,
|
||||
id: row.get::<_, Option<String>>(1)?,
|
||||
collection: row.get(2)?,
|
||||
hits: row.get(3)?,
|
||||
last_used: row.get(4)?,
|
||||
metadata: None,
|
||||
})
|
||||
},
|
||||
)?
|
||||
};
|
||||
|
||||
if with_metadata {
|
||||
entry.metadata = self.get_metadata(&entry.pk)?;
|
||||
}
|
||||
|
||||
Ok(entry)
|
||||
}
|
||||
|
||||
/// Indique si une collection contient un identifiant donné.
|
||||
///
|
||||
/// Retourne `false` si l'enregistrement n'existe pas ou si la requête échoue.
|
||||
pub fn does_collection_contain_id(&self, collection: &str, id: &str) -> bool {
|
||||
let conn = self.lock_conn("does_collection_contain_id");
|
||||
conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM asset WHERE collection = ?1 AND id = ?2)",
|
||||
params![collection, id],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)
|
||||
.map(|flag| flag != 0)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Retourne la clé primaire associée à la paire `(collection, id)`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Retourne `QueryReturnedNoRows` si aucun enregistrement ne correspond.
|
||||
pub fn get_pk_from_id(&self, collection: &str, id: &str) -> rusqlite::Result<String> {
|
||||
let conn = self.lock_conn("get_pk_from_id");
|
||||
conn.query_row(
|
||||
"SELECT pk FROM asset WHERE collection = ?1 AND id = ?2",
|
||||
params![collection, id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
}
|
||||
|
||||
/// Définit ou remplace l'identifiant logique (`id`) d'une entrée.
|
||||
///
|
||||
/// Retourne `QueryReturnedNoRows` si la clé primaire est inconnue.
|
||||
pub fn set_id(&self, pk: &str, id: &str) -> rusqlite::Result<()> {
|
||||
let conn = self.lock_conn("set_id");
|
||||
let updated = conn.execute("UPDATE asset SET id = ?2 WHERE pk = ?1", params![pk, id])?;
|
||||
|
||||
if updated == 0 {
|
||||
return Err(Error::QueryReturnedNoRows);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Met à jour le compteur d'accès et la date du dernier accès
|
||||
@@ -182,47 +535,59 @@ impl DB {
|
||||
///
|
||||
/// * `pk` - Clé primaire de l'élément
|
||||
pub fn update_hit(&self, pk: &str) -> rusqlite::Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let sql = format!(
|
||||
"UPDATE {} SET hits = hits + 1, last_used = ?1 WHERE pk = ?2",
|
||||
self.table_name
|
||||
);
|
||||
let conn = self.lock_conn("update_hit");
|
||||
|
||||
conn.execute(&sql, params![Utc::now().to_rfc3339(), pk])?;
|
||||
conn.execute(
|
||||
&"UPDATE asset
|
||||
SET hits = hits + 1, last_used = ?1
|
||||
WHERE pk = ?2",
|
||||
params![Utc::now().to_rfc3339(), pk],
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Purge toutes les entrées de la base de données
|
||||
/// Purge toutes les entrées de la base de données.
|
||||
pub fn purge(&self) -> rusqlite::Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let sql = format!("DELETE FROM {}", self.table_name);
|
||||
conn.execute(&sql, [])?;
|
||||
let conn = self.lock_conn("purge");
|
||||
conn.execute("DELETE FROM asset", [])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Récupère toutes les entrées, triées par nombre d'accès décroissant
|
||||
pub fn get_all(&self) -> rusqlite::Result<Vec<CacheEntry>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let sql = format!(
|
||||
"SELECT pk, source_url, collection, hits, last_used, metadata_json FROM {} ORDER BY hits DESC",
|
||||
self.table_name
|
||||
);
|
||||
/// Récupère toutes les entrées, triées par nombre d'accès décroissant.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `include_metadata` - Ajoute les métadonnées à chaque entrée si `true`.
|
||||
pub fn get_all(&self, include_metadata: bool) -> rusqlite::Result<Vec<CacheEntry>> {
|
||||
let mut entries = {
|
||||
let conn = self.lock_conn("get_all");
|
||||
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT pk, id, collection, hits, last_used
|
||||
FROM asset
|
||||
ORDER BY hits DESC",
|
||||
)?;
|
||||
|
||||
let entries = stmt
|
||||
.query_map([], |row| {
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok(CacheEntry {
|
||||
pk: row.get(0)?,
|
||||
source_url: row.get(1)?,
|
||||
id: row.get::<_, Option<String>>(1)?,
|
||||
collection: row.get(2)?,
|
||||
hits: row.get(3)?,
|
||||
last_used: row.get(4)?,
|
||||
metadata_json: row.get(5)?,
|
||||
metadata: None,
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
})?;
|
||||
|
||||
rows.collect::<rusqlite::Result<Vec<_>>>()?
|
||||
};
|
||||
|
||||
if include_metadata {
|
||||
for entry in entries.iter_mut() {
|
||||
entry.metadata = self.get_metadata(&entry.pk)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
@@ -231,53 +596,58 @@ impl DB {
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `collection` - Identifiant de la collection
|
||||
pub fn get_by_collection(&self, collection: &str) -> rusqlite::Result<Vec<CacheEntry>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let sql = format!(
|
||||
"SELECT pk, source_url, collection, hits, last_used, metadata_json FROM {} WHERE collection = ?1 ORDER BY hits DESC",
|
||||
self.table_name
|
||||
);
|
||||
/// * `collection` - Identifiant de la collection.
|
||||
/// * `include_metadata` - Ajoute les métadonnées à chaque entrée si `true`.
|
||||
pub fn get_by_collection(
|
||||
&self,
|
||||
collection: &str,
|
||||
include_metadata: bool,
|
||||
) -> rusqlite::Result<Vec<CacheEntry>> {
|
||||
let mut entries = {
|
||||
let conn = self.lock_conn("get_by_collection");
|
||||
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
|
||||
let entries = stmt
|
||||
.query_map([collection], |row| {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT pk, id, collection, hits, last_used
|
||||
FROM asset
|
||||
WHERE collection = ?1 ORDER BY hits DESC",
|
||||
)?;
|
||||
let rows = stmt.query_map([collection], |row| {
|
||||
Ok(CacheEntry {
|
||||
pk: row.get(0)?,
|
||||
source_url: row.get(1)?,
|
||||
id: row.get::<_, Option<String>>(1)?,
|
||||
collection: row.get(2)?,
|
||||
hits: row.get(3)?,
|
||||
last_used: row.get(4)?,
|
||||
metadata_json: row.get(5)?,
|
||||
metadata: None,
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
})?;
|
||||
|
||||
rows.collect::<rusqlite::Result<Vec<_>>>()?
|
||||
};
|
||||
|
||||
if include_metadata {
|
||||
for entry in entries.iter_mut() {
|
||||
entry.metadata = self.get_metadata(&entry.pk)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// Supprime toutes les entrées d'une collection
|
||||
/// Supprime toutes les entrées d'une collection.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `collection` - Identifiant de la collection à supprimer
|
||||
/// Les métadonnées associées sont supprimées automatiquement grâce à la
|
||||
/// contrainte `ON DELETE CASCADE`.
|
||||
pub fn delete_collection(&self, collection: &str) -> rusqlite::Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let sql = format!("DELETE FROM {} WHERE collection = ?1", self.table_name);
|
||||
conn.execute(&sql, [collection])?;
|
||||
let conn = self.lock_conn("delete_collection");
|
||||
conn.execute("DELETE FROM asset WHERE collection = ?1", [collection])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Supprime une entrée de la base de données
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `pk` - Clé primaire de l'élément à supprimer
|
||||
/// Supprime une entrée de la base de données ainsi que ses métadonnées.
|
||||
pub fn delete(&self, pk: &str) -> rusqlite::Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let sql = format!("DELETE FROM {} WHERE pk = ?1", self.table_name);
|
||||
conn.execute(&sql, [pk])?;
|
||||
let conn = self.lock_conn("delete");
|
||||
conn.execute("DELETE FROM asset WHERE pk = ?1", [pk])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -287,9 +657,8 @@ impl DB {
|
||||
///
|
||||
/// Le nombre total d'entrées
|
||||
pub fn count(&self) -> rusqlite::Result<usize> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let sql = format!("SELECT COUNT(*) FROM {}", self.table_name);
|
||||
let count: i64 = conn.query_row(&sql, [], |row| row.get(0))?;
|
||||
let conn = self.lock_conn("count");
|
||||
let count: i64 = conn.query_row("SELECT COUNT(*) FROM asset", [], |row| row.get(0))?;
|
||||
Ok(count as usize)
|
||||
}
|
||||
|
||||
@@ -306,66 +675,68 @@ impl DB {
|
||||
///
|
||||
/// Liste des entrées les plus anciennes, triées par last_used ASC
|
||||
pub fn get_oldest(&self, limit: usize) -> rusqlite::Result<Vec<CacheEntry>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let sql = format!(
|
||||
let conn = self.lock_conn("get_oldest");
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT pk, source_url, collection, hits, last_used, metadata_json
|
||||
FROM {}
|
||||
FROM asset
|
||||
ORDER BY last_used ASC, hits ASC
|
||||
LIMIT ?1",
|
||||
self.table_name
|
||||
);
|
||||
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
)?;
|
||||
|
||||
let entries = stmt
|
||||
.query_map([limit], |row| {
|
||||
Ok(CacheEntry {
|
||||
pk: row.get(0)?,
|
||||
source_url: row.get(1)?,
|
||||
id: row.get::<_, Option<String>>(1)?,
|
||||
collection: row.get(2)?,
|
||||
hits: row.get(3)?,
|
||||
last_used: row.get(4)?,
|
||||
metadata_json: row.get(5)?,
|
||||
metadata: None,
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère uniquement les métadonnées JSON d'une entrée
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `pk` - Clé primaire de l'élément
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Les métadonnées JSON si présentes, None sinon
|
||||
pub fn get_metadata_json(&self, pk: &str) -> rusqlite::Result<Option<String>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let sql = format!(
|
||||
"SELECT metadata_json FROM {} WHERE pk = ?1",
|
||||
self.table_name
|
||||
);
|
||||
|
||||
conn.query_row(&sql, [pk], |row| row.get(0))
|
||||
/// Convertit une ligne de la table `metadata` en valeur JSON.
|
||||
fn decode_metadata_value(
|
||||
key: &str,
|
||||
value_type: &str,
|
||||
raw: Option<String>,
|
||||
) -> rusqlite::Result<Value> {
|
||||
match value_type {
|
||||
"null" => Ok(Value::Null),
|
||||
"boolean" => {
|
||||
let raw = raw.as_deref().ok_or_else(|| {
|
||||
Error::InvalidParameterName(format!("missing boolean metadata for '{key}'"))
|
||||
})?;
|
||||
raw.parse::<bool>().map(Value::Bool).map_err(|_| {
|
||||
Error::InvalidParameterName(format!("invalid boolean metadata for '{key}'"))
|
||||
})
|
||||
}
|
||||
|
||||
/// Met à jour uniquement les métadonnées JSON d'une entrée existante
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `pk` - Clé primaire de l'élément
|
||||
/// * `metadata_json` - Métadonnées JSON à stocker
|
||||
pub fn update_metadata(&self, pk: &str, metadata_json: &str) -> rusqlite::Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let sql = format!(
|
||||
"UPDATE {} SET metadata_json = ?1 WHERE pk = ?2",
|
||||
self.table_name
|
||||
);
|
||||
|
||||
conn.execute(&sql, params![metadata_json, pk])?;
|
||||
Ok(())
|
||||
"number" => {
|
||||
let raw = raw.as_deref().ok_or_else(|| {
|
||||
Error::InvalidParameterName(format!("missing number metadata for '{key}'"))
|
||||
})?;
|
||||
Number::from_str(raw).map(Value::Number).map_err(|_| {
|
||||
Error::InvalidParameterName(format!("invalid number metadata for '{key}'"))
|
||||
})
|
||||
}
|
||||
"string" => {
|
||||
let raw = raw.unwrap_or_default();
|
||||
let trimmed = raw.trim_start();
|
||||
if trimmed.starts_with('{') || trimmed.starts_with('[') {
|
||||
if let Ok(json) = serde_json::from_str::<Value>(&raw) {
|
||||
return Ok(json);
|
||||
}
|
||||
}
|
||||
Ok(Value::String(raw))
|
||||
}
|
||||
other => Err(Error::InvalidParameterName(format!(
|
||||
"unknown metadata type '{other}' for key '{key}'"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,18 +15,20 @@ use tokio_util::io::ReaderStream;
|
||||
/// La fonction reçoit :
|
||||
/// - Un `CacheInput` abstrait (HTTP ou lecteur en streaming)
|
||||
/// - Un writer pour écrire les données transformées
|
||||
/// - Un callback pour mettre à jour la progression
|
||||
/// - Un contexte fournissant des utilitaires (progression, métadonnées)
|
||||
///
|
||||
/// Elle retourne un `Future` qui se résout en `Result`.
|
||||
pub type StreamTransformer = Box<
|
||||
dyn FnOnce(
|
||||
CacheInput,
|
||||
tokio::fs::File,
|
||||
Arc<dyn Fn(u64) + Send + Sync>,
|
||||
TransformContextHandle,
|
||||
) -> Pin<Box<dyn Future<Output = Result<(), String>> + Send>>
|
||||
+ Send,
|
||||
>;
|
||||
|
||||
pub type TransformContextHandle = Arc<TransformContext>;
|
||||
|
||||
type ByteStream = Pin<Box<dyn Stream<Item = Result<Bytes, String>> + Send>>;
|
||||
|
||||
/// Source générique (HTTP ou lecteur) exposée aux transformers.
|
||||
@@ -180,6 +182,7 @@ struct DownloadState {
|
||||
finished: bool,
|
||||
read_position: u64,
|
||||
error: Option<String>,
|
||||
transform_metadata: Option<TransformMetadata>,
|
||||
}
|
||||
|
||||
/// Objet représentant un téléchargement en cours
|
||||
@@ -200,6 +203,7 @@ impl Download {
|
||||
finished: false,
|
||||
read_position: 0,
|
||||
error: None,
|
||||
transform_metadata: None,
|
||||
})),
|
||||
})
|
||||
}
|
||||
@@ -274,6 +278,45 @@ impl Download {
|
||||
let state = self.state.read().await;
|
||||
state.error.clone()
|
||||
}
|
||||
|
||||
pub async fn transform_metadata(&self) -> Option<TransformMetadata> {
|
||||
let state = self.state.read().await;
|
||||
state.transform_metadata.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct TransformMetadata {
|
||||
pub mode: Option<String>,
|
||||
pub input_codec: Option<String>,
|
||||
pub details: Option<String>,
|
||||
}
|
||||
|
||||
pub struct TransformContext {
|
||||
state: Arc<RwLock<DownloadState>>,
|
||||
progress_cb: Arc<dyn Fn(u64) + Send + Sync>,
|
||||
}
|
||||
|
||||
impl TransformContext {
|
||||
fn new(state: Arc<RwLock<DownloadState>>, progress_cb: Arc<dyn Fn(u64) + Send + Sync>) -> Self {
|
||||
Self { state, progress_cb }
|
||||
}
|
||||
|
||||
/// Reports progress (in bytes) to the download state.
|
||||
pub fn report_progress(&self, bytes: u64) {
|
||||
(self.progress_cb)(bytes);
|
||||
}
|
||||
|
||||
/// Returns the underlying progress callback (useful for piping into other APIs).
|
||||
pub fn progress_callback(&self) -> Arc<dyn Fn(u64) + Send + Sync> {
|
||||
Arc::clone(&self.progress_cb)
|
||||
}
|
||||
|
||||
/// Stores metadata describing the transformation that occurred.
|
||||
pub async fn set_metadata(&self, metadata: TransformMetadata) {
|
||||
let mut state = self.state.write().await;
|
||||
state.transform_metadata = Some(metadata);
|
||||
}
|
||||
}
|
||||
|
||||
/// Lance le téléchargement d'une URL dans un fichier.
|
||||
@@ -346,7 +389,7 @@ async fn download_impl(
|
||||
Ok(resp) => resp,
|
||||
Err(e) => {
|
||||
let mut s = state.write().await;
|
||||
let error = format!("Failed to fetch URL: {}", e);
|
||||
let error = format!("Failed to fetch URL '{}': {}", url, e);
|
||||
s.error = Some(error.clone());
|
||||
s.finished = true;
|
||||
return Err(error);
|
||||
@@ -402,7 +445,9 @@ async fn process_input(
|
||||
});
|
||||
});
|
||||
|
||||
match transformer(input, file, Arc::clone(&progress_callback)).await {
|
||||
let context = Arc::new(TransformContext::new(Arc::clone(&state), progress_callback));
|
||||
|
||||
match transformer(input, file, Arc::clone(&context)).await {
|
||||
Ok(_) => {
|
||||
let mut s = state.write().await;
|
||||
if s.current_size == 0 {
|
||||
@@ -460,3 +505,98 @@ async fn default_copy(
|
||||
s.finished = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Lit les premiers octets d'une URL sans télécharger le fichier complet
|
||||
///
|
||||
/// Cette fonction effectue une requête HTTP partielle (Range header) pour télécharger
|
||||
/// uniquement les premiers octets d'un fichier. C'est utilisé pour calculer l'identifiant
|
||||
/// basé sur le contenu sans avoir à télécharger tout le fichier.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `url` - URL du fichier à télécharger
|
||||
/// * `max_bytes` - Nombre maximum d'octets à lire (par défaut 512)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Un `Vec<u8>` contenant les premiers octets du fichier
|
||||
///
|
||||
/// # Exemple
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// use pmocache::download::peek_header;
|
||||
///
|
||||
/// let header = peek_header("http://example.com/file.dat", 512).await?;
|
||||
/// let pk = pk_from_content_header(&header);
|
||||
/// ```
|
||||
pub async fn peek_header(url: &str, max_bytes: usize) -> Result<Vec<u8>, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Essayer d'abord avec une requête Range
|
||||
let range_header = format!("bytes=0-{}", max_bytes - 1);
|
||||
let mut response = client
|
||||
.get(url)
|
||||
.header("Range", range_header)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch URL '{}': {}", url, e))?;
|
||||
|
||||
// Si le serveur ne supporte pas Range (status 200 au lieu de 206),
|
||||
// on lit quand même mais on limite la lecture
|
||||
if !response.status().is_success() && response.status() != reqwest::StatusCode::PARTIAL_CONTENT
|
||||
{
|
||||
return Err(format!("HTTP error: {}", response.status()));
|
||||
}
|
||||
|
||||
let mut buffer = Vec::new();
|
||||
while let Some(chunk) = response.chunk().await.map_err(|e| e.to_string())? {
|
||||
buffer.extend_from_slice(&chunk);
|
||||
if buffer.len() >= max_bytes {
|
||||
buffer.truncate(max_bytes);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(buffer)
|
||||
}
|
||||
|
||||
/// Lit les premiers octets d'un reader asynchrone
|
||||
///
|
||||
/// Cette fonction lit jusqu'à `max_bytes` octets depuis un reader asynchrone.
|
||||
/// C'est utilisé pour calculer l'identifiant basé sur le contenu des fichiers locaux
|
||||
/// ou des streams.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `reader` - Le reader asynchrone à lire
|
||||
/// * `max_bytes` - Nombre maximum d'octets à lire (par défaut 512)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Un `Vec<u8>` contenant les premiers octets lus
|
||||
///
|
||||
/// # Exemple
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// use pmocache::download::peek_reader_header;
|
||||
/// use tokio::fs::File;
|
||||
///
|
||||
/// let mut file = File::open("file.dat").await?;
|
||||
/// let header = peek_reader_header(&mut file, 512).await?;
|
||||
/// let pk = pk_from_content_header(&header);
|
||||
/// ```
|
||||
pub async fn peek_reader_header<R>(reader: &mut R, max_bytes: usize) -> Result<Vec<u8>, String>
|
||||
where
|
||||
R: AsyncRead + Unpin,
|
||||
{
|
||||
let mut buffer = vec![0u8; max_bytes];
|
||||
let n = reader
|
||||
.read(&mut buffer)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read from stream: {}", e))?;
|
||||
buffer.truncate(n);
|
||||
Ok(buffer)
|
||||
}
|
||||
|
||||
@@ -1,66 +1,48 @@
|
||||
//! # pmocache - Système de cache générique pour PMOMusic
|
||||
//! # pmocache – Système de cache générique pour PMOMusic
|
||||
//!
|
||||
//! Cette crate fournit un système de cache générique avec support de base de données SQLite
|
||||
//! et stockage sur disque. Elle est utilisée comme base pour des caches spécialisés comme
|
||||
//! `pmocovers` (cache d'images) et `pmoaudiocache` (cache de pistes audio).
|
||||
//! Cette crate fournit les briques communes utilisées par les caches de PMOMusic.
|
||||
//! Elle gère l'association entre fichiers stockés sur disque et métadonnées
|
||||
//! conservées dans une base SQLite, ainsi que les opérations de téléchargement,
|
||||
//! d'éviction et de mise à jour.
|
||||
//!
|
||||
//! ## Vue d'ensemble
|
||||
//!
|
||||
//! `pmocache` fournit les composants de base pour :
|
||||
//! - Stocker des fichiers sur disque avec une base de données SQLite pour les métadonnées
|
||||
//! - Gérer des collections d'éléments (albums, playlists, etc.)
|
||||
//! - Suivre les statistiques d'utilisation (hits, dernière utilisation)
|
||||
//! - Télécharger automatiquement depuis des URLs
|
||||
//! - Consolider et purger le cache
|
||||
//! `pmocache` met à disposition :
|
||||
//! - un modèle `Cache` asynchrone pour stocker des fichiers et leurs métadonnées ;
|
||||
//! - un module `db` encapsulant l'accès SQLite (table `asset` + table `metadata`) ;
|
||||
//! - des utilitaires de téléchargement (`download`) réutilisables par les caches spécialisés ;
|
||||
//! - un trait `CacheConfig` permettant de paramétrer l'extension, le nom et le type du cache.
|
||||
//!
|
||||
//! ## Architecture
|
||||
//! Les crates `pmocovers` (images) et `pmoaudiocache` (pistes audio) s'appuient sur ces
|
||||
//! composants et ajoutent leurs propres contraintes métier (conversion WebP, métadonnées audio…).
|
||||
//!
|
||||
//! `pmocache` est conçu comme une base générique :
|
||||
//!
|
||||
//! ```text
|
||||
//! pmocache (générique)
|
||||
//! ├── db.rs - Base de données SQLite générique
|
||||
//! └── cache.rs - Système de cache générique
|
||||
//!
|
||||
//! pmocovers (spécialisé pour les images)
|
||||
//! └── Utilise pmocache + conversion WebP
|
||||
//!
|
||||
//! pmoaudiocache (spécialisé pour l'audio)
|
||||
//! └── Utilise pmocache + métadonnées audio
|
||||
//! ```
|
||||
//!
|
||||
//! ## Utilisation
|
||||
//!
|
||||
//! ### Exemple basique
|
||||
//! ## Exemple basique
|
||||
//!
|
||||
//! ```rust,no_run
|
||||
//! use pmocache::{Cache, CacheConfig};
|
||||
//!
|
||||
//! // Définir la configuration du cache
|
||||
//! struct MyConfig;
|
||||
//! impl CacheConfig for MyConfig {
|
||||
//! fn file_extension() -> &'static str { "dat" }
|
||||
//! fn table_name() -> &'static str { "my_cache" }
|
||||
//! fn cache_type() -> &'static str { "generic" }
|
||||
//! }
|
||||
//!
|
||||
//! #[tokio::main]
|
||||
//! async fn main() -> anyhow::Result<()> {
|
||||
//! let cache = Cache::<MyConfig>::new("./cache", 1000, "http://localhost:8080")?;
|
||||
//! let cache = Cache::<MyConfig>::new("./cache", 1000)?;
|
||||
//!
|
||||
//! // Ajouter un fichier depuis une URL
|
||||
//! let pk = cache.add_from_url("http://example.com/file.dat", None).await?;
|
||||
//! println!("Fichier ajouté avec clé: {}", pk);
|
||||
//! // Ajout d'un fichier depuis une URL
|
||||
//! let pk = cache.add_from_url("https://example.com/file.dat", None).await?;
|
||||
//! println!("Fichier ajouté avec la clé {pk}");
|
||||
//!
|
||||
//! // Récupérer le fichier
|
||||
//! // Récupération du fichier local
|
||||
//! let path = cache.get(&pk).await?;
|
||||
//! println!("Fichier stocké à: {:?}", path);
|
||||
//! println!("Fichier disponible à {path:?}");
|
||||
//!
|
||||
//! Ok(())
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! ### Utilisation avec des collections
|
||||
//! ## Collections
|
||||
//!
|
||||
//! ```rust,no_run
|
||||
//! use pmocache::{Cache, CacheConfig};
|
||||
@@ -68,64 +50,72 @@
|
||||
//! struct AudioConfig;
|
||||
//! impl CacheConfig for AudioConfig {
|
||||
//! fn file_extension() -> &'static str { "flac" }
|
||||
//! fn table_name() -> &'static str { "audio" }
|
||||
//! fn cache_type() -> &'static str { "audio" }
|
||||
//! fn cache_name() -> &'static str { "tracks" }
|
||||
//! }
|
||||
//!
|
||||
//! #[tokio::main]
|
||||
//! async fn main() -> anyhow::Result<()> {
|
||||
//! let cache = Cache::<AudioConfig>::new("./cache", 1000, "http://localhost:8080")?;
|
||||
//! let cache = Cache::<AudioConfig>::new("./audio-cache", 200)?;
|
||||
//!
|
||||
//! // Ajouter des pistes d'un album
|
||||
//! let album_id = "album:the_wall";
|
||||
//! cache.add_from_url("http://example.com/track1.flac", Some(album_id)).await?;
|
||||
//! cache.add_from_url("http://example.com/track2.flac", Some(album_id)).await?;
|
||||
//! let album = "album:the_wall";
|
||||
//! cache.add_from_url("https://example.com/track1.flac", Some(album)).await?;
|
||||
//! cache.add_from_url("https://example.com/track2.flac", Some(album)).await?;
|
||||
//!
|
||||
//! // Récupérer toutes les pistes de l'album
|
||||
//! let tracks = cache.get_collection(album_id).await?;
|
||||
//! println!("Album contient {} pistes", tracks.len());
|
||||
//! let files = cache.get_collection(album).await?;
|
||||
//! println!("Album {album} : {} fichiers en cache", files.len());
|
||||
//!
|
||||
//! Ok(())
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! ## Structure des fichiers
|
||||
//! ## Structure sur disque
|
||||
//!
|
||||
//! ```text
|
||||
//! cache/
|
||||
//! ├── cache.db # Base de données SQLite
|
||||
//! ├── 1a2b3c4d.webp # Fichier 1
|
||||
//! └── 5e6f7a8b.flac # Fichier 2
|
||||
//! ├── cache.db # Base SQLite
|
||||
//! ├── 1a2b3c4d.orig.dat # Fichier original
|
||||
//! └── 1a2b3c4d.thumb.dat # Variante (qualifier différent)
|
||||
//! ```
|
||||
//!
|
||||
//! ## Schéma de base de données
|
||||
//! Les métadonnées sont conservées dans deux tables :
|
||||
//!
|
||||
//! ```sql
|
||||
//! CREATE TABLE {table_name} (
|
||||
//! pk TEXT PRIMARY KEY, -- Clé unique (hash SHA1 de l'URL)
|
||||
//! source_url TEXT, -- URL source
|
||||
//! collection TEXT, -- Collection (album, playlist, etc.)
|
||||
//! hits INTEGER DEFAULT 0, -- Nombre d'accès
|
||||
//! last_used TEXT -- Dernière utilisation (RFC3339)
|
||||
//! CREATE TABLE asset (
|
||||
//! pk TEXT PRIMARY KEY,
|
||||
//! collection TEXT,
|
||||
//! id TEXT,
|
||||
//! hits INTEGER DEFAULT 0,
|
||||
//! last_used TEXT
|
||||
//! );
|
||||
//!
|
||||
//! CREATE TABLE metadata (
|
||||
//! pk TEXT,
|
||||
//! key TEXT,
|
||||
//! value_type TEXT CHECK(value_type IN ('string','number','boolean','null')),
|
||||
//! value TEXT,
|
||||
//! PRIMARY KEY (pk, key),
|
||||
//! FOREIGN KEY (pk) REFERENCES asset(pk) ON DELETE CASCADE
|
||||
//! );
|
||||
//! ```
|
||||
//!
|
||||
//! ## Dépendances principales
|
||||
//! ## Modules principaux
|
||||
//!
|
||||
//! - `rusqlite` : Base de données SQLite
|
||||
//! - `reqwest` : Téléchargement HTTP
|
||||
//! - `sha1` : Génération de clés
|
||||
//! - `tokio` : Runtime asynchrone
|
||||
//! - [`cache`] : gestion du cache sur disque + opérations asynchrones ;
|
||||
//! - [`db`] : accès SQLite, contraintes et helpers métadonnées ;
|
||||
//! - [`download`] : primitives de téléchargement et de transformation ;
|
||||
//! - [`cache_trait`] : trait partagé entre implémentations spécialisées.
|
||||
//!
|
||||
//! ## Voir aussi
|
||||
//! ## Crates associées
|
||||
//!
|
||||
//! - [`pmocovers`] : Cache d'images avec conversion WebP
|
||||
//! - [`pmoaudiocache`] : Cache de pistes audio
|
||||
//! - [`pmocovers`] : cache d'images reposant sur `pmocache` ;
|
||||
//! - [`pmoaudiocache`] : spécialisation audio avec extraction de métadonnées.
|
||||
|
||||
pub mod cache;
|
||||
pub mod cache_trait;
|
||||
pub mod db;
|
||||
pub mod download;
|
||||
pub mod metadata_macros;
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
pub mod pmoserver_ext;
|
||||
@@ -136,11 +126,15 @@ pub mod api;
|
||||
#[cfg(feature = "openapi")]
|
||||
pub mod openapi;
|
||||
|
||||
#[cfg(feature = "pmoconfig")]
|
||||
pub mod config_ext;
|
||||
|
||||
pub use cache::{Cache, CacheConfig};
|
||||
pub use cache_trait::{pk_from_url, FileCache};
|
||||
pub use cache_trait::{pk_from_content_header, FileCache};
|
||||
pub use db::{CacheEntry, DB};
|
||||
pub use download::{
|
||||
download, download_with_transformer, ingest_with_transformer, Download, StreamTransformer,
|
||||
download, download_with_transformer, ingest_with_transformer, peek_header, peek_reader_header,
|
||||
Download, StreamTransformer, TransformContextHandle, TransformMetadata,
|
||||
};
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
@@ -148,3 +142,6 @@ pub use pmoserver_ext::{create_api_router, create_file_router, GenericCacheExt};
|
||||
|
||||
#[cfg(all(feature = "pmoserver", feature = "openapi"))]
|
||||
pub use api::{AddItemRequest, AddItemResponse, DeleteItemResponse, DownloadStatus, ErrorResponse};
|
||||
|
||||
#[cfg(feature = "pmoconfig")]
|
||||
pub use config_ext::CacheConfigExt;
|
||||
|
||||
234
pmocache/src/metadata_macros.rs
Normal file
234
pmocache/src/metadata_macros.rs
Normal file
@@ -0,0 +1,234 @@
|
||||
//! Macros pour générer des extension traits typés sur les métadonnées
|
||||
//!
|
||||
//! La macro `define_metadata_properties!` génère automatiquement :
|
||||
//! - Un trait avec des méthodes `get_XXX()` et `set_XXX()` pour chaque métadonnée
|
||||
//! - L'implémentation complète pour `Cache<Config>`
|
||||
//! - Les conversions JSON ↔ Rust selon le type
|
||||
//!
|
||||
//! # Types supportés
|
||||
//!
|
||||
//! - `String` : Métadonnée texte (JSON String)
|
||||
//! - `i64` : Métadonnée numérique entière signée (JSON Number)
|
||||
//! - `f64` : Métadonnée numérique décimale (JSON Number)
|
||||
//! - `bool` : Métadonnée booléenne (JSON Boolean)
|
||||
//! - `Value` : Métadonnée JSON brute (Array, Object, ou tout type JSON)
|
||||
//!
|
||||
//! # Mécanisme de stockage
|
||||
//!
|
||||
//! - Types simples (String, Number, Boolean) : stockés directement
|
||||
//! - Types complexes (Array, Object) : sérialisés en string JSON, parsés automatiquement à la lecture
|
||||
//! - La conversion est transparente grâce à `decode_metadata_value`
|
||||
//!
|
||||
//! # Exemple
|
||||
//!
|
||||
//! ```ignore
|
||||
//! use pmocache::define_metadata_properties;
|
||||
//!
|
||||
//! struct AudioConfig;
|
||||
//! impl CacheConfig for AudioConfig { ... }
|
||||
//!
|
||||
//! define_metadata_properties! {
|
||||
//! AudioMetadataExt for pmocache::Cache<AudioConfig> {
|
||||
//! title: String as string,
|
||||
//! duration_secs: i64 as i64,
|
||||
//! custom_tags: serde_json::Value as value, // Pour JSON complexe
|
||||
//! }
|
||||
//! }
|
||||
//!
|
||||
//! // Utilisation
|
||||
//! use AudioMetadataExt;
|
||||
//! let title = cache.get_title("pk123").await?;
|
||||
//! cache.set_title("pk123", "New Title".into()).await?;
|
||||
//!
|
||||
//! // JSON complexe
|
||||
//! let tags = json!({"mood": "happy", "bpm": 120});
|
||||
//! cache.set_custom_tags("pk123", tags).await?;
|
||||
//! ```
|
||||
|
||||
/// Génère un extension trait pour accéder aux métadonnées de manière typée
|
||||
///
|
||||
/// Cette macro génère un trait complet avec toutes les méthodes get/set
|
||||
/// et son implémentation pour le type de cache spécifié.
|
||||
///
|
||||
/// # Syntaxe
|
||||
///
|
||||
/// ```ignore
|
||||
/// define_metadata_properties! {
|
||||
/// TraitName for CacheType {
|
||||
/// field_name: RustType as type_kind,
|
||||
/// field_name2: RustType as type_kind,
|
||||
/// ...
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// Type kinds available: `string`, `i64`, `f64`, `bool`, `value`
|
||||
///
|
||||
/// Pour chaque champ, génère :
|
||||
/// - `async fn get_FIELD(&self, pk: &str) -> Result<Option<TYPE>>`
|
||||
/// - `async fn set_FIELD(&self, pk: &str, value: TYPE) -> Result<()>`
|
||||
///
|
||||
/// La clé JSON utilisée est le nom du champ (ex: `title` → clé `"title"`).
|
||||
#[macro_export]
|
||||
macro_rules! define_metadata_properties {
|
||||
(
|
||||
$trait_name:ident for $cache_type:ty {
|
||||
$(
|
||||
$field:ident: $rust_type:ty as $type_kind:ident
|
||||
),* $(,)?
|
||||
}
|
||||
) => {
|
||||
// Définition du trait
|
||||
pub trait $trait_name {
|
||||
$(
|
||||
// Génère get_FIELD
|
||||
paste::paste! {
|
||||
async fn [<get_ $field>](&self, pk: &str) -> anyhow::Result<Option<$rust_type>>;
|
||||
}
|
||||
|
||||
// Génère set_FIELD
|
||||
paste::paste! {
|
||||
async fn [<set_ $field>](&self, pk: &str, value: $rust_type) -> anyhow::Result<()>;
|
||||
}
|
||||
)*
|
||||
}
|
||||
|
||||
// Implémentation du trait
|
||||
impl $trait_name for $cache_type {
|
||||
$(
|
||||
// Implémentation de get_FIELD selon le type
|
||||
$crate::__impl_getter!($field, $rust_type, $type_kind);
|
||||
|
||||
// Implémentation de set_FIELD selon le type
|
||||
$crate::__impl_setter!($field, $rust_type, $type_kind);
|
||||
)*
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Macros internes pour générer les getters selon le type
|
||||
// ============================================================================
|
||||
|
||||
#[doc(hidden)]
|
||||
#[macro_export]
|
||||
macro_rules! __impl_getter {
|
||||
// String - utilise get_a_metadata_as_string
|
||||
($field:ident, $rust_type:ty, string) => {
|
||||
paste::paste! {
|
||||
async fn [<get_ $field>](&self, pk: &str) -> anyhow::Result<Option<$rust_type>> {
|
||||
self.get_a_metadata_as_string(pk, stringify!($field)).await
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// i64 - utilise get_a_metadata_as_number puis as_i64()
|
||||
($field:ident, $rust_type:ty, i64) => {
|
||||
paste::paste! {
|
||||
async fn [<get_ $field>](&self, pk: &str) -> anyhow::Result<Option<$rust_type>> {
|
||||
match self.get_a_metadata_as_number(pk, stringify!($field)).await? {
|
||||
Some(n) => Ok(n.as_i64()),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// f64 - utilise get_a_metadata_as_number puis as_f64()
|
||||
($field:ident, $rust_type:ty, f64) => {
|
||||
paste::paste! {
|
||||
async fn [<get_ $field>](&self, pk: &str) -> anyhow::Result<Option<$rust_type>> {
|
||||
match self.get_a_metadata_as_number(pk, stringify!($field)).await? {
|
||||
Some(n) => Ok(n.as_f64()),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// bool - utilise get_a_metadata_as_bool
|
||||
($field:ident, $rust_type:ty, bool) => {
|
||||
paste::paste! {
|
||||
async fn [<get_ $field>](&self, pk: &str) -> anyhow::Result<Option<$rust_type>> {
|
||||
self.get_a_metadata_as_bool(pk, stringify!($field)).await
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Value - utilise get_a_metadata directement (retourne JSON brut)
|
||||
($field:ident, $rust_type:ty, value) => {
|
||||
paste::paste! {
|
||||
async fn [<get_ $field>](&self, pk: &str) -> anyhow::Result<Option<$rust_type>> {
|
||||
self.get_a_metadata(pk, stringify!($field)).await
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Macros internes pour générer les setters selon le type
|
||||
// ============================================================================
|
||||
|
||||
#[doc(hidden)]
|
||||
#[macro_export]
|
||||
macro_rules! __impl_setter {
|
||||
// String - stocke comme Value::String
|
||||
($field:ident, $rust_type:ty, string) => {
|
||||
paste::paste! {
|
||||
async fn [<set_ $field>](&self, pk: &str, value: $rust_type) -> anyhow::Result<()> {
|
||||
use serde_json::Value;
|
||||
self.db.set_a_metadata(pk, stringify!($field), Value::String(value))
|
||||
.map_err(|e| anyhow::anyhow!("DB error: {}", e))
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// i64 - stocke comme Value::Number
|
||||
($field:ident, $rust_type:ty, i64) => {
|
||||
paste::paste! {
|
||||
async fn [<set_ $field>](&self, pk: &str, value: $rust_type) -> anyhow::Result<()> {
|
||||
use serde_json::{Value, Number};
|
||||
self.db.set_a_metadata(
|
||||
pk,
|
||||
stringify!($field),
|
||||
Value::Number(Number::from(value))
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("DB error: {}", e))
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// f64 - stocke comme Value::Number (avec validation)
|
||||
($field:ident, $rust_type:ty, f64) => {
|
||||
paste::paste! {
|
||||
async fn [<set_ $field>](&self, pk: &str, value: $rust_type) -> anyhow::Result<()> {
|
||||
use serde_json::{Value, Number};
|
||||
let number = Number::from_f64(value)
|
||||
.ok_or_else(|| anyhow::anyhow!("Invalid f64 value: {}", value))?;
|
||||
self.db.set_a_metadata(pk, stringify!($field), Value::Number(number))
|
||||
.map_err(|e| anyhow::anyhow!("DB error: {}", e))
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// bool - stocke comme Value::Bool
|
||||
($field:ident, $rust_type:ty, bool) => {
|
||||
paste::paste! {
|
||||
async fn [<set_ $field>](&self, pk: &str, value: $rust_type) -> anyhow::Result<()> {
|
||||
use serde_json::Value;
|
||||
self.db.set_a_metadata(pk, stringify!($field), Value::Bool(value))
|
||||
.map_err(|e| anyhow::anyhow!("DB error: {}", e))
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Value - stocke directement (Array/Object sont sérialisés automatiquement)
|
||||
($field:ident, $rust_type:ty, value) => {
|
||||
paste::paste! {
|
||||
async fn [<set_ $field>](&self, pk: &str, value: $rust_type) -> anyhow::Result<()> {
|
||||
self.db.set_a_metadata(pk, stringify!($field), value)
|
||||
.map_err(|e| anyhow::anyhow!("DB error: {}", e))
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -123,7 +123,7 @@ async fn serve_file_with_streaming<C: CacheConfig>(
|
||||
content_type: &'static str,
|
||||
param_generator: Option<ParamGenerator<C>>,
|
||||
) -> Response {
|
||||
let file_path = cache.file_path_with_qualifier(pk, param);
|
||||
let file_path = cache.get_file_path_with_qualifier(pk, param);
|
||||
|
||||
// Si le fichier n'existe pas et qu'on a un générateur, l'utiliser
|
||||
if !file_path.exists() {
|
||||
|
||||
@@ -9,9 +9,21 @@ pmoutils ={ path = "../pmoutils" }
|
||||
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_yaml = "0.9.33"
|
||||
serde_json = "1.0"
|
||||
lazy_static = "1.4.0"
|
||||
dirs = "6.0.0"
|
||||
log = "0.4.20"
|
||||
anyhow = "1.0.75"
|
||||
uuid = { version = "1.18.1", features = ["v4"] }
|
||||
tracing = "0.1.41"
|
||||
|
||||
# Async
|
||||
tokio = { version = "1.0", features = ["full"], optional = true }
|
||||
|
||||
# Serveur HTTP (pour l'API REST)
|
||||
axum = { version = "0.8", optional = true }
|
||||
utoipa = { version = "5.3", features = ["axum_extras"], optional = true }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
api = ["dep:tokio", "dep:axum", "dep:utoipa"]
|
||||
152
pmoconfig/src/api.rs
Normal file
152
pmoconfig/src/api.rs
Normal file
@@ -0,0 +1,152 @@
|
||||
use crate::Config;
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value as JsonValue;
|
||||
use serde_yaml::Value;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Structure pour récupérer une valeur de configuration
|
||||
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct ConfigValue {
|
||||
/// Chemin de la clé (ex: "host.http_port")
|
||||
pub path: String,
|
||||
/// Valeur au format JSON
|
||||
pub value: JsonValue,
|
||||
}
|
||||
|
||||
/// Structure pour mettre à jour une valeur de configuration
|
||||
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct UpdateConfigRequest {
|
||||
/// Chemin de la clé (ex: "host.http_port")
|
||||
pub path: String,
|
||||
/// Nouvelle valeur au format JSON
|
||||
pub value: JsonValue,
|
||||
}
|
||||
|
||||
/// Structure pour la réponse d'une mise à jour
|
||||
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct UpdateConfigResponse {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Erreur API
|
||||
#[derive(Debug)]
|
||||
pub struct ApiError(anyhow::Error);
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": self.0.to_string()
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
impl<E> From<E> for ApiError
|
||||
where
|
||||
E: Into<anyhow::Error>,
|
||||
{
|
||||
fn from(err: E) -> Self {
|
||||
Self(err.into())
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/config - Récupérer toute la configuration
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/config",
|
||||
tag = "config",
|
||||
responses(
|
||||
(status = 200, description = "Configuration complète", body = serde_json::Value)
|
||||
)
|
||||
)]
|
||||
async fn get_full_config(State(config): State<Arc<Config>>) -> Result<Json<JsonValue>, ApiError> {
|
||||
let value = config.get_value(&[])?;
|
||||
let json_value = yaml_to_json(&value)?;
|
||||
Ok(Json(json_value))
|
||||
}
|
||||
|
||||
/// GET /api/config/{path} - Récupérer une valeur à un chemin spécifique
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/config/{path}",
|
||||
tag = "config",
|
||||
params(
|
||||
("path" = String, Path, description = "Chemin de la configuration (séparé par des points, ex: host.http_port)")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Valeur de configuration", body = ConfigValue),
|
||||
(status = 404, description = "Chemin non trouvé")
|
||||
)
|
||||
)]
|
||||
async fn get_config_value(
|
||||
State(config): State<Arc<Config>>,
|
||||
Path(path): Path<String>,
|
||||
) -> Result<Json<ConfigValue>, ApiError> {
|
||||
let path_parts: Vec<&str> = path.split('.').collect();
|
||||
let value = config.get_value(&path_parts)?;
|
||||
let json_value = yaml_to_json(&value)?;
|
||||
|
||||
Ok(Json(ConfigValue {
|
||||
path,
|
||||
value: json_value,
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /api/config - Mettre à jour une valeur de configuration
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/config",
|
||||
tag = "config",
|
||||
request_body = UpdateConfigRequest,
|
||||
responses(
|
||||
(status = 200, description = "Configuration mise à jour", body = UpdateConfigResponse)
|
||||
)
|
||||
)]
|
||||
async fn update_config_value(
|
||||
State(config): State<Arc<Config>>,
|
||||
Json(request): Json<UpdateConfigRequest>,
|
||||
) -> Result<Json<UpdateConfigResponse>, ApiError> {
|
||||
let path_parts: Vec<&str> = request.path.split('.').collect();
|
||||
let yaml_value = json_to_yaml(&request.value)?;
|
||||
|
||||
config.set_value(&path_parts, yaml_value)?;
|
||||
|
||||
Ok(Json(UpdateConfigResponse {
|
||||
success: true,
|
||||
message: format!("Configuration updated at path: {}", request.path),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Convertit une valeur YAML en JSON
|
||||
fn yaml_to_json(yaml: &Value) -> Result<JsonValue, ApiError> {
|
||||
// Serialize YAML to string then parse as JSON
|
||||
let yaml_str = serde_yaml::to_string(yaml)?;
|
||||
Ok(serde_json::from_str(&yaml_str)?)
|
||||
}
|
||||
|
||||
/// Convertit une valeur JSON en YAML
|
||||
fn json_to_yaml(json: &JsonValue) -> Result<Value, ApiError> {
|
||||
// Serialize JSON to string then parse as YAML
|
||||
let json_str = serde_json::to_string(json)?;
|
||||
Ok(serde_yaml::from_str(&json_str)?)
|
||||
}
|
||||
|
||||
/// Crée le router API pour la configuration
|
||||
pub fn create_router(config: Arc<Config>) -> Router {
|
||||
Router::new()
|
||||
.route("/api/config", get(get_full_config))
|
||||
.route("/api/config", post(update_config_value))
|
||||
.route("/api/config/:path", get(get_config_value))
|
||||
.with_state(config)
|
||||
}
|
||||
@@ -1,3 +1,29 @@
|
||||
//! # PMOMusic Configuration Module
|
||||
//!
|
||||
//! This module provides configuration management for PMOMusic, including:
|
||||
//! - Loading configuration from YAML files
|
||||
//! - Merging with embedded default configuration
|
||||
//! - Environment variable overrides
|
||||
//! - Type-safe getters and setters for configuration values
|
||||
//! - Thread-safe singleton access pattern
|
||||
//!
|
||||
//! ## Usage
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use pmoconfig::get_config;
|
||||
//!
|
||||
//! // Get the global configuration
|
||||
//! let config = get_config();
|
||||
//!
|
||||
//! // Access configuration values
|
||||
//! let port = config.get_http_port();
|
||||
//! let cache_dir = config.get_cover_cache_dir()?;
|
||||
//!
|
||||
//! // Update configuration values
|
||||
//! config.set_http_port(9000)?;
|
||||
//! # Ok::<(), anyhow::Error>(())
|
||||
//! ```
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use dirs::home_dir;
|
||||
use lazy_static::lazy_static;
|
||||
@@ -5,12 +31,21 @@ use pmoutils::guess_local_ip;
|
||||
use serde_yaml::{Mapping, Number, Value};
|
||||
use std::{
|
||||
env, fs,
|
||||
path::{Path, PathBuf},
|
||||
path::Path,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
use tracing::{info, warn};
|
||||
use tracing::info;
|
||||
use uuid::Uuid;
|
||||
|
||||
// Modules conditionnels pour l'API REST
|
||||
#[cfg(feature = "api")]
|
||||
pub mod api;
|
||||
#[cfg(feature = "api")]
|
||||
pub mod openapi;
|
||||
|
||||
#[cfg(feature = "api")]
|
||||
pub use openapi::ApiDoc;
|
||||
|
||||
// Configuration par défaut intégrée
|
||||
const DEFAULT_CONFIG: &str = include_str!("pmomusic.yaml");
|
||||
|
||||
@@ -19,11 +54,87 @@ lazy_static! {
|
||||
Arc::new(Config::load_config("").expect("Failed to load PMOMusic configuration"));
|
||||
}
|
||||
|
||||
const ENV_CONFIG_FILE: &str = "PMOMUSIC_CONFIG";
|
||||
const ENV_CONFIG_DIR: &str = "PMOMUSIC_CONFIG";
|
||||
const ENV_PREFIX: &str = "PMOMUSIC_CONFIG__";
|
||||
|
||||
// Default values for configuration
|
||||
const DEFAULT_HTTP_PORT: u16 = 8080;
|
||||
const DEFAULT_LOG_BUFFER_CAPACITY: usize = 1000;
|
||||
const DEFAULT_LOG_MIN_LEVEL: &str = "TRACE";
|
||||
const DEFAULT_LOG_ENABLE_CONSOLE: bool = true;
|
||||
|
||||
/// Macro to generate getter/setter for String values
|
||||
macro_rules! impl_string_config {
|
||||
($(#[$meta:meta])* $getter:ident, $setter:ident, $path:expr, $default:expr) => {
|
||||
$(#[$meta])*
|
||||
pub fn $getter(&self) -> Result<String> {
|
||||
match self.get_value($path)? {
|
||||
Value::String(s) => Ok(s),
|
||||
_ => Err(anyhow!(concat!(stringify!($getter), " not configured"))),
|
||||
}
|
||||
}
|
||||
|
||||
$(#[$meta])*
|
||||
pub fn $setter(&self, value: &str) -> Result<()> {
|
||||
self.set_value($path, Value::String(value.to_string()))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Macro to generate getter/setter for usize values with default
|
||||
macro_rules! impl_usize_config {
|
||||
($getter:ident, $setter:ident, $path:expr, $default:expr) => {
|
||||
pub fn $getter(&self) -> Result<usize> {
|
||||
match self.get_value($path)? {
|
||||
Value::Number(n) if n.is_i64() => Ok(n.as_i64().unwrap() as usize),
|
||||
Value::Number(n) if n.is_u64() => Ok(n.as_u64().unwrap() as usize),
|
||||
_ => Ok($default),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn $setter(&self, size: usize) -> Result<()> {
|
||||
let n = Number::from(size);
|
||||
self.set_value($path, Value::Number(n))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Macro to generate getter/setter for bool values with default
|
||||
macro_rules! impl_bool_config {
|
||||
($getter:ident, $setter:ident, $path:expr, $default:expr) => {
|
||||
pub fn $getter(&self) -> Result<bool> {
|
||||
match self.get_value($path)? {
|
||||
Value::Bool(b) => Ok(b),
|
||||
_ => Ok($default),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn $setter(&self, value: bool) -> Result<()> {
|
||||
self.set_value($path, Value::Bool(value))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Configuration manager for PMOMusic
|
||||
///
|
||||
/// This structure manages the application configuration, including:
|
||||
/// - Loading configuration from YAML files
|
||||
/// - Merging with default configuration
|
||||
/// - Handling environment variable overrides
|
||||
/// - Providing typed getters/setters for configuration values
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// use pmoconfig::get_config;
|
||||
///
|
||||
/// let config = get_config();
|
||||
/// let port = config.get_http_port();
|
||||
/// println!("HTTP port: {}", port);
|
||||
/// ```
|
||||
#[derive(Debug)]
|
||||
pub struct Config {
|
||||
config_dir: String,
|
||||
path: String,
|
||||
data: Mutex<Value>,
|
||||
}
|
||||
@@ -33,6 +144,7 @@ impl Clone for Config {
|
||||
fn clone(&self) -> Self {
|
||||
let data = self.data.lock().unwrap().clone();
|
||||
Self {
|
||||
config_dir: self.config_dir.clone(),
|
||||
path: self.path.clone(),
|
||||
data: Mutex::new(data),
|
||||
}
|
||||
@@ -40,100 +152,144 @@ impl Clone for Config {
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load_config(filename: &str) -> Result<Self> {
|
||||
let mut path = filename.to_string();
|
||||
let mut data: Option<Vec<u8>> = None;
|
||||
/// Finds a config directory by trying different locations in order
|
||||
fn find_config_dir(directory: &str) -> String {
|
||||
// 1. Try provided directory
|
||||
if !directory.is_empty() {
|
||||
return directory.to_string();
|
||||
}
|
||||
|
||||
// 2. Try environment variable
|
||||
if let Ok(env_path) = env::var(ENV_CONFIG_DIR) {
|
||||
info!(env_var=ENV_CONFIG_DIR, path=%env_path, "Trying to load config from env");
|
||||
return env_path;
|
||||
}
|
||||
|
||||
// 3. Try current directory
|
||||
if Path::new(".pmomusic").exists() {
|
||||
return ".pmomusic".to_string();
|
||||
}
|
||||
|
||||
// 4. Try home directory
|
||||
if let Some(home) = home_dir() {
|
||||
let home_config = home.join(".pmomusic");
|
||||
if home_config.exists() {
|
||||
return home_config.to_string_lossy().to_string();
|
||||
}
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
".pmomusic".to_string()
|
||||
}
|
||||
|
||||
/// Validates and prepares a config directory
|
||||
fn validate_config_dir(path: &Path) -> Result<()> {
|
||||
// Create if doesn't exist
|
||||
if !path.exists() {
|
||||
fs::create_dir_all(path)?;
|
||||
}
|
||||
|
||||
// Verify it's a directory
|
||||
if !path.is_dir() {
|
||||
return Err(anyhow!("Le chemin spécifié n'est pas un répertoire"));
|
||||
}
|
||||
|
||||
// Test write permission
|
||||
let test_file = path.join(".write_test");
|
||||
fs::write(&test_file, b"test")?;
|
||||
fs::remove_file(&test_file)?;
|
||||
|
||||
// Test read permission
|
||||
fs::read_dir(path)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Determines and validates the configuration directory
|
||||
///
|
||||
/// The directory is searched in the following order:
|
||||
/// 1. The provided `directory` parameter if not empty
|
||||
/// 2. The `PMOMUSIC_CONFIG` environment variable
|
||||
/// 3. `.pmomusic` in the current directory
|
||||
/// 4. `.pmomusic` in the user's home directory
|
||||
///
|
||||
/// The directory is created if it doesn't exist, and validated for read/write permissions.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the directory cannot be created or validated
|
||||
pub fn config_dir(directory: &str) -> String {
|
||||
let dir_path = Self::find_config_dir(directory);
|
||||
let path = Path::new(&dir_path);
|
||||
|
||||
Self::validate_config_dir(path)
|
||||
.expect("Impossible de valider le répertoire de configuration");
|
||||
|
||||
dir_path
|
||||
}
|
||||
|
||||
/// Loads the configuration from the specified directory
|
||||
///
|
||||
/// This method:
|
||||
/// 1. Determines the configuration directory
|
||||
/// 2. Loads the default embedded configuration
|
||||
/// 3. Merges it with the external config.yaml file if present
|
||||
/// 4. Applies environment variable overrides
|
||||
/// 5. Saves the merged configuration
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `directory` - The directory containing the config.yaml file, or empty to use defaults
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns a `Result` containing the loaded `Config` or an error
|
||||
pub fn load_config(directory: &str) -> Result<Self> {
|
||||
// Obtenir le répertoire de configuration
|
||||
let config_dir = Self::config_dir(directory);
|
||||
info!(config_dir=%config_dir, "Using config directory");
|
||||
|
||||
// Construire le chemin du fichier config.yaml
|
||||
let config_file_path = Path::new(&config_dir).join("config.yaml");
|
||||
let path = config_file_path.to_string_lossy().to_string();
|
||||
|
||||
// Charger la configuration par défaut
|
||||
let mut default_value: Value = serde_yaml::from_str(DEFAULT_CONFIG)?;
|
||||
|
||||
// Essayer de charger depuis différents emplacements
|
||||
if !filename.is_empty() {
|
||||
info!(config_file=%path, "Trying to load config");
|
||||
data = fs::read(&path).ok();
|
||||
if data.is_none() {
|
||||
warn!(config_file=%path, "Cannot read config file");
|
||||
path.clear();
|
||||
}
|
||||
}
|
||||
|
||||
if path.is_empty() {
|
||||
if let Ok(env_path) = env::var(ENV_CONFIG_FILE) {
|
||||
info!(env_var=ENV_CONFIG_FILE, path=%env_path, "Trying to load config from env");
|
||||
path = env_path.clone();
|
||||
data = fs::read(&path).ok();
|
||||
if data.is_none() {
|
||||
warn!(config_file=%path, "Cannot read config file from env var");
|
||||
path.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if path.is_empty() {
|
||||
let current_dir = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
||||
path = current_dir
|
||||
.join(".pmomusic.yml")
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
info!(config_file=%path, "Trying to load config file from current directory");
|
||||
data = fs::read(&path).ok();
|
||||
if data.is_none() {
|
||||
warn!(config_file=%path, "Cannot read config file in current dir");
|
||||
path.clear();
|
||||
}
|
||||
}
|
||||
|
||||
if path.is_empty() {
|
||||
path = Self::get_home_yml_path();
|
||||
info!(config_file=%path, "Trying to load config file from home directory");
|
||||
data = fs::read(&path).ok();
|
||||
if data.is_none() {
|
||||
warn!(config_file=%path, "Cannot read config file in home directory");
|
||||
path.clear();
|
||||
}
|
||||
}
|
||||
|
||||
let yaml_data = if let Some(d) = data {
|
||||
d
|
||||
// Essayer de charger le fichier de configuration
|
||||
let yaml_data = if let Ok(data) = fs::read(&path) {
|
||||
info!(config_file=%path, "Loaded config file");
|
||||
data
|
||||
} else {
|
||||
info!("Using default embedded config");
|
||||
info!(config_file=%path, "Config file not found, using default embedded config");
|
||||
DEFAULT_CONFIG.as_bytes().to_vec()
|
||||
};
|
||||
|
||||
// Merger avec la config par défaut
|
||||
let external_value: Value = serde_yaml::from_slice(&yaml_data)?;
|
||||
merge_yaml(&mut default_value, &external_value);
|
||||
let mut config_value = Self::lower_keys_value(default_value);
|
||||
|
||||
// Appliquer les overrides depuis les variables d'environnement
|
||||
Self::apply_env_overrides(&mut config_value);
|
||||
|
||||
if path.is_empty() || !Self::is_writable(&path) {
|
||||
let candidates = [
|
||||
filename.to_string(),
|
||||
env::var(ENV_CONFIG_FILE).unwrap_or_default(),
|
||||
".pmomusic.yml".to_string(),
|
||||
Self::get_home_yml_path(),
|
||||
];
|
||||
for candidate in candidates.iter().filter(|c| !c.is_empty()) {
|
||||
if Self::is_writable(candidate) {
|
||||
path = candidate.clone();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if path.is_empty() {
|
||||
return Err(anyhow!("Cannot find a place to store config file"));
|
||||
}
|
||||
|
||||
info!(config_file=%path, "Config file will be stored here");
|
||||
|
||||
// Créer la configuration
|
||||
let config = Config {
|
||||
config_dir,
|
||||
path,
|
||||
data: Mutex::new(config_value),
|
||||
};
|
||||
|
||||
// Sauvegarder la configuration
|
||||
config.save()?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Saves the current configuration to the config.yaml file
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns a `Result` indicating success or failure
|
||||
pub fn save(&self) -> Result<()> {
|
||||
let data = self.data.lock().unwrap();
|
||||
let yaml = serde_yaml::to_string(&*data)?;
|
||||
@@ -141,6 +297,16 @@ impl Config {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sets a configuration value at the specified path and saves it
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `path` - Array of keys representing the path (e.g., `&["host", "http_port"]`)
|
||||
/// * `value` - The YAML value to set
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns a `Result` indicating success or failure
|
||||
pub fn set_value(&self, path: &[&str], value: Value) -> Result<()> {
|
||||
let mut data = self.data.lock().unwrap();
|
||||
Self::set_value_internal(&mut data, path, value.clone())?;
|
||||
@@ -171,6 +337,15 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets a configuration value at the specified path
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `path` - Array of keys representing the path (e.g., `&["host", "http_port"]`)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns a `Result` containing the YAML value or an error if the path doesn't exist
|
||||
pub fn get_value(&self, path: &[&str]) -> Result<Value> {
|
||||
let data = self.data.lock().unwrap();
|
||||
Self::get_value_internal(&data, path)
|
||||
@@ -194,14 +369,6 @@ impl Config {
|
||||
Ok(current.clone())
|
||||
}
|
||||
|
||||
fn get_home_yml_path() -> String {
|
||||
home_dir()
|
||||
.map(|p| p.join(".pmomusic.yml"))
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn apply_env_overrides(config: &mut Value) {
|
||||
for (key, value) in env::vars() {
|
||||
if key.starts_with(ENV_PREFIX) {
|
||||
@@ -244,17 +411,91 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
fn is_writable(path: &str) -> bool {
|
||||
let path = Path::new(path);
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::metadata(parent)
|
||||
.map(|m| !m.permissions().readonly())
|
||||
.unwrap_or(false)
|
||||
/// Résout un chemin relatif ou absolu et crée le répertoire si nécessaire
|
||||
fn resolve_and_create_dir(&self, dir_path: &str) -> Result<String> {
|
||||
let path = Path::new(dir_path);
|
||||
|
||||
// Déterminer si le chemin est relatif ou absolu
|
||||
let absolute_path = if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
// Chemin relatif : le résoudre par rapport à config_dir
|
||||
Path::new(&self.config_dir).join(path)
|
||||
};
|
||||
|
||||
// Créer le répertoire s'il n'existe pas
|
||||
if !absolute_path.exists() {
|
||||
fs::create_dir_all(&absolute_path)?;
|
||||
info!(directory=%absolute_path.display(), "Created cache directory");
|
||||
}
|
||||
|
||||
// Retourner le chemin absolu
|
||||
Ok(absolute_path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// Récupère un répertoire géré par la configuration
|
||||
///
|
||||
/// Cette méthode générique permet de récupérer n'importe quel répertoire
|
||||
/// configuré dans le YAML. Le répertoire peut être absolu ou relatif au
|
||||
/// répertoire de configuration. Il sera créé s'il n'existe pas.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `path` - Chemin dans l'arbre de configuration (ex: `&["host", "cache", "directory"]`)
|
||||
/// * `default` - Nom de répertoire par défaut si non configuré
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Le chemin absolu du répertoire, créé s'il n'existait pas
|
||||
///
|
||||
/// # Exemple
|
||||
///
|
||||
/// ```no_run
|
||||
/// use pmoconfig::get_config;
|
||||
///
|
||||
/// let config = get_config();
|
||||
/// let cache_dir = config.get_managed_dir(&["host", "audio_cache", "directory"], "cache_audio")?;
|
||||
/// println!("Audio cache directory: {}", cache_dir);
|
||||
/// # Ok::<(), anyhow::Error>(())
|
||||
/// ```
|
||||
pub fn get_managed_dir(&self, path: &[&str], default: &str) -> Result<String> {
|
||||
let dir_path = match self.get_value(path) {
|
||||
Ok(Value::String(s)) => s,
|
||||
_ => {
|
||||
self.set_managed_dir(path, default.to_string())?;
|
||||
default.to_string()
|
||||
}
|
||||
};
|
||||
self.resolve_and_create_dir(&dir_path)
|
||||
}
|
||||
|
||||
/// Définit un répertoire géré par la configuration
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `path` - Chemin dans l'arbre de configuration (ex: `&["host", "cache", "directory"]`)
|
||||
/// * `directory` - Chemin du répertoire (absolu ou relatif au config_dir)
|
||||
///
|
||||
/// # Exemple
|
||||
///
|
||||
/// ```no_run
|
||||
/// use pmoconfig::get_config;
|
||||
///
|
||||
/// let config = get_config();
|
||||
/// config.set_managed_dir(&["host", "audio_cache", "directory"], "/var/cache/audio".to_string())?;
|
||||
/// # Ok::<(), anyhow::Error>(())
|
||||
/// ```
|
||||
pub fn set_managed_dir(&self, path: &[&str], directory: String) -> Result<()> {
|
||||
self.set_value(path, Value::String(directory))
|
||||
}
|
||||
|
||||
/// Gets the base URL for the HTTP server
|
||||
///
|
||||
/// Returns the configured base URL, or attempts to guess the local IP address if not configured.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The base URL as a String
|
||||
pub fn get_base_url(&self) -> String {
|
||||
match self.get_value(&["host", "base_url"]) {
|
||||
Ok(Value::String(s)) if !s.is_empty() => s,
|
||||
@@ -269,36 +510,77 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the HTTP port from configuration
|
||||
///
|
||||
/// Returns the configured HTTP port, or the default port (8080) if not configured or invalid.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The HTTP port as a u16
|
||||
pub fn get_http_port(&self) -> u16 {
|
||||
match self.get_value(&["host", "http_port"]) {
|
||||
Ok(Value::Number(n)) if n.is_i64() => n.as_i64().unwrap() as u16,
|
||||
Ok(Value::String(s)) => match s.parse::<u16>() {
|
||||
Ok(port) => port,
|
||||
Err(_) => {
|
||||
tracing::warn!("Invalid HTTP port '{}', using default 8080", s);
|
||||
8080
|
||||
tracing::warn!(
|
||||
"Invalid HTTP port '{}', using default {}",
|
||||
s,
|
||||
DEFAULT_HTTP_PORT
|
||||
);
|
||||
DEFAULT_HTTP_PORT
|
||||
}
|
||||
},
|
||||
Ok(_) => {
|
||||
tracing::warn!("HTTP port not a number or string, using default 8080");
|
||||
8080
|
||||
tracing::warn!(
|
||||
"HTTP port not a number or string, using default {}",
|
||||
DEFAULT_HTTP_PORT
|
||||
);
|
||||
DEFAULT_HTTP_PORT
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!("Failed to get HTTP port: {}, using default 8080", err);
|
||||
8080
|
||||
tracing::warn!(
|
||||
"Failed to get HTTP port: {}, using default {}",
|
||||
err,
|
||||
DEFAULT_HTTP_PORT
|
||||
);
|
||||
DEFAULT_HTTP_PORT
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the HTTP port in configuration
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `port` - The port number to set
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns a `Result` indicating success or failure
|
||||
pub fn set_http_port(&self, port: u16) -> Result<()> {
|
||||
let n = Number::from(port);
|
||||
self.set_value(&["host", "http_port"], Value::Number(n))
|
||||
}
|
||||
|
||||
/// Gets the UDN (Unique Device Name) for a device, generating one if it doesn't exist
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `devtype` - The device type (e.g., "mediarenderer")
|
||||
/// * `name` - The device name
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns a `Result` containing the UDN string, generating a new UUID if not found
|
||||
pub fn get_device_udn(&self, devtype: &str, name: &str) -> Result<String> {
|
||||
let path = &["devices", devtype, name, "udn"];
|
||||
match self.get_value(path) {
|
||||
Ok(Value::String(udn)) => Ok(udn),
|
||||
Ok(Value::String(udn)) => {
|
||||
let udn_str = udn.trim();
|
||||
let sanitized = udn_str.strip_prefix("uuid:").unwrap_or(udn_str).to_string();
|
||||
Ok(sanitized)
|
||||
}
|
||||
_ => {
|
||||
let new_udn = Uuid::new_v4().to_string();
|
||||
self.set_value(path, Value::String(new_udn.clone()))?;
|
||||
@@ -307,146 +589,113 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the UDN (Unique Device Name) for a device
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `devtype` - The device type (e.g., "mediarenderer")
|
||||
/// * `name` - The device name
|
||||
/// * `udn` - The UDN to set
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns a `Result` indicating success or failure
|
||||
pub fn set_device_udn(&self, devtype: &str, name: &str, udn: String) -> Result<()> {
|
||||
self.set_value(&["devices", devtype, name, "udn"], Value::String(udn))
|
||||
let udn_str = udn.trim();
|
||||
let sanitized = udn_str.strip_prefix("uuid:").unwrap_or(udn_str).to_string();
|
||||
self.set_value(&["devices", devtype, name, "udn"], Value::String(sanitized))
|
||||
}
|
||||
|
||||
pub fn get_cover_cache_dir(&self) -> Result<String> {
|
||||
match self.get_value(&["host", "cover_cache", "directory"])? {
|
||||
Value::String(s) => Ok(s),
|
||||
_ => Ok("./.pmomusic_covers".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_cover_cache_dir(&self, directory: String) -> Result<()> {
|
||||
self.set_value(
|
||||
&["host", "cover_cache", "directory"],
|
||||
Value::String(directory),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn get_cover_cache_size(&self) -> Result<usize> {
|
||||
match self.get_value(&["host", "cover_cache", "size"])? {
|
||||
Value::Number(n) if n.is_i64() => Ok(n.as_i64().unwrap() as usize),
|
||||
Value::Number(n) if n.is_u64() => Ok(n.as_u64().unwrap() as usize),
|
||||
_ => Ok(2000),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_cover_cache_size(&self, size: usize) -> Result<()> {
|
||||
let n = Number::from(size);
|
||||
self.set_value(&["host", "cover_cache", "size"], Value::Number(n))
|
||||
}
|
||||
|
||||
pub fn get_audio_cache_dir(&self) -> Result<String> {
|
||||
match self.get_value(&["host", "audio_cache", "directory"])? {
|
||||
Value::String(s) => Ok(s),
|
||||
_ => Ok("./.pmomusic_audio".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_audio_cache_dir(&self, directory: String) -> Result<()> {
|
||||
self.set_value(
|
||||
&["host", "audio_cache", "directory"],
|
||||
Value::String(directory),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn get_audio_cache_size(&self) -> Result<usize> {
|
||||
match self.get_value(&["host", "audio_cache", "size"])? {
|
||||
Value::Number(n) if n.is_i64() => Ok(n.as_i64().unwrap() as usize),
|
||||
Value::Number(n) if n.is_u64() => Ok(n.as_u64().unwrap() as usize),
|
||||
_ => Ok(500),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_audio_cache_size(&self, size: usize) -> Result<()> {
|
||||
let n = Number::from(size);
|
||||
self.set_value(&["host", "audio_cache", "size"], Value::Number(n))
|
||||
}
|
||||
|
||||
/// Récupère le nom d'utilisateur Qobuz depuis la configuration
|
||||
pub fn get_qobuz_username(&self) -> Result<String> {
|
||||
match self.get_value(&["accounts", "qobuz", "username"])? {
|
||||
Value::String(s) => Ok(s),
|
||||
_ => Err(anyhow!("Qobuz username not configured")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Définit le nom d'utilisateur Qobuz dans la configuration
|
||||
pub fn set_qobuz_username(&self, username: &str) -> Result<()> {
|
||||
self.set_value(
|
||||
impl_string_config!(
|
||||
/// Gets the Qobuz username from configuration
|
||||
get_qobuz_username,
|
||||
set_qobuz_username,
|
||||
&["accounts", "qobuz", "username"],
|
||||
Value::String(username.to_string()),
|
||||
)
|
||||
}
|
||||
""
|
||||
);
|
||||
|
||||
/// Récupère le mot de passe Qobuz depuis la configuration
|
||||
pub fn get_qobuz_password(&self) -> Result<String> {
|
||||
match self.get_value(&["accounts", "qobuz", "password"])? {
|
||||
Value::String(s) => Ok(s),
|
||||
_ => Err(anyhow!("Qobuz password not configured")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Définit le mot de passe Qobuz dans la configuration
|
||||
pub fn set_qobuz_password(&self, password: &str) -> Result<()> {
|
||||
self.set_value(
|
||||
impl_string_config!(
|
||||
/// Gets the Qobuz password from configuration
|
||||
get_qobuz_password,
|
||||
set_qobuz_password,
|
||||
&["accounts", "qobuz", "password"],
|
||||
Value::String(password.to_string()),
|
||||
)
|
||||
}
|
||||
""
|
||||
);
|
||||
|
||||
/// Récupère les credentials Qobuz (username + password) depuis la configuration
|
||||
/// Gets the Qobuz credentials (username and password) from configuration
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns a `Result` containing a tuple of (username, password)
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if either username or password is not configured
|
||||
pub fn get_qobuz_credentials(&self) -> Result<(String, String)> {
|
||||
let username = self.get_qobuz_username()?;
|
||||
let password = self.get_qobuz_password()?;
|
||||
Ok((username, password))
|
||||
}
|
||||
|
||||
pub fn get_log_cache_size(&self) -> Result<usize> {
|
||||
match self.get_value(&["host", "logger", "buffer_capacity"])? {
|
||||
Value::Number(n) => n
|
||||
.as_u64()
|
||||
.map(|v| v as usize)
|
||||
.ok_or_else(|| anyhow::anyhow!("Number is not an unsigned integer")),
|
||||
_ => Ok(1000),
|
||||
}
|
||||
}
|
||||
impl_usize_config!(
|
||||
get_log_cache_size,
|
||||
set_log_cache_size,
|
||||
&["host", "logger", "buffer_capacity"],
|
||||
DEFAULT_LOG_BUFFER_CAPACITY
|
||||
);
|
||||
|
||||
pub fn set_log_cache_size(&self, size: usize) -> Result<()> {
|
||||
let n = Number::from(size);
|
||||
self.set_value(&["host", "logger", "buffer_capacity"], Value::Number(n))
|
||||
}
|
||||
|
||||
pub fn get_log_enable_console(&self) -> Result<bool> {
|
||||
match self.get_value(&["host", "logger", "enable_console"])? {
|
||||
Value::Bool(b) => Ok(b),
|
||||
_ => Ok(true),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_log_enable_console(&self, enable: bool) -> Result<()> {
|
||||
self.set_value(&["host", "logger", "enable_console"], Value::Bool(enable))
|
||||
}
|
||||
impl_bool_config!(
|
||||
get_log_enable_console,
|
||||
set_log_enable_console,
|
||||
&["host", "logger", "enable_console"],
|
||||
DEFAULT_LOG_ENABLE_CONSOLE
|
||||
);
|
||||
|
||||
/// Récupère le niveau de log minimum depuis la configuration
|
||||
pub fn get_log_min_level(&self) -> Result<String> {
|
||||
match self.get_value(&["host", "logger", "min_level"])? {
|
||||
Value::String(s) => Ok(s),
|
||||
_ => Ok("TRACE".to_string()),
|
||||
_ => Ok(DEFAULT_LOG_MIN_LEVEL.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Définit le niveau de log minimum dans la configuration
|
||||
pub fn set_log_min_level(&self, level: String) -> Result<()> {
|
||||
self.set_value(&["host", "logger", "min_level"], Value::String(level))
|
||||
}
|
||||
}
|
||||
|
||||
/// Retourne l'instance globale
|
||||
/// Returns the global configuration instance
|
||||
///
|
||||
/// This function provides access to the singleton configuration instance,
|
||||
/// which is lazily loaded on first access.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// An `Arc<Config>` pointing to the global configuration
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// use pmoconfig::get_config;
|
||||
///
|
||||
/// let config = get_config();
|
||||
/// let port = config.get_http_port();
|
||||
/// ```
|
||||
pub fn get_config() -> Arc<Config> {
|
||||
CONFIG.clone()
|
||||
}
|
||||
|
||||
/// Merges external YAML configuration into default configuration
|
||||
///
|
||||
/// This function recursively merges two YAML value trees:
|
||||
/// - For mappings (objects), it merges keys from external into default
|
||||
/// - For scalars and sequences, external values replace default values
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `default` - The default configuration to merge into (modified in place)
|
||||
/// * `external` - The external configuration to merge from
|
||||
fn merge_yaml(default: &mut Value, external: &Value) {
|
||||
match (default, external) {
|
||||
(Value::Mapping(dmap), Value::Mapping(emap)) => {
|
||||
|
||||
29
pmoconfig/src/openapi.rs
Normal file
29
pmoconfig/src/openapi.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
use utoipa::OpenApi;
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
info(
|
||||
title = "PMOMusic Configuration API",
|
||||
version = "0.1.0",
|
||||
description = "API REST pour gérer la configuration de PMOMusic",
|
||||
contact(
|
||||
name = "PMOMusic Team",
|
||||
)
|
||||
),
|
||||
paths(
|
||||
crate::api::get_full_config,
|
||||
crate::api::get_config_value,
|
||||
crate::api::update_config_value,
|
||||
),
|
||||
components(
|
||||
schemas(
|
||||
crate::api::ConfigValue,
|
||||
crate::api::UpdateConfigRequest,
|
||||
crate::api::UpdateConfigResponse,
|
||||
)
|
||||
),
|
||||
tags(
|
||||
(name = "config", description = "Endpoints de gestion de la configuration")
|
||||
)
|
||||
)]
|
||||
pub struct ApiDoc;
|
||||
@@ -1,18 +1,13 @@
|
||||
host:
|
||||
http_port: "8080"
|
||||
cover_cache:
|
||||
directory: "./.pmomusic_covers"
|
||||
directory: "cache_covers"
|
||||
size: 2000
|
||||
audio_cache:
|
||||
directory: "./.pmomusic_audio"
|
||||
directory: "cache_audio"
|
||||
size: 500
|
||||
logger:
|
||||
buffer_capacity: 200
|
||||
enable_console: true
|
||||
min_level: "INFO"
|
||||
|
||||
mediarenderer:
|
||||
mpd_renderer:
|
||||
mediaserver:
|
||||
qobuz:
|
||||
udn: "uuid:28963b75-4c5f-4da7-b10e-ffafd"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user