Compare commits
114 Commits
push-ytvsv
...
push-puzom
| Author | SHA1 | Date | |
|---|---|---|---|
| 40950164e9 | |||
| 1eff9a57a5 | |||
| 6bce26c4fc | |||
| ec0a0e675e | |||
| a5f0859c43 | |||
| 8fe110eeb7 | |||
| 579109374d | |||
| 27a5279378 | |||
| 785f8d52a5 | |||
| 8245fa6f41 | |||
| eb59aacc23 | |||
| d5aa26edff | |||
| a8a5c2db9f | |||
| 56366ec678 | |||
| 28381bf19e | |||
| 5a785a445a | |||
| 0313492978 | |||
| ddf4e6812f | |||
| b7661b5767 | |||
| 6b52b6ffee | |||
| 0e26ebea31 | |||
| ca908fbe69 | |||
| a383980412 | |||
| 3cb90b72ac | |||
| 92c35116f8 | |||
| a0e5d92bf9 | |||
| fee4c706da | |||
| ea6dd03ac0 | |||
| 078d6cb5f8 | |||
| 217ebb84c9 | |||
| bbecd8efb5 | |||
| 79271514b4 | |||
| 80dd1aa2d6 | |||
| bfaddfce0f | |||
| 6c9192b6ea | |||
| 261ac885c1 | |||
| 1e0a0e2acb | |||
| 1268c24faf | |||
| 33761f1cef | |||
| 9d15d2a127 | |||
| 998205b0db | |||
| fbef57890d | |||
| 9e4a8410e1 | |||
| e17722a99c | |||
| d78acc254d | |||
| 2290ae3cd7 | |||
| ecb362ae48 | |||
| 180241a315 | |||
| 2bb2e7b7db | |||
| b95bebdb6a | |||
| 28b3888498 | |||
| a04c79a1f8 | |||
| 79940e685c | |||
| 45599787ba | |||
| a809fca1ac | |||
| 455fc4ed21 | |||
| 0c648a9765 | |||
| 208fe8be76 | |||
| 756ae7f82c | |||
| d2fe0a1bf6 | |||
| 2c814bd7c0 | |||
| efa4855555 | |||
| 34822bef1e | |||
| 8d62e50a20 | |||
| 336329a4ed | |||
| 9932007bba | |||
| 23c6d8b7a7 | |||
| ee9dca75ba | |||
| 5fe1624f26 | |||
| 10724430ce | |||
| 1d2e25368c | |||
| 8cd14da8ec | |||
| fb0394b427 | |||
| f27d5d3483 | |||
| ff515e22bd | |||
| d86cfe46df | |||
| 0559a210ea | |||
| 993ef18ac6 | |||
| 9bd0cd173b | |||
| 082914cf8c | |||
| 2eec78ab4f | |||
| e33f8a7d54 | |||
| 823617c5f3 | |||
| 1f263c2295 | |||
| 218e527e24 | |||
| 622c1d952e | |||
| 6a87046845 | |||
| 552d8d90fb | |||
| d64b96cef4 | |||
| ee1072a491 | |||
| b22a82bb50 | |||
| 2149d1a797 | |||
| 3f85067426 | |||
| 015cc69a31 | |||
| e7e6727123 | |||
| 1c83416be4 | |||
| 664be97ea6 | |||
| b8154a4837 | |||
| 3ca6fa9884 | |||
| 24068b6eb0 | |||
| 6a98fcc2b9 | |||
| e70537ed1b | |||
| fa0206a3f9 | |||
| 1abe80f74a | |||
| 777cc0b3fe | |||
| aded870495 | |||
| ff2e998794 | |||
| 3c1b1a449d | |||
| 5d13255155 | |||
| 05aa1634e9 | |||
| 913da8a5a3 | |||
| 22dc0c7374 | |||
| c7d7ed749a | |||
| cd4954111f |
20
.gitignore
vendored
20
.gitignore
vendored
@@ -1,3 +1,5 @@
|
||||
.ollama
|
||||
.ollamacode
|
||||
/bin/
|
||||
/pkg/
|
||||
/vendor/
|
||||
@@ -6,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/
|
||||
@@ -21,3 +34,6 @@ xxx
|
||||
xx
|
||||
all.txt
|
||||
pmo_src.txt
|
||||
upmpdcli/
|
||||
/*.xml
|
||||
test_upnp
|
||||
@@ -3,13 +3,26 @@ host:
|
||||
cover_cache:
|
||||
directory: ./.pmomusic_covers
|
||||
size: 2000
|
||||
devices:
|
||||
audio_cache:
|
||||
directory: ./.pmomusic_audio
|
||||
size: 500
|
||||
logger:
|
||||
buffer_capacity: 200
|
||||
enable_console: true
|
||||
min_level: TRACE
|
||||
mediarenderer:
|
||||
mpd_renderer: null
|
||||
fakerenderer:
|
||||
udn: d7eaad15-7d21-4411-926a-bc1eea0713db
|
||||
mediarenderer:
|
||||
udn: f9ef6c21-0ed3-470c-9846-bc1ae85fea62
|
||||
mediaserver:
|
||||
qobuz:
|
||||
udn: 28963b75-4c5f-4da7-b10e-ffafd
|
||||
udn: uuid:28963b75-4c5f-4da7-b10e-ffafd
|
||||
accounts:
|
||||
qobuz:
|
||||
username: eric@coissac.eu
|
||||
password: '*Misfcr73110$'
|
||||
devices:
|
||||
mediarenderer:
|
||||
pmo_mediarenderer:
|
||||
udn: 15a13316-daac-47f0-b64e-47e56f5e3b51
|
||||
mediaserver:
|
||||
pmo_mediaserver:
|
||||
udn: 23df0bfa-cfef-4724-b731-00f66fadf176
|
||||
|
||||
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
|
||||
}
|
||||
2361
Cargo.lock
generated
2361
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
22
Cargo.toml
22
Cargo.toml
@@ -1,3 +1,23 @@
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["PMOMusic", "pmoupnp","pmoconfig", "pmoutils", "pmodidl", "pmoserver", "pmoapp", "pmocovers"]
|
||||
members = [
|
||||
"PMOMusic",
|
||||
"pmoupnp",
|
||||
"pmomediarenderer",
|
||||
"pmomediaserver",
|
||||
"pmoconfig",
|
||||
"pmoutils",
|
||||
"pmodidl",
|
||||
"pmoserver",
|
||||
"pmoapp",
|
||||
"pmocache",
|
||||
"pmocovers",
|
||||
"pmoaudiocache",
|
||||
"pmoaudio",
|
||||
"pmoqobuz",
|
||||
"pmoparadise",
|
||||
"pmosource",
|
||||
"pmoplaylist",
|
||||
"pmoflac",
|
||||
"pmometadata",
|
||||
]
|
||||
|
||||
24
Makefile
24
Makefile
@@ -1,6 +1,8 @@
|
||||
# Makefile pour projet Rust + Vue.js
|
||||
# Variables de configuration
|
||||
CARGO = cargo
|
||||
CARGO_NIGHTLY = rustup run nightly cargo
|
||||
FEATURES ?=
|
||||
NPM = npm
|
||||
WEBAPP_DIR = pmoapp/webapp
|
||||
DIST_DIR = $(WEBAPP_DIR)/dist
|
||||
@@ -14,7 +16,9 @@ YELLOW = \033[1;33m
|
||||
RED = \033[0;31m
|
||||
NC = \033[0m # No Color
|
||||
|
||||
.PHONY: all help build release debug test doc webapp clean install dev check fmt clippy watch
|
||||
.DEFAULT_GOAL := simd
|
||||
|
||||
.PHONY: all help build release debug test doc webapp clean install dev check fmt clippy watch simd scalar
|
||||
|
||||
# Cible par défaut
|
||||
all: build
|
||||
@@ -31,13 +35,13 @@ build: webapp release
|
||||
## release: Compile le binaire Rust en mode release
|
||||
release: webapp
|
||||
@echo "$(YELLOW)→ Compilation Rust (release)...$(NC)"
|
||||
$(CARGO) build --release
|
||||
$(CARGO) build --release $(FEATURES)
|
||||
@echo "$(GREEN)✓ Binaire disponible : $(RUST_TARGET)/$(BINARY_NAME)$(NC)"
|
||||
|
||||
## debug: Compile le binaire Rust en mode debug
|
||||
debug: webapp
|
||||
@echo "$(YELLOW)→ Compilation Rust (debug)...$(NC)"
|
||||
$(CARGO) build
|
||||
$(CARGO) build $(FEATURES)
|
||||
@echo "$(GREEN)✓ Binaire disponible : target/debug/$(BINARY_NAME)$(NC)"
|
||||
|
||||
## test: Exécute tous les tests Rust
|
||||
@@ -46,6 +50,18 @@ test:
|
||||
$(CARGO) test --all
|
||||
@echo "$(GREEN)✓ Tests terminés$(NC)"
|
||||
|
||||
## simd: Compile l'application en mode SIMD (nightly requis)
|
||||
simd:
|
||||
@echo "$(YELLOW)→ Build SIMD (nightly)...$(NC)"
|
||||
$(MAKE) release CARGO="$(CARGO_NIGHTLY)" FEATURES="--features simd"
|
||||
@echo "$(GREEN)✓ Build SIMD terminé$(NC)"
|
||||
|
||||
## scalar: Compile l'application en mode scalaire
|
||||
scalar:
|
||||
@echo "$(YELLOW)→ Build scalaire...$(NC)"
|
||||
$(MAKE) release FEATURES=""
|
||||
@echo "$(GREEN)✓ Build scalaire terminé$(NC)"
|
||||
|
||||
## test-doc: Teste les exemples dans la documentation
|
||||
test-doc:
|
||||
@echo "$(YELLOW)→ Test des exemples de documentation...$(NC)"
|
||||
@@ -187,4 +203,4 @@ coverage:
|
||||
@echo "$(YELLOW)→ Génération du rapport de couverture...$(NC)"
|
||||
$(CARGO) tarpaulin --out Html --output-dir target/coverage
|
||||
@echo "$(GREEN)✓ Rapport disponible dans target/coverage/index.html$(NC)"
|
||||
|
||||
|
||||
|
||||
@@ -6,13 +6,19 @@ edition = "2024"
|
||||
[dependencies]
|
||||
pmoconfig = { path = "../pmoconfig" }
|
||||
pmoupnp = { path = "../pmoupnp"}
|
||||
pmomediarenderer = { path = "../pmomediarenderer" }
|
||||
pmomediaserver = { path = "../pmomediaserver", features = ["qobuz", "paradise", "paradise-api", "api"] }
|
||||
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"] }
|
||||
tracing = "0.1.41"
|
||||
tracing-subscriber = "0.3.20"
|
||||
axum = "0.8.4"
|
||||
serde_json = "1.0.145"
|
||||
utoipa = "5.4"
|
||||
console-subscriber = "0.4.1"
|
||||
|
||||
@@ -1,70 +1,102 @@
|
||||
use pmoupnp::{
|
||||
mediarenderer::MEDIA_RENDERER,
|
||||
ssdp::SsdpServer,
|
||||
UpnpServer,
|
||||
UpnpModel,
|
||||
};
|
||||
use pmoserver::{
|
||||
logs::LoggingOptions,
|
||||
ServerBuilder
|
||||
};
|
||||
use pmoapp::{Webapp, WebAppExt};
|
||||
use pmocovers::CoverCacheExt;
|
||||
use pmoapp::{WebAppExt, Webapp};
|
||||
use pmomediarenderer::MEDIA_RENDERER;
|
||||
use pmomediaserver::{MEDIA_SERVER, sources::SourcesExt};
|
||||
use pmoserver::Server;
|
||||
use pmosource::MusicSourceExt;
|
||||
use pmoupnp::UpnpServerExt;
|
||||
use tracing::info;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
// Créer le serveur
|
||||
let mut server = ServerBuilder::new_configured().build();
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// ========== PHASE 1 : Infrastructure UPnP ==========
|
||||
// #[cfg(tokio_unstable)]
|
||||
// console_subscriber::init();
|
||||
|
||||
// Initialiser le logging et enregistrer les routes de logs
|
||||
server.init_logging(LoggingOptions::default()).await;
|
||||
|
||||
|
||||
info!("📡 Registering the cover cache...");
|
||||
let cache = server.init_cover_cache_configured()
|
||||
.await
|
||||
.expect("Cannot initialise the image cache");
|
||||
|
||||
info!("✅ Cover cache ready at {}",
|
||||
cache.cache_dir(),
|
||||
);
|
||||
|
||||
|
||||
|
||||
// Routes de base
|
||||
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"})
|
||||
})
|
||||
.await;
|
||||
|
||||
|
||||
// Ajouter la webapp via le trait WebAppExt
|
||||
info!("📡 Registering Web application...");
|
||||
server.add_webapp_with_redirect::<Webapp>("/app").await;
|
||||
|
||||
info!("📡 Registering MediaRenderer...");
|
||||
let renderer_instance = server.register_device(MEDIA_RENDERER.clone())
|
||||
// Initialiser le système de gestion des sources musicales avec API REST
|
||||
info!("📡 Initializing music sources management system...");
|
||||
server
|
||||
.write()
|
||||
.await
|
||||
.expect("Failed to register MediaRenderer routes");
|
||||
.init_music_sources()
|
||||
.await
|
||||
.expect("Failed to initialize music sources API");
|
||||
|
||||
info!("✅ MediaRenderer ready at {}{}",
|
||||
// ========== PHASE 2 : Configuration métier ==========
|
||||
|
||||
// Enregistrer les sources musicales
|
||||
info!("🎵 Registering music sources...");
|
||||
|
||||
// // Enregistrer Qobuz
|
||||
// if let Err(e) = server.register_qobuz().await {
|
||||
// tracing::warn!("⚠️ Failed to register Qobuz: {}", e);
|
||||
// }
|
||||
|
||||
// Enregistrer Radio Paradise (inclut l'initialisation de l'API)
|
||||
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.read().await.list_music_sources().await;
|
||||
info!("✅ {} music source(s) registered", sources.len());
|
||||
for source in sources {
|
||||
info!(" - {} ({})", source.name(), source.id());
|
||||
}
|
||||
|
||||
// Enregistrer les devices UPnP (HTTP + SSDP automatique)
|
||||
info!("📡 Registering UPnP devices...");
|
||||
|
||||
let renderer_instance = server
|
||||
.write()
|
||||
.await
|
||||
.register_device(MEDIA_RENDERER.clone())
|
||||
.await
|
||||
.expect("Failed to register MediaRenderer");
|
||||
|
||||
info!(
|
||||
"✅ MediaRenderer ready at {}{}",
|
||||
renderer_instance.base_url(),
|
||||
renderer_instance.description_route()
|
||||
);
|
||||
|
||||
// Créer et démarrer le serveur SSDP
|
||||
info!("📡 Starting SSDP discovery...");
|
||||
let mut ssdp_server = SsdpServer::new();
|
||||
ssdp_server.start().expect("Failed to start SSDP server");
|
||||
let server_instance = server
|
||||
.write()
|
||||
.await
|
||||
.register_device(MEDIA_SERVER.clone())
|
||||
.await
|
||||
.expect("Failed to register MediaServer");
|
||||
|
||||
// Créer et enregistrer le device SSDP pour le MediaRenderer
|
||||
let ssdp_device = renderer_instance
|
||||
.to_ssdp_device("PMOMusic", "1.0");
|
||||
ssdp_server.add_device(ssdp_device);
|
||||
info!("✅ SSDP announcements sent for MediaRenderer");
|
||||
info!(
|
||||
"✅ MediaServer ready at {}{}",
|
||||
server_instance.base_url(),
|
||||
server_instance.description_route()
|
||||
);
|
||||
|
||||
server.start().await;
|
||||
server.wait().await;
|
||||
// Ajouter la webapp via le trait WebAppExt
|
||||
info!("📡 Registering Web application...");
|
||||
server
|
||||
.write()
|
||||
.await
|
||||
.add_webapp_with_redirect::<Webapp>("/app")
|
||||
.await;
|
||||
|
||||
// ========== PHASE 3 : Démarrage du serveur ==========
|
||||
|
||||
info!("🌐 Starting HTTP server...");
|
||||
server.write().await.start().await;
|
||||
|
||||
info!("✅ PMOMusic is ready!");
|
||||
info!("Press Ctrl+C to stop...");
|
||||
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
|
||||
```
|
||||
BIN
audio_cache/audio_cache.db
Normal file
BIN
audio_cache/audio_cache.db
Normal file
Binary file not shown.
5
headers.txt
Normal file
5
headers.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
HTTP/1.1 200 OK
|
||||
content-type: text/xml; charset="utf-8"
|
||||
content-length: 1593
|
||||
date: Mon, 20 Oct 2025 17:44:48 GMT
|
||||
|
||||
245
old_code/pmoaudio/nodes/buffer_node.rs
Normal file
245
old_code/pmoaudio/nodes/buffer_node.rs
Normal file
@@ -0,0 +1,245 @@
|
||||
use crate::{
|
||||
nodes::{AudioError, MultiSubscriberNode},
|
||||
AudioSegment,
|
||||
};
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{mpsc, RwLock};
|
||||
|
||||
/// Subscriber avec son propre offset dans le buffer
|
||||
struct BufferSubscriber {
|
||||
tx: mpsc::Sender<Arc<AudioSegment>>,
|
||||
offset: usize, // Position dans le buffer circulaire
|
||||
}
|
||||
|
||||
/// BufferNode avec buffer circulaire pour support multiroom
|
||||
///
|
||||
/// Ce node maintient un buffer circulaire de chunks et permet à plusieurs
|
||||
/// abonnés de lire avec des offsets différents, ce qui est idéal pour des
|
||||
/// configurations multiroom où différentes pièces peuvent avoir un léger
|
||||
/// délai de synchronisation.
|
||||
///
|
||||
/// # Fonctionnement
|
||||
///
|
||||
/// - Le buffer est implémenté avec un `VecDeque` de taille fixe
|
||||
/// - Chaque abonné peut avoir un offset indépendant (en nombre de chunks)
|
||||
/// - Utilise `try_send` pour éviter de bloquer si un abonné est saturé
|
||||
///
|
||||
/// # Exemples
|
||||
///
|
||||
/// ```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
|
||||
/// buffer.add_subscriber_with_offset(sink1_tx, 0).await;
|
||||
///
|
||||
/// // Room 2 avec 5 chunks de retard
|
||||
/// buffer.add_subscriber_with_offset(sink2_tx, 5).await;
|
||||
///
|
||||
/// tokio::spawn(async move { buffer.run().await.unwrap() });
|
||||
/// // ... spawn sinks et source
|
||||
/// }
|
||||
/// ```
|
||||
pub struct BufferNode {
|
||||
buffer: Arc<RwLock<VecDeque<Arc<AudioSegment>>>>,
|
||||
subscribers: Arc<RwLock<Vec<BufferSubscriber>>>,
|
||||
buffer_size: usize,
|
||||
rx: mpsc::Receiver<Arc<AudioSegment>>,
|
||||
next_subscribers: MultiSubscriberNode, // Pour passer au node suivant
|
||||
}
|
||||
|
||||
impl BufferNode {
|
||||
/// Crée un nouveau 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<AudioSegment>>) {
|
||||
let (tx, rx) = mpsc::channel(channel_size);
|
||||
|
||||
let node = Self {
|
||||
buffer: Arc::new(RwLock::new(VecDeque::with_capacity(buffer_size))),
|
||||
subscribers: Arc::new(RwLock::new(Vec::new())),
|
||||
buffer_size,
|
||||
rx,
|
||||
next_subscribers: MultiSubscriberNode::new(),
|
||||
};
|
||||
|
||||
(node, tx)
|
||||
}
|
||||
|
||||
/// Ajoute un abonné avec un offset spécifique (pour multiroom)
|
||||
pub async fn add_subscriber_with_offset(
|
||||
&self,
|
||||
tx: mpsc::Sender<Arc<AudioSegment>>,
|
||||
offset: usize,
|
||||
) {
|
||||
let mut subs = self.subscribers.write().await;
|
||||
subs.push(BufferSubscriber { tx, offset });
|
||||
}
|
||||
|
||||
/// Ajoute un abonné sans offset (commence au chunk courant)
|
||||
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<AudioSegment>>) {
|
||||
self.next_subscribers.add_subscriber(tx);
|
||||
}
|
||||
|
||||
/// Démarre la boucle de traitement du BufferNode
|
||||
pub async fn run(mut self) -> Result<(), AudioError> {
|
||||
let mut chunk_index = 0usize;
|
||||
|
||||
while let Some(chunk) = self.rx.recv().await {
|
||||
// Ajouter au buffer circulaire
|
||||
{
|
||||
let mut buffer = self.buffer.write().await;
|
||||
if buffer.len() >= self.buffer_size {
|
||||
buffer.pop_front();
|
||||
}
|
||||
buffer.push_back(chunk.clone());
|
||||
}
|
||||
|
||||
// Envoyer aux abonnés avec offset
|
||||
{
|
||||
let buffer = self.buffer.read().await;
|
||||
let mut subs = self.subscribers.write().await;
|
||||
|
||||
for sub in subs.iter_mut() {
|
||||
// Calculer l'index dans le buffer en fonction de l'offset
|
||||
let target_index = if chunk_index >= sub.offset {
|
||||
chunk_index - sub.offset
|
||||
} else {
|
||||
continue; // Pas encore assez de données
|
||||
};
|
||||
|
||||
// Vérifier si le chunk est disponible dans le buffer
|
||||
let buffer_age = chunk_index - target_index;
|
||||
if buffer_age < buffer.len() {
|
||||
let chunk_to_send = &buffer[buffer.len() - buffer_age - 1];
|
||||
// try_send non-bloquant pour éviter de bloquer la source
|
||||
let _ = sub.tx.try_send(chunk_to_send.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Push vers les nodes suivants sans buffer
|
||||
self.next_subscribers.try_push(chunk).await?;
|
||||
|
||||
chunk_index += 1;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Version avec push synchrone au lieu de try_push
|
||||
pub async fn run_blocking(mut self) -> Result<(), AudioError> {
|
||||
let mut chunk_index = 0usize;
|
||||
|
||||
while let Some(chunk) = self.rx.recv().await {
|
||||
// Ajouter au buffer circulaire
|
||||
{
|
||||
let mut buffer = self.buffer.write().await;
|
||||
if buffer.len() >= self.buffer_size {
|
||||
buffer.pop_front();
|
||||
}
|
||||
buffer.push_back(chunk.clone());
|
||||
}
|
||||
|
||||
// Envoyer aux abonnés avec offset
|
||||
{
|
||||
let buffer = self.buffer.read().await;
|
||||
let subs = self.subscribers.read().await;
|
||||
|
||||
for sub in subs.iter() {
|
||||
let target_index = if chunk_index >= sub.offset {
|
||||
chunk_index - sub.offset
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let buffer_age = chunk_index - target_index;
|
||||
if buffer_age < buffer.len() {
|
||||
let chunk_to_send = &buffer[buffer.len() - buffer_age - 1];
|
||||
let _ = sub.tx.send(chunk_to_send.clone()).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Push vers les nodes suivants
|
||||
for _ in 0..self.next_subscribers.subscribers.len() {
|
||||
self.next_subscribers.push(chunk.clone()).await?;
|
||||
}
|
||||
|
||||
chunk_index += 1;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::BitDepth;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_buffer_node_basic() {
|
||||
let (mut node, tx) = BufferNode::new(10, 5);
|
||||
let (out_tx, mut out_rx) = mpsc::channel(5);
|
||||
|
||||
node.add_next_subscriber(out_tx);
|
||||
|
||||
// Spawn le node
|
||||
tokio::spawn(async move {
|
||||
node.run().await.unwrap();
|
||||
});
|
||||
|
||||
// Envoyer des chunks
|
||||
for i in 0..3 {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_buffer_node_with_offset() {
|
||||
let (node, tx) = BufferNode::new(10, 10);
|
||||
let (out_tx, mut out_rx) = mpsc::channel(10);
|
||||
|
||||
// Ajouter un abonné avec offset de 2 chunks
|
||||
node.add_subscriber_with_offset(out_tx, 2).await;
|
||||
|
||||
// Spawn le node
|
||||
tokio::spawn(async move {
|
||||
node.run().await.unwrap();
|
||||
});
|
||||
|
||||
// Envoyer 5 chunks
|
||||
for i in 0..5 {
|
||||
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);
|
||||
}
|
||||
}
|
||||
290
old_code/pmoaudio/nodes/chromecast_sink.rs
Normal file
290
old_code/pmoaudio/nodes/chromecast_sink.rs
Normal file
@@ -0,0 +1,290 @@
|
||||
//! ChromecastSink - Diffuse le flux audio vers un périphérique Chromecast
|
||||
//!
|
||||
//! Ce module fournit un sink qui envoie le flux audio à un Chromecast.
|
||||
//! Note: Cette implémentation est une version mock/skeleton. Une vraie implémentation
|
||||
//! nécessiterait une bibliothèque comme `rust-cast` ou similaire.
|
||||
|
||||
use crate::{nodes::AudioError, AudioChunk};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
/// Configuration pour le ChromecastSink
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChromecastConfig {
|
||||
/// Nom ou adresse IP du Chromecast
|
||||
pub device_address: String,
|
||||
|
||||
/// Nom amical du device
|
||||
pub device_name: String,
|
||||
|
||||
/// Port de communication (défaut: 8009)
|
||||
pub port: u16,
|
||||
|
||||
/// Taille du buffer de streaming
|
||||
pub buffer_size: usize,
|
||||
|
||||
/// Format d'encodage pour le streaming
|
||||
pub encoding: StreamEncoding,
|
||||
}
|
||||
|
||||
impl Default for ChromecastConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
device_address: "192.168.1.100".to_string(),
|
||||
device_name: "Living Room".to_string(),
|
||||
port: 8009,
|
||||
buffer_size: 50,
|
||||
encoding: StreamEncoding::Mp3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats d'encodage supportés pour le streaming
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum StreamEncoding {
|
||||
/// MP3 (compatible avec la plupart des Chromecasts)
|
||||
Mp3,
|
||||
/// AAC
|
||||
Aac,
|
||||
/// Opus
|
||||
Opus,
|
||||
/// PCM non compressé (haute qualité, bande passante élevée)
|
||||
Pcm,
|
||||
}
|
||||
|
||||
/// ChromecastSink - Diffuse vers un périphérique Chromecast
|
||||
///
|
||||
/// Ce sink encode le flux audio et le streame vers un Chromecast.
|
||||
/// La connexion est établie lors de l'initialisation et maintenue pendant toute la durée.
|
||||
///
|
||||
/// # Implémentation actuelle
|
||||
///
|
||||
/// Cette version est un mock qui simule l'envoi au Chromecast.
|
||||
/// Pour une vraie implémentation, il faudrait:
|
||||
/// - Utiliser une bibliothèque comme `rust-cast`
|
||||
/// - Établir une connexion TLS avec le device
|
||||
/// - Lancer une application de récepteur sur le Chromecast
|
||||
/// - Encoder l'audio dans le format approprié
|
||||
/// - Streamer via HTTP ou WebSocket
|
||||
///
|
||||
/// # Exemples
|
||||
///
|
||||
/// ```no_run
|
||||
/// use pmoaudio::{ChromecastSink, ChromecastConfig};
|
||||
///
|
||||
/// #[tokio::main]
|
||||
/// async fn main() {
|
||||
/// let config = ChromecastConfig {
|
||||
/// device_address: "192.168.1.100".to_string(),
|
||||
/// device_name: "Living Room".to_string(),
|
||||
/// ..Default::default()
|
||||
/// };
|
||||
///
|
||||
/// let (sink, sink_tx) = ChromecastSink::new("chromecast1".to_string(), config, 10);
|
||||
///
|
||||
/// tokio::spawn(async move {
|
||||
/// sink.run().await.unwrap()
|
||||
/// });
|
||||
/// }
|
||||
/// ```
|
||||
pub struct ChromecastSink {
|
||||
/// Identifiant du sink
|
||||
node_id: String,
|
||||
|
||||
/// Channel pour recevoir les chunks audio
|
||||
rx: mpsc::Receiver<Arc<AudioChunk>>,
|
||||
|
||||
/// Configuration
|
||||
config: ChromecastConfig,
|
||||
|
||||
/// État de la connexion (mock)
|
||||
connected: bool,
|
||||
}
|
||||
|
||||
impl ChromecastSink {
|
||||
/// Crée un nouveau ChromecastSink
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `node_id` - Identifiant unique du sink
|
||||
/// * `config` - Configuration du Chromecast
|
||||
/// * `channel_size` - Taille du buffer du channel
|
||||
pub fn new(
|
||||
node_id: String,
|
||||
config: ChromecastConfig,
|
||||
channel_size: usize,
|
||||
) -> (Self, mpsc::Sender<Arc<AudioChunk>>) {
|
||||
let (tx, rx) = mpsc::channel(channel_size);
|
||||
|
||||
let sink = Self {
|
||||
node_id,
|
||||
rx,
|
||||
config,
|
||||
connected: false,
|
||||
};
|
||||
|
||||
(sink, tx)
|
||||
}
|
||||
|
||||
/// Établit la connexion avec le Chromecast (mock)
|
||||
async fn connect(&mut self) -> Result<(), AudioError> {
|
||||
println!(
|
||||
"[{}] Connecting to Chromecast '{}' at {}:{}...",
|
||||
self.node_id, self.config.device_name, self.config.device_address, self.config.port
|
||||
);
|
||||
|
||||
// Simuler une connexion
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||
|
||||
self.connected = true;
|
||||
|
||||
println!(
|
||||
"[{}] Connected to Chromecast '{}' successfully",
|
||||
self.node_id, self.config.device_name
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Envoie un chunk au Chromecast (mock)
|
||||
async fn send_chunk(&self, _chunk: &AudioChunk) -> Result<(), AudioError> {
|
||||
if !self.connected {
|
||||
return Err(AudioError::ProcessingError(
|
||||
"Not connected to Chromecast".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Dans une vraie implémentation:
|
||||
// 1. Appliquer le gain
|
||||
// 2. Encoder dans le format approprié (MP3, AAC, etc.)
|
||||
// 3. Envoyer via le protocole Chromecast
|
||||
|
||||
// Pour l'instant, simplement simuler un délai d'envoi
|
||||
tokio::time::sleep(tokio::time::Duration::from_micros(50)).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Déconnecte proprement du Chromecast (mock)
|
||||
async fn disconnect(&mut self) -> Result<(), AudioError> {
|
||||
if self.connected {
|
||||
println!(
|
||||
"[{}] Disconnecting from Chromecast '{}'...",
|
||||
self.node_id, self.config.device_name
|
||||
);
|
||||
|
||||
// Simuler la déconnexion
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
|
||||
|
||||
self.connected = false;
|
||||
|
||||
println!("[{}] Disconnected successfully", self.node_id);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Démarre la boucle de traitement du ChromecastSink
|
||||
pub async fn run(mut self) -> Result<ChromecastStats, AudioError> {
|
||||
// Établir la connexion
|
||||
self.connect().await?;
|
||||
|
||||
let mut stats = ChromecastStats::new(self.node_id.clone(), self.config.device_name.clone());
|
||||
|
||||
// Boucle principale
|
||||
while let Some(chunk) = self.rx.recv().await {
|
||||
// Appliquer le gain si nécessaire
|
||||
let chunk_to_send = if chunk.gain_db().abs() > f64::EPSILON {
|
||||
Arc::clone(&chunk).apply_gain()
|
||||
} else {
|
||||
Arc::clone(&chunk)
|
||||
};
|
||||
|
||||
// Envoyer au Chromecast
|
||||
self.send_chunk(&chunk_to_send).await?;
|
||||
|
||||
stats.record_chunk(&chunk_to_send);
|
||||
}
|
||||
|
||||
// Déconnexion propre
|
||||
self.disconnect().await?;
|
||||
|
||||
stats.finalize();
|
||||
Ok(stats)
|
||||
}
|
||||
}
|
||||
|
||||
/// Statistiques du ChromecastSink
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChromecastStats {
|
||||
pub node_id: String,
|
||||
pub device_name: String,
|
||||
pub chunks_sent: u64,
|
||||
pub total_samples: u64,
|
||||
pub total_duration_sec: f64,
|
||||
}
|
||||
|
||||
impl ChromecastStats {
|
||||
pub fn new(node_id: String, device_name: String) -> Self {
|
||||
Self {
|
||||
node_id,
|
||||
device_name,
|
||||
chunks_sent: 0,
|
||||
total_samples: 0,
|
||||
total_duration_sec: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
pub fn finalize(&mut self) {
|
||||
// Calculs finaux si nécessaire
|
||||
}
|
||||
|
||||
pub fn display(&self) {
|
||||
println!("\n=== Chromecast Statistics: {} ===", self.node_id);
|
||||
println!("Device: {}", self.device_name);
|
||||
println!("Chunks sent: {}", self.chunks_sent);
|
||||
println!("Total samples: {}", self.total_samples);
|
||||
println!("Total duration: {:.3} sec", self.total_duration_sec);
|
||||
println!("==================================\n");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::i32;
|
||||
|
||||
use super::*;
|
||||
use crate::BitDepth;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_chromecast_sink_basic() {
|
||||
let config = ChromecastConfig {
|
||||
device_address: "127.0.0.1".to_string(),
|
||||
device_name: "Test Device".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let (sink, tx) = ChromecastSink::new("test".to_string(), config, 10);
|
||||
|
||||
let handle = tokio::spawn(async move { sink.run().await });
|
||||
|
||||
// Envoyer quelques chunks
|
||||
for i in 0..5 {
|
||||
let stereo = vec![[i32::MAX / 2; 2]; 1000];
|
||||
let chunk = AudioChunk::new(i, stereo, 48000, BitDepth::B24);
|
||||
tx.send(chunk).await.unwrap();
|
||||
}
|
||||
|
||||
drop(tx);
|
||||
|
||||
let stats = handle.await.unwrap().unwrap();
|
||||
assert_eq!(stats.chunks_sent, 5);
|
||||
assert_eq!(stats.device_name, "Test Device");
|
||||
}
|
||||
}
|
||||
162
old_code/pmoaudio/nodes/decoder_node.rs
Normal file
162
old_code/pmoaudio/nodes/decoder_node.rs
Normal file
@@ -0,0 +1,162 @@
|
||||
use crate::{
|
||||
nodes::{AudioError, MultiSubscriberNode},
|
||||
AudioChunk,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
/// DecoderNode - Décode des chunks audio
|
||||
///
|
||||
/// Version mock qui passe simplement les chunks (ou simule un décodage simple)
|
||||
pub struct DecoderNode {
|
||||
rx: mpsc::Receiver<Arc<AudioChunk>>,
|
||||
subscribers: MultiSubscriberNode,
|
||||
}
|
||||
|
||||
impl DecoderNode {
|
||||
pub fn new(channel_size: usize) -> (Self, mpsc::Sender<Arc<AudioChunk>>) {
|
||||
let (tx, rx) = mpsc::channel(channel_size);
|
||||
|
||||
let node = Self {
|
||||
rx,
|
||||
subscribers: MultiSubscriberNode::new(),
|
||||
};
|
||||
|
||||
(node, tx)
|
||||
}
|
||||
|
||||
pub fn add_subscriber(&mut self, tx: mpsc::Sender<Arc<AudioChunk>>) {
|
||||
self.subscribers.add_subscriber(tx);
|
||||
}
|
||||
|
||||
/// Mode passthrough - passe les chunks sans modification
|
||||
pub async fn run_passthrough(mut self) -> Result<(), AudioError> {
|
||||
while let Some(chunk) = self.rx.recv().await {
|
||||
self.subscribers.push(chunk).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
// 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 new_len = (chunk.len() as f64 * ratio) as usize;
|
||||
|
||||
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 + 1 < pairs.len() {
|
||||
let frac = src_pos - src_idx as f64;
|
||||
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;
|
||||
|
||||
resampled.push([left_sample, right_sample]);
|
||||
} else if src_idx < pairs.len() {
|
||||
resampled.push(pairs[src_idx]);
|
||||
}
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::BitDepth;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_decoder_passthrough() {
|
||||
let (mut node, tx) = DecoderNode::new(10);
|
||||
let (out_tx, mut out_rx) = mpsc::channel(10);
|
||||
|
||||
node.add_subscriber(out_tx);
|
||||
|
||||
tokio::spawn(async move {
|
||||
node.run_passthrough().await.unwrap();
|
||||
});
|
||||
|
||||
// Envoyer un chunk
|
||||
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, &received));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_decoder_resampling() {
|
||||
let (mut node, tx) = DecoderNode::new(10);
|
||||
let (out_tx, mut out_rx) = mpsc::channel(10);
|
||||
|
||||
node.add_subscriber(out_tx);
|
||||
|
||||
tokio::spawn(async move {
|
||||
node.run_with_resampling(96000).await.unwrap();
|
||||
});
|
||||
|
||||
// Envoyer un chunk à 48000 Hz
|
||||
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);
|
||||
// Le chunk devrait être environ 2x plus grand
|
||||
assert!(received.len() > 150 && received.len() < 250);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_decoder_no_resampling_needed() {
|
||||
let (mut node, tx) = DecoderNode::new(10);
|
||||
let (out_tx, mut out_rx) = mpsc::channel(10);
|
||||
|
||||
node.add_subscriber(out_tx);
|
||||
|
||||
tokio::spawn(async move {
|
||||
node.run_with_resampling(48000).await.unwrap();
|
||||
});
|
||||
|
||||
// Envoyer un chunk déjà au bon sample rate
|
||||
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, &received));
|
||||
}
|
||||
}
|
||||
500
old_code/pmoaudio/nodes/disk_sink.rs
Normal file
500
old_code/pmoaudio/nodes/disk_sink.rs
Normal file
@@ -0,0 +1,500 @@
|
||||
//! DiskSink - Écrit le flux audio dans un fichier
|
||||
//!
|
||||
//! Ce module fournit un sink qui écrit les chunks audio sur disque,
|
||||
//! avec support de la dérivation automatique du nom de fichier depuis la source.
|
||||
|
||||
use crate::{events::SourceNameUpdateEvent, nodes::AudioError, AudioChunk};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tokio::fs::File;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::sync::{mpsc, RwLock};
|
||||
|
||||
/// Configuration pour le DiskSink
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiskSinkConfig {
|
||||
/// Chemin racine où écrire les fichiers
|
||||
pub output_dir: PathBuf,
|
||||
|
||||
/// Nom de fichier explicite (optionnel)
|
||||
/// Si None, sera dérivé du nom de la source
|
||||
pub filename: Option<String>,
|
||||
|
||||
/// Format d'écriture
|
||||
pub format: AudioFileFormat,
|
||||
|
||||
/// Taille du buffer d'écriture (en chunks)
|
||||
pub buffer_size: usize,
|
||||
}
|
||||
|
||||
impl Default for DiskSinkConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
output_dir: PathBuf::from("."),
|
||||
filename: None,
|
||||
format: AudioFileFormat::Wav,
|
||||
buffer_size: 100,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats de fichiers audio supportés
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum AudioFileFormat {
|
||||
/// Format WAV (non compressé)
|
||||
Wav,
|
||||
/// Format FLAC (compressé sans perte)
|
||||
Flac,
|
||||
/// Format brut PCM
|
||||
Raw,
|
||||
}
|
||||
|
||||
impl AudioFileFormat {
|
||||
/// Retourne l'extension de fichier appropriée
|
||||
pub fn extension(&self) -> &str {
|
||||
match self {
|
||||
AudioFileFormat::Wav => "wav",
|
||||
AudioFileFormat::Flac => "flac",
|
||||
AudioFileFormat::Raw => "pcm",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// DiskSink - Écrit le flux audio dans un fichier sur disque
|
||||
///
|
||||
/// Ce sink consomme les chunks audio et les écrit dans un fichier.
|
||||
/// Le nom du fichier peut être dérivé automatiquement du nom de la source
|
||||
/// via les événements `SourceNameUpdateEvent`.
|
||||
///
|
||||
/// # Caractéristiques
|
||||
///
|
||||
/// - Écriture asynchrone avec buffer
|
||||
/// - Dérivation automatique du nom de fichier depuis la source
|
||||
/// - Support de plusieurs formats (WAV, FLAC, PCM brut)
|
||||
/// - Gestion du gain : applique le gain avant l'écriture
|
||||
///
|
||||
/// # Exemples
|
||||
///
|
||||
/// ```no_run
|
||||
/// use pmoaudio::{DiskSink, DiskSinkConfig};
|
||||
/// use std::path::PathBuf;
|
||||
///
|
||||
/// #[tokio::main]
|
||||
/// async fn main() {
|
||||
/// let config = DiskSinkConfig {
|
||||
/// output_dir: PathBuf::from("/tmp/audio"),
|
||||
/// filename: Some("output.wav".to_string()),
|
||||
/// ..Default::default()
|
||||
/// };
|
||||
///
|
||||
/// let (sink, sink_tx) = DiskSink::new("disk1".to_string(), config, 10);
|
||||
///
|
||||
/// tokio::spawn(async move {
|
||||
/// sink.run().await.unwrap()
|
||||
/// });
|
||||
/// }
|
||||
/// ```
|
||||
pub struct DiskSink {
|
||||
/// Identifiant du sink
|
||||
node_id: String,
|
||||
|
||||
/// Channel pour recevoir les chunks audio
|
||||
rx: mpsc::Receiver<Arc<AudioChunk>>,
|
||||
|
||||
/// Configuration
|
||||
config: DiskSinkConfig,
|
||||
|
||||
/// Nom de fichier résolu (partagé)
|
||||
resolved_filename: Arc<RwLock<Option<PathBuf>>>,
|
||||
|
||||
/// Receiver pour les événements de nom de source (optionnel)
|
||||
source_name_rx: Option<mpsc::Receiver<SourceNameUpdateEvent>>,
|
||||
|
||||
/// Writer pour le fichier
|
||||
writer: Option<AudioFileWriter>,
|
||||
}
|
||||
|
||||
impl DiskSink {
|
||||
/// Crée un nouveau DiskSink
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `node_id` - Identifiant unique du sink
|
||||
/// * `config` - Configuration du sink
|
||||
/// * `channel_size` - Taille du buffer du channel
|
||||
pub fn new(
|
||||
node_id: String,
|
||||
config: DiskSinkConfig,
|
||||
channel_size: usize,
|
||||
) -> (Self, mpsc::Sender<Arc<AudioChunk>>) {
|
||||
let (tx, rx) = mpsc::channel(channel_size);
|
||||
|
||||
let sink = Self {
|
||||
node_id,
|
||||
rx,
|
||||
config,
|
||||
resolved_filename: Arc::new(RwLock::new(None)),
|
||||
source_name_rx: None,
|
||||
writer: None,
|
||||
};
|
||||
|
||||
(sink, tx)
|
||||
}
|
||||
|
||||
/// Configure la source des événements de nom de source
|
||||
pub fn set_source_name_source(&mut self, rx: mpsc::Receiver<SourceNameUpdateEvent>) {
|
||||
self.source_name_rx = Some(rx);
|
||||
}
|
||||
|
||||
/// Résout le nom du fichier de sortie
|
||||
///
|
||||
/// Si un filename explicite est fourni dans la config, l'utilise.
|
||||
/// Sinon, utilise le source_name avec l'extension appropriée.
|
||||
fn resolve_filename(&self, source_name: Option<&str>) -> PathBuf {
|
||||
let filename = if let Some(ref explicit_name) = self.config.filename {
|
||||
explicit_name.clone()
|
||||
} else if let Some(name) = source_name {
|
||||
// Nettoyer le nom de la source pour en faire un nom de fichier valide
|
||||
let clean_name = name
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_alphanumeric() || c == '_' || c == '-' {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect::<String>();
|
||||
|
||||
format!("{}.{}", clean_name, self.config.format.extension())
|
||||
} else {
|
||||
// Fallback sur un nom par défaut
|
||||
format!("{}.{}", self.node_id, self.config.format.extension())
|
||||
};
|
||||
|
||||
self.config.output_dir.join(filename)
|
||||
}
|
||||
|
||||
/// Initialise le writer pour le fichier de sortie
|
||||
async fn initialize_writer(&mut self, source_name: Option<&str>) -> Result<(), AudioError> {
|
||||
let path = self.resolve_filename(source_name);
|
||||
*self.resolved_filename.write().await = Some(path.clone());
|
||||
|
||||
// Créer le répertoire parent si nécessaire
|
||||
if let Some(parent) = path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await.map_err(|e| {
|
||||
AudioError::ProcessingError(format!("Failed to create directory: {}", e))
|
||||
})?;
|
||||
}
|
||||
|
||||
// Créer le writer approprié selon le format
|
||||
let writer = match self.config.format {
|
||||
AudioFileFormat::Wav => AudioFileWriter::new_wav(path).await?,
|
||||
AudioFileFormat::Flac => {
|
||||
// FLAC nécessiterait une bibliothèque externe, pour l'instant utiliser WAV
|
||||
AudioFileWriter::new_wav(path).await?
|
||||
}
|
||||
AudioFileFormat::Raw => AudioFileWriter::new_raw(path).await?,
|
||||
};
|
||||
|
||||
self.writer = Some(writer);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Démarre la boucle de traitement du DiskSink
|
||||
pub async fn run(mut self) -> Result<DiskSinkStats, AudioError> {
|
||||
let mut stats = DiskSinkStats::new(self.node_id.clone());
|
||||
let mut source_name: Option<String> = None;
|
||||
let mut initialized = false;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
// Recevoir les chunks audio
|
||||
chunk_opt = self.rx.recv() => {
|
||||
match chunk_opt {
|
||||
Some(chunk) => {
|
||||
// Initialiser le writer à la réception du premier chunk
|
||||
if !initialized {
|
||||
self.initialize_writer(source_name.as_deref()).await?;
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
// Appliquer le gain avant l'écriture
|
||||
let chunk_with_gain = if chunk.gain_db().abs() > f64::EPSILON {
|
||||
Arc::clone(&chunk).apply_gain()
|
||||
} else {
|
||||
Arc::clone(&chunk)
|
||||
};
|
||||
|
||||
// Écrire le chunk
|
||||
if let Some(ref mut writer) = self.writer {
|
||||
writer.write_chunk(&chunk_with_gain).await?;
|
||||
stats.record_chunk(&chunk_with_gain);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// Channel fermé, terminer
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Recevoir les mises à jour du nom de source
|
||||
source_event_opt = async {
|
||||
if let Some(ref mut rx) = self.source_name_rx {
|
||||
rx.recv().await
|
||||
} else {
|
||||
std::future::pending().await
|
||||
}
|
||||
} => {
|
||||
if let Some(event) = source_event_opt {
|
||||
source_name = Some(event.source_name.clone());
|
||||
|
||||
// Si on n'a pas encore initialisé, le nom sera utilisé plus tard
|
||||
// Sinon, on pourrait décider de fermer le fichier actuel et d'en créer un nouveau
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fermer le fichier proprement
|
||||
if let Some(writer) = self.writer {
|
||||
writer.close().await?;
|
||||
}
|
||||
|
||||
stats.finalize();
|
||||
Ok(stats)
|
||||
}
|
||||
}
|
||||
|
||||
/// Writer pour fichiers audio
|
||||
struct AudioFileWriter {
|
||||
file: File,
|
||||
format: AudioFileFormat,
|
||||
sample_rate: Option<u32>,
|
||||
total_samples: usize,
|
||||
}
|
||||
|
||||
impl AudioFileWriter {
|
||||
/// Crée un writer WAV
|
||||
async fn new_wav(path: PathBuf) -> Result<Self, AudioError> {
|
||||
let file = File::create(path)
|
||||
.await
|
||||
.map_err(|e| AudioError::ProcessingError(format!("Failed to create file: {}", e)))?;
|
||||
|
||||
Ok(Self {
|
||||
file,
|
||||
format: AudioFileFormat::Wav,
|
||||
sample_rate: None,
|
||||
total_samples: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Crée un writer pour PCM brut
|
||||
async fn new_raw(path: PathBuf) -> Result<Self, AudioError> {
|
||||
let file = File::create(path)
|
||||
.await
|
||||
.map_err(|e| AudioError::ProcessingError(format!("Failed to create file: {}", e)))?;
|
||||
|
||||
Ok(Self {
|
||||
file,
|
||||
format: AudioFileFormat::Raw,
|
||||
sample_rate: None,
|
||||
total_samples: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Écrit un chunk audio
|
||||
async fn write_chunk(&mut self, chunk: &AudioChunk) -> Result<(), AudioError> {
|
||||
// Enregistrer le sample rate du premier chunk
|
||||
if self.sample_rate.is_none() {
|
||||
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(sr).await?;
|
||||
}
|
||||
}
|
||||
|
||||
// Convertir en bytes (little-endian 16-bit PCM)
|
||||
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| {
|
||||
AudioError::ProcessingError(format!("Failed to write audio data: {}", e))
|
||||
})?;
|
||||
|
||||
self.total_samples += chunk.len();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Écrit un en-tête WAV simplifié
|
||||
async fn write_wav_header(&mut self, sample_rate: u32) -> Result<(), AudioError> {
|
||||
// En-tête WAV basique (sera mis à jour à la fermeture)
|
||||
let mut header = Vec::new();
|
||||
|
||||
// RIFF chunk
|
||||
header.extend_from_slice(b"RIFF");
|
||||
header.extend_from_slice(&0u32.to_le_bytes()); // Taille (à mettre à jour)
|
||||
header.extend_from_slice(b"WAVE");
|
||||
|
||||
// fmt chunk
|
||||
header.extend_from_slice(b"fmt ");
|
||||
header.extend_from_slice(&16u32.to_le_bytes()); // Taille du fmt chunk
|
||||
header.extend_from_slice(&1u16.to_le_bytes()); // Format PCM
|
||||
header.extend_from_slice(&2u16.to_le_bytes()); // 2 canaux (stéréo)
|
||||
header.extend_from_slice(&sample_rate.to_le_bytes());
|
||||
header.extend_from_slice(&(sample_rate * 4).to_le_bytes()); // Byte rate
|
||||
header.extend_from_slice(&4u16.to_le_bytes()); // Block align
|
||||
header.extend_from_slice(&16u16.to_le_bytes()); // Bits per sample
|
||||
|
||||
// data chunk header
|
||||
header.extend_from_slice(b"data");
|
||||
header.extend_from_slice(&0u32.to_le_bytes()); // Taille des données (à mettre à jour)
|
||||
|
||||
self.file.write_all(&header).await.map_err(|e| {
|
||||
AudioError::ProcessingError(format!("Failed to write WAV header: {}", e))
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ferme le fichier et met à jour l'en-tête si nécessaire
|
||||
async fn close(mut self) -> Result<(), AudioError> {
|
||||
if matches!(self.format, AudioFileFormat::Wav) {
|
||||
// Mettre à jour les tailles dans l'en-tête WAV
|
||||
let data_size = (self.total_samples * 4) as u32; // 2 bytes per sample * 2 channels
|
||||
let file_size = data_size + 36;
|
||||
|
||||
// Positionner au début et réécrire les tailles
|
||||
use tokio::io::AsyncSeekExt;
|
||||
self.file
|
||||
.seek(std::io::SeekFrom::Start(4))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AudioError::ProcessingError(format!("Failed to seek in file: {}", e))
|
||||
})?;
|
||||
self.file
|
||||
.write_all(&file_size.to_le_bytes())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AudioError::ProcessingError(format!("Failed to update file size: {}", e))
|
||||
})?;
|
||||
|
||||
self.file
|
||||
.seek(std::io::SeekFrom::Start(40))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AudioError::ProcessingError(format!("Failed to seek in file: {}", e))
|
||||
})?;
|
||||
self.file
|
||||
.write_all(&data_size.to_le_bytes())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AudioError::ProcessingError(format!("Failed to update data size: {}", e))
|
||||
})?;
|
||||
}
|
||||
|
||||
self.file
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|e| AudioError::ProcessingError(format!("Failed to flush file: {}", e)))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Statistiques du DiskSink
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiskSinkStats {
|
||||
pub node_id: String,
|
||||
pub chunks_written: u64,
|
||||
pub total_samples: u64,
|
||||
pub total_duration_sec: f64,
|
||||
}
|
||||
|
||||
impl DiskSinkStats {
|
||||
pub fn new(node_id: String) -> Self {
|
||||
Self {
|
||||
node_id,
|
||||
chunks_written: 0,
|
||||
total_samples: 0,
|
||||
total_duration_sec: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
pub fn finalize(&mut self) {
|
||||
// Pourrait effectuer des calculs finaux ici
|
||||
}
|
||||
|
||||
pub fn display(&self) {
|
||||
println!("\n=== DiskSink Statistics: {} ===", self.node_id);
|
||||
println!("Chunks written: {}", self.chunks_written);
|
||||
println!("Total samples: {}", self.total_samples);
|
||||
println!("Total duration: {:.3} sec", self.total_duration_sec);
|
||||
println!("============================\n");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::BitDepth;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_disk_sink_basic() {
|
||||
let temp_dir = std::env::temp_dir().join("pmoaudio_test");
|
||||
tokio::fs::create_dir_all(&temp_dir).await.unwrap();
|
||||
|
||||
let config = DiskSinkConfig {
|
||||
output_dir: temp_dir.clone(),
|
||||
filename: Some("test_output.wav".to_string()),
|
||||
format: AudioFileFormat::Wav,
|
||||
buffer_size: 10,
|
||||
};
|
||||
|
||||
let (sink, tx) = DiskSink::new("test".to_string(), config, 10);
|
||||
|
||||
let handle = tokio::spawn(async move { sink.run().await });
|
||||
|
||||
// Envoyer quelques chunks
|
||||
for i in 0..5 {
|
||||
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);
|
||||
|
||||
let stats = handle.await.unwrap().unwrap();
|
||||
assert_eq!(stats.chunks_written, 5);
|
||||
|
||||
// Vérifier que le fichier existe
|
||||
let output_path = temp_dir.join("test_output.wav");
|
||||
assert!(output_path.exists());
|
||||
|
||||
// Nettoyage
|
||||
tokio::fs::remove_file(output_path).await.ok();
|
||||
tokio::fs::remove_dir(temp_dir).await.ok();
|
||||
}
|
||||
}
|
||||
254
old_code/pmoaudio/nodes/dsp_node.rs
Normal file
254
old_code/pmoaudio/nodes/dsp_node.rs
Normal file
@@ -0,0 +1,254 @@
|
||||
use crate::{
|
||||
nodes::{AudioError, MultiSubscriberNode},
|
||||
AudioChunk,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
/// DspNode - Applique des transformations DSP aux chunks audio
|
||||
///
|
||||
/// Clone les données uniquement si elles doivent être modifiées
|
||||
pub struct DspNode {
|
||||
rx: mpsc::Receiver<Arc<AudioChunk>>,
|
||||
subscribers: MultiSubscriberNode,
|
||||
gain_db: f32,
|
||||
}
|
||||
|
||||
impl DspNode {
|
||||
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_db,
|
||||
};
|
||||
|
||||
(node, tx)
|
||||
}
|
||||
|
||||
pub fn add_subscriber(&mut self, tx: mpsc::Sender<Arc<AudioChunk>>) {
|
||||
self.subscribers.add_subscriber(tx);
|
||||
}
|
||||
|
||||
/// Applique le gain aux chunks
|
||||
pub async fn run(mut self) -> Result<(), AudioError> {
|
||||
while let Some(chunk) = self.rx.recv().await {
|
||||
if self.gain_db.abs() < f32::EPSILON {
|
||||
// Gain = 0 dB, pas de transformation nécessaire
|
||||
self.subscribers.push(chunk).await?;
|
||||
continue;
|
||||
}
|
||||
|
||||
let gain_linear = AudioChunk::gain_linear_from_db(self.gain_db as f64) as f32;
|
||||
let mut pairs = chunk.to_pairs_f32();
|
||||
for frame in &mut pairs {
|
||||
frame[0] *= gain_linear;
|
||||
frame[1] *= gain_linear;
|
||||
}
|
||||
|
||||
let mut new_chunk = AudioChunk::from_pairs_f32(
|
||||
chunk.order(),
|
||||
pairs,
|
||||
chunk.sample_rate(),
|
||||
chunk.bit_depth(),
|
||||
);
|
||||
if chunk.gain_db().abs() > f64::EPSILON {
|
||||
new_chunk = new_chunk.set_gain_db(chunk.gain_db());
|
||||
}
|
||||
self.subscribers.push(new_chunk).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Met à jour le gain dynamiquement (nécessite un `Arc<RwLock<f32>>` dans une version réelle)
|
||||
pub fn set_gain_db(&mut self, gain_db: f32) {
|
||||
self.gain_db = gain_db;
|
||||
}
|
||||
}
|
||||
|
||||
/// DspNode avec filtre passe-bas simple (mock)
|
||||
#[allow(dead_code)]
|
||||
pub struct LowPassDspNode {
|
||||
rx: mpsc::Receiver<Arc<AudioChunk>>,
|
||||
subscribers: MultiSubscriberNode,
|
||||
alpha: f32, // Coefficient du filtre
|
||||
prev_left: f32,
|
||||
prev_right: f32,
|
||||
}
|
||||
|
||||
impl LowPassDspNode {
|
||||
#[allow(dead_code)]
|
||||
pub fn new(channel_size: usize, cutoff_ratio: f32) -> (Self, mpsc::Sender<Arc<AudioChunk>>) {
|
||||
let (tx, rx) = mpsc::channel(channel_size);
|
||||
|
||||
// Filtre RC simple: alpha = dt / (RC + dt)
|
||||
// cutoff_ratio entre 0 (tout couper) et 1 (tout passer)
|
||||
let alpha = cutoff_ratio.clamp(0.0, 1.0);
|
||||
|
||||
let node = Self {
|
||||
rx,
|
||||
subscribers: MultiSubscriberNode::new(),
|
||||
alpha,
|
||||
prev_left: 0.0,
|
||||
prev_right: 0.0,
|
||||
};
|
||||
|
||||
(node, tx)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn add_subscriber(&mut self, tx: mpsc::Sender<Arc<AudioChunk>>) {
|
||||
self.subscribers.add_subscriber(tx);
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn run(mut self) -> Result<(), AudioError> {
|
||||
while let Some(chunk) = self.rx.recv().await {
|
||||
let pairs = chunk.to_pairs_f32();
|
||||
let mut filtered = Vec::with_capacity(pairs.len());
|
||||
|
||||
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]);
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
self.subscribers.push(new_chunk).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[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, 0.0);
|
||||
let (out_tx, mut out_rx) = mpsc::channel(10);
|
||||
|
||||
node.add_subscriber(out_tx);
|
||||
|
||||
tokio::spawn(async move {
|
||||
node.run().await.unwrap();
|
||||
});
|
||||
|
||||
// Envoyer un chunk
|
||||
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, &received));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_dsp_node_gain() {
|
||||
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);
|
||||
|
||||
tokio::spawn(async move {
|
||||
node.run().await.unwrap();
|
||||
});
|
||||
|
||||
// Envoyer un chunk
|
||||
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();
|
||||
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]
|
||||
async fn test_lowpass_dsp_node() {
|
||||
let (mut node, tx) = LowPassDspNode::new(10, 0.5);
|
||||
let (out_tx, mut out_rx) = mpsc::channel(10);
|
||||
|
||||
node.add_subscriber(out_tx);
|
||||
|
||||
tokio::spawn(async move {
|
||||
node.run().await.unwrap();
|
||||
});
|
||||
|
||||
// Envoyer un chunk avec un signal carré
|
||||
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(chunk).await.unwrap();
|
||||
|
||||
// Le filtre devrait lisser le signal
|
||||
let received = out_rx.recv().await.unwrap();
|
||||
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 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);
|
||||
|
||||
node.add_subscriber(out_tx1);
|
||||
node.add_subscriber(out_tx2);
|
||||
|
||||
tokio::spawn(async move {
|
||||
node.run().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));
|
||||
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
|
||||
}
|
||||
}
|
||||
398
old_code/pmoaudio/nodes/mpd_sink.rs
Normal file
398
old_code/pmoaudio/nodes/mpd_sink.rs
Normal file
@@ -0,0 +1,398 @@
|
||||
//! MpdSink - Envoie le flux audio à un démon MPD (Music Player Daemon)
|
||||
//!
|
||||
//! Ce module fournit un sink qui streame l'audio vers un démon MPD distant ou local.
|
||||
//! Note: Cette implémentation est une version mock/skeleton. Une vraie implémentation
|
||||
//! nécessiterait le protocole MPD complet et l'utilisation de bibliothèques comme `mpd`.
|
||||
|
||||
use crate::{nodes::AudioError, AudioChunk};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
/// Configuration pour le MpdSink
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MpdConfig {
|
||||
/// Adresse du serveur MPD
|
||||
pub host: String,
|
||||
|
||||
/// Port du serveur MPD (défaut: 6600)
|
||||
pub port: u16,
|
||||
|
||||
/// Mot de passe optionnel
|
||||
pub password: Option<String>,
|
||||
|
||||
/// Nom de l'output MPD à utiliser (optionnel)
|
||||
pub output_name: Option<String>,
|
||||
|
||||
/// Taille du buffer
|
||||
pub buffer_size: usize,
|
||||
|
||||
/// Format d'envoi
|
||||
pub format: MpdAudioFormat,
|
||||
}
|
||||
|
||||
impl Default for MpdConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
host: "localhost".to_string(),
|
||||
port: 6600,
|
||||
password: None,
|
||||
output_name: None,
|
||||
buffer_size: 50,
|
||||
format: MpdAudioFormat::S16Le,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats audio supportés par MPD
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum MpdAudioFormat {
|
||||
/// Signed 16-bit Little Endian
|
||||
S16Le,
|
||||
/// Signed 24-bit Little Endian
|
||||
S24Le,
|
||||
/// Signed 32-bit Little Endian
|
||||
S32Le,
|
||||
/// Float 32-bit
|
||||
F32,
|
||||
}
|
||||
|
||||
impl MpdAudioFormat {
|
||||
/// Retourne le nom du format pour le protocole MPD
|
||||
pub fn as_mpd_string(&self) -> &str {
|
||||
match self {
|
||||
MpdAudioFormat::S16Le => "16:16:2",
|
||||
MpdAudioFormat::S24Le => "24:24:2",
|
||||
MpdAudioFormat::S32Le => "32:32:2",
|
||||
MpdAudioFormat::F32 => "f:32:2",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// MpdSink - Streame vers un démon MPD
|
||||
///
|
||||
/// Ce sink se connecte à un serveur MPD et lui envoie le flux audio.
|
||||
/// MPD peut ensuite router l'audio vers différents outputs (ALSA, PulseAudio, HTTP, etc.).
|
||||
///
|
||||
/// # Implémentation actuelle
|
||||
///
|
||||
/// Cette version est un mock qui simule la communication avec MPD.
|
||||
/// Pour une vraie implémentation, il faudrait:
|
||||
/// - Implémenter le protocole MPD (commandes textuelles sur TCP)
|
||||
/// - S'authentifier si nécessaire
|
||||
/// - Configurer le format audio
|
||||
/// - Envoyer les données PCM via le protocole approprié
|
||||
/// - Gérer les commandes de contrôle (play, pause, stop)
|
||||
///
|
||||
/// # Exemples
|
||||
///
|
||||
/// ```no_run
|
||||
/// use pmoaudio::{MpdSink, MpdConfig};
|
||||
///
|
||||
/// #[tokio::main]
|
||||
/// async fn main() {
|
||||
/// let config = MpdConfig {
|
||||
/// host: "localhost".to_string(),
|
||||
/// port: 6600,
|
||||
/// password: None,
|
||||
/// ..Default::default()
|
||||
/// };
|
||||
///
|
||||
/// let (sink, sink_tx) = MpdSink::new("mpd1".to_string(), config, 10);
|
||||
///
|
||||
/// tokio::spawn(async move {
|
||||
/// sink.run().await.unwrap()
|
||||
/// });
|
||||
/// }
|
||||
/// ```
|
||||
pub struct MpdSink {
|
||||
/// Identifiant du sink
|
||||
node_id: String,
|
||||
|
||||
/// Channel pour recevoir les chunks audio
|
||||
rx: mpsc::Receiver<Arc<AudioChunk>>,
|
||||
|
||||
/// Configuration
|
||||
config: MpdConfig,
|
||||
|
||||
/// État de la connexion (mock)
|
||||
connected: bool,
|
||||
|
||||
/// Version du serveur MPD (mock)
|
||||
mpd_version: Option<String>,
|
||||
}
|
||||
|
||||
impl MpdSink {
|
||||
/// Crée un nouveau MpdSink
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `node_id` - Identifiant unique du sink
|
||||
/// * `config` - Configuration MPD
|
||||
/// * `channel_size` - Taille du buffer du channel
|
||||
pub fn new(
|
||||
node_id: String,
|
||||
config: MpdConfig,
|
||||
channel_size: usize,
|
||||
) -> (Self, mpsc::Sender<Arc<AudioChunk>>) {
|
||||
let (tx, rx) = mpsc::channel(channel_size);
|
||||
|
||||
let sink = Self {
|
||||
node_id,
|
||||
rx,
|
||||
config,
|
||||
connected: false,
|
||||
mpd_version: None,
|
||||
};
|
||||
|
||||
(sink, tx)
|
||||
}
|
||||
|
||||
/// Établit la connexion avec le serveur MPD (mock)
|
||||
async fn connect(&mut self) -> Result<(), AudioError> {
|
||||
println!(
|
||||
"[{}] Connecting to MPD at {}:{}...",
|
||||
self.node_id, self.config.host, self.config.port
|
||||
);
|
||||
|
||||
// Simuler une connexion TCP
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(300)).await;
|
||||
|
||||
// Dans une vraie implémentation:
|
||||
// 1. Établir connexion TCP
|
||||
// 2. Lire la bannière de version
|
||||
// 3. S'authentifier si password fourni
|
||||
// 4. Configurer le format audio
|
||||
|
||||
self.mpd_version = Some("0.23.0".to_string());
|
||||
self.connected = true;
|
||||
|
||||
println!(
|
||||
"[{}] Connected to MPD v{} successfully",
|
||||
self.node_id,
|
||||
self.mpd_version.as_ref().unwrap()
|
||||
);
|
||||
|
||||
// Configurer le format audio
|
||||
self.configure_audio_format().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Configure le format audio sur MPD (mock)
|
||||
async fn configure_audio_format(&self) -> Result<(), AudioError> {
|
||||
println!(
|
||||
"[{}] Configuring audio format: {}",
|
||||
self.node_id,
|
||||
self.config.format.as_mpd_string()
|
||||
);
|
||||
|
||||
// Dans une vraie implémentation:
|
||||
// Envoyer une commande MPD pour configurer le format
|
||||
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Envoie un chunk au serveur MPD (mock)
|
||||
async fn send_chunk(&self, _chunk: &AudioChunk) -> Result<(), AudioError> {
|
||||
if !self.connected {
|
||||
return Err(AudioError::ProcessingError(
|
||||
"Not connected to MPD".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Dans une vraie implémentation:
|
||||
// 1. Appliquer le gain
|
||||
// 2. Convertir dans le format approprié (S16LE, etc.)
|
||||
// 3. Envoyer via le protocole MPD (probablement via une commande `sendmessage` ou pipe)
|
||||
|
||||
// Simuler un délai d'envoi
|
||||
tokio::time::sleep(tokio::time::Duration::from_micros(50)).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Déconnecte proprement du serveur MPD (mock)
|
||||
async fn disconnect(&mut self) -> Result<(), AudioError> {
|
||||
if self.connected {
|
||||
println!("[{}] Disconnecting from MPD...", self.node_id);
|
||||
|
||||
// Dans une vraie implémentation:
|
||||
// Envoyer la commande "close"
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||
|
||||
self.connected = false;
|
||||
|
||||
println!("[{}] Disconnected successfully", self.node_id);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Démarre la boucle de traitement du MpdSink
|
||||
pub async fn run(mut self) -> Result<MpdStats, AudioError> {
|
||||
// Établir la connexion
|
||||
self.connect().await?;
|
||||
|
||||
let mut stats = MpdStats::new(
|
||||
self.node_id.clone(),
|
||||
format!("{}:{}", self.config.host, self.config.port),
|
||||
);
|
||||
|
||||
// Boucle principale
|
||||
while let Some(chunk) = self.rx.recv().await {
|
||||
// Appliquer le gain si nécessaire
|
||||
let chunk_to_send = if chunk.gain_db().abs() > f64::EPSILON {
|
||||
Arc::clone(&chunk).apply_gain()
|
||||
} else {
|
||||
Arc::clone(&chunk)
|
||||
};
|
||||
|
||||
// Envoyer au serveur MPD
|
||||
self.send_chunk(&chunk_to_send).await?;
|
||||
|
||||
stats.record_chunk(&chunk_to_send);
|
||||
}
|
||||
|
||||
// Déconnexion propre
|
||||
self.disconnect().await?;
|
||||
|
||||
stats.finalize();
|
||||
Ok(stats)
|
||||
}
|
||||
|
||||
/// Retourne un handle pour contrôler le sink (mock)
|
||||
pub fn get_handle(&self) -> MpdHandle {
|
||||
MpdHandle {
|
||||
node_id: self.node_id.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle pour contrôler le MpdSink
|
||||
///
|
||||
/// Permet d'envoyer des commandes de contrôle au serveur MPD
|
||||
#[derive(Clone)]
|
||||
pub struct MpdHandle {
|
||||
node_id: String,
|
||||
}
|
||||
|
||||
impl MpdHandle {
|
||||
/// Commande play (mock)
|
||||
pub async fn play(&self) -> Result<(), AudioError> {
|
||||
println!("[{}] MPD command: play", self.node_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Commande pause (mock)
|
||||
pub async fn pause(&self) -> Result<(), AudioError> {
|
||||
println!("[{}] MPD command: pause", self.node_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Commande stop (mock)
|
||||
pub async fn stop(&self) -> Result<(), AudioError> {
|
||||
println!("[{}] MPD command: stop", self.node_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Change le volume MPD (0-100) (mock)
|
||||
pub async fn set_volume(&self, volume: u8) -> Result<(), AudioError> {
|
||||
let clamped = volume.min(100);
|
||||
println!("[{}] MPD command: setvol {}", self.node_id, clamped);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Statistiques du MpdSink
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MpdStats {
|
||||
pub node_id: String,
|
||||
pub server_address: String,
|
||||
pub chunks_sent: u64,
|
||||
pub total_samples: u64,
|
||||
pub total_duration_sec: f64,
|
||||
}
|
||||
|
||||
impl MpdStats {
|
||||
pub fn new(node_id: String, server_address: String) -> Self {
|
||||
Self {
|
||||
node_id,
|
||||
server_address,
|
||||
chunks_sent: 0,
|
||||
total_samples: 0,
|
||||
total_duration_sec: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
pub fn finalize(&mut self) {
|
||||
// Calculs finaux si nécessaire
|
||||
}
|
||||
|
||||
pub fn display(&self) {
|
||||
println!("\n=== MPD Sink Statistics: {} ===", self.node_id);
|
||||
println!("Server: {}", self.server_address);
|
||||
println!("Chunks sent: {}", self.chunks_sent);
|
||||
println!("Total samples: {}", self.total_samples);
|
||||
println!("Total duration: {:.3} sec", self.total_duration_sec);
|
||||
println!("===============================\n");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::BitDepth;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_mpd_sink_basic() {
|
||||
let config = MpdConfig {
|
||||
host: "localhost".to_string(),
|
||||
port: 6600,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let (sink, tx) = MpdSink::new("test".to_string(), config, 10);
|
||||
|
||||
let handle = tokio::spawn(async move { sink.run().await });
|
||||
|
||||
// Envoyer quelques chunks
|
||||
for i in 0..5 {
|
||||
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);
|
||||
|
||||
let stats = handle.await.unwrap().unwrap();
|
||||
assert_eq!(stats.chunks_sent, 5);
|
||||
assert_eq!(stats.server_address, "localhost:6600");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_mpd_handle() {
|
||||
let config = MpdConfig::default();
|
||||
let (sink, _tx) = MpdSink::new("test".to_string(), config, 10);
|
||||
|
||||
let handle = sink.get_handle();
|
||||
|
||||
// Tester les commandes (mock)
|
||||
handle.play().await.unwrap();
|
||||
handle.pause().await.unwrap();
|
||||
handle.set_volume(75).await.unwrap();
|
||||
handle.stop().await.unwrap();
|
||||
}
|
||||
}
|
||||
212
old_code/pmoaudio/nodes/sink_node.rs
Normal file
212
old_code/pmoaudio/nodes/sink_node.rs
Normal file
@@ -0,0 +1,212 @@
|
||||
use crate::{nodes::AudioError, AudioChunk};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
/// SinkNode - Node terminal qui consomme les chunks audio
|
||||
///
|
||||
/// Version mock pour tests et logging
|
||||
pub struct SinkNode {
|
||||
rx: mpsc::Receiver<Arc<AudioChunk>>,
|
||||
name: String,
|
||||
}
|
||||
|
||||
impl SinkNode {
|
||||
pub fn new(name: String, channel_size: usize) -> (Self, mpsc::Sender<Arc<AudioChunk>>) {
|
||||
let (tx, rx) = mpsc::channel(channel_size);
|
||||
|
||||
let node = Self { rx, name };
|
||||
|
||||
(node, tx)
|
||||
}
|
||||
|
||||
/// Version silencieuse - consomme les chunks sans action
|
||||
pub async fn run_silent(mut self) -> Result<(), AudioError> {
|
||||
while let Some(_chunk) = self.rx.recv().await {
|
||||
// Ne rien faire, juste consommer
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Version avec logging
|
||||
pub async fn run_with_logging(mut self) -> Result<(), AudioError> {
|
||||
while let Some(chunk) = self.rx.recv().await {
|
||||
println!(
|
||||
"[{}] Received chunk #{} - {} samples @ {} Hz",
|
||||
self.name,
|
||||
chunk.order(),
|
||||
chunk.len(),
|
||||
chunk.sample_rate()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Version avec statistiques
|
||||
pub async fn run_with_stats(mut self) -> Result<SinkStats, AudioError> {
|
||||
let mut stats = SinkStats::new(self.name.clone());
|
||||
|
||||
while let Some(chunk) = self.rx.recv().await {
|
||||
stats.process_chunk(&chunk);
|
||||
}
|
||||
|
||||
Ok(stats)
|
||||
}
|
||||
|
||||
/// Version mock pour écriture dans un fichier (simule l'écriture)
|
||||
pub async fn run_mock_file_writer(mut self) -> Result<usize, AudioError> {
|
||||
let mut total_samples = 0;
|
||||
|
||||
while let Some(chunk) = self.rx.recv().await {
|
||||
total_samples += chunk.len();
|
||||
// Simuler l'écriture avec un petit délai
|
||||
tokio::time::sleep(tokio::time::Duration::from_micros(10)).await;
|
||||
}
|
||||
|
||||
Ok(total_samples)
|
||||
}
|
||||
}
|
||||
|
||||
/// Statistiques collectées par un SinkNode
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SinkStats {
|
||||
pub name: String,
|
||||
pub chunks_received: u64,
|
||||
pub total_samples: u64,
|
||||
pub total_duration_sec: f64,
|
||||
pub peak_left: f32,
|
||||
pub peak_right: f32,
|
||||
pub rms_left: f64,
|
||||
pub rms_right: f64,
|
||||
}
|
||||
|
||||
impl SinkStats {
|
||||
pub fn new(name: String) -> Self {
|
||||
Self {
|
||||
name,
|
||||
chunks_received: 0,
|
||||
total_samples: 0,
|
||||
total_duration_sec: 0.0,
|
||||
peak_left: 0.0,
|
||||
peak_right: 0.0,
|
||||
rms_left: 0.0,
|
||||
rms_right: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn process_chunk(&mut self, chunk: &AudioChunk) {
|
||||
self.chunks_received += 1;
|
||||
let len = chunk.len() as u64;
|
||||
self.total_samples += len;
|
||||
self.total_duration_sec += chunk.len() as f64 / chunk.sample_rate() as f64;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
self.peak_left = peak_left;
|
||||
self.peak_right = peak_right;
|
||||
|
||||
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) * prev_samples as f64 + sum_squares_right)
|
||||
/ self.total_samples as f64)
|
||||
.sqrt();
|
||||
}
|
||||
|
||||
pub fn display(&self) {
|
||||
println!("\n=== Sink Statistics: {} ===", self.name);
|
||||
println!("Chunks received: {}", self.chunks_received);
|
||||
println!("Total samples: {}", self.total_samples);
|
||||
println!("Total duration: {:.3} sec", self.total_duration_sec);
|
||||
println!("Peak L/R: {:.3} / {:.3}", self.peak_left, self.peak_right);
|
||||
println!("RMS L/R: {:.3} / {:.3}", self.rms_left, self.rms_right);
|
||||
println!("========================\n");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::BitDepth;
|
||||
|
||||
const BD: BitDepth = BitDepth::B24;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_sink_node_silent() {
|
||||
let (node, tx) = SinkNode::new("test".to_string(), 10);
|
||||
|
||||
let handle = tokio::spawn(async move { node.run_silent().await });
|
||||
|
||||
// Envoyer quelques chunks
|
||||
for i in 0..3 {
|
||||
let chunk = AudioChunk::from_channels_f32(i, vec![0.0; 100], vec![0.0; 100], 48000, BD);
|
||||
tx.send(chunk).await.unwrap();
|
||||
}
|
||||
|
||||
drop(tx);
|
||||
handle.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_sink_node_stats() {
|
||||
let (node, tx) = SinkNode::new("test".to_string(), 10);
|
||||
|
||||
let handle = tokio::spawn(async move { node.run_with_stats().await });
|
||||
|
||||
// Envoyer des chunks avec signal connu
|
||||
for i in 0..3 {
|
||||
let chunk =
|
||||
AudioChunk::from_channels_f32(i, vec![1.0; 1000], vec![0.5; 1000], 48000, BD);
|
||||
tx.send(chunk).await.unwrap();
|
||||
}
|
||||
|
||||
drop(tx);
|
||||
let stats = handle.await.unwrap().unwrap();
|
||||
|
||||
assert_eq!(stats.chunks_received, 3);
|
||||
assert_eq!(stats.total_samples, 3000);
|
||||
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);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_sink_node_file_writer() {
|
||||
let (node, tx) = SinkNode::new("writer".to_string(), 10);
|
||||
|
||||
let handle = tokio::spawn(async move { node.run_mock_file_writer().await });
|
||||
|
||||
// Envoyer des chunks
|
||||
for i in 0..5 {
|
||||
let chunk = AudioChunk::from_channels_f32(i, vec![0.0; 100], vec![0.0; 100], 48000, BD);
|
||||
tx.send(chunk).await.unwrap();
|
||||
}
|
||||
|
||||
drop(tx);
|
||||
let total_samples = handle.await.unwrap().unwrap();
|
||||
|
||||
assert_eq!(total_samples, 500);
|
||||
}
|
||||
}
|
||||
171
old_code/pmoaudio/nodes/source_node.rs
Normal file
171
old_code/pmoaudio/nodes/source_node.rs
Normal file
@@ -0,0 +1,171 @@
|
||||
use crate::{
|
||||
nodes::{AudioError, MultiSubscriberNode},
|
||||
AudioChunk, BitDepth,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
/// SourceNode - Génère ou lit des chunks audio depuis une source
|
||||
///
|
||||
/// Ce node est la source du pipeline. Version mock pour tests.
|
||||
pub struct SourceNode {
|
||||
subscribers: MultiSubscriberNode,
|
||||
}
|
||||
|
||||
const DEFAULT_BIT_DEPTH: BitDepth = BitDepth::B24;
|
||||
|
||||
impl SourceNode {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
subscribers: MultiSubscriberNode::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_subscriber(&mut self, tx: mpsc::Sender<Arc<AudioChunk>>) {
|
||||
self.subscribers.add_subscriber(tx);
|
||||
}
|
||||
|
||||
/// Génère un chunk de test avec une forme d'onde sinusoïdale
|
||||
pub fn generate_test_chunk(
|
||||
order: u64,
|
||||
size: usize,
|
||||
sample_rate: u32,
|
||||
frequency: f32,
|
||||
) -> Arc<AudioChunk> {
|
||||
let mut left = Vec::with_capacity(size);
|
||||
let mut right = Vec::with_capacity(size);
|
||||
|
||||
for i in 0..size {
|
||||
let t = (order * size as u64 + i as u64) as f32 / sample_rate as f32;
|
||||
let sample = (2.0 * std::f32::consts::PI * frequency * t).sin();
|
||||
left.push(sample);
|
||||
right.push(sample * 0.8); // Légèrement différent pour la stéréo
|
||||
}
|
||||
|
||||
AudioChunk::from_channels_f32(order, left, right, sample_rate, DEFAULT_BIT_DEPTH)
|
||||
}
|
||||
|
||||
/// Génère et envoie des chunks de test
|
||||
pub async fn generate_chunks(
|
||||
&self,
|
||||
count: u64,
|
||||
chunk_size: usize,
|
||||
sample_rate: u32,
|
||||
frequency: f32,
|
||||
) -> Result<(), AudioError> {
|
||||
for i in 0..count {
|
||||
let chunk = Self::generate_test_chunk(i, chunk_size, sample_rate, frequency);
|
||||
self.subscribers.push(chunk).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Génère des chunks silencieux
|
||||
pub async fn generate_silence(
|
||||
&self,
|
||||
count: u64,
|
||||
chunk_size: usize,
|
||||
sample_rate: u32,
|
||||
) -> Result<(), AudioError> {
|
||||
for i in 0..count {
|
||||
let stereo = vec![[0i32; 2]; chunk_size];
|
||||
let chunk = AudioChunk::new(i, stereo, sample_rate, DEFAULT_BIT_DEPTH);
|
||||
self.subscribers.push(chunk).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Version streaming : génère des chunks continuellement avec délai
|
||||
pub async fn stream_chunks(
|
||||
&self,
|
||||
chunk_size: usize,
|
||||
sample_rate: u32,
|
||||
frequency: f32,
|
||||
duration_ms: u64,
|
||||
) -> Result<(), AudioError> {
|
||||
let chunk_duration_ms = (chunk_size as f64 / sample_rate as f64 * 1000.0) as u64;
|
||||
let mut order = 0u64;
|
||||
|
||||
let start = tokio::time::Instant::now();
|
||||
let duration = tokio::time::Duration::from_millis(duration_ms);
|
||||
|
||||
while start.elapsed() < duration {
|
||||
let chunk = Self::generate_test_chunk(order, chunk_size, sample_rate, frequency);
|
||||
self.subscribers.push(chunk).await?;
|
||||
|
||||
order += 1;
|
||||
|
||||
// Attendre pour simuler le timing réel
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(chunk_duration_ms)).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SourceNode {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_source_node_generation() {
|
||||
let mut source = SourceNode::new();
|
||||
let (tx, mut rx) = mpsc::channel(10);
|
||||
|
||||
source.add_subscriber(tx);
|
||||
|
||||
// Générer 3 chunks
|
||||
source.generate_chunks(3, 100, 48000, 440.0).await.unwrap();
|
||||
|
||||
// Vérifier la réception
|
||||
for i in 0..3 {
|
||||
let chunk = rx.recv().await.unwrap();
|
||||
assert_eq!(chunk.order(), i);
|
||||
assert_eq!(chunk.len(), 100);
|
||||
assert_eq!(chunk.sample_rate(), 48000);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sine_wave_generation() {
|
||||
let chunk = SourceNode::generate_test_chunk(0, 48000, 48000, 440.0);
|
||||
|
||||
// Vérifier qu'on a bien une sinusoïde
|
||||
// À 440 Hz avec 48000 samples/s, on devrait avoir 440 cycles
|
||||
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;
|
||||
for i in 1..left.len() {
|
||||
if (left[i - 1] < 0.0 && left[i] >= 0.0) || (left[i - 1] >= 0.0 && left[i] < 0.0) {
|
||||
zero_crossings += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 440 cycles = 880 passages par zéro (approximativement)
|
||||
assert!(zero_crossings > 850 && zero_crossings < 910);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_source_node_silence() {
|
||||
let mut source = SourceNode::new();
|
||||
let (tx, mut rx) = mpsc::channel(10);
|
||||
|
||||
source.add_subscriber(tx);
|
||||
|
||||
source.generate_silence(2, 100, 48000).await.unwrap();
|
||||
|
||||
for _ in 0..2 {
|
||||
let chunk = rx.recv().await.unwrap();
|
||||
assert!(chunk.frames().iter().all(|frame| frame[0] == 0));
|
||||
assert!(chunk.frames().iter().all(|frame| frame[1] == 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
291
old_code/pmoaudio/nodes/timer_node.rs
Normal file
291
old_code/pmoaudio/nodes/timer_node.rs
Normal file
@@ -0,0 +1,291 @@
|
||||
use crate::{
|
||||
nodes::{AudioError, MultiSubscriberNode},
|
||||
AudioChunk,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{mpsc, RwLock};
|
||||
|
||||
/// TimerNode - Node passthrough qui calcule la position temporelle
|
||||
///
|
||||
/// Ce node ne modifie pas les données audio, il les passe directement
|
||||
/// aux abonnés tout en maintenant un compteur de samples pour calculer
|
||||
/// la position en secondes.
|
||||
///
|
||||
/// # Fonctionnement
|
||||
///
|
||||
/// Pour chaque chunk reçu:
|
||||
/// 1. Incrémente `elapsed_samples += chunk.len()`
|
||||
/// 2. Calcule `position_sec = elapsed_samples / sample_rate`
|
||||
/// 3. Push le chunk (sans modification) vers les abonnés
|
||||
///
|
||||
/// # Utilisation
|
||||
///
|
||||
/// Le TimerNode fournit un [`TimerHandle`] qui permet de lire la position
|
||||
/// depuis d'autres threads/tasks sans bloquer le pipeline.
|
||||
///
|
||||
/// # Exemples
|
||||
///
|
||||
/// ```no_run
|
||||
/// use pmoaudio::TimerNode;
|
||||
///
|
||||
/// #[tokio::main]
|
||||
/// async fn main() {
|
||||
/// let (mut timer, timer_tx) = TimerNode::new(10);
|
||||
/// let handle = timer.get_position_handle();
|
||||
///
|
||||
/// tokio::spawn(async move {
|
||||
/// timer.run().await.unwrap();
|
||||
/// });
|
||||
///
|
||||
/// // Lire la position depuis un autre thread
|
||||
/// let position = handle.position_sec().await;
|
||||
/// println!("Position: {:.2} sec", position);
|
||||
/// }
|
||||
/// ```
|
||||
pub struct TimerNode {
|
||||
rx: mpsc::Receiver<Arc<AudioChunk>>,
|
||||
subscribers: MultiSubscriberNode,
|
||||
elapsed_samples: Arc<RwLock<u64>>,
|
||||
current_sample_rate: Arc<RwLock<u32>>,
|
||||
}
|
||||
|
||||
impl TimerNode {
|
||||
/// Crée un nouveau TimerNode
|
||||
pub fn new(channel_size: usize) -> (Self, mpsc::Sender<Arc<AudioChunk>>) {
|
||||
let (tx, rx) = mpsc::channel(channel_size);
|
||||
|
||||
let node = Self {
|
||||
rx,
|
||||
subscribers: MultiSubscriberNode::new(),
|
||||
elapsed_samples: Arc::new(RwLock::new(0)),
|
||||
current_sample_rate: Arc::new(RwLock::new(48000)), // Default
|
||||
};
|
||||
|
||||
(node, tx)
|
||||
}
|
||||
|
||||
/// Ajoute un abonné
|
||||
pub fn add_subscriber(&mut self, tx: mpsc::Sender<Arc<AudioChunk>>) {
|
||||
self.subscribers.add_subscriber(tx);
|
||||
}
|
||||
|
||||
/// Retourne la position actuelle en secondes
|
||||
pub async fn position_sec(&self) -> f64 {
|
||||
let elapsed = *self.elapsed_samples.read().await;
|
||||
let sample_rate = *self.current_sample_rate.read().await;
|
||||
elapsed as f64 / sample_rate as f64
|
||||
}
|
||||
|
||||
/// Retourne le nombre total d'échantillons écoulés
|
||||
pub async fn elapsed_samples(&self) -> u64 {
|
||||
*self.elapsed_samples.read().await
|
||||
}
|
||||
|
||||
/// Reset le compteur
|
||||
pub async fn reset(&self) {
|
||||
let mut elapsed = self.elapsed_samples.write().await;
|
||||
*elapsed = 0;
|
||||
}
|
||||
|
||||
/// Démarre la boucle de traitement du TimerNode
|
||||
pub async fn run(mut self) -> Result<(), AudioError> {
|
||||
while let Some(chunk) = self.rx.recv().await {
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
||||
// Incrémenter le compteur d'échantillons
|
||||
{
|
||||
let mut elapsed = self.elapsed_samples.write().await;
|
||||
*elapsed += chunk.len() as u64;
|
||||
}
|
||||
|
||||
// Push immédiatement le même chunk vers les abonnés (passthrough)
|
||||
self.subscribers.push(chunk).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Version non-bloquante avec try_push
|
||||
pub async fn run_nonblocking(mut self) -> Result<(), AudioError> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let mut elapsed = self.elapsed_samples.write().await;
|
||||
*elapsed += chunk.len() as u64;
|
||||
}
|
||||
|
||||
self.subscribers.try_push(chunk).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Retourne un handle pour lire la position depuis d'autres threads
|
||||
pub fn get_position_handle(&self) -> TimerHandle {
|
||||
TimerHandle {
|
||||
elapsed_samples: self.elapsed_samples.clone(),
|
||||
current_sample_rate: self.current_sample_rate.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle pour lire la position du TimerNode depuis d'autres threads
|
||||
///
|
||||
/// Ce handle peut être cloné et utilisé depuis plusieurs threads/tasks
|
||||
/// pour monitorer la position de lecture sans bloquer le pipeline.
|
||||
///
|
||||
/// # Exemples
|
||||
///
|
||||
/// ```no_run
|
||||
/// use pmoaudio::TimerNode;
|
||||
///
|
||||
/// #[tokio::main]
|
||||
/// async fn main() {
|
||||
/// let (mut timer, _tx) = TimerNode::new(10);
|
||||
/// let handle = timer.get_position_handle();
|
||||
/// let handle_clone = handle.clone();
|
||||
///
|
||||
/// // Utiliser depuis plusieurs tasks
|
||||
/// tokio::spawn(async move {
|
||||
/// loop {
|
||||
/// let pos = handle_clone.position_sec().await;
|
||||
/// println!("Position: {:.2}s", pos);
|
||||
/// tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
||||
/// }
|
||||
/// });
|
||||
/// }
|
||||
/// ```
|
||||
#[derive(Clone)]
|
||||
pub struct TimerHandle {
|
||||
elapsed_samples: Arc<RwLock<u64>>,
|
||||
current_sample_rate: Arc<RwLock<u32>>,
|
||||
}
|
||||
|
||||
impl TimerHandle {
|
||||
/// Retourne la position actuelle en secondes
|
||||
pub async fn position_sec(&self) -> f64 {
|
||||
let elapsed = *self.elapsed_samples.read().await;
|
||||
let sample_rate = *self.current_sample_rate.read().await;
|
||||
elapsed as f64 / sample_rate as f64
|
||||
}
|
||||
|
||||
/// Retourne le nombre total d'échantillons écoulés
|
||||
pub async fn elapsed_samples(&self) -> u64 {
|
||||
*self.elapsed_samples.read().await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::BitDepth;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_timer_node_position_calculation() {
|
||||
let (mut node, tx) = TimerNode::new(10);
|
||||
let (out_tx, mut out_rx) = mpsc::channel(10);
|
||||
|
||||
node.add_subscriber(out_tx);
|
||||
|
||||
let handle = node.get_position_handle();
|
||||
|
||||
// Spawn le node
|
||||
tokio::spawn(async move {
|
||||
node.run().await.unwrap();
|
||||
});
|
||||
|
||||
// Envoyer 3 chunks de 1000 samples à 48000 Hz
|
||||
for i in 0..3 {
|
||||
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
|
||||
for _ in 0..3 {
|
||||
out_rx.recv().await.unwrap();
|
||||
}
|
||||
|
||||
// Vérifier la position
|
||||
let position = handle.position_sec().await;
|
||||
let expected = 3000.0 / 48000.0; // 3 chunks * 1000 samples / 48000 Hz
|
||||
assert!((position - expected).abs() < 0.0001);
|
||||
|
||||
let elapsed = handle.elapsed_samples().await;
|
||||
assert_eq!(elapsed, 3000);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_timer_node_passthrough() {
|
||||
let (mut node, tx) = TimerNode::new(10);
|
||||
let (out_tx, mut out_rx) = mpsc::channel(10);
|
||||
|
||||
node.add_subscriber(out_tx);
|
||||
|
||||
tokio::spawn(async move {
|
||||
node.run().await.unwrap();
|
||||
});
|
||||
|
||||
// Envoyer un chunk
|
||||
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, &received));
|
||||
assert_eq!(received.order(), 42);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_timer_node_sample_rate_change() {
|
||||
let (mut node, tx) = TimerNode::new(10);
|
||||
let (out_tx, mut out_rx) = mpsc::channel(10);
|
||||
|
||||
node.add_subscriber(out_tx);
|
||||
|
||||
let handle = node.get_position_handle();
|
||||
|
||||
tokio::spawn(async move {
|
||||
node.run().await.unwrap();
|
||||
});
|
||||
|
||||
// Chunk à 48000 Hz
|
||||
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
|
||||
let pos1 = handle.position_sec().await;
|
||||
assert!((pos1 - 1.0).abs() < 0.0001);
|
||||
|
||||
// Chunk à 96000 Hz
|
||||
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
|
||||
let pos2 = handle.position_sec().await;
|
||||
let expected = (48000.0 + 96000.0) / 96000.0;
|
||||
assert!((pos2 - expected).abs() < 0.0001);
|
||||
}
|
||||
}
|
||||
363
old_code/pmoaudio/nodes/volume_node.rs
Normal file
363
old_code/pmoaudio/nodes/volume_node.rs
Normal file
@@ -0,0 +1,363 @@
|
||||
//! Volume nodes - Contrôle du volume audio
|
||||
//!
|
||||
//! Ce module fournit des nodes pour ajuster le volume du flux audio,
|
||||
//! avec support du volume master/secondaire et notification des changements.
|
||||
|
||||
use crate::{
|
||||
events::{EventPublisher, VolumeChangeEvent},
|
||||
nodes::{AudioError, MultiSubscriberNode},
|
||||
AudioChunk,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{mpsc, RwLock};
|
||||
|
||||
/// VolumeNode - Applique un gain au flux audio (contrôle software)
|
||||
///
|
||||
/// Ce node modifie le champ `gain` de chaque `AudioChunk` qui le traverse.
|
||||
/// Le gain est multiplié avec le gain existant du chunk, permettant ainsi
|
||||
/// une chaîne de contrôles de volume.
|
||||
///
|
||||
/// # Caractéristiques
|
||||
///
|
||||
/// - Thread-safe : le volume peut être modifié pendant l'exécution via `set_volume`
|
||||
/// - Notification : émet des événements `VolumeChangeEvent` lors des changements
|
||||
/// - Master/Slave : peut s'abonner à un volume master pour synchronisation
|
||||
///
|
||||
/// # Exemples
|
||||
///
|
||||
/// ```no_run
|
||||
/// use pmoaudio::VolumeNode;
|
||||
///
|
||||
/// #[tokio::main]
|
||||
/// async fn main() {
|
||||
/// let (volume_node, volume_tx) = VolumeNode::new("Room 1".to_string(), 0.8, 10);
|
||||
///
|
||||
/// // Modifier le volume pendant l'exécution
|
||||
/// let handle = volume_node.get_handle();
|
||||
/// tokio::spawn(async move {
|
||||
/// tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
||||
/// handle.set_volume(0.5).await;
|
||||
/// });
|
||||
///
|
||||
/// tokio::spawn(async move { volume_node.run().await.unwrap() });
|
||||
/// }
|
||||
/// ```
|
||||
pub struct VolumeNode {
|
||||
/// Channel pour recevoir les chunks audio
|
||||
rx: mpsc::Receiver<Arc<AudioChunk>>,
|
||||
|
||||
/// Subscribers pour les chunks modifiés
|
||||
subscribers: MultiSubscriberNode,
|
||||
|
||||
/// Volume courant (partagé via RwLock pour lecture/écriture thread-safe)
|
||||
volume: Arc<RwLock<f32>>,
|
||||
|
||||
/// Publisher pour les événements de changement de volume
|
||||
volume_publisher: EventPublisher<VolumeChangeEvent>,
|
||||
|
||||
/// Identifiant unique du node (pour traçabilité)
|
||||
node_id: String,
|
||||
|
||||
/// Receiver pour les événements de volume master (optionnel)
|
||||
master_volume_rx: Option<mpsc::Receiver<VolumeChangeEvent>>,
|
||||
}
|
||||
|
||||
impl VolumeNode {
|
||||
/// Crée un nouveau VolumeNode
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `node_id` - Identifiant unique du node
|
||||
/// * `initial_volume` - Volume initial (0.0 à 1.0)
|
||||
/// * `channel_size` - Taille du buffer du channel
|
||||
pub fn new(
|
||||
node_id: String,
|
||||
initial_volume: f32,
|
||||
channel_size: usize,
|
||||
) -> (Self, mpsc::Sender<Arc<AudioChunk>>) {
|
||||
let (tx, rx) = mpsc::channel(channel_size);
|
||||
|
||||
let node = Self {
|
||||
rx,
|
||||
subscribers: MultiSubscriberNode::new(),
|
||||
volume: Arc::new(RwLock::new(initial_volume)),
|
||||
volume_publisher: EventPublisher::new(),
|
||||
node_id,
|
||||
master_volume_rx: None,
|
||||
};
|
||||
|
||||
(node, tx)
|
||||
}
|
||||
|
||||
/// Ajoute un subscriber pour recevoir les chunks audio modifiés
|
||||
pub fn add_subscriber(&mut self, tx: mpsc::Sender<Arc<AudioChunk>>) {
|
||||
self.subscribers.add_subscriber(tx);
|
||||
}
|
||||
|
||||
/// Ajoute un subscriber pour les événements de changement de volume
|
||||
pub fn subscribe_volume_events(&mut self, tx: mpsc::Sender<VolumeChangeEvent>) {
|
||||
self.volume_publisher.subscribe(tx);
|
||||
}
|
||||
|
||||
/// Configure ce node pour écouter un volume master
|
||||
///
|
||||
/// Le node appliquera à la fois son volume local ET le volume master reçu.
|
||||
pub fn set_master_volume_source(&mut self, rx: mpsc::Receiver<VolumeChangeEvent>) {
|
||||
self.master_volume_rx = Some(rx);
|
||||
}
|
||||
|
||||
/// Retourne un handle pour contrôler le volume depuis un autre contexte
|
||||
pub fn get_handle(&self) -> VolumeHandle {
|
||||
VolumeHandle {
|
||||
volume: self.volume.clone(),
|
||||
node_id: self.node_id.clone(),
|
||||
publisher: Arc::new(RwLock::new(self.volume_publisher.clone())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Démarre la boucle de traitement du VolumeNode
|
||||
pub async fn run(mut self) -> Result<(), AudioError> {
|
||||
let mut master_volume = 1.0f32;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
// Recevoir les chunks audio
|
||||
chunk_opt = self.rx.recv() => {
|
||||
match chunk_opt {
|
||||
Some(chunk) => {
|
||||
let local_volume = *self.volume.read().await;
|
||||
let total_volume = (local_volume * master_volume).max(0.0);
|
||||
|
||||
// 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(modified_chunk).await?;
|
||||
}
|
||||
None => {
|
||||
// Channel fermé, terminer
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Recevoir les mises à jour du volume master (si configuré)
|
||||
master_event_opt = async {
|
||||
if let Some(ref mut rx) = self.master_volume_rx {
|
||||
rx.recv().await
|
||||
} else {
|
||||
// Bloquer indéfiniment si pas de master
|
||||
std::future::pending().await
|
||||
}
|
||||
} => {
|
||||
if let Some(event) = master_event_opt {
|
||||
master_volume = event.volume;
|
||||
|
||||
// Optionnel : re-publier l'événement combiné
|
||||
let local_volume = *self.volume.read().await;
|
||||
let combined_event = VolumeChangeEvent {
|
||||
volume: local_volume * master_volume,
|
||||
source_node_id: self.node_id.clone(),
|
||||
};
|
||||
self.volume_publisher.publish(combined_event).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle pour contrôler un VolumeNode depuis un autre contexte
|
||||
///
|
||||
/// Ce handle permet de modifier le volume et de notifier les subscribers
|
||||
/// sans avoir accès direct au node.
|
||||
#[derive(Clone)]
|
||||
pub struct VolumeHandle {
|
||||
volume: Arc<RwLock<f32>>,
|
||||
node_id: String,
|
||||
publisher: Arc<RwLock<EventPublisher<VolumeChangeEvent>>>,
|
||||
}
|
||||
|
||||
impl VolumeHandle {
|
||||
/// Modifie le volume
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `new_volume` - Nouveau volume (0.0 à 1.0)
|
||||
pub async fn set_volume(&self, new_volume: f32) {
|
||||
let clamped = new_volume.clamp(0.0, 1.0);
|
||||
*self.volume.write().await = clamped;
|
||||
|
||||
// Publier l'événement de changement
|
||||
let event = VolumeChangeEvent {
|
||||
volume: clamped,
|
||||
source_node_id: self.node_id.clone(),
|
||||
};
|
||||
|
||||
self.publisher.read().await.publish(event).await;
|
||||
}
|
||||
|
||||
/// Obtient le volume courant
|
||||
pub async fn get_volume(&self) -> f32 {
|
||||
*self.volume.read().await
|
||||
}
|
||||
|
||||
/// Augmente le volume de manière relative
|
||||
pub async fn adjust_volume(&self, delta: f32) {
|
||||
let current = *self.volume.read().await;
|
||||
self.set_volume(current + delta).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// HardwareVolumeNode - Contrôle matériel du volume
|
||||
///
|
||||
/// Ce node simule un contrôle hardware du volume. Dans une implémentation réelle,
|
||||
/// il communiquerait avec le driver audio pour ajuster le volume matériel.
|
||||
///
|
||||
/// Pour cette version, il agit de manière similaire à `VolumeNode` mais pourrait
|
||||
/// être étendu pour utiliser des APIs système spécifiques.
|
||||
pub struct HardwareVolumeNode {
|
||||
inner: VolumeNode,
|
||||
}
|
||||
|
||||
impl HardwareVolumeNode {
|
||||
/// Crée un nouveau HardwareVolumeNode
|
||||
pub fn new(
|
||||
node_id: String,
|
||||
initial_volume: f32,
|
||||
channel_size: usize,
|
||||
) -> (Self, mpsc::Sender<Arc<AudioChunk>>) {
|
||||
let (inner, tx) = VolumeNode::new(node_id, initial_volume, channel_size);
|
||||
|
||||
(Self { inner }, tx)
|
||||
}
|
||||
|
||||
/// Ajoute un subscriber
|
||||
pub fn add_subscriber(&mut self, tx: mpsc::Sender<Arc<AudioChunk>>) {
|
||||
self.inner.add_subscriber(tx);
|
||||
}
|
||||
|
||||
/// Obtient un handle pour contrôler le volume
|
||||
pub fn get_handle(&self) -> VolumeHandle {
|
||||
self.inner.get_handle()
|
||||
}
|
||||
|
||||
/// Démarre la boucle de traitement
|
||||
pub async fn run(self) -> Result<(), AudioError> {
|
||||
// Dans une vraie implémentation, on communiquerait avec le hardware ici
|
||||
// Pour l'instant, délègue au VolumeNode standard
|
||||
self.inner.run().await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::BitDepth;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_volume_node_basic() {
|
||||
let (mut node, tx) = VolumeNode::new("test".to_string(), 0.5, 10);
|
||||
let (out_tx, mut out_rx) = mpsc::channel(10);
|
||||
|
||||
node.add_subscriber(out_tx);
|
||||
|
||||
let handle = tokio::spawn(async move { node.run().await });
|
||||
|
||||
// Envoyer un chunk avec gain 1.0
|
||||
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_linear() - 0.5).abs() < 1e-6);
|
||||
|
||||
drop(tx);
|
||||
handle.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_volume_handle() {
|
||||
let (node, tx) = VolumeNode::new("test".to_string(), 1.0, 10);
|
||||
let handle = node.get_handle();
|
||||
|
||||
tokio::spawn(async move { node.run().await });
|
||||
|
||||
// Modifier le volume via le handle
|
||||
handle.set_volume(0.3).await;
|
||||
|
||||
let volume = handle.get_volume().await;
|
||||
assert!((volume - 0.3).abs() < f32::EPSILON);
|
||||
|
||||
drop(tx);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_volume_events() {
|
||||
let (mut node, tx) = VolumeNode::new("test".to_string(), 1.0, 10);
|
||||
let (event_tx, mut event_rx) = mpsc::channel(10);
|
||||
|
||||
node.subscribe_volume_events(event_tx);
|
||||
let handle = node.get_handle();
|
||||
|
||||
tokio::spawn(async move { node.run().await });
|
||||
|
||||
// Changer le volume
|
||||
handle.set_volume(0.7).await;
|
||||
|
||||
// Vérifier l'événement
|
||||
let event = event_rx.recv().await.unwrap();
|
||||
assert!((event.volume - 0.7).abs() < f32::EPSILON);
|
||||
assert_eq!(event.source_node_id, "test");
|
||||
|
||||
drop(tx);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_master_slave_volume() {
|
||||
// Créer le master
|
||||
let (mut master, master_tx) = VolumeNode::new("master".to_string(), 1.0, 10);
|
||||
let (master_event_tx, master_event_rx) = mpsc::channel(10);
|
||||
master.subscribe_volume_events(master_event_tx);
|
||||
let master_handle = master.get_handle();
|
||||
|
||||
// Créer le slave
|
||||
let (mut slave, slave_tx) = VolumeNode::new("slave".to_string(), 0.8, 10);
|
||||
slave.set_master_volume_source(master_event_rx);
|
||||
let (out_tx, mut out_rx) = mpsc::channel(10);
|
||||
slave.add_subscriber(out_tx);
|
||||
|
||||
tokio::spawn(async move { master.run().await });
|
||||
tokio::spawn(async move { slave.run().await });
|
||||
|
||||
// Envoyer un chunk au slave
|
||||
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;
|
||||
|
||||
// Modifier le volume master
|
||||
master_handle.set_volume(0.5).await;
|
||||
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
|
||||
|
||||
// Envoyer un autre chunk
|
||||
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 (≈ -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_linear() - 0.4).abs() < 0.01);
|
||||
|
||||
drop(master_tx);
|
||||
drop(slave_tx);
|
||||
}
|
||||
}
|
||||
@@ -99,14 +99,13 @@
|
||||
//!
|
||||
//! ### Exemple basique
|
||||
//!
|
||||
//! ```rust,no_run
|
||||
//! ```rust,ignore
|
||||
//! use pmoapp::Webapp;
|
||||
//! use pmoserver::ServerBuilder;
|
||||
//!
|
||||
//! #[tokio::main]
|
||||
//! async fn main() {
|
||||
//! let mut server = ServerBuilder::new("MyApp")
|
||||
//! .http_port(8080)
|
||||
//! let mut server = ServerBuilder::new("MyApp", "http://localhost", 8080)
|
||||
//! .build();
|
||||
//!
|
||||
//! // Ajouter la webapp comme Single Page Application
|
||||
@@ -122,7 +121,7 @@
|
||||
//!
|
||||
//! ### Exemple avec logs SSE
|
||||
//!
|
||||
//! ```rust,no_run
|
||||
//! ```rust,ignore
|
||||
//! use pmoapp::Webapp;
|
||||
//! use pmoserver::{ServerBuilder, logs::{LogState, SseLayer}};
|
||||
//! use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
@@ -136,7 +135,7 @@
|
||||
//! .with(SseLayer::new(log_state.clone()))
|
||||
//! .init();
|
||||
//!
|
||||
//! let mut server = ServerBuilder::new("MyApp").build();
|
||||
//! let mut server = ServerBuilder::new("MyApp", "http://localhost", 8080).build();
|
||||
//!
|
||||
//! // Endpoints SSE pour les logs
|
||||
//! server.add_handler_with_state("/log-sse", pmoserver::logs::log_sse, log_state.clone()).await;
|
||||
@@ -193,7 +192,7 @@
|
||||
//!
|
||||
//! Le composant LogView détecte automatiquement le XML dans les messages :
|
||||
//!
|
||||
//! ```
|
||||
//! ```text
|
||||
//! Input: "INFO: <?xml version=\"1.0\"?><scpd>...</scpd>"
|
||||
//! Output: Bloc de code avec coloration syntaxique XML
|
||||
//! ```
|
||||
@@ -239,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.
|
||||
///
|
||||
@@ -249,12 +246,12 @@ use std::pin::Pin;
|
||||
///
|
||||
/// ## Exemple
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// ```rust,ignore
|
||||
/// use pmoapp::{Webapp, WebAppExt};
|
||||
/// use pmoserver::ServerBuilder;
|
||||
///
|
||||
/// # async fn example() {
|
||||
/// let mut server = ServerBuilder::new("MyApp").build();
|
||||
/// let mut server = ServerBuilder::new("MyApp", "http://localhost", 8080).build();
|
||||
///
|
||||
/// // Ajouter la webapp via le trait WebAppExt
|
||||
/// server.add_webapp::<Webapp>("/app").await;
|
||||
@@ -298,7 +295,7 @@ pub trait WebAppExt {
|
||||
/// # Type Parameter
|
||||
///
|
||||
/// * `W` - Type RustEmbed contenant les fichiers de la webapp
|
||||
fn add_webapp<W>(&mut self, path: &str) -> Pin<Box<dyn Future<Output = ()> + Send + '_>>
|
||||
async fn add_webapp<W>(&mut self, path: &str)
|
||||
where
|
||||
W: RustEmbed + Clone + Send + Sync + 'static;
|
||||
|
||||
@@ -311,7 +308,7 @@ pub trait WebAppExt {
|
||||
/// # Type Parameter
|
||||
///
|
||||
/// * `W` - Type RustEmbed contenant les fichiers de la webapp
|
||||
fn add_webapp_with_redirect<W>(&mut self, path: &str) -> Pin<Box<dyn Future<Output = ()> + Send + '_>>
|
||||
async fn add_webapp_with_redirect<W>(&mut self, path: &str)
|
||||
where
|
||||
W: RustEmbed + Clone + Send + Sync + 'static;
|
||||
}
|
||||
|
||||
@@ -12,12 +12,12 @@
|
||||
//!
|
||||
//! ## Exemple d'utilisation
|
||||
//!
|
||||
//! ```rust,no_run
|
||||
//! ```rust,ignore
|
||||
//! use pmoapp::{Webapp, WebAppExt};
|
||||
//! use pmoserver::ServerBuilder;
|
||||
//!
|
||||
//! # async fn example() {
|
||||
//! let mut server = ServerBuilder::new("MyApp").build();
|
||||
//! let mut server = ServerBuilder::new("MyApp", "http://localhost", 8080).build();
|
||||
//!
|
||||
//! // Le trait WebAppExt est automatiquement disponible
|
||||
//! server.add_webapp::<Webapp>("/app").await;
|
||||
@@ -30,28 +30,24 @@
|
||||
use crate::WebAppExt;
|
||||
use pmoserver::Server;
|
||||
use rust_embed::RustEmbed;
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
|
||||
impl WebAppExt for Server {
|
||||
fn add_webapp<W>(&mut self, path: &str) -> Pin<Box<dyn Future<Output = ()> + Send + '_>>
|
||||
async fn add_webapp<W>(&mut self, path: &str)
|
||||
where
|
||||
W: RustEmbed + Clone + Send + Sync + 'static,
|
||||
{
|
||||
let path = path.to_string();
|
||||
Box::pin(async move {
|
||||
self.add_spa::<W>(&path).await;
|
||||
})
|
||||
|
||||
self.add_spa::<W>(&path).await;
|
||||
}
|
||||
|
||||
fn add_webapp_with_redirect<W>(&mut self, path: &str) -> Pin<Box<dyn Future<Output = ()> + Send + '_>>
|
||||
async fn add_webapp_with_redirect<W>(&mut self, path: &str)
|
||||
where
|
||||
W: RustEmbed + Clone + Send + Sync + 'static,
|
||||
{
|
||||
let path = path.to_string();
|
||||
Box::pin(async move {
|
||||
self.add_spa::<W>(&path).await;
|
||||
self.add_redirect("/", &path).await;
|
||||
})
|
||||
|
||||
self.add_spa::<W>(&path).await;
|
||||
self.add_redirect("/", &path).await;
|
||||
}
|
||||
}
|
||||
|
||||
BIN
pmoapp/webapp/.pmomusic_audio/cache.db
Normal file
BIN
pmoapp/webapp/.pmomusic_audio/cache.db
Normal file
Binary file not shown.
@@ -1,30 +1,203 @@
|
||||
<template>
|
||||
<div>
|
||||
<nav>
|
||||
<router-link to="/">Accueil</router-link> |
|
||||
<router-link to="/logs">Logs</router-link> |
|
||||
<router-link to="/covers-cache">Cover Cache</router-link>
|
||||
<div class="app-container">
|
||||
<nav class="main-nav">
|
||||
<router-link to="/">🏠 Accueil</router-link>
|
||||
|
||||
<!-- Menu déroulant Debug -->
|
||||
<div class="dropdown" @mouseenter="showDebugMenu = true" @mouseleave="showDebugMenu = false">
|
||||
<button class="dropdown-toggle" :class="{ active: isDebugRoute }">
|
||||
🔧 Debug
|
||||
<span class="arrow">{{ showDebugMenu ? '▼' : '▶' }}</span>
|
||||
</button>
|
||||
<div v-show="showDebugMenu" class="dropdown-menu">
|
||||
<router-link to="/logs" @click="showDebugMenu = false">📋 Logs</router-link>
|
||||
<router-link to="/upnp" @click="showDebugMenu = false">🎵 UPnP Explorer</router-link>
|
||||
<router-link to="/covers-cache" @click="showDebugMenu = false">🎨 Cover Cache</router-link>
|
||||
<router-link to="/audio-cache" @click="showDebugMenu = false">🎵 Audio Cache</router-link>
|
||||
<router-link to="/api-dashboard" @click="showDebugMenu = false">🚀 API Dashboard</router-link>
|
||||
|
||||
<div class="submenu-divider">Sources</div>
|
||||
<router-link to="/radio-paradise" @click="showDebugMenu = false">📻 Radio Paradise</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<router-view />
|
||||
<main class="main-content">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// rien à importer
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const showDebugMenu = ref(false)
|
||||
const route = useRoute()
|
||||
|
||||
const isDebugRoute = computed(() => {
|
||||
return ['/logs', '/upnp', '/covers-cache', '/audio-cache', '/api-dashboard', '/radio-paradise'].includes(route.path)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
nav {
|
||||
.app-container {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.main-nav {
|
||||
background: #333;
|
||||
width: 100vw;
|
||||
padding: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
a {
|
||||
|
||||
.main-nav a {
|
||||
color: #eee;
|
||||
margin: 0 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
a.router-link-active {
|
||||
|
||||
.main-nav a:hover {
|
||||
background: #555;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.main-nav a.router-link-active {
|
||||
background: #569cd6;
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Dropdown menu */
|
||||
.dropdown {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.dropdown-toggle {
|
||||
color: #eee;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.dropdown-toggle:hover {
|
||||
background: #555;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.dropdown-toggle.active {
|
||||
background: #569cd6;
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.dropdown-toggle .arrow {
|
||||
font-size: 0.7em;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
background: #2d2d2d;
|
||||
border: 1px solid #555;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
min-width: 200px;
|
||||
margin-top: 0;
|
||||
z-index: 1001;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.dropdown-menu a {
|
||||
padding: 0.75rem 1rem;
|
||||
color: #eee;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
border-radius: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dropdown-menu a:hover {
|
||||
background: #555;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.dropdown-menu a.router-link-active {
|
||||
background: #569cd6;
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.submenu-divider {
|
||||
padding: 0.5rem 1rem;
|
||||
margin-top: 0.5rem;
|
||||
border-top: 1px solid #555;
|
||||
color: #999;
|
||||
font-size: 0.85em;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Responsive pour petits écrans */
|
||||
@media (max-width: 768px) {
|
||||
.main-nav {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.main-nav a {
|
||||
font-size: 0.9rem;
|
||||
padding: 0.4rem 0.8rem;
|
||||
}
|
||||
|
||||
.dropdown-toggle {
|
||||
font-size: 0.9rem;
|
||||
padding: 0.4rem 0.8rem;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.dropdown-menu a {
|
||||
font-size: 0.9rem;
|
||||
padding: 0.6rem 0.8rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
449
pmoapp/webapp/src/components/APIDashboard.vue
Normal file
449
pmoapp/webapp/src/components/APIDashboard.vue
Normal file
@@ -0,0 +1,449 @@
|
||||
<template>
|
||||
<div class="api-dashboard">
|
||||
<div class="header">
|
||||
<h1>API Dashboard</h1>
|
||||
<p class="subtitle">Vue d'ensemble des APIs disponibles dans PMOMusic</p>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Chargement des APIs...</div>
|
||||
<div v-else-if="error" class="error">{{ error }}</div>
|
||||
|
||||
<div v-else class="dashboard-content">
|
||||
<!-- Stats globales -->
|
||||
<div class="stats-section">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">🚀</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ registry?.apis.length || 0 }}</div>
|
||||
<div class="stat-label">APIs disponibles</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">🔌</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ registry?.total_endpoints || 0 }}</div>
|
||||
<div class="stat-label">Endpoints totaux</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Liste des APIs -->
|
||||
<div class="apis-grid">
|
||||
<div
|
||||
v-for="api in registry?.apis"
|
||||
:key="api.name"
|
||||
class="api-card"
|
||||
>
|
||||
<div class="api-header">
|
||||
<div class="api-icon">{{ getApiIcon(api.name) }}</div>
|
||||
<div class="api-title-section">
|
||||
<h3>{{ api.title }}</h3>
|
||||
<p class="api-name">{{ api.name }}</p>
|
||||
</div>
|
||||
<div class="api-version">v{{ api.version }}</div>
|
||||
</div>
|
||||
|
||||
<div class="api-body">
|
||||
<p v-if="api.description" class="api-description">
|
||||
{{ api.description }}
|
||||
</p>
|
||||
<p v-else class="api-description empty">Aucune description disponible</p>
|
||||
|
||||
<div class="api-stats">
|
||||
<div class="api-stat">
|
||||
<span class="stat-icon">📍</span>
|
||||
<span class="stat-text">{{ api.endpoint_count }} endpoints</span>
|
||||
</div>
|
||||
<div class="api-stat">
|
||||
<span class="stat-icon">🔗</span>
|
||||
<span class="stat-text">{{ api.path }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="api-footer">
|
||||
<a :href="api.swagger_ui_path" target="_blank" class="btn-swagger">
|
||||
<span class="btn-icon">📖</span>
|
||||
<span>Documentation Swagger</span>
|
||||
</a>
|
||||
<a :href="api.openapi_json_path" target="_blank" class="btn-json">
|
||||
<span class="btn-icon">📄</span>
|
||||
<span>Spec OpenAPI</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Message si aucune API -->
|
||||
<div v-if="!registry?.apis || registry.apis.length === 0" class="empty-state">
|
||||
<div class="empty-icon">🔍</div>
|
||||
<h3>Aucune API enregistrée</h3>
|
||||
<p>Les APIs seront affichées ici au fur et à mesure de leur enregistrement.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
|
||||
interface ApiRegistryEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
swagger_ui_path: string;
|
||||
openapi_json_path: string;
|
||||
endpoint_count: number;
|
||||
version: string;
|
||||
description?: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface ApiRegistry {
|
||||
apis: ApiRegistryEntry[];
|
||||
total_endpoints: number;
|
||||
}
|
||||
|
||||
const loading = ref(true);
|
||||
const error = ref<string | null>(null);
|
||||
const registry = ref<ApiRegistry | null>(null);
|
||||
|
||||
async function fetchRegistry() {
|
||||
try {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
const response = await fetch('/api/registry');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch API registry: ${response.statusText}`);
|
||||
}
|
||||
|
||||
registry.value = await response.json();
|
||||
} catch (e: any) {
|
||||
error.value = e.message || 'Failed to load API registry';
|
||||
console.error('Error fetching API registry:', e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getApiIcon(name: string): string {
|
||||
const icons: Record<string, string> = {
|
||||
covers: '🎨',
|
||||
audio: '🎵',
|
||||
sources: '📡',
|
||||
upnp: '🔌',
|
||||
devices: '📱',
|
||||
cache: '💾',
|
||||
mediaserver: '🎬',
|
||||
renderer: '🎭',
|
||||
};
|
||||
|
||||
return icons[name.toLowerCase()] || '🔧';
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchRegistry();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.api-dashboard {
|
||||
padding: 2rem;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #2c3e50;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1.1rem;
|
||||
color: #7f8c8d;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error {
|
||||
padding: 3rem;
|
||||
text-align: center;
|
||||
font-size: 1.2rem;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #e74c3c;
|
||||
background: #fff5f5;
|
||||
border: 2px solid #fc8181;
|
||||
}
|
||||
|
||||
.dashboard-content {
|
||||
animation: fadeIn 0.5s ease-in;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Stats Section */
|
||||
.stats-section {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 2rem;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
box-shadow: 0 8px 16px rgba(102, 126, 234, 0.3);
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 12px 24px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
font-size: 3rem;
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 1rem;
|
||||
opacity: 0.9;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* APIs Grid */
|
||||
.apis-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.api-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.api-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.api-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.api-icon {
|
||||
font-size: 2.5rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.api-title-section {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.api-title-section h3 {
|
||||
margin: 0 0 0.25rem 0;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.api-name {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.9;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.api-version {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.api-body {
|
||||
padding: 1.5rem;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.api-description {
|
||||
color: #4a5568;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.api-description.empty {
|
||||
color: #a0aec0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.api-stats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.api-stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
.api-stat .stat-icon {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.api-stat .stat-text {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.api-footer {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.btn-swagger,
|
||||
.btn-json {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
transition: background 0.2s ease;
|
||||
color: #667eea;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.btn-swagger {
|
||||
border-right: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.btn-swagger:hover {
|
||||
background: #f7fafc;
|
||||
color: #5a67d8;
|
||||
}
|
||||
|
||||
.btn-json {
|
||||
color: #48bb78;
|
||||
}
|
||||
|
||||
.btn-json:hover {
|
||||
background: #f7fafc;
|
||||
color: #38a169;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
color: #2c3e50;
|
||||
font-size: 1.5rem;
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: #7f8c8d;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.api-dashboard {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.apis-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.api-footer {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.btn-swagger {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
945
pmoapp/webapp/src/components/AudioCacheManager.vue
Normal file
945
pmoapp/webapp/src/components/AudioCacheManager.vue
Normal file
@@ -0,0 +1,945 @@
|
||||
<template>
|
||||
<div class="audio-cache-manager">
|
||||
<div class="header">
|
||||
<h2>🎵 Audio Cache Manager</h2>
|
||||
<div class="stats">
|
||||
<span>{{ tracks.length }} tracks</span>
|
||||
<span v-if="totalHits > 0">{{ totalHits }} hits</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Formulaire d'ajout -->
|
||||
<div class="add-form">
|
||||
<h3>➕ Add New Track</h3>
|
||||
<form @submit.prevent="handleAddTrack">
|
||||
<div class="form-group">
|
||||
<input
|
||||
v-model="newTrackUrl"
|
||||
type="url"
|
||||
placeholder="https://example.com/track.flac"
|
||||
required
|
||||
:disabled="isAdding"
|
||||
/>
|
||||
<input
|
||||
v-model="newTrackCollection"
|
||||
type="text"
|
||||
placeholder="Collection (optional)"
|
||||
:disabled="isAdding"
|
||||
class="collection-input"
|
||||
/>
|
||||
<button type="submit" :disabled="isAdding || !newTrackUrl">
|
||||
{{ isAdding ? "Adding..." : "Add Track" }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="addError" class="error">{{ addError }}</p>
|
||||
<p v-if="addSuccess" class="success">{{ addSuccess }}</p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Contrôles -->
|
||||
<div class="controls">
|
||||
<div class="sort-controls">
|
||||
<label>Sort by:</label>
|
||||
<select v-model="sortBy">
|
||||
<option value="hits">Most Used</option>
|
||||
<option value="last_used">Recently Used</option>
|
||||
<option value="recent">Recently Added</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button @click="refreshTracks" :disabled="isLoading">
|
||||
{{ isLoading ? "Loading..." : "Refresh" }}
|
||||
</button>
|
||||
<button @click="handleConsolidate" :disabled="isConsolidating" class="btn-secondary">
|
||||
{{ isConsolidating ? "Consolidating..." : "Consolidate" }}
|
||||
</button>
|
||||
<button @click="handlePurge" class="btn-danger" :disabled="isPurging">
|
||||
{{ isPurging ? "Purging..." : "Purge All" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Liste des pistes -->
|
||||
<div v-if="isLoading && tracks.length === 0" class="loading-state">
|
||||
Loading tracks...
|
||||
</div>
|
||||
|
||||
<div v-else-if="tracks.length === 0" class="empty-state">
|
||||
No tracks in cache. Add one using the form above!
|
||||
</div>
|
||||
|
||||
<div v-else class="track-grid">
|
||||
<div
|
||||
v-for="track in sortedTracks"
|
||||
:key="track.pk"
|
||||
class="track-card"
|
||||
@click="selectedTrack = track"
|
||||
>
|
||||
<div class="track-icon">
|
||||
<div class="music-icon">🎵</div>
|
||||
<div class="track-overlay">
|
||||
<span class="hits">{{ track.hits }} plays</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="track-info">
|
||||
<div class="track-title">
|
||||
{{ track.metadata?.title || "Unknown Title" }}
|
||||
</div>
|
||||
<div class="track-artist">
|
||||
{{ track.metadata?.artist || "Unknown Artist" }}
|
||||
</div>
|
||||
<div class="track-album" v-if="track.metadata?.album">
|
||||
{{ track.metadata.album }}
|
||||
</div>
|
||||
<div class="pk">{{ track.pk }}</div>
|
||||
<div class="meta">
|
||||
<span v-if="durationMs(track) !== undefined">
|
||||
{{ formatDuration(durationMs(track)!) }}
|
||||
</span>
|
||||
<span v-if="track.metadata?.sample_rate">
|
||||
{{ formatSampleRate(track.metadata.sample_rate) }}
|
||||
</span>
|
||||
<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 }}
|
||||
</div>
|
||||
<div class="last-used" v-if="track.last_used">
|
||||
Last used: {{ formatDate(track.last_used) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="track-actions">
|
||||
<button
|
||||
@click.stop="playTrack(track.pk)"
|
||||
class="btn-play"
|
||||
title="Play"
|
||||
>
|
||||
▶️
|
||||
</button>
|
||||
<button
|
||||
@click.stop="handleDeleteTrack(track.pk)"
|
||||
class="btn-delete"
|
||||
:disabled="deletingTracks.has(track.pk)"
|
||||
title="Delete"
|
||||
>
|
||||
{{ deletingTracks.has(track.pk) ? "..." : "🗑️" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal de détails -->
|
||||
<div v-if="selectedTrack" class="modal" @click="selectedTrack = null">
|
||||
<div class="modal-content" @click.stop>
|
||||
<button class="modal-close" @click="selectedTrack = null">✕</button>
|
||||
<div class="modal-header">
|
||||
<div class="modal-icon">🎵</div>
|
||||
<h3>Track Details</h3>
|
||||
</div>
|
||||
<div class="modal-info">
|
||||
<div class="metadata-section" v-if="selectedTrack.metadata">
|
||||
<h4>Metadata</h4>
|
||||
<p><strong>Title:</strong> {{ selectedTrack.metadata.title || "Unknown" }}</p>
|
||||
<p><strong>Artist:</strong> {{ selectedTrack.metadata.artist || "Unknown" }}</p>
|
||||
<p v-if="selectedTrack.metadata.album"><strong>Album:</strong> {{ selectedTrack.metadata.album }}</p>
|
||||
<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="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 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>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button @click="playTrack(selectedTrack.pk)" class="btn-play">
|
||||
▶️ Play
|
||||
</button>
|
||||
<button @click="downloadTrack(selectedTrack.pk)" class="btn-secondary">
|
||||
⬇️ Download
|
||||
</button>
|
||||
<button @click="copyTrackUrl(selectedTrack.pk)" class="btn-secondary">
|
||||
📋 Copy URL
|
||||
</button>
|
||||
<button @click="handleDeleteTrack(selectedTrack.pk); selectedTrack = null" class="btn-danger">
|
||||
🗑️ Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lecteur audio -->
|
||||
<div v-if="isPlaying || audioError" class="audio-player-container">
|
||||
<audio
|
||||
ref="audioPlayer"
|
||||
controls
|
||||
v-if="!audioError"
|
||||
@ended="handleAudioEnded"
|
||||
@error="handleAudioError"
|
||||
></audio>
|
||||
<p v-if="audioError" class="audio-error">{{ audioError }}</p>
|
||||
<button @click="stopTrack" class="btn-stop" title="Stop">
|
||||
{{ audioError ? '✕ Close' : '⏹️ Stop' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import type { AudioCacheEntry } from "../services/audioCache";
|
||||
import {
|
||||
listTracks,
|
||||
addTrack,
|
||||
deleteTrack,
|
||||
purgeCache,
|
||||
consolidateCache,
|
||||
getTrackUrl,
|
||||
getOriginalTrackUrl,
|
||||
getOriginUrl,
|
||||
getDurationMs,
|
||||
formatDuration,
|
||||
formatBitrate,
|
||||
formatSampleRate,
|
||||
} from "../services/audioCache";
|
||||
|
||||
// --- États ---
|
||||
const tracks = ref<AudioCacheEntry[]>([]);
|
||||
const selectedTrack = ref<AudioCacheEntry | null>(null);
|
||||
const isLoading = ref(false);
|
||||
const sortBy = ref<"hits" | "last_used" | "recent">("hits");
|
||||
const audioPlayer = ref<HTMLAudioElement | null>(null);
|
||||
|
||||
// Formulaire d'ajout
|
||||
const newTrackUrl = ref("");
|
||||
const newTrackCollection = ref("");
|
||||
const isAdding = ref(false);
|
||||
const addError = ref("");
|
||||
const addSuccess = ref("");
|
||||
|
||||
// Contrôles
|
||||
const isConsolidating = ref(false);
|
||||
const isPurging = ref(false);
|
||||
const deletingTracks = ref(new Set<string>());
|
||||
|
||||
// Lecteur audio
|
||||
const isPlaying = ref(false);
|
||||
const audioError = ref("");
|
||||
|
||||
// --- Computed ---
|
||||
const totalHits = computed(() => tracks.value.reduce((sum, t) => sum + t.hits, 0));
|
||||
|
||||
const sortedTracks = computed(() => {
|
||||
const arr = [...tracks.value];
|
||||
switch (sortBy.value) {
|
||||
case "hits":
|
||||
return arr.sort((a, b) => b.hits - a.hits);
|
||||
case "last_used":
|
||||
return arr.sort((a, b) => {
|
||||
if (!a.last_used) return 1;
|
||||
if (!b.last_used) return -1;
|
||||
return new Date(b.last_used).getTime() - new Date(a.last_used).getTime();
|
||||
});
|
||||
case "recent":
|
||||
return arr.reverse();
|
||||
default:
|
||||
return arr;
|
||||
}
|
||||
});
|
||||
|
||||
// --- Fonctions ---
|
||||
async function refreshTracks() {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
tracks.value = await listTracks();
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddTrack() {
|
||||
if (!newTrackUrl.value) return;
|
||||
isAdding.value = true;
|
||||
addError.value = "";
|
||||
addSuccess.value = "";
|
||||
try {
|
||||
const result = await addTrack(
|
||||
newTrackUrl.value,
|
||||
newTrackCollection.value || undefined
|
||||
);
|
||||
addSuccess.value = `Track added! PK: ${result.pk}`;
|
||||
newTrackUrl.value = "";
|
||||
newTrackCollection.value = "";
|
||||
await refreshTracks();
|
||||
} catch (e: any) {
|
||||
addError.value = e.message ?? "Failed to add track";
|
||||
} finally {
|
||||
isAdding.value = false;
|
||||
setTimeout(() => (addSuccess.value = ""), 3000);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteTrack(pk: string) {
|
||||
if (!confirm(`Delete track ${pk}?`)) return;
|
||||
deletingTracks.value.add(pk);
|
||||
try {
|
||||
await deleteTrack(pk);
|
||||
await refreshTracks();
|
||||
} finally {
|
||||
deletingTracks.value.delete(pk);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePurge() {
|
||||
if (!confirm("⚠️ Delete ALL tracks?")) return;
|
||||
isPurging.value = true;
|
||||
try {
|
||||
await purgeCache();
|
||||
await refreshTracks();
|
||||
} finally {
|
||||
isPurging.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConsolidate() {
|
||||
if (!confirm("Consolidate cache? This will re-download missing tracks.")) return;
|
||||
isConsolidating.value = true;
|
||||
try {
|
||||
await consolidateCache();
|
||||
await refreshTracks();
|
||||
} finally {
|
||||
isConsolidating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function playTrack(pk: string) {
|
||||
audioError.value = "";
|
||||
isPlaying.value = true;
|
||||
|
||||
// Attendre que le DOM soit mis à jour (car le lecteur audio est dans un v-if)
|
||||
setTimeout(() => {
|
||||
if (audioPlayer.value) {
|
||||
const url = getTrackUrl(pk);
|
||||
audioPlayer.value.src = url;
|
||||
audioPlayer.value.play().catch((error) => {
|
||||
console.error("Failed to play audio:", error);
|
||||
audioError.value = `Cannot play audio: ${error.message}. Your browser may not support FLAC format.`;
|
||||
isPlaying.value = false;
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function stopTrack() {
|
||||
if (audioPlayer.value) {
|
||||
audioPlayer.value.pause();
|
||||
audioPlayer.value.currentTime = 0;
|
||||
audioPlayer.value.src = "";
|
||||
}
|
||||
isPlaying.value = false;
|
||||
audioError.value = "";
|
||||
}
|
||||
|
||||
function handleAudioEnded() {
|
||||
isPlaying.value = false;
|
||||
audioError.value = "";
|
||||
}
|
||||
|
||||
function handleAudioError() {
|
||||
const audio = audioPlayer.value;
|
||||
if (audio?.error) {
|
||||
let message = "Audio playback error: ";
|
||||
switch (audio.error.code) {
|
||||
case 1:
|
||||
message += "Loading aborted";
|
||||
break;
|
||||
case 2:
|
||||
message += "Network error";
|
||||
break;
|
||||
case 3:
|
||||
message += "Format not supported";
|
||||
break;
|
||||
case 4:
|
||||
message += "Source not found";
|
||||
break;
|
||||
default:
|
||||
message += "Unknown error";
|
||||
}
|
||||
console.error('Audio player error:', message, 'code:', audio.error.code);
|
||||
audioError.value = message;
|
||||
isPlaying.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function downloadTrack(pk: string) {
|
||||
window.open(getOriginalTrackUrl(pk), "_blank");
|
||||
}
|
||||
|
||||
function copyTrackUrl(pk: string) {
|
||||
navigator.clipboard.writeText(window.location.origin + getTrackUrl(pk));
|
||||
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();
|
||||
const 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();
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.audio-cache-manager {
|
||||
padding: 1rem;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media (min-width: 1400px) {
|
||||
.audio-cache-manager {
|
||||
padding: 2rem;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.audio-cache-manager {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 2px solid #444;
|
||||
}
|
||||
|
||||
.header h2 {
|
||||
margin: 0;
|
||||
color: #61dafb;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.9rem;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* Formulaire d'ajout */
|
||||
.add-form {
|
||||
background: #2a2a2a;
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.add-form h3 {
|
||||
margin-top: 0;
|
||||
color: #61dafb;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #444;
|
||||
border-radius: 4px;
|
||||
background: #1a1a1a;
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.form-group input[type="url"] {
|
||||
flex: 2;
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
.collection-input {
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.form-group button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #61dafb;
|
||||
color: #000;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.form-group button:hover:not(:disabled) {
|
||||
background: #4fa8c5;
|
||||
}
|
||||
|
||||
.form-group button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #ff6b6b;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.success {
|
||||
color: #51cf66;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* Contrôles */
|
||||
.controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: #2a2a2a;
|
||||
border-radius: 8px;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.sort-controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sort-controls label {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.sort-controls select {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #444;
|
||||
border-radius: 4px;
|
||||
background: #1a1a1a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
button:not(.btn-danger):not(.btn-secondary):not(.btn-play):not(.btn-delete) {
|
||||
background: #61dafb;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
button:not(.btn-danger):not(.btn-secondary):not(.btn-play):not(.btn-delete):hover:not(:disabled) {
|
||||
background: #4fa8c5;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #555;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: #666;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #ff6b6b;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: #ee5a52;
|
||||
}
|
||||
|
||||
.btn-play {
|
||||
background: #51cf66;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-play:hover:not(:disabled) {
|
||||
background: #40c057;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* États */
|
||||
.loading-state,
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #999;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
/* Grille de pistes */
|
||||
.track-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.track-card {
|
||||
background: #2a2a2a;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.track-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.track-icon {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding-top: 56.25%; /* Ratio 16:9 */
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.music-icon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 4rem;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.track-overlay {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.8), transparent);
|
||||
padding: 0.5rem;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.hits {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.track-info {
|
||||
padding: 1rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.track-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: bold;
|
||||
color: #61dafb;
|
||||
margin-bottom: 0.25rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.track-artist {
|
||||
color: #ccc;
|
||||
margin-bottom: 0.25rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.track-album {
|
||||
color: #999;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.5rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pk {
|
||||
font-family: monospace;
|
||||
color: #777;
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
color: #999;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.collection {
|
||||
color: #888;
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0.5rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.last-used {
|
||||
color: #777;
|
||||
font-size: 0.8rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.track-actions {
|
||||
padding: 0 1rem 1rem;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.track-actions button {
|
||||
flex: 1;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: #2a2a2a;
|
||||
border-radius: 12px;
|
||||
max-width: 700px;
|
||||
max-height: 90vh;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: #fff;
|
||||
border: none;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid #444;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.modal-icon {
|
||||
font-size: 3rem;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
color: #61dafb;
|
||||
}
|
||||
|
||||
.modal-info {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.metadata-section,
|
||||
.cache-section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.metadata-section h4,
|
||||
.cache-section h4 {
|
||||
color: #61dafb;
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.modal-info p {
|
||||
margin: 0.5rem 0;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.modal-info a {
|
||||
color: #61dafb;
|
||||
text-decoration: none;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.modal-info a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.modal-actions button {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
/* Lecteur audio */
|
||||
.audio-player-container {
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
right: 2rem;
|
||||
background: #2a2a2a;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
z-index: 1001;
|
||||
}
|
||||
|
||||
.audio-player-container audio {
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.audio-error {
|
||||
color: #ff6b6b;
|
||||
margin: 0;
|
||||
padding: 0.5rem;
|
||||
background: rgba(255, 107, 107, 0.1);
|
||||
border-radius: 4px;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.btn-stop {
|
||||
background: #ff6b6b;
|
||||
color: #fff;
|
||||
padding: 0.75rem 1.5rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-stop:hover:not(:disabled) {
|
||||
background: #ee5a52;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.audio-player-container {
|
||||
bottom: 1rem;
|
||||
right: 1rem;
|
||||
left: 1rem;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.audio-player-container audio {
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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();
|
||||
@@ -236,8 +254,24 @@ onMounted(()=>refreshImages());
|
||||
<style scoped>
|
||||
.cover-cache-manager {
|
||||
padding: 1rem;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media (min-width: 1400px) {
|
||||
.cover-cache-manager {
|
||||
padding: 2rem;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.cover-cache-manager {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
|
||||
@@ -7,14 +7,29 @@
|
||||
{{ autoScroll ? '📌 Auto-scroll ON' : '📌 Auto-scroll OFF' }}
|
||||
</button>
|
||||
<button @click="clearLogs">🗑️ Clear</button>
|
||||
<select v-model="levelFilter" class="filter">
|
||||
<option value="ALL">All Levels</option>
|
||||
<option value="TRACE">TRACE</option>
|
||||
<option value="DEBUG">DEBUG</option>
|
||||
<option value="INFO">INFO</option>
|
||||
<option value="WARN">WARN</option>
|
||||
<option value="ERROR">ERROR</option>
|
||||
|
||||
<!-- Sélection du niveau de log -->
|
||||
<select v-model="serverLogLevel" @change="updateServerLogLevel" class="filter log-level">
|
||||
<option value="ERROR">🔴 ERROR only</option>
|
||||
<option value="WARN">🟡 WARN+</option>
|
||||
<option value="INFO">🟢 INFO+</option>
|
||||
<option value="DEBUG">🔵 DEBUG+</option>
|
||||
<option value="TRACE">⚪ TRACE (all)</option>
|
||||
</select>
|
||||
|
||||
<!-- Champ de recherche -->
|
||||
<div class="search-box">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="🔍 Search logs..."
|
||||
class="search-input"
|
||||
@keyup.escape="searchQuery = ''"
|
||||
/>
|
||||
<button v-if="searchQuery" @click="searchQuery = ''" class="clear-search" title="Clear search">
|
||||
✖
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -24,16 +39,29 @@
|
||||
:key="index"
|
||||
:class="['log-entry', `level-${log.level.toLowerCase()}`, { 'is-history': log.isHistory }]"
|
||||
>
|
||||
<span class="timestamp">{{ formatTimestamp(log.timestamp) }}</span>
|
||||
<span class="level">{{ log.level }}</span>
|
||||
<span class="target">{{ log.target }}</span>
|
||||
<span class="message markdown-content" v-html="renderMarkdown(log.message)"></span>
|
||||
<div class="log-header">
|
||||
<span class="timestamp">{{ formatTimestamp(log.timestamp) }}</span>
|
||||
<span class="level">{{ log.level }}</span>
|
||||
<span class="target">{{ log.target }}</span>
|
||||
</div>
|
||||
<div class="log-content">
|
||||
<div class="message markdown-content">
|
||||
<template v-if="log.isTooLong">
|
||||
<div v-show="!log.expanded" class="truncated-preview" v-html="highlightSearchTerm(log.truncatedHtml)"></div>
|
||||
<details class="log-details" @toggle="log.expanded = $event.target.open">
|
||||
<summary class="log-summary"></summary>
|
||||
<div class="full-message" v-html="highlightSearchTerm(log.renderedHtml)"></div>
|
||||
</details>
|
||||
</template>
|
||||
<div v-else v-html="highlightSearchTerm(log.renderedHtml)"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div v-if="isLoadingHistory" class="loading-state">
|
||||
⏳ Loading history...
|
||||
</div>
|
||||
|
||||
|
||||
<div v-else-if="filteredLogs.length === 0" class="empty-state">
|
||||
{{ isConnected ? 'Waiting for logs...' : 'Connecting to log stream...' }}
|
||||
</div>
|
||||
@@ -43,7 +71,13 @@
|
||||
<span :class="['status', { connected: isConnected }]">
|
||||
{{ isConnected ? '🟢 Connected' : '🔴 Disconnected' }}
|
||||
</span>
|
||||
<span class="count">{{ filteredLogs.length }} logs</span>
|
||||
<span class="server-info">Level: {{ serverLogLevel }}</span>
|
||||
<span class="count">
|
||||
{{ filteredLogs.length }} log{{ filteredLogs.length !== 1 ? 's' : '' }}
|
||||
<span v-if="searchQuery.trim()" class="search-results">
|
||||
({{ filteredLogs.length }} / {{ logs.length }} matching)
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -63,19 +97,83 @@ const logs = ref([])
|
||||
const autoScroll = ref(true)
|
||||
const isConnected = ref(false)
|
||||
const isLoadingHistory = ref(true)
|
||||
const levelFilter = ref('ALL')
|
||||
const serverLogLevel = ref('TRACE')
|
||||
const searchQuery = ref('')
|
||||
const logContainer = ref(null)
|
||||
let eventSource = null
|
||||
let historyLoaded = false
|
||||
const seenLogIds = new Set() // Pour détecter les duplicatas
|
||||
|
||||
// Filtrer les logs uniquement par recherche
|
||||
const filteredLogs = computed(() => {
|
||||
if (levelFilter.value === 'ALL') {
|
||||
return logs.value
|
||||
let filtered = logs.value
|
||||
|
||||
// Filtre par recherche
|
||||
if (searchQuery.value.trim()) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
filtered = filtered.filter(log => {
|
||||
return (
|
||||
log.message.toLowerCase().includes(query) ||
|
||||
log.level.toLowerCase().includes(query) ||
|
||||
log.target.toLowerCase().includes(query)
|
||||
)
|
||||
})
|
||||
}
|
||||
return logs.value.filter(log => log.level === levelFilter.value)
|
||||
|
||||
return filtered
|
||||
})
|
||||
|
||||
// Fonction pour mettre à jour le niveau de log côté serveur
|
||||
async function updateServerLogLevel() {
|
||||
try {
|
||||
const response = await fetch('/api/logs/log_setup', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
level: serverLogLevel.value
|
||||
})
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
console.log('Log level updated:', data.current_level)
|
||||
|
||||
// Fermer la connexion SSE actuelle
|
||||
if (eventSource) {
|
||||
eventSource.close()
|
||||
}
|
||||
|
||||
// Vider les logs actuels et réinitialiser
|
||||
logs.value = []
|
||||
seenLogIds.clear()
|
||||
historyLoaded = false
|
||||
isLoadingHistory.value = true
|
||||
|
||||
// Reconnecter au SSE avec le nouveau niveau
|
||||
connectSSE()
|
||||
} else {
|
||||
console.error('Failed to update log level')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating log level:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Charger le niveau de log actuel au démarrage
|
||||
async function loadServerLogLevel() {
|
||||
try {
|
||||
const response = await fetch('/api/logs/log_setup')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
serverLogLevel.value = data.current_level
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading log level:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimestamp(timestamp) {
|
||||
const date = new Date(timestamp.secs_since_epoch * 1000)
|
||||
return date.toLocaleTimeString('fr-FR', {
|
||||
@@ -86,43 +184,126 @@ function formatTimestamp(timestamp) {
|
||||
})
|
||||
}
|
||||
|
||||
function renderMarkdown(text) {
|
||||
// ÉTAPE 1 : Pré-processing pour détecter et protéger le XML
|
||||
let processedText = text
|
||||
// Pré-traiter un log : calculer HTML, troncature, etc. UNE SEULE FOIS
|
||||
function preprocessLog(message) {
|
||||
// ÉTAPE 1: Déterminer si trop long
|
||||
const firstLineEnd = message.indexOf('\n')
|
||||
const isTooLong = firstLineEnd !== -1 || message.length > 200
|
||||
|
||||
// Détecter si le message contient du XML
|
||||
// Pattern : cherche <?xml ou des balises XML racine communes (scpd, root, service, etc.)
|
||||
const hasXml = /<\?xml|<(scpd|root|service|device|actionList|stateVariable)[>\s]/i.test(text)
|
||||
// ÉTAPE 2: Calculer le message tronqué si nécessaire
|
||||
const truncatedMessage = isTooLong
|
||||
? (firstLineEnd !== -1
|
||||
? message.substring(0, firstLineEnd).trim()
|
||||
: message.substring(0, 200).trim())
|
||||
: null
|
||||
|
||||
// ÉTAPE 3: Pré-processing pour détecter et protéger le XML, images et audio
|
||||
let processedText = message
|
||||
|
||||
// ÉTAPE 3a: Détecter et marquer les URLs audio AVANT tout traitement markdown
|
||||
// On utilise des marqueurs UUID pour éviter les conflits
|
||||
const audioMarkers = new Map()
|
||||
const audioUrlPattern = /(https?:\/\/[^\s"<>]+\.(?:mp3|wav|ogg|m4a|flac|aac|opus|weba)(?:\?[^\s"<>]*)?)/gi
|
||||
processedText = processedText.replace(audioUrlPattern, (match) => {
|
||||
const markerId = `AUDIO_MARKER_${Math.random().toString(36).substring(2, 11)}`
|
||||
audioMarkers.set(markerId, match)
|
||||
return markerId
|
||||
})
|
||||
|
||||
// ÉTAPE 3b: Détecter et transformer les liens d'images
|
||||
const imageUrlPattern = /(https?:\/\/[^\s"<>]+\.(?:png|jpg|jpeg|gif|webp|svg)(?:\?[^\s"<>]*)?)/gi
|
||||
processedText = processedText.replace(imageUrlPattern, (match) => {
|
||||
return `\n\n`
|
||||
})
|
||||
|
||||
// ÉTAPE 3c: Détecter si le message contient du XML
|
||||
const hasXml = /<\?xml|<(scpd|root|service|device|actionList|stateVariable)[>\s]/i.test(processedText)
|
||||
|
||||
if (hasXml) {
|
||||
// Extraire tout ce qui ressemble à du XML (du <?xml ou première balise jusqu'à la fin)
|
||||
const xmlStartMatch = text.match(/<\?xml[\s\S]*$/)
|
||||
const xmlStartMatch = processedText.match(/<\?xml[\s\S]*$/)
|
||||
|
||||
if (xmlStartMatch) {
|
||||
const xmlContent = xmlStartMatch[0]
|
||||
const beforeXml = text.substring(0, text.indexOf(xmlContent))
|
||||
|
||||
// Créer le texte avec le XML dans un bloc de code
|
||||
const beforeXml = processedText.substring(0, processedText.indexOf(xmlContent))
|
||||
processedText = beforeXml + '\n```xml\n' + xmlContent + '\n```\n'
|
||||
} else {
|
||||
// Fallback : chercher une balise racine XML
|
||||
const xmlMatch = text.match(/<([a-zA-Z][a-zA-Z0-9:-]*)[>\s][\s\S]*/)
|
||||
const xmlMatch = processedText.match(/<([a-zA-Z][a-zA-Z0-9:-]*)[>\s][\s\S]*/)
|
||||
if (xmlMatch) {
|
||||
const xmlContent = xmlMatch[0]
|
||||
const beforeXml = text.substring(0, text.indexOf(xmlContent))
|
||||
const beforeXml = processedText.substring(0, processedText.indexOf(xmlContent))
|
||||
processedText = beforeXml + '\n```xml\n' + xmlContent + '\n```\n'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ÉTAPE 2 : Convertir markdown en HTML
|
||||
// ÉTAPE 5: Convertir markdown en HTML
|
||||
const rawHtml = marked.parse(processedText, { async: false })
|
||||
|
||||
// ÉTAPE 3 : Nettoyer pour la sécurité
|
||||
return DOMPurify.sanitize(rawHtml, {
|
||||
ALLOWED_TAGS: ['strong', 'em', 'code', 'pre', 'a', 'ul', 'ol', 'li', 'p', 'br', 'span'],
|
||||
ALLOWED_ATTR: ['href', 'target', 'class']
|
||||
// ÉTAPE 6: Nettoyer pour la sécurité
|
||||
let renderedHtml = DOMPurify.sanitize(rawHtml, {
|
||||
ALLOWED_TAGS: ['strong', 'em', 'code', 'pre', 'a', 'ul', 'ol', 'li', 'p', 'br', 'span', 'img'],
|
||||
ALLOWED_ATTR: ['href', 'target', 'class', 'src', 'alt', 'title']
|
||||
})
|
||||
|
||||
// ÉTAPE 6b: Remplacer les marqueurs audio par de vrais lecteurs HTML5
|
||||
for (const [markerId, audioUrl] of audioMarkers.entries()) {
|
||||
const audioPlayer = `<div class="audio-player-wrapper">
|
||||
<audio controls preload="metadata" class="log-audio-player">
|
||||
<source src="${audioUrl}" type="audio/${audioUrl.split('.').pop().split('?')[0]}">
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
<div class="audio-url"><a href="${audioUrl}" target="_blank" rel="noopener">${audioUrl}</a></div>
|
||||
</div>`
|
||||
|
||||
renderedHtml = renderedHtml.replace(new RegExp(markerId, 'g'), audioPlayer)
|
||||
}
|
||||
|
||||
const finalHtml = renderedHtml
|
||||
|
||||
// ÉTAPE 7: Générer le HTML du message tronqué si nécessaire
|
||||
let truncatedHtml = null
|
||||
if (isTooLong && truncatedMessage) {
|
||||
const truncatedRaw = marked.parse(truncatedMessage, { async: false })
|
||||
let cleanTruncated = DOMPurify.sanitize(truncatedRaw, {
|
||||
ALLOWED_TAGS: ['strong', 'em', 'code', 'pre', 'a', 'ul', 'ol', 'li', 'p', 'br', 'span', 'img'],
|
||||
ALLOWED_ATTR: ['href', 'target', 'class', 'src', 'alt', 'title']
|
||||
})
|
||||
|
||||
// Remplacer les marqueurs audio dans le message tronqué aussi
|
||||
for (const [markerId, audioUrl] of audioMarkers.entries()) {
|
||||
const audioPlayer = `<div class="audio-player-wrapper">
|
||||
<audio controls preload="metadata" class="log-audio-player">
|
||||
<source src="${audioUrl}" type="audio/${audioUrl.split('.').pop().split('?')[0]}">
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
<div class="audio-url"><a href="${audioUrl}" target="_blank" rel="noopener">${audioUrl}</a></div>
|
||||
</div>`
|
||||
|
||||
cleanTruncated = cleanTruncated.replace(new RegExp(markerId, 'g'), audioPlayer)
|
||||
}
|
||||
|
||||
truncatedHtml = cleanTruncated
|
||||
}
|
||||
|
||||
return {
|
||||
isTooLong,
|
||||
truncatedMessage,
|
||||
truncatedHtml,
|
||||
renderedHtml: finalHtml
|
||||
}
|
||||
}
|
||||
|
||||
function highlightSearchTerm(html) {
|
||||
if (!searchQuery.value.trim() || !html) {
|
||||
return html
|
||||
}
|
||||
|
||||
const query = searchQuery.value.trim()
|
||||
// Créer une regex insensible à la casse pour trouver le terme
|
||||
// Utiliser un lookahead négatif pour éviter de matcher dans les balises HTML
|
||||
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})(?![^<]*>)`, 'gi')
|
||||
|
||||
return html.replace(regex, '<mark class="search-highlight">$1</mark>')
|
||||
}
|
||||
|
||||
function toggleAutoScroll() {
|
||||
@@ -157,23 +338,31 @@ function connectSSE() {
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const logEntry = JSON.parse(event.data)
|
||||
|
||||
|
||||
// Créer un ID unique basé sur timestamp + message + target
|
||||
const logId = `${logEntry.timestamp.secs_since_epoch}-${logEntry.timestamp.nanos_since_epoch}-${logEntry.message}-${logEntry.target}`
|
||||
|
||||
|
||||
// Ignorer les duplicatas
|
||||
if (seenLogIds.has(logId)) {
|
||||
return
|
||||
}
|
||||
seenLogIds.add(logId)
|
||||
|
||||
|
||||
// Marquer les logs historiques
|
||||
if (!historyLoaded) {
|
||||
logEntry.isHistory = true
|
||||
}
|
||||
|
||||
|
||||
// PRÉ-TRAITER le log UNE SEULE FOIS à la réception
|
||||
const processed = preprocessLog(logEntry.message)
|
||||
logEntry.isTooLong = processed.isTooLong
|
||||
logEntry.truncatedMessage = processed.truncatedMessage
|
||||
logEntry.truncatedHtml = processed.truncatedHtml
|
||||
logEntry.renderedHtml = processed.renderedHtml
|
||||
logEntry.expanded = false // État de dépliage initial
|
||||
|
||||
logs.value.push(logEntry)
|
||||
|
||||
|
||||
// Limiter à 1000 logs en mémoire
|
||||
if (logs.value.length > 1000) {
|
||||
const removed = logs.value.shift()
|
||||
@@ -181,7 +370,7 @@ function connectSSE() {
|
||||
const removedId = `${removed.timestamp.secs_since_epoch}-${removed.timestamp.nanos_since_epoch}-${removed.message}-${removed.target}`
|
||||
seenLogIds.delete(removedId)
|
||||
}
|
||||
|
||||
|
||||
scrollToBottom()
|
||||
} catch (error) {
|
||||
console.error('Failed to parse log entry:', error)
|
||||
@@ -192,7 +381,7 @@ function connectSSE() {
|
||||
isConnected.value = false
|
||||
isLoadingHistory.value = false
|
||||
console.error('SSE connection error')
|
||||
|
||||
|
||||
// Reconnexion automatique après 3 secondes
|
||||
setTimeout(() => {
|
||||
if (eventSource.readyState === EventSource.CLOSED) {
|
||||
@@ -209,7 +398,7 @@ function connectSSE() {
|
||||
eventSource.onmessage = (event) => {
|
||||
clearTimeout(historyTimeout)
|
||||
originalOnMessage(event)
|
||||
|
||||
|
||||
if (!historyLoaded) {
|
||||
historyTimeout = setTimeout(() => {
|
||||
historyLoaded = true
|
||||
@@ -221,6 +410,7 @@ function connectSSE() {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadServerLogLevel()
|
||||
connectSSE()
|
||||
})
|
||||
|
||||
@@ -231,17 +421,32 @@ onUnmounted(() => {
|
||||
})
|
||||
|
||||
// Désactiver auto-scroll si l'utilisateur scroll manuellement
|
||||
watch(logContainer, (container) => {
|
||||
let scrollHandler = null
|
||||
watch(logContainer, (container, oldContainer) => {
|
||||
// Nettoyer l'ancien listener si existant
|
||||
if (oldContainer && scrollHandler) {
|
||||
oldContainer.removeEventListener('scroll', scrollHandler)
|
||||
}
|
||||
|
||||
if (!container) return
|
||||
|
||||
container.addEventListener('scroll', () => {
|
||||
const isAtBottom =
|
||||
|
||||
scrollHandler = () => {
|
||||
const isAtBottom =
|
||||
container.scrollHeight - container.scrollTop <= container.clientHeight + 50
|
||||
|
||||
|
||||
if (!isAtBottom && autoScroll.value) {
|
||||
autoScroll.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
container.addEventListener('scroll', scrollHandler, { passive: true })
|
||||
})
|
||||
|
||||
// Nettoyer au démontage
|
||||
onUnmounted(() => {
|
||||
if (logContainer.value && scrollHandler) {
|
||||
logContainer.value.removeEventListener('scroll', scrollHandler)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -249,14 +454,15 @@ watch(logContainer, (container) => {
|
||||
.log-viewer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 80vh;
|
||||
width: 100vw;
|
||||
height: calc(100vh - 60px); /* Hauteur viewport - nav */
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden; /* Empêcher le scroll sur le conteneur principal */
|
||||
}
|
||||
|
||||
.header {
|
||||
@@ -268,6 +474,7 @@ watch(logContainer, (container) => {
|
||||
border-bottom: 1px solid #3e3e42;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0; /* Ne pas réduire le header */
|
||||
}
|
||||
|
||||
.header h2 {
|
||||
@@ -281,7 +488,7 @@ watch(logContainer, (container) => {
|
||||
.header {
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
|
||||
.header h2 {
|
||||
font-size: 1rem;
|
||||
width: 100%;
|
||||
@@ -338,6 +545,13 @@ button.active {
|
||||
border: 1px solid #555;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.filter.log-level {
|
||||
background: #1e3a5f;
|
||||
border-color: #569cd6;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@@ -349,6 +563,69 @@ button.active {
|
||||
}
|
||||
}
|
||||
|
||||
.search-box {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
padding: 0.5rem 2rem 0.5rem 0.75rem;
|
||||
background: #3c3c3c;
|
||||
color: #d4d4d4;
|
||||
border: 1px solid #555;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
outline: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: #569cd6;
|
||||
background: #2d2d30;
|
||||
box-shadow: 0 0 0 2px rgba(86, 156, 214, 0.2);
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: #858585;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.clear-search {
|
||||
position: absolute;
|
||||
right: 0.25rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #858585;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
border-radius: 3px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.clear-search:hover {
|
||||
background: #505050;
|
||||
color: #d4d4d4;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.search-box {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.4rem 2rem 0.4rem 0.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
.log-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
@@ -357,20 +634,18 @@ button.active {
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
display: grid;
|
||||
grid-template-columns: 130px 80px 200px 1fr;
|
||||
gap: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
border-left: 3px solid transparent;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.log-entry {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.3rem;
|
||||
padding: 0.75rem 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
border-left-width: 4px;
|
||||
@@ -385,15 +660,32 @@ button.active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.log-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.log-header {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.log-content {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
color: #858585;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.timestamp {
|
||||
font-size: 0.75rem;
|
||||
order: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -403,12 +695,11 @@ button.active {
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 3px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.level {
|
||||
order: 2;
|
||||
width: fit-content;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.2rem 0.6rem;
|
||||
}
|
||||
@@ -417,11 +708,15 @@ button.active {
|
||||
.target {
|
||||
color: #4ec9b0;
|
||||
font-style: italic;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.target {
|
||||
order: 3;
|
||||
font-size: 0.8rem;
|
||||
color: #6eb8a5;
|
||||
}
|
||||
@@ -433,11 +728,59 @@ button.active {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.message {
|
||||
order: 4;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.log-details {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.log-summary {
|
||||
cursor: pointer;
|
||||
color: #569cd6;
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 1em;
|
||||
}
|
||||
|
||||
.log-summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.log-summary::marker {
|
||||
content: '';
|
||||
}
|
||||
|
||||
.log-summary::before {
|
||||
content: '▶ Afficher plus';
|
||||
display: inline-block;
|
||||
transition: transform 0.2s;
|
||||
color: #569cd6;
|
||||
font-size: 0.85em;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.log-details[open] .log-summary::before {
|
||||
content: '▼ Afficher moins';
|
||||
}
|
||||
|
||||
.log-summary:hover::before {
|
||||
color: #6fa8dc;
|
||||
}
|
||||
|
||||
.truncated-preview {
|
||||
color: #d4d4d4;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.full-message {
|
||||
margin-top: 0.5rem;
|
||||
padding-left: 1.5em;
|
||||
border-left: 2px solid #569cd6;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.markdown-content {
|
||||
@@ -478,6 +821,59 @@ button.active {
|
||||
color: #ce9178;
|
||||
}
|
||||
|
||||
/* Style pour les images */
|
||||
.markdown-content :deep(img) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 4px;
|
||||
margin: 0.5rem 0;
|
||||
border: 1px solid #3e3e42;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Style pour les lecteurs audio */
|
||||
.markdown-content :deep(.audio-player-wrapper) {
|
||||
margin: 0.75rem 0;
|
||||
padding: 0.75rem;
|
||||
background: #2d2d30;
|
||||
border: 1px solid #3e3e42;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.markdown-content :deep(.log-audio-player) {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
height: 40px;
|
||||
border-radius: 4px;
|
||||
background: #1e1e1e;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.markdown-content :deep(.log-audio-player:focus) {
|
||||
outline: 2px solid #569cd6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.markdown-content :deep(.audio-url) {
|
||||
font-size: 0.85em;
|
||||
color: #858585;
|
||||
font-style: italic;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.markdown-content :deep(.audio-url a) {
|
||||
color: #569cd6;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.markdown-content :deep(.audio-url a:hover) {
|
||||
color: #6fa8dc;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Scrollbar pour les blocs de code longs */
|
||||
.markdown-content :deep(pre)::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
@@ -527,32 +923,14 @@ button.active {
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
/* Level colors */
|
||||
.level-trace {
|
||||
border-left-color: #808080;
|
||||
/* Level colors - Classés par ordre de gravité */
|
||||
.level-error {
|
||||
border-left-color: #f48771;
|
||||
}
|
||||
|
||||
.level-trace .level {
|
||||
background: #3a3a3a;
|
||||
color: #a0a0a0;
|
||||
}
|
||||
|
||||
.level-debug {
|
||||
border-left-color: #569cd6;
|
||||
}
|
||||
|
||||
.level-debug .level {
|
||||
background: #1e3a5f;
|
||||
color: #569cd6;
|
||||
}
|
||||
|
||||
.level-info {
|
||||
border-left-color: #4ec9b0;
|
||||
}
|
||||
|
||||
.level-info .level {
|
||||
background: #1e4d42;
|
||||
color: #4ec9b0;
|
||||
.level-error .level {
|
||||
background: #5a1e1e;
|
||||
color: #f48771;
|
||||
}
|
||||
|
||||
.level-warn {
|
||||
@@ -564,13 +942,31 @@ button.active {
|
||||
color: #dcdcaa;
|
||||
}
|
||||
|
||||
.level-error {
|
||||
border-left-color: #f48771;
|
||||
.level-info {
|
||||
border-left-color: #4ec9b0;
|
||||
}
|
||||
|
||||
.level-error .level {
|
||||
background: #5a1e1e;
|
||||
color: #f48771;
|
||||
.level-info .level {
|
||||
background: #1e4d42;
|
||||
color: #4ec9b0;
|
||||
}
|
||||
|
||||
.level-debug {
|
||||
border-left-color: #569cd6;
|
||||
}
|
||||
|
||||
.level-debug .level {
|
||||
background: #1e3a5f;
|
||||
color: #569cd6;
|
||||
}
|
||||
|
||||
.level-trace {
|
||||
border-left-color: #808080;
|
||||
}
|
||||
|
||||
.level-trace .level {
|
||||
background: #3a3a3a;
|
||||
color: #a0a0a0;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
@@ -600,6 +996,8 @@ button.active {
|
||||
background: #252526;
|
||||
border-top: 1px solid #3e3e42;
|
||||
font-size: 0.9rem;
|
||||
gap: 1rem;
|
||||
flex-shrink: 0; /* Ne pas réduire le footer */
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@@ -617,10 +1015,30 @@ button.active {
|
||||
color: #4ec9b0;
|
||||
}
|
||||
|
||||
.server-info {
|
||||
color: #569cd6;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.count {
|
||||
color: #858585;
|
||||
}
|
||||
|
||||
.search-results {
|
||||
color: #569cd6;
|
||||
font-weight: bold;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Surlignage des termes recherchés */
|
||||
.markdown-content :deep(mark.search-highlight) {
|
||||
background: #ffd700;
|
||||
color: #1e1e1e;
|
||||
padding: 0.1rem 0.2rem;
|
||||
border-radius: 2px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.log-container::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
@@ -638,4 +1056,4 @@ button.active {
|
||||
.log-container::-webkit-scrollbar-thumb:hover {
|
||||
background: #4e4e4e;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
2009
pmoapp/webapp/src/components/RadioParadiseExplorer.vue
Normal file
2009
pmoapp/webapp/src/components/RadioParadiseExplorer.vue
Normal file
File diff suppressed because it is too large
Load Diff
506
pmoapp/webapp/src/components/UpnpExplorer.vue
Normal file
506
pmoapp/webapp/src/components/UpnpExplorer.vue
Normal file
@@ -0,0 +1,506 @@
|
||||
<template>
|
||||
<div class="upnp-explorer">
|
||||
<div class="header">
|
||||
<h2>🎵 UPnP Device Explorer</h2>
|
||||
<div class="controls">
|
||||
<button @click="refreshDevices" :disabled="isLoading" class="refresh-btn">
|
||||
{{ isLoading ? '⏳ Loading...' : '🔄 Refresh' }}
|
||||
</button>
|
||||
<span class="device-count">{{ devices.length }} device(s)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- État de chargement -->
|
||||
<div v-if="isLoading && devices.length === 0" class="loading-state">
|
||||
⏳ Loading UPnP devices...
|
||||
</div>
|
||||
|
||||
<!-- État vide -->
|
||||
<div v-else-if="!isLoading && devices.length === 0" class="empty-state">
|
||||
<div class="empty-icon">📡</div>
|
||||
<p>No UPnP devices found</p>
|
||||
<p class="hint">Devices will appear here once registered</p>
|
||||
</div>
|
||||
|
||||
<!-- Liste des devices avec leurs services intégrés -->
|
||||
<div v-else class="devices-list">
|
||||
<div
|
||||
v-for="device in devicesWithDetails"
|
||||
:key="device.udn"
|
||||
class="device-section"
|
||||
>
|
||||
<!-- En-tête du device -->
|
||||
<div class="device-header" @click="toggleDevice(device.udn)">
|
||||
<div class="device-title">
|
||||
<span class="device-icon">{{ getDeviceIcon(device.device_type) }}</span>
|
||||
<div class="device-names">
|
||||
<span class="device-name">{{ device.friendly_name }}</span>
|
||||
<span class="device-type">{{ device.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="device-meta">
|
||||
<span v-if="device.services" class="service-count">
|
||||
{{ device.services.length }} service(s)
|
||||
</span>
|
||||
<span class="expand-icon">{{ expandedDevices.has(device.udn) ? '▼' : '▶' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Détails du device (expandable) -->
|
||||
<transition name="expand">
|
||||
<div v-if="expandedDevices.has(device.udn)" class="device-details">
|
||||
<!-- Chargement des détails -->
|
||||
<div v-if="!device.services" class="loading-services">
|
||||
⏳ Loading services...
|
||||
</div>
|
||||
|
||||
<!-- Services -->
|
||||
<div v-else class="device-content">
|
||||
<div class="device-summary">
|
||||
<div class="meta-row">
|
||||
<span class="meta-label">UDN:</span>
|
||||
<code class="meta-value">{{ device.udn }}</code>
|
||||
</div>
|
||||
<div class="meta-row" v-if="device.description_url">
|
||||
<span class="meta-label">Description:</span>
|
||||
<a
|
||||
:href="device.description_url"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="meta-link"
|
||||
>
|
||||
View XML
|
||||
</a>
|
||||
</div>
|
||||
<div class="meta-row" v-if="device.base_url">
|
||||
<span class="meta-label">Base URL:</span>
|
||||
<code class="meta-value">{{ device.base_url }}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="services-list">
|
||||
<ServicePanel
|
||||
v-for="service in device.services"
|
||||
:key="service.name"
|
||||
:service="service"
|
||||
:device-udn="device.udn"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast de notification d'erreur -->
|
||||
<transition name="fade">
|
||||
<div v-if="error" class="error-toast" @click="error = null">
|
||||
❌ {{ error }}
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import ServicePanel from './upnp/ServicePanel.vue'
|
||||
|
||||
const devices = ref([])
|
||||
const deviceDetails = ref(new Map()) // UDN -> détails complets
|
||||
const isLoading = ref(false)
|
||||
const error = ref(null)
|
||||
const expandedDevices = ref(new Set())
|
||||
const refreshInterval = ref(null)
|
||||
|
||||
// Devices avec leurs détails fusionnés
|
||||
const devicesWithDetails = computed(() => {
|
||||
return devices.value.map(device => {
|
||||
const details = deviceDetails.value.get(device.udn)
|
||||
return details ? { ...device, ...details } : device
|
||||
})
|
||||
})
|
||||
|
||||
function getDeviceIcon(deviceType) {
|
||||
if (deviceType?.includes('MediaRenderer')) return '🎵'
|
||||
if (deviceType?.includes('MediaServer')) return '💿'
|
||||
return '📱'
|
||||
}
|
||||
|
||||
async function loadDevices() {
|
||||
try {
|
||||
const response = await fetch('/api/upnp/devices')
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||
|
||||
const data = await response.json()
|
||||
devices.value = data.devices || []
|
||||
} catch (err) {
|
||||
console.error('Failed to load devices:', err)
|
||||
error.value = `Failed to load devices: ${err.message}`
|
||||
setTimeout(() => error.value = null, 5000)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDeviceDetails(udn) {
|
||||
try {
|
||||
const response = await fetch(`/api/upnp/devices/${encodeURIComponent(udn)}`)
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||
|
||||
const details = await response.json()
|
||||
deviceDetails.value.set(udn, details)
|
||||
} catch (err) {
|
||||
console.error('Failed to load device details:', err)
|
||||
error.value = `Failed to load device details: ${err.message}`
|
||||
setTimeout(() => error.value = null, 5000)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDevice(udn) {
|
||||
if (expandedDevices.value.has(udn)) {
|
||||
expandedDevices.value.delete(udn)
|
||||
} else {
|
||||
expandedDevices.value.add(udn)
|
||||
// Charger les détails si pas encore fait
|
||||
if (!deviceDetails.value.has(udn)) {
|
||||
loadDeviceDetails(udn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshDevices() {
|
||||
isLoading.value = true
|
||||
await loadDevices()
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
// Auto-refresh toutes les 30 secondes
|
||||
onMounted(() => {
|
||||
refreshDevices()
|
||||
refreshInterval.value = setInterval(loadDevices, 30000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (refreshInterval.value) {
|
||||
clearInterval(refreshInterval.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.upnp-explorer {
|
||||
padding: 1rem;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media (min-width: 1400px) {
|
||||
.upnp-explorer {
|
||||
padding: 2rem;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.upnp-explorer {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 2px solid rgba(52, 152, 219, 0.3);
|
||||
}
|
||||
|
||||
.header h2 {
|
||||
margin: 0;
|
||||
color: #ecf0f1;
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
padding: 0.6rem 1.2rem;
|
||||
background: linear-gradient(135deg, #3498db, #2980b9);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.refresh-btn:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #5dade2, #3498db);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.refresh-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.device-count {
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgba(52, 152, 219, 0.2);
|
||||
border: 1px solid rgba(52, 152, 219, 0.4);
|
||||
border-radius: 20px;
|
||||
color: #3498db;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* États */
|
||||
.loading-state,
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
color: #95a5a6;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.9rem;
|
||||
color: #7f8c8d;
|
||||
}
|
||||
|
||||
/* Devices list */
|
||||
.devices-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.device-section {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(52, 152, 219, 0.3);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.device-section:hover {
|
||||
border-color: rgba(52, 152, 219, 0.6);
|
||||
box-shadow: 0 4px 12px rgba(52, 152, 219, 0.2);
|
||||
}
|
||||
|
||||
.device-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.2rem 1.5rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.device-header:hover {
|
||||
background: rgba(52, 152, 219, 0.05);
|
||||
}
|
||||
|
||||
.device-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.device-icon {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.device-names {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.device-name {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
color: #ecf0f1;
|
||||
}
|
||||
|
||||
.device-type {
|
||||
font-size: 0.85rem;
|
||||
color: #95a5a6;
|
||||
}
|
||||
|
||||
.device-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.service-count {
|
||||
padding: 0.3rem 0.8rem;
|
||||
background: rgba(46, 204, 113, 0.2);
|
||||
border: 1px solid rgba(46, 204, 113, 0.3);
|
||||
border-radius: 12px;
|
||||
color: #2ecc71;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
color: #3498db;
|
||||
font-size: 1rem;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
/* Device details */
|
||||
.device-details {
|
||||
padding: 0 1.5rem 1.5rem 1.5rem;
|
||||
border-top: 1px solid rgba(52, 152, 219, 0.2);
|
||||
}
|
||||
|
||||
.loading-services {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: #95a5a6;
|
||||
}
|
||||
|
||||
.device-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.device-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem 1.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 6px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid rgba(52, 152, 219, 0.25);
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
font-weight: 600;
|
||||
color: #95a5a6;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
background: rgba(44, 62, 80, 0.6);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
color: #ecf0f1;
|
||||
}
|
||||
|
||||
.meta-link {
|
||||
color: #1abc9c;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.meta-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.services-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* Transitions */
|
||||
.expand-enter-active,
|
||||
.expand-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
max-height: 5000px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.expand-enter-from,
|
||||
.expand-leave-to {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Error toast */
|
||||
.error-toast {
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
right: 2rem;
|
||||
background: linear-gradient(135deg, #e74c3c, #c0392b);
|
||||
color: white;
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
cursor: pointer;
|
||||
z-index: 1000;
|
||||
max-width: 400px;
|
||||
animation: slideIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.upnp-explorer {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.device-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.device-meta {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
527
pmoapp/webapp/src/components/upnp/ActionsList.vue
Normal file
527
pmoapp/webapp/src/components/upnp/ActionsList.vue
Normal file
@@ -0,0 +1,527 @@
|
||||
<template>
|
||||
<div class="actions-list">
|
||||
<div v-if="!service.actions || service.actions.length === 0" class="empty-state">
|
||||
<span class="empty-icon">⚡</span>
|
||||
<p>No actions available for this service</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="actions-content">
|
||||
<div class="actions-header">
|
||||
<h4>Actions ({{ service.actions.length }})</h4>
|
||||
</div>
|
||||
|
||||
<div class="actions-grid">
|
||||
<div
|
||||
v-for="action in service.actions"
|
||||
:key="action.name"
|
||||
class="action-card"
|
||||
:class="{ expanded: expandedAction === action.name }"
|
||||
@click="toggleAction(action.name)"
|
||||
>
|
||||
<div class="action-header">
|
||||
<div class="action-title">
|
||||
<span class="action-icon">⚡</span>
|
||||
<span class="action-name">{{ action.name }}</span>
|
||||
</div>
|
||||
<div class="action-badges">
|
||||
<span v-if="action.in_arguments.length > 0" class="badge in-badge" title="Input arguments">
|
||||
➡️ {{ action.in_arguments.length }}
|
||||
</span>
|
||||
<span v-if="action.out_arguments.length > 0" class="badge out-badge" title="Output arguments">
|
||||
⬅️ {{ action.out_arguments.length }}
|
||||
</span>
|
||||
<span
|
||||
v-if="action.stateless"
|
||||
class="badge stateless-badge"
|
||||
title="Does not mutate state variables"
|
||||
>
|
||||
🧊 Stateless
|
||||
</span>
|
||||
<span class="expand-indicator">
|
||||
{{ expandedAction === action.name ? '▼' : '▶' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<transition name="expand-args">
|
||||
<div v-if="expandedAction === action.name" class="action-details">
|
||||
<div v-if="action.stateless" class="action-flags">
|
||||
<span class="stateless-pill">
|
||||
Stateless action — no state variables updated
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Input arguments -->
|
||||
<div v-if="action.in_arguments.length > 0" class="arguments-section">
|
||||
<h5 class="section-title">
|
||||
<span class="section-icon">➡️</span>
|
||||
Input Arguments
|
||||
</h5>
|
||||
<div class="arguments-list">
|
||||
<div
|
||||
v-for="arg in action.in_arguments"
|
||||
:key="arg.name"
|
||||
class="argument-item"
|
||||
>
|
||||
<div class="argument-header">
|
||||
<span class="argument-name">{{ arg.name }}</span>
|
||||
<span class="var-link" @click.stop="scrollToVariable(arg.related_state_variable)">
|
||||
{{ arg.related_state_variable }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="getVariableInfo(arg.related_state_variable)" class="variable-preview">
|
||||
<div class="preview-row">
|
||||
<span class="preview-label">Type:</span>
|
||||
<code class="preview-value type">{{ getVariableInfo(arg.related_state_variable).data_type }}</code>
|
||||
</div>
|
||||
<div class="preview-row">
|
||||
<span class="preview-label">Value:</span>
|
||||
<code class="preview-value" :class="{ empty: !getVariableInfo(arg.related_state_variable).value }">
|
||||
{{ getVariableInfo(arg.related_state_variable).value || '(empty)' }}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Output arguments -->
|
||||
<div v-if="action.out_arguments.length > 0" class="arguments-section">
|
||||
<h5 class="section-title">
|
||||
<span class="section-icon">⬅️</span>
|
||||
Output Arguments
|
||||
</h5>
|
||||
<div class="arguments-list">
|
||||
<div
|
||||
v-for="arg in action.out_arguments"
|
||||
:key="arg.name"
|
||||
class="argument-item out"
|
||||
>
|
||||
<div class="argument-header">
|
||||
<span class="argument-name">{{ arg.name }}</span>
|
||||
<span class="var-link" @click.stop="scrollToVariable(arg.related_state_variable)">
|
||||
{{ arg.related_state_variable }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="getVariableInfo(arg.related_state_variable)" class="variable-preview">
|
||||
<div class="preview-row">
|
||||
<span class="preview-label">Type:</span>
|
||||
<code class="preview-value type">{{ getVariableInfo(arg.related_state_variable).data_type }}</code>
|
||||
</div>
|
||||
<div class="preview-row">
|
||||
<span class="preview-label">Value:</span>
|
||||
<code class="preview-value" :class="{ empty: !getVariableInfo(arg.related_state_variable).value }">
|
||||
{{ getVariableInfo(arg.related_state_variable).value || '(empty)' }}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No arguments -->
|
||||
<div v-if="action.in_arguments.length === 0 && action.out_arguments.length === 0" class="no-arguments">
|
||||
<span class="no-args-icon">∅</span>
|
||||
<p>This action has no arguments</p>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
service: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
deviceUdn: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const expandedAction = ref(null)
|
||||
const variables = ref([])
|
||||
|
||||
function toggleAction(actionName) {
|
||||
expandedAction.value = expandedAction.value === actionName ? null : actionName
|
||||
}
|
||||
|
||||
function getVariableInfo(varName) {
|
||||
return variables.value.find(v => v.name === varName)
|
||||
}
|
||||
|
||||
function scrollToVariable(varName) {
|
||||
// TODO: Implement scroll to variable in Variables tab
|
||||
console.log('Scroll to variable:', varName)
|
||||
}
|
||||
|
||||
async function loadVariables() {
|
||||
if (!props.deviceUdn || !props.service.name) return
|
||||
|
||||
try {
|
||||
const url = `/api/upnp/devices/${encodeURIComponent(props.deviceUdn)}/services/${encodeURIComponent(props.service.name)}/variables`
|
||||
const response = await fetch(url)
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||
|
||||
const data = await response.json()
|
||||
variables.value = data.variables || []
|
||||
} catch (err) {
|
||||
console.error('Error loading variables for actions:', err)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadVariables()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.actions-list {
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem;
|
||||
color: #95a5a6;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Actions content */
|
||||
.actions-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.actions-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.actions-header h4 {
|
||||
margin: 0;
|
||||
color: #ecf0f1;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Actions grid */
|
||||
.actions-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.action-card {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(52, 152, 219, 0.3);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.action-card:hover {
|
||||
border-color: rgba(52, 152, 219, 0.6);
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.action-card.expanded {
|
||||
border-color: #3498db;
|
||||
background: rgba(52, 152, 219, 0.05);
|
||||
}
|
||||
|
||||
.action-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.action-card:hover .action-header {
|
||||
background: rgba(52, 152, 219, 0.05);
|
||||
}
|
||||
|
||||
.action-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.action-name {
|
||||
font-weight: 600;
|
||||
color: #ecf0f1;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.action-badges {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.in-badge {
|
||||
background: rgba(52, 152, 219, 0.2);
|
||||
color: #3498db;
|
||||
border: 1px solid rgba(52, 152, 219, 0.3);
|
||||
}
|
||||
|
||||
.out-badge {
|
||||
background: rgba(46, 204, 113, 0.2);
|
||||
color: #2ecc71;
|
||||
border: 1px solid rgba(46, 204, 113, 0.3);
|
||||
}
|
||||
|
||||
.stateless-badge {
|
||||
background: rgba(155, 89, 182, 0.2);
|
||||
color: #9b59b6;
|
||||
border: 1px solid rgba(155, 89, 182, 0.3);
|
||||
}
|
||||
|
||||
.expand-indicator {
|
||||
color: #3498db;
|
||||
font-size: 0.9rem;
|
||||
transition: transform 0.3s;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.action-card.expanded .expand-indicator {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
/* Action details */
|
||||
.action-details {
|
||||
padding: 0 1rem 1rem 1rem;
|
||||
border-top: 1px solid rgba(52, 152, 219, 0.2);
|
||||
}
|
||||
|
||||
.action-flags {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.stateless-pill {
|
||||
display: inline-block;
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(155, 89, 182, 0.15);
|
||||
border: 1px solid rgba(155, 89, 182, 0.25);
|
||||
color: #d2a6e6;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
}
|
||||
|
||||
.arguments-section {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.arguments-section:first-child {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: #95a5a6;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.arguments-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.argument-item {
|
||||
background: rgba(52, 152, 219, 0.1);
|
||||
border: 1px solid rgba(52, 152, 219, 0.2);
|
||||
border-left: 3px solid #3498db;
|
||||
border-radius: 4px;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.argument-item.out {
|
||||
background: rgba(46, 204, 113, 0.1);
|
||||
border: 1px solid rgba(46, 204, 113, 0.2);
|
||||
border-left: 3px solid #2ecc71;
|
||||
}
|
||||
|
||||
.argument-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.argument-name {
|
||||
font-weight: 600;
|
||||
color: #ecf0f1;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.var-link {
|
||||
font-size: 0.75rem;
|
||||
color: #9b59b6;
|
||||
background: rgba(155, 89, 182, 0.2);
|
||||
border: 1px solid rgba(155, 89, 182, 0.3);
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.var-link:hover {
|
||||
background: rgba(155, 89, 182, 0.3);
|
||||
border-color: #9b59b6;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Variable preview */
|
||||
.variable-preview {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 4px;
|
||||
padding: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.preview-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.preview-label {
|
||||
font-size: 0.7rem;
|
||||
color: #7f8c8d;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
min-width: 50px;
|
||||
}
|
||||
|
||||
.preview-value {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.8rem;
|
||||
color: #ecf0f1;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.preview-value.type {
|
||||
color: #9b59b6;
|
||||
background: rgba(155, 89, 182, 0.15);
|
||||
}
|
||||
|
||||
.preview-value.empty {
|
||||
color: #7f8c8d;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* No arguments state */
|
||||
.no-arguments {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
color: #95a5a6;
|
||||
}
|
||||
|
||||
.no-args-icon {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.no-arguments p {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Expand animation */
|
||||
.expand-args-enter-active,
|
||||
.expand-args-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
max-height: 1000px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.expand-args-enter-from,
|
||||
.expand-args-leave-to {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.action-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.action-badges {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.argument-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
307
pmoapp/webapp/src/components/upnp/DeviceCard.vue
Normal file
307
pmoapp/webapp/src/components/upnp/DeviceCard.vue
Normal file
@@ -0,0 +1,307 @@
|
||||
<template>
|
||||
<div
|
||||
class="device-card"
|
||||
:class="{ expanded: isExpanded }"
|
||||
@click="handleClick"
|
||||
>
|
||||
<div class="card-header">
|
||||
<div class="device-icon">
|
||||
{{ getDeviceIcon(device.device_type) }}
|
||||
</div>
|
||||
<div class="device-info">
|
||||
<h3 class="device-name">{{ device.friendly_name }}</h3>
|
||||
<p class="device-type">{{ formatDeviceType(device.device_type) }}</p>
|
||||
</div>
|
||||
<div class="expand-icon">
|
||||
{{ isExpanded ? '▼' : '▶' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<div class="device-details">
|
||||
<div class="detail-item">
|
||||
<span class="detail-icon">🏷️</span>
|
||||
<span class="detail-label">Name:</span>
|
||||
<span class="detail-value">{{ device.name }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-icon">🏭</span>
|
||||
<span class="detail-label">Manufacturer:</span>
|
||||
<span class="detail-value">{{ device.manufacturer }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-icon">📦</span>
|
||||
<span class="detail-label">Model:</span>
|
||||
<span class="detail-value">{{ device.model_name }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-icon">🔗</span>
|
||||
<span class="detail-label">Base URL:</span>
|
||||
<a :href="device.base_url" target="_blank" class="detail-value link">
|
||||
{{ device.base_url }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="detail-item udn">
|
||||
<span class="detail-icon">🆔</span>
|
||||
<span class="detail-label">UDN:</span>
|
||||
<code class="detail-value monospace">{{ device.udn }}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-actions">
|
||||
<button
|
||||
@click.stop="$emit('load-details')"
|
||||
class="details-btn"
|
||||
>
|
||||
📋 View Services
|
||||
</button>
|
||||
<a
|
||||
:href="device.description_url"
|
||||
target="_blank"
|
||||
class="xml-btn"
|
||||
@click.stop
|
||||
>
|
||||
📄 Device XML
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps, defineEmits } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
device: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
isExpanded: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['toggle', 'load-details'])
|
||||
|
||||
function handleClick() {
|
||||
emit('toggle')
|
||||
}
|
||||
|
||||
function getDeviceIcon(deviceType) {
|
||||
if (deviceType.includes('MediaRenderer')) return '🎵'
|
||||
if (deviceType.includes('MediaServer')) return '💿'
|
||||
if (deviceType.includes('Display')) return '🖥️'
|
||||
return '📱'
|
||||
}
|
||||
|
||||
function formatDeviceType(deviceType) {
|
||||
// Extraire le type simple depuis l'URN
|
||||
const match = deviceType.match(/device:([^:]+)/)
|
||||
return match ? match[1] : deviceType
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.device-card {
|
||||
background: linear-gradient(135deg, #2c3e50 0%, #34495e 100%);
|
||||
border-radius: 12px;
|
||||
border: 2px solid #3498db;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.device-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 16px rgba(52, 152, 219, 0.4);
|
||||
border-color: #5dade2;
|
||||
}
|
||||
|
||||
.device-card.expanded {
|
||||
border-color: #2ecc71;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 1.25rem;
|
||||
gap: 1rem;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.device-icon {
|
||||
font-size: 2.5rem;
|
||||
flex-shrink: 0;
|
||||
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));
|
||||
}
|
||||
|
||||
.device-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.device-name {
|
||||
margin: 0 0 0.25rem 0;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
color: #ecf0f1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.device-type {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: #3498db;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
font-size: 1.2rem;
|
||||
color: #3498db;
|
||||
transition: transform 0.3s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.device-card.expanded .expand-icon {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.3s ease;
|
||||
}
|
||||
|
||||
.device-card.expanded .card-body {
|
||||
max-height: 500px;
|
||||
}
|
||||
|
||||
.device-details {
|
||||
padding: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 6px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.detail-item:hover {
|
||||
background: rgba(52, 152, 219, 0.1);
|
||||
}
|
||||
|
||||
.detail-item.udn {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.detail-icon {
|
||||
font-size: 1.1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-weight: 600;
|
||||
color: #95a5a6;
|
||||
min-width: 100px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
color: #ecf0f1;
|
||||
flex: 1;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.monospace {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.8rem;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: #3498db;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
color: #5dade2;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.25rem;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-top: 1px solid rgba(52, 152, 219, 0.3);
|
||||
}
|
||||
|
||||
.details-btn,
|
||||
.xml-btn {
|
||||
flex: 1;
|
||||
padding: 0.75rem 1rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.details-btn {
|
||||
background: #3498db;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.details-btn:hover {
|
||||
background: #2980b9;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(52, 152, 219, 0.3);
|
||||
}
|
||||
|
||||
.xml-btn {
|
||||
background: #2ecc71;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.xml-btn:hover {
|
||||
background: #27ae60;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(46, 204, 113, 0.3);
|
||||
}
|
||||
|
||||
/* Animation d'entrée */
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.device-card {
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
</style>
|
||||
281
pmoapp/webapp/src/components/upnp/ServicePanel.vue
Normal file
281
pmoapp/webapp/src/components/upnp/ServicePanel.vue
Normal file
@@ -0,0 +1,281 @@
|
||||
<template>
|
||||
<div class="service-panel" :class="{ expanded: isExpanded }">
|
||||
<div class="service-header" @click="toggleExpand">
|
||||
<div class="service-icon">🔧</div>
|
||||
<div class="service-info">
|
||||
<h4 class="service-name">{{ service.name }}</h4>
|
||||
<p class="service-type">{{ formatServiceType(service.service_type) }}</p>
|
||||
</div>
|
||||
<div class="service-badge">
|
||||
{{ isExpanded ? '▼' : '▶' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<transition name="expand">
|
||||
<div v-if="isExpanded" class="service-content">
|
||||
<!-- URLs du service -->
|
||||
<div class="service-urls">
|
||||
<div class="url-item">
|
||||
<span class="url-label">Control:</span>
|
||||
<a :href="service.control_url" target="_blank" class="url-value">
|
||||
{{ service.control_url }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="url-item">
|
||||
<span class="url-label">Events:</span>
|
||||
<a :href="service.event_url" target="_blank" class="url-value">
|
||||
{{ service.event_url }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="url-item">
|
||||
<span class="url-label">SCPD:</span>
|
||||
<a :href="service.scpd_url" target="_blank" class="url-value">
|
||||
{{ service.scpd_url }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Onglets pour Variables / Actions -->
|
||||
<div class="tabs">
|
||||
<button
|
||||
:class="['tab', { active: activeTab === 'variables' }]"
|
||||
@click="activeTab = 'variables'"
|
||||
>
|
||||
📊 Variables
|
||||
<span class="badge">{{ variablesCount }}</span>
|
||||
</button>
|
||||
<button
|
||||
:class="['tab', { active: activeTab === 'actions' }]"
|
||||
@click="activeTab = 'actions'"
|
||||
>
|
||||
⚡ Actions
|
||||
<span class="badge">{{ actionsCount }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Contenu des onglets -->
|
||||
<div class="tab-content">
|
||||
<VariablesList
|
||||
v-if="activeTab === 'variables'"
|
||||
:device-udn="deviceUdn"
|
||||
:service-name="service.name"
|
||||
/>
|
||||
<ActionsList
|
||||
v-else
|
||||
:service="service"
|
||||
:device-udn="deviceUdn"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import VariablesList from './VariablesList.vue'
|
||||
import ActionsList from './ActionsList.vue'
|
||||
|
||||
const props = defineProps({
|
||||
service: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
deviceUdn: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const isExpanded = ref(false)
|
||||
const activeTab = ref('variables')
|
||||
|
||||
const variablesCount = computed(() => {
|
||||
// Sera mis à jour dynamiquement par VariablesList
|
||||
return '...'
|
||||
})
|
||||
|
||||
const actionsCount = computed(() => {
|
||||
return '...'
|
||||
})
|
||||
|
||||
function toggleExpand() {
|
||||
isExpanded.value = !isExpanded.value
|
||||
}
|
||||
|
||||
function formatServiceType(serviceType) {
|
||||
const match = serviceType.match(/service:([^:]+)/)
|
||||
return match ? match[1] : serviceType
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.service-panel {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(52, 152, 219, 0.3);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.service-panel:hover {
|
||||
border-color: rgba(52, 152, 219, 0.6);
|
||||
box-shadow: 0 2px 8px rgba(52, 152, 219, 0.2);
|
||||
}
|
||||
|
||||
.service-panel.expanded {
|
||||
border-color: #3498db;
|
||||
}
|
||||
|
||||
.service-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
cursor: pointer;
|
||||
gap: 0.75rem;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.service-header:hover {
|
||||
background: rgba(52, 152, 219, 0.1);
|
||||
}
|
||||
|
||||
.service-icon {
|
||||
font-size: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.service-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.service-name {
|
||||
margin: 0 0 0.25rem 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #ecf0f1;
|
||||
}
|
||||
|
||||
.service-type {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
color: #7f8c8d;
|
||||
}
|
||||
|
||||
.service-badge {
|
||||
color: #3498db;
|
||||
font-size: 1rem;
|
||||
transition: transform 0.3s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.service-content {
|
||||
padding: 0 1rem 1rem 1rem;
|
||||
}
|
||||
|
||||
.service-urls {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.url-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.url-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.url-label {
|
||||
font-weight: 600;
|
||||
color: #95a5a6;
|
||||
min-width: 80px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.url-value {
|
||||
color: #3498db;
|
||||
text-decoration: none;
|
||||
font-size: 0.85rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.url-value:hover {
|
||||
text-decoration: underline;
|
||||
color: #5dade2;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
border-bottom: 2px solid rgba(52, 152, 219, 0.2);
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
padding: 0.75rem 1rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #95a5a6;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border-bottom: 3px solid transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
background: rgba(52, 152, 219, 0.1);
|
||||
color: #ecf0f1;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: #3498db;
|
||||
border-bottom-color: #3498db;
|
||||
background: rgba(52, 152, 219, 0.05);
|
||||
}
|
||||
|
||||
.badge {
|
||||
background: rgba(52, 152, 219, 0.3);
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tab.active .badge {
|
||||
background: #3498db;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
.expand-enter-active,
|
||||
.expand-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
max-height: 1000px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.expand-enter-from,
|
||||
.expand-leave-to {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
546
pmoapp/webapp/src/components/upnp/VariablesList.vue
Normal file
546
pmoapp/webapp/src/components/upnp/VariablesList.vue
Normal file
@@ -0,0 +1,546 @@
|
||||
<template>
|
||||
<div class="variables-list">
|
||||
<div v-if="loading" class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
<p>Loading variables...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="error-state">
|
||||
<span class="error-icon">⚠️</span>
|
||||
<p>{{ error }}</p>
|
||||
<button @click="loadVariables" class="retry-btn">Retry</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="variables.length === 0" class="empty-state">
|
||||
<span class="empty-icon">📭</span>
|
||||
<p>No variables found for this service</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="variables-content">
|
||||
<div class="variables-header">
|
||||
<h4>State Variables ({{ variables.length }})</h4>
|
||||
<button @click="loadVariables" class="refresh-btn" :disabled="loading">
|
||||
🔄 Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="variables-grid">
|
||||
<div
|
||||
v-for="variable in variables"
|
||||
:key="variable.name"
|
||||
class="variable-card"
|
||||
:class="{ 'has-events': variable.sends_events, 'has-value': variable.value }"
|
||||
>
|
||||
<div class="variable-header">
|
||||
<span class="variable-name">{{ variable.name }}</span>
|
||||
<div class="header-badges">
|
||||
<span v-if="variable.sends_events" class="event-badge" title="Sends events">
|
||||
🔔
|
||||
</span>
|
||||
<span class="type-badge" :title="variable.data_type">
|
||||
{{ variable.data_type }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="variable-details">
|
||||
<!-- Valeur actuelle - toujours affichée en premier -->
|
||||
<div class="variable-row current-value">
|
||||
<span class="variable-label">Current Value:</span>
|
||||
<div class="value-display">
|
||||
<code class="variable-value" :class="{ empty: !variable.value }">
|
||||
{{ variable.value || '(empty)' }}
|
||||
</code>
|
||||
<button
|
||||
v-if="variable.value"
|
||||
@click="editingVar = editingVar === variable.name ? null : variable.name"
|
||||
class="edit-btn"
|
||||
title="Edit value"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Formulaire d'édition -->
|
||||
<div v-if="editingVar === variable.name" class="edit-form">
|
||||
<input
|
||||
v-model="editValue"
|
||||
:type="getInputType(variable.data_type)"
|
||||
:placeholder="`Enter ${variable.data_type} value`"
|
||||
class="edit-input"
|
||||
@keyup.enter="saveValue(variable)"
|
||||
@keyup.escape="editingVar = null"
|
||||
/>
|
||||
<div class="edit-actions">
|
||||
<button @click="saveValue(variable)" class="save-btn">💾 Save</button>
|
||||
<button @click="editingVar = null" class="cancel-btn">✖ Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="variable.default_value" class="variable-row">
|
||||
<span class="variable-label">Default:</span>
|
||||
<code class="variable-value">{{ variable.default_value }}</code>
|
||||
</div>
|
||||
|
||||
<div v-if="variable.allowed_values && variable.allowed_values.length > 0" class="variable-row">
|
||||
<span class="variable-label">Allowed:</span>
|
||||
<div class="allowed-values">
|
||||
<code
|
||||
v-for="(value, idx) in variable.allowed_values"
|
||||
:key="idx"
|
||||
class="allowed-value"
|
||||
>
|
||||
{{ value }}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="variable.min || variable.max" class="variable-row">
|
||||
<span class="variable-label">Range:</span>
|
||||
<code class="variable-value">
|
||||
{{ variable.min ?? '−∞' }} → {{ variable.max ?? '+∞' }}
|
||||
<span v-if="variable.step"> (step: {{ variable.step }})</span>
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
deviceUdn: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
serviceName: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const variables = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
const editingVar = ref(null)
|
||||
const editValue = ref('')
|
||||
|
||||
function getInputType(dataType) {
|
||||
if (dataType.includes('int') || dataType.includes('ui')) return 'number'
|
||||
if (dataType.includes('bool')) return 'checkbox'
|
||||
return 'text'
|
||||
}
|
||||
|
||||
async function loadVariables() {
|
||||
if (!props.deviceUdn || !props.serviceName) return
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const url = `/api/upnp/devices/${encodeURIComponent(props.deviceUdn)}/services/${encodeURIComponent(props.serviceName)}/variables`
|
||||
const response = await fetch(url)
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||
|
||||
const data = await response.json()
|
||||
variables.value = data.variables || []
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Failed to load variables'
|
||||
console.error('Error loading variables:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveValue(variable) {
|
||||
// TODO: Implement API call to update variable value
|
||||
console.log(`Saving ${variable.name} = ${editValue.value}`)
|
||||
editingVar.value = null
|
||||
editValue.value = ''
|
||||
// Refresh to get updated value
|
||||
await loadVariables()
|
||||
}
|
||||
|
||||
// Load on mount
|
||||
onMounted(() => {
|
||||
loadVariables()
|
||||
})
|
||||
|
||||
// Reload when props change
|
||||
watch(() => [props.deviceUdn, props.serviceName], () => {
|
||||
loadVariables()
|
||||
})
|
||||
|
||||
// Set edit value when starting to edit
|
||||
watch(editingVar, (newVar) => {
|
||||
if (newVar) {
|
||||
const variable = variables.value.find(v => v.name === newVar)
|
||||
if (variable) {
|
||||
editValue.value = variable.value || ''
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.variables-list {
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Loading state */
|
||||
.loading-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem;
|
||||
color: #95a5a6;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid rgba(52, 152, 219, 0.3);
|
||||
border-top-color: #3498db;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Error state */
|
||||
.error-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem;
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.error-state p {
|
||||
margin: 0 0 1rem 0;
|
||||
color: #ecf0f1;
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
background: #e74c3c;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.retry-btn:hover {
|
||||
background: #c0392b;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem;
|
||||
color: #95a5a6;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Variables content */
|
||||
.variables-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.variables-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.variables-header h4 {
|
||||
margin: 0;
|
||||
color: #ecf0f1;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgba(52, 152, 219, 0.2);
|
||||
color: #3498db;
|
||||
border: 1px solid rgba(52, 152, 219, 0.3);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.refresh-btn:hover:not(:disabled) {
|
||||
background: rgba(52, 152, 219, 0.3);
|
||||
border-color: #3498db;
|
||||
}
|
||||
|
||||
.refresh-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Variables grid */
|
||||
.variables-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.variable-card {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(52, 152, 219, 0.3);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.variable-card:hover {
|
||||
border-color: rgba(52, 152, 219, 0.6);
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.variable-card.has-events {
|
||||
border-color: rgba(46, 204, 113, 0.4);
|
||||
}
|
||||
|
||||
.variable-card.has-events:hover {
|
||||
border-color: rgba(46, 204, 113, 0.7);
|
||||
}
|
||||
|
||||
.variable-card.has-value {
|
||||
border-left: 3px solid #3498db;
|
||||
}
|
||||
|
||||
.variable-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid rgba(52, 152, 219, 0.2);
|
||||
}
|
||||
|
||||
.variable-name {
|
||||
font-weight: 600;
|
||||
color: #3498db;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.header-badges {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.event-badge {
|
||||
font-size: 1rem;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
padding: 0.2rem 0.5rem;
|
||||
background: rgba(155, 89, 182, 0.2);
|
||||
border: 1px solid rgba(155, 89, 182, 0.3);
|
||||
border-radius: 4px;
|
||||
color: #9b59b6;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.variable-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.variable-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.variable-row.current-value {
|
||||
background: rgba(52, 152, 219, 0.1);
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
border-left: 3px solid #3498db;
|
||||
}
|
||||
|
||||
.variable-label {
|
||||
font-size: 0.75rem;
|
||||
color: #95a5a6;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.value-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.variable-value {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.9rem;
|
||||
color: #ecf0f1;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.variable-value.empty {
|
||||
color: #7f8c8d;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
padding: 0.3rem 0.5rem;
|
||||
background: rgba(241, 196, 15, 0.2);
|
||||
border: 1px solid rgba(241, 196, 15, 0.3);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.edit-btn:hover {
|
||||
background: rgba(241, 196, 15, 0.3);
|
||||
border-color: #f1c40f;
|
||||
}
|
||||
|
||||
/* Edit form */
|
||||
.edit-form {
|
||||
background: rgba(241, 196, 15, 0.1);
|
||||
padding: 0.75rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(241, 196, 15, 0.3);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.edit-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(241, 196, 15, 0.3);
|
||||
border-radius: 4px;
|
||||
color: #ecf0f1;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.edit-input:focus {
|
||||
outline: none;
|
||||
border-color: #f1c40f;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.edit-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.save-btn,
|
||||
.cancel-btn {
|
||||
flex: 1;
|
||||
padding: 0.5rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
background: #27ae60;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.save-btn:hover {
|
||||
background: #229954;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background: rgba(231, 76, 60, 0.2);
|
||||
color: #e74c3c;
|
||||
border: 1px solid rgba(231, 76, 60, 0.3);
|
||||
}
|
||||
|
||||
.cancel-btn:hover {
|
||||
background: rgba(231, 76, 60, 0.3);
|
||||
border-color: #e74c3c;
|
||||
}
|
||||
|
||||
.allowed-values {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.allowed-value {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.75rem;
|
||||
color: #2ecc71;
|
||||
background: rgba(46, 204, 113, 0.1);
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(46, 204, 113, 0.3);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.variables-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -2,11 +2,19 @@ import { createRouter, createWebHistory } from "vue-router";
|
||||
import HelloWorld from "../components/HelloWorld.vue";
|
||||
import LogView from "../components/LogView.vue";
|
||||
import CoverCacheManager from "../components/CoverCacheManager.vue";
|
||||
import AudioCacheManager from "../components/AudioCacheManager.vue";
|
||||
import UpnpExplorer from "../components/UpnpExplorer.vue";
|
||||
import APIDashboard from "../components/APIDashboard.vue";
|
||||
import RadioParadiseExplorer from "../components/RadioParadiseExplorer.vue";
|
||||
|
||||
const routes = [
|
||||
{ path: "/", name: "home", component: HelloWorld },
|
||||
{ path: "/logs", name: "logs", component: LogView },
|
||||
{ path: "/covers-cache", name: "covers-cache", component: CoverCacheManager },
|
||||
{ path: "/audio-cache", name: "audio-cache", component: AudioCacheManager },
|
||||
{ path: "/upnp", name: "upnp", component: UpnpExplorer },
|
||||
{ path: "/api-dashboard", name: "api-dashboard", component: APIDashboard },
|
||||
{ path: "/radio-paradise", name: "radio-paradise", component: RadioParadiseExplorer },
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
|
||||
230
pmoapp/webapp/src/services/audioCache.ts
Normal file
230
pmoapp/webapp/src/services/audioCache.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Service API pour interagir avec le cache de pistes audio
|
||||
*/
|
||||
|
||||
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;
|
||||
id: string | null;
|
||||
hits: number;
|
||||
last_used: string | null;
|
||||
collection?: string | null;
|
||||
metadata?: AudioCacheMetadata | null;
|
||||
}
|
||||
|
||||
export interface ConversionInfo {
|
||||
mode: string;
|
||||
input_codec?: string;
|
||||
details?: string;
|
||||
}
|
||||
|
||||
export interface AddTrackRequest {
|
||||
url: string;
|
||||
collection?: string;
|
||||
}
|
||||
|
||||
export interface AddTrackResponse {
|
||||
pk: string;
|
||||
url: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface DownloadStatus {
|
||||
pk: string;
|
||||
in_progress: boolean;
|
||||
finished: boolean;
|
||||
current_size?: number;
|
||||
transformed_size?: number;
|
||||
expected_size?: number;
|
||||
error?: string;
|
||||
conversion?: ConversionInfo;
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
error: string;
|
||||
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
|
||||
*/
|
||||
export async function listTracks(): Promise<AudioCacheEntry[]> {
|
||||
const response = await fetch("/api/audio");
|
||||
if (!response.ok) {
|
||||
const error: ApiError = await response.json();
|
||||
throw new Error(error.message || "Failed to fetch tracks");
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les informations d'une piste spécifique
|
||||
*/
|
||||
export async function getTrackInfo(pk: string): Promise<AudioCacheEntry> {
|
||||
const response = await fetch(`/api/audio/${pk}`);
|
||||
if (!response.ok) {
|
||||
const error: ApiError = await response.json();
|
||||
throw new Error(error.message || "Failed to fetch track info");
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère le statut de téléchargement d'une piste
|
||||
*/
|
||||
export async function getDownloadStatus(pk: string): Promise<DownloadStatus> {
|
||||
const response = await fetch(`/api/audio/${pk}/status`);
|
||||
if (!response.ok) {
|
||||
const error: ApiError = await response.json();
|
||||
throw new Error(error.message || "Failed to fetch download status");
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute une nouvelle piste au cache depuis une URL
|
||||
*/
|
||||
export async function addTrack(url: string, collection?: string): Promise<AddTrackResponse> {
|
||||
const body: AddTrackRequest = { url };
|
||||
if (collection) {
|
||||
body.collection = collection;
|
||||
}
|
||||
|
||||
const response = await fetch("/api/audio", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error: ApiError = await response.json();
|
||||
throw new Error(error.message || "Failed to add track");
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime une piste du cache
|
||||
*/
|
||||
export async function deleteTrack(pk: string): Promise<void> {
|
||||
const response = await fetch(`/api/audio/${pk}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error: ApiError = await response.json();
|
||||
throw new Error(error.message || "Failed to delete track");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Purge complètement le cache
|
||||
*/
|
||||
export async function purgeCache(): Promise<void> {
|
||||
const response = await fetch("/api/audio", {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error: ApiError = await response.json();
|
||||
throw new Error(error.message || "Failed to purge cache");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Consolide le cache (re-télécharge les pistes manquantes)
|
||||
*/
|
||||
export async function consolidateCache(): Promise<void> {
|
||||
const response = await fetch("/api/audio/consolidate", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error: ApiError = await response.json();
|
||||
throw new Error(error.message || "Failed to consolidate cache");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère l'URL pour streamer une piste
|
||||
*/
|
||||
export function getTrackUrl(pk: string): string {
|
||||
return `/audio/flac/${pk}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère l'URL pour télécharger la piste originale
|
||||
*/
|
||||
export function getOriginalTrackUrl(pk: string): string {
|
||||
return `/audio/flac/${pk}/orig`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatte la durée en millisecondes au format MM:SS
|
||||
*/
|
||||
export function formatDuration(ms?: number): string {
|
||||
if (!ms) return "Unknown";
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatte le bitrate en kbps
|
||||
*/
|
||||
export function formatBitrate(bitrate?: number): string {
|
||||
if (!bitrate) return "Unknown";
|
||||
return `${Math.round(bitrate / 1000)} kbps`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatte le sample rate en kHz
|
||||
*/
|
||||
export function formatSampleRate(sampleRate?: number): string {
|
||||
if (!sampleRate) return "Unknown";
|
||||
return `${(sampleRate / 1000).toFixed(1)} kHz`;
|
||||
}
|
||||
@@ -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,12 +124,71 @@ 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/images/${pk}/${size}`;
|
||||
return `/covers/image/${pk}/${size}`;
|
||||
}
|
||||
return `/covers/images/${pk}`;
|
||||
return `/covers/image/${pk}`;
|
||||
}
|
||||
|
||||
@@ -25,10 +25,11 @@ a:hover {
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
flex-direction: column;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
width: 100vw;
|
||||
width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
h1 {
|
||||
@@ -60,10 +61,13 @@ button:focus-visible {
|
||||
}
|
||||
|
||||
#app {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
|
||||
@@ -5,4 +5,16 @@ import vue from '@vitejs/plugin-vue'
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
base: '/app/', // Base path pour le déploiement
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/audio': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
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" }
|
||||
|
||||
# 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
Normal file
30
pmoaudio-ext/src/lib.rs
Normal 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::*;
|
||||
623
pmoaudio-ext/src/sinks/flac_cache_sink.rs
Normal file
623
pmoaudio-ext/src/sinks/flac_cache_sink.rs
Normal file
@@ -0,0 +1,623 @@
|
||||
//! Sink qui encode les AudioSegment au format FLAC et les stocke dans le cache audio
|
||||
|
||||
use pmoaudio::{
|
||||
nodes::{AudioError, TypedAudioNode, DEFAULT_CHANNEL_SIZE},
|
||||
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;
|
||||
|
||||
/// 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
|
||||
pub struct FlacCacheSink {
|
||||
tx: mpsc::Sender<Arc<AudioSegment>>,
|
||||
rx: mpsc::Receiver<Arc<AudioSegment>>,
|
||||
cache: Arc<pmoaudiocache::Cache>,
|
||||
collection: Option<String>,
|
||||
encoder_options: EncoderOptions,
|
||||
pcm_buffer_capacity: usize,
|
||||
#[cfg(feature = "playlist")]
|
||||
playlist_handle: 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>) -> Self {
|
||||
Self::with_channel_size(cache, 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>, channel_size: usize) -> Self {
|
||||
Self::with_config(cache, 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>,
|
||||
channel_size: usize,
|
||||
encoder_options: EncoderOptions,
|
||||
collection: Option<String>,
|
||||
) -> Self {
|
||||
let (tx, rx) = mpsc::channel(channel_size);
|
||||
Self {
|
||||
tx,
|
||||
rx,
|
||||
cache,
|
||||
collection,
|
||||
encoder_options,
|
||||
pcm_buffer_capacity: 8,
|
||||
#[cfg(feature = "playlist")]
|
||||
playlist_handle: 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 = Some(Arc::new(handle));
|
||||
}
|
||||
|
||||
/// Lance l'encodage et l'ingestion dans le cache (version interne).
|
||||
///
|
||||
/// Cette méthode crée une nouvelle entrée de cache pour chaque TrackBoundary rencontré.
|
||||
/// Les métadonnées du TrackBoundary sont copiées dans le cache après l'ingestion.
|
||||
async fn run_internal(
|
||||
self,
|
||||
stop_token: CancellationToken,
|
||||
) -> Result<FlacCacheSinkStats, AudioError> {
|
||||
let FlacCacheSink {
|
||||
tx: _,
|
||||
mut rx,
|
||||
cache,
|
||||
collection,
|
||||
encoder_options,
|
||||
pcm_buffer_capacity,
|
||||
#[cfg(feature = "playlist")]
|
||||
playlist_handle,
|
||||
} = self;
|
||||
|
||||
let mut all_tracks = Vec::new();
|
||||
let mut track_number = 0;
|
||||
|
||||
loop {
|
||||
// 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
|
||||
if all_tracks.is_empty() {
|
||||
return Err(AudioError::ProcessingError(
|
||||
"No audio data received".into(),
|
||||
));
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// 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>>(pcm_buffer_capacity);
|
||||
|
||||
// Préparer les options d'encodage avec les métadonnées du TrackBoundary
|
||||
let mut options_with_metadata = 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): (Result<(), AudioError>, Result<(u64, u64, f64, StopReason), AudioError>) =
|
||||
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 = collection.as_deref();
|
||||
let pk = 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 = 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
|
||||
))
|
||||
})?;
|
||||
}
|
||||
|
||||
// Ajouter à la playlist si enregistrée
|
||||
#[cfg(feature = "playlist")]
|
||||
if let Some(ref playlist_handle) = playlist_handle {
|
||||
playlist_handle.push(pk.clone()).await
|
||||
.map_err(|e| AudioError::ProcessingError(
|
||||
format!("Failed to add to playlist: {}", e)
|
||||
))?;
|
||||
}
|
||||
|
||||
// Ajouter les stats de cette track
|
||||
all_tracks.push(TrackStats {
|
||||
pk,
|
||||
track_number,
|
||||
chunks_received: chunks,
|
||||
total_samples: samples,
|
||||
total_duration_sec: duration_sec,
|
||||
});
|
||||
|
||||
// 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
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(FlacCacheSinkStats { tracks: all_tracks })
|
||||
}
|
||||
}
|
||||
|
||||
/// Signal retourné par pump_segments indiquant pourquoi l'encodage s'est arrêté.
|
||||
enum StopReason {
|
||||
TrackBoundary(Arc<RwLock<dyn pmometadata::TrackMetadata>>),
|
||||
EndOfStream,
|
||||
ChannelClosed,
|
||||
}
|
||||
|
||||
/// 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.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.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.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.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.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.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.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.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.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>>> {
|
||||
Some(self.tx.clone())
|
||||
}
|
||||
|
||||
fn register(&mut self, _child: Box<dyn AudioPipelineNode>) {
|
||||
panic!("FlacCacheSink is a terminal sink and cannot have children");
|
||||
}
|
||||
|
||||
async fn run(
|
||||
self: Box<Self>,
|
||||
stop_token: CancellationToken,
|
||||
) -> Result<(), AudioError> {
|
||||
self.run_internal(stop_token).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
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
Normal file
11
pmoaudio-ext/src/sinks/mod.rs
Normal 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};
|
||||
246
pmoaudio/CHANGELOG_EXTENSIONS.md
Normal file
246
pmoaudio/CHANGELOG_EXTENSIONS.md
Normal file
@@ -0,0 +1,246 @@
|
||||
# Changelog - Extensions Multiroom et Volume
|
||||
|
||||
## Version 0.2.0 - Extensions Multiroom
|
||||
|
||||
### Nouvelles fonctionnalités
|
||||
|
||||
#### 1. Contrôle de volume dynamique
|
||||
- **VolumeNode** : node de contrôle de volume software thread-safe
|
||||
- **HardwareVolumeNode** : variant pour contrôle matériel (prévu)
|
||||
- **VolumeHandle** : handle pour contrôler le volume depuis un autre contexte
|
||||
- **Système master/slave** : synchronisation automatique du volume entre branches
|
||||
|
||||
#### 2. Nouveaux types de sinks
|
||||
- **DiskSink** : écriture sur disque (WAV, FLAC, PCM)
|
||||
- Dérivation automatique du nom de fichier depuis la source
|
||||
- Application automatique du gain avant écriture
|
||||
- **ChromecastSink** : diffusion vers Chromecast (mock)
|
||||
- **MpdSink** : streaming vers MPD (mock)
|
||||
|
||||
#### 3. Système d'événements
|
||||
- **EventPublisher/EventReceiver** : système d'abonnement générique type-safe
|
||||
- **VolumeChangeEvent** : notification de changement de volume
|
||||
- **SourceNameUpdateEvent** : mise à jour du nom de source
|
||||
- **AudioDataEvent** : transport de données audio via événements
|
||||
|
||||
#### 4. Extensions AudioChunk
|
||||
- Nouveau champ `gain: f32` pour contrôle de volume lazy
|
||||
- `with_gain()` : constructeur avec gain
|
||||
- `apply_gain()` : application du gain sur les samples
|
||||
- `with_modified_gain()` : modification du gain sans copie
|
||||
|
||||
### Modules ajoutés
|
||||
```
|
||||
src/
|
||||
├── events.rs [NOUVEAU]
|
||||
└── nodes/
|
||||
├── volume_node.rs [NOUVEAU]
|
||||
├── disk_sink.rs [NOUVEAU]
|
||||
├── chromecast_sink.rs [NOUVEAU]
|
||||
└── mpd_sink.rs [NOUVEAU]
|
||||
|
||||
examples/
|
||||
├── volume_control_demo.rs [NOUVEAU]
|
||||
└── multiroom_volume_demo.rs [NOUVEAU]
|
||||
```
|
||||
|
||||
### API publique
|
||||
|
||||
#### Exports ajoutés dans lib.rs
|
||||
```rust
|
||||
// Events
|
||||
pub use events::{
|
||||
AudioDataEvent,
|
||||
EventPublisher,
|
||||
EventReceiver,
|
||||
NodeEvent,
|
||||
NodeListener,
|
||||
SourceNameUpdateEvent,
|
||||
VolumeChangeEvent,
|
||||
};
|
||||
|
||||
// Volume nodes
|
||||
pub use nodes::volume_node::{
|
||||
HardwareVolumeNode,
|
||||
VolumeHandle,
|
||||
VolumeNode,
|
||||
};
|
||||
|
||||
// Sinks
|
||||
pub use nodes::disk_sink::{
|
||||
AudioFileFormat,
|
||||
DiskSink,
|
||||
DiskSinkConfig,
|
||||
DiskSinkStats,
|
||||
};
|
||||
|
||||
pub use nodes::chromecast_sink::{
|
||||
ChromecastConfig,
|
||||
ChromecastSink,
|
||||
ChromecastStats,
|
||||
StreamEncoding,
|
||||
};
|
||||
|
||||
pub use nodes::mpd_sink::{
|
||||
MpdAudioFormat,
|
||||
MpdConfig,
|
||||
MpdHandle,
|
||||
MpdSink,
|
||||
MpdStats,
|
||||
};
|
||||
```
|
||||
|
||||
### Modifications de types existants
|
||||
|
||||
#### AudioChunk
|
||||
```rust
|
||||
pub struct AudioChunk {
|
||||
pub order: u64,
|
||||
pub left: Arc<Vec<f32>>,
|
||||
pub right: Arc<Vec<f32>>,
|
||||
pub sample_rate: u32,
|
||||
pub gain: f32, // [NOUVEAU]
|
||||
}
|
||||
|
||||
impl AudioChunk {
|
||||
// Méthodes existantes (inchangées)
|
||||
pub fn new(...) -> Self;
|
||||
pub fn from_arc(...) -> Self;
|
||||
pub fn len(&self) -> usize;
|
||||
pub fn is_empty(&self) -> bool;
|
||||
pub fn clone_data(&self) -> (Vec<f32>, Vec<f32>);
|
||||
|
||||
// Nouvelles méthodes
|
||||
pub fn with_gain(..., gain: f32) -> Self; // [NOUVEAU]
|
||||
pub fn from_arc_with_gain(..., gain: f32) -> Self;// [NOUVEAU]
|
||||
pub fn apply_gain(&self) -> Self; // [NOUVEAU]
|
||||
pub fn with_modified_gain(&self, new_gain: f32) -> Self; // [NOUVEAU]
|
||||
}
|
||||
```
|
||||
|
||||
### Tests
|
||||
- 12 nouveaux tests unitaires
|
||||
- Tous les tests existants continuent de passer
|
||||
- **Total : 31 tests, 0 failures**
|
||||
|
||||
### Exemples
|
||||
- `volume_control_demo` : contrôle de volume simple
|
||||
- `multiroom_volume_demo` : pipeline multiroom complet
|
||||
|
||||
### Breaking changes
|
||||
**Aucun** - Toutes les modifications sont additives.
|
||||
|
||||
### Performances
|
||||
- **Zero-copy maintenu** : partage des `Arc<AudioChunk>` entre branches
|
||||
- **Lazy evaluation** : gain non appliqué jusqu'au sink
|
||||
- **Thread-safe** : `RwLock` pour le volume, channels Tokio
|
||||
|
||||
### Documentation
|
||||
- `FEATURES_EXTENDED.md` : documentation complète des fonctionnalités
|
||||
- `IMPLEMENTATION_SUMMARY.md` : résumé technique de l'implémentation
|
||||
- Commentaires inline dans le code
|
||||
|
||||
---
|
||||
|
||||
## Migration depuis 0.1.0
|
||||
|
||||
Aucune migration nécessaire. Le code existant fonctionne sans modification.
|
||||
|
||||
### Pour utiliser les nouvelles fonctionnalités
|
||||
|
||||
#### Ajouter un contrôle de volume
|
||||
```rust
|
||||
// Avant
|
||||
source.add_subscriber(sink_tx);
|
||||
|
||||
// Après
|
||||
let (mut volume, volume_tx) = VolumeNode::new("main", 1.0, 10);
|
||||
let handle = volume.get_handle();
|
||||
volume.add_subscriber(sink_tx);
|
||||
source.add_subscriber(volume_tx);
|
||||
|
||||
tokio::spawn(async move { volume.run().await });
|
||||
|
||||
// Modifier le volume dynamiquement
|
||||
handle.set_volume(0.5).await;
|
||||
```
|
||||
|
||||
#### Écrire sur disque
|
||||
```rust
|
||||
let config = DiskSinkConfig {
|
||||
output_dir: PathBuf::from("/tmp/audio"),
|
||||
filename: Some("output.wav".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let (disk_sink, disk_tx) = DiskSink::new("disk1".to_string(), config, 10);
|
||||
|
||||
// Connecter au pipeline
|
||||
volume.add_subscriber(disk_tx);
|
||||
|
||||
// Lancer
|
||||
tokio::spawn(async move {
|
||||
let stats = disk_sink.run().await.unwrap();
|
||||
stats.display();
|
||||
});
|
||||
```
|
||||
|
||||
#### Configuration multiroom
|
||||
```rust
|
||||
// Volume master
|
||||
let (mut master, master_tx) = VolumeNode::new("master", 1.0, 50);
|
||||
let (event_tx, event_rx1) = mpsc::channel(10);
|
||||
let (_, event_rx2) = mpsc::channel(10);
|
||||
master.subscribe_volume_events(event_tx);
|
||||
source.add_subscriber(master_tx);
|
||||
|
||||
// Branche 1
|
||||
let (mut vol1, vol1_tx) = VolumeNode::new("room1", 0.8, 50);
|
||||
vol1.set_master_volume_source(event_rx1);
|
||||
vol1.add_subscriber(sink1_tx);
|
||||
master.add_subscriber(vol1_tx);
|
||||
|
||||
// Branche 2
|
||||
let (mut vol2, vol2_tx) = VolumeNode::new("room2", 0.9, 50);
|
||||
vol2.set_master_volume_source(event_rx2);
|
||||
vol2.add_subscriber(sink2_tx);
|
||||
master.add_subscriber(vol2_tx);
|
||||
|
||||
// Contrôle master
|
||||
let master_handle = master.get_handle();
|
||||
master_handle.set_volume(0.7).await; // Affecte toutes les branches
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
### v0.3.0 (prévu)
|
||||
- [ ] Implémentation réelle ChromecastSink avec `rust-cast`
|
||||
- [ ] Implémentation réelle MpdSink avec protocole MPD
|
||||
- [ ] Support FLAC dans DiskSink avec `claxon`
|
||||
- [ ] AirPlaySink (diffusion AirPlay/AirPlay 2)
|
||||
- [ ] EqualizerNode (égaliseur paramétrique)
|
||||
|
||||
### v0.4.0 (prévu)
|
||||
- [ ] PulseAudioSink / AlsaSink / CoreAudioSink
|
||||
- [ ] CompressorNode / LimiterNode (dynamiques)
|
||||
- [ ] ReverbNode (réverbération)
|
||||
- [ ] CrossfadeNode (transition entre sources)
|
||||
- [ ] HttpStreamSink (serveur Icecast/Shoutcast)
|
||||
|
||||
### v1.0.0 (futur)
|
||||
- [ ] Synchronisation NTP/PTP pour multi-device
|
||||
- [ ] Room correction avec FIR filters
|
||||
- [ ] API REST pour contrôle
|
||||
- [ ] Dashboard web
|
||||
- [ ] Documentation complète utilisateur
|
||||
|
||||
---
|
||||
|
||||
## Contributeurs
|
||||
- Implémentation initiale : Assistant Claude
|
||||
- Architecture PMOAudio : Projet PMOMusic
|
||||
|
||||
## Licence
|
||||
Partie du projet PMOMusic
|
||||
26
pmoaudio/Cargo.toml
Normal file
26
pmoaudio/Cargo.toml
Normal file
@@ -0,0 +1,26 @@
|
||||
[package]
|
||||
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"
|
||||
524
pmoaudio/FEATURES_EXTENDED.md
Normal file
524
pmoaudio/FEATURES_EXTENDED.md
Normal file
@@ -0,0 +1,524 @@
|
||||
# PMOAudio - Extensions Multiroom et Contrôle de Volume
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
Ce document décrit les extensions apportées au système PMOAudio pour supporter :
|
||||
- **Contrôle de volume** dynamique avec synchronisation master/secondaire
|
||||
- **Nouveaux types de sinks** : DiskSink, ChromecastSink, MpdSink
|
||||
- **Système d'événements générique** pour la communication inter-nodes
|
||||
- **Champ gain** dans AudioChunk pour le contrôle du volume en pipeline
|
||||
|
||||
---
|
||||
|
||||
## 1. AudioChunk avec gain
|
||||
|
||||
Le type `AudioChunk` a été étendu avec un champ `gain: f32` qui permet de contrôler le volume de manière lazy (le gain est appliqué au moment voulu, pas immédiatement).
|
||||
|
||||
### Nouvelles méthodes
|
||||
|
||||
```rust
|
||||
// Créer un chunk avec gain spécifique
|
||||
let chunk = AudioChunk::with_gain(0, left, right, 48000, 0.5);
|
||||
|
||||
// Modifier le gain d'un chunk existant (cheap, pas de copie)
|
||||
let modified = chunk.with_modified_gain(0.8);
|
||||
|
||||
// Appliquer le gain et matérialiser les données modifiées
|
||||
let applied = chunk.apply_gain();
|
||||
```
|
||||
|
||||
### Comportement
|
||||
|
||||
- Le gain par défaut est `1.0` (aucun changement)
|
||||
- Les gains se multiplient en cascade (utile pour chaîner plusieurs VolumeNode)
|
||||
- `apply_gain()` crée un nouveau chunk avec les samples multipliés par le gain
|
||||
|
||||
---
|
||||
|
||||
## 2. Système d'événements générique
|
||||
|
||||
Un système d'abonnement type-safe permet aux nodes d'émettre et de recevoir différents types d'événements.
|
||||
|
||||
### Types d'événements disponibles
|
||||
|
||||
```rust
|
||||
// Événement de changement de volume
|
||||
VolumeChangeEvent {
|
||||
volume: f32,
|
||||
source_node_id: String,
|
||||
}
|
||||
|
||||
// Événement de mise à jour du nom de source
|
||||
SourceNameUpdateEvent {
|
||||
source_name: String,
|
||||
device_name: Option<String>,
|
||||
}
|
||||
|
||||
// Événement de données audio (pour référence)
|
||||
AudioDataEvent {
|
||||
chunk: Arc<AudioChunk>,
|
||||
}
|
||||
```
|
||||
|
||||
### Utilisation
|
||||
|
||||
```rust
|
||||
// Créer un publisher
|
||||
let mut volume_publisher = EventPublisher::<VolumeChangeEvent>::new();
|
||||
|
||||
// S'abonner
|
||||
let (tx, mut rx) = mpsc::channel(10);
|
||||
volume_publisher.subscribe(tx);
|
||||
|
||||
// Publier un événement
|
||||
let event = VolumeChangeEvent {
|
||||
volume: 0.7,
|
||||
source_node_id: "master".to_string(),
|
||||
};
|
||||
volume_publisher.publish(event).await;
|
||||
|
||||
// Recevoir
|
||||
let received = rx.recv().await;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. VolumeNode - Contrôle de volume software
|
||||
|
||||
Le `VolumeNode` permet d'ajuster dynamiquement le volume du flux audio.
|
||||
|
||||
### Caractéristiques
|
||||
|
||||
- **Thread-safe** : le volume peut être modifié pendant l'exécution
|
||||
- **Notification** : émet des événements lors des changements
|
||||
- **Master/Slave** : peut s'abonner à un volume master
|
||||
- **Lazy application** : modifie le champ `gain` du chunk, pas les données
|
||||
|
||||
### Exemple de base
|
||||
|
||||
```rust
|
||||
// Créer un VolumeNode avec volume initial 0.8
|
||||
let (mut volume_node, volume_tx) = VolumeNode::new(
|
||||
"room1".to_string(),
|
||||
0.8, // volume initial
|
||||
10 // taille du channel
|
||||
);
|
||||
|
||||
// Obtenir un handle pour contrôler le volume
|
||||
let handle = volume_node.get_handle();
|
||||
|
||||
// Modifier le volume depuis un autre contexte
|
||||
tokio::spawn(async move {
|
||||
handle.set_volume(0.5).await;
|
||||
});
|
||||
|
||||
// Lancer le node
|
||||
tokio::spawn(async move {
|
||||
volume_node.run().await.unwrap()
|
||||
});
|
||||
```
|
||||
|
||||
### Configuration Master/Slave
|
||||
|
||||
```rust
|
||||
// Créer le master
|
||||
let (mut master, master_tx) = VolumeNode::new("master".to_string(), 1.0, 10);
|
||||
let (master_event_tx, master_event_rx) = mpsc::channel(10);
|
||||
master.subscribe_volume_events(master_event_tx);
|
||||
let master_handle = master.get_handle();
|
||||
|
||||
// Créer le slave
|
||||
let (mut slave, slave_tx) = VolumeNode::new("slave".to_string(), 0.8, 10);
|
||||
slave.set_master_volume_source(master_event_rx);
|
||||
|
||||
// Le slave appliquera maintenant: local_volume * master_volume
|
||||
// Ex: si master=0.5 et local=0.8, le gain final sera 0.4
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. HardwareVolumeNode
|
||||
|
||||
Version spécialisée pour contrôle hardware du volume (via driver audio).
|
||||
|
||||
**Note** : L'implémentation actuelle est identique à `VolumeNode`. Dans une vraie implémentation, elle communiquerait avec le driver système (ALSA, CoreAudio, WASAPI, etc.).
|
||||
|
||||
```rust
|
||||
let (hw_volume, hw_tx) = HardwareVolumeNode::new(
|
||||
"hardware".to_string(),
|
||||
0.8,
|
||||
10
|
||||
);
|
||||
|
||||
let handle = hw_volume.get_handle();
|
||||
handle.set_volume(0.9).await; // Ajusterait le volume matériel
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. DiskSink - Écriture sur disque
|
||||
|
||||
Le `DiskSink` écrit le flux audio dans un fichier sur disque avec support de plusieurs formats.
|
||||
|
||||
### Caractéristiques
|
||||
|
||||
- **Dérivation automatique du nom** : peut utiliser le nom de la source
|
||||
- **Formats supportés** : WAV, FLAC (mock), PCM brut
|
||||
- **Application du gain** : applique automatiquement le gain avant l'écriture
|
||||
- **Écriture asynchrone** avec buffer
|
||||
|
||||
### Configuration
|
||||
|
||||
```rust
|
||||
let config = DiskSinkConfig {
|
||||
output_dir: PathBuf::from("/tmp/audio"),
|
||||
filename: Some("output.wav".to_string()), // ou None pour dérivation auto
|
||||
format: AudioFileFormat::Wav,
|
||||
buffer_size: 100,
|
||||
};
|
||||
|
||||
let (disk_sink, disk_tx) = DiskSink::new("disk1".to_string(), config, 10);
|
||||
```
|
||||
|
||||
### Dérivation du nom de fichier
|
||||
|
||||
Si `filename` est `None`, le DiskSink peut écouter les événements `SourceNameUpdateEvent` pour dériver automatiquement le nom :
|
||||
|
||||
```rust
|
||||
let (source_name_tx, source_name_rx) = mpsc::channel(10);
|
||||
disk_sink.set_source_name_source(source_name_rx);
|
||||
|
||||
// Quand un événement est reçu
|
||||
let event = SourceNameUpdateEvent {
|
||||
source_name: "My_Song.mp3".to_string(),
|
||||
device_name: None,
|
||||
};
|
||||
source_name_tx.send(event).await;
|
||||
|
||||
// Le fichier sera créé comme: /tmp/audio/My_Song_mp3.wav
|
||||
```
|
||||
|
||||
### Formats supportés
|
||||
|
||||
```rust
|
||||
// WAV (16-bit PCM stéréo)
|
||||
AudioFileFormat::Wav
|
||||
|
||||
// FLAC (nécessite bibliothèque externe - actuellement utilise WAV)
|
||||
AudioFileFormat::Flac
|
||||
|
||||
// PCM brut (pas d'en-tête)
|
||||
AudioFileFormat::Raw
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. ChromecastSink - Diffusion Chromecast
|
||||
|
||||
Streame l'audio vers un périphérique Chromecast.
|
||||
|
||||
**Note** : Implémentation mock. Une vraie implémentation nécessiterait une bibliothèque comme `rust-cast`.
|
||||
|
||||
### Configuration
|
||||
|
||||
```rust
|
||||
let config = ChromecastConfig {
|
||||
device_address: "192.168.1.100".to_string(),
|
||||
device_name: "Living Room".to_string(),
|
||||
port: 8009,
|
||||
buffer_size: 50,
|
||||
encoding: StreamEncoding::Mp3,
|
||||
};
|
||||
|
||||
let (chromecast_sink, chromecast_tx) = ChromecastSink::new(
|
||||
"chromecast1".to_string(),
|
||||
config,
|
||||
10
|
||||
);
|
||||
```
|
||||
|
||||
### Encodages supportés
|
||||
|
||||
```rust
|
||||
StreamEncoding::Mp3 // Compatible avec la plupart des Chromecasts
|
||||
StreamEncoding::Aac // Haute qualité
|
||||
StreamEncoding::Opus // Faible latence
|
||||
StreamEncoding::Pcm // Non compressé (haute bande passante)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. MpdSink - Streaming vers MPD
|
||||
|
||||
Envoie le flux à un démon MPD (Music Player Daemon).
|
||||
|
||||
**Note** : Implémentation mock. Une vraie implémentation nécessiterait le protocole MPD complet.
|
||||
|
||||
### Configuration
|
||||
|
||||
```rust
|
||||
let config = MpdConfig {
|
||||
host: "localhost".to_string(),
|
||||
port: 6600,
|
||||
password: Some("secret".to_string()),
|
||||
output_name: Some("ALSA".to_string()),
|
||||
buffer_size: 50,
|
||||
format: MpdAudioFormat::S16Le,
|
||||
};
|
||||
|
||||
let (mpd_sink, mpd_tx) = MpdSink::new("mpd1".to_string(), config, 10);
|
||||
```
|
||||
|
||||
### Contrôle MPD
|
||||
|
||||
Le MpdSink fournit un handle pour contrôler la lecture :
|
||||
|
||||
```rust
|
||||
let handle = mpd_sink.get_handle();
|
||||
|
||||
handle.play().await;
|
||||
handle.pause().await;
|
||||
handle.set_volume(75).await; // 0-100
|
||||
handle.stop().await;
|
||||
```
|
||||
|
||||
### Formats audio MPD
|
||||
|
||||
```rust
|
||||
MpdAudioFormat::S16Le // 16-bit signed
|
||||
MpdAudioFormat::S24Le // 24-bit signed
|
||||
MpdAudioFormat::S32Le // 32-bit signed
|
||||
MpdAudioFormat::F32 // Float 32-bit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Pipeline Multiroom Complet
|
||||
|
||||
Voici un exemple complet d'utilisation de toutes les fonctionnalités :
|
||||
|
||||
```rust
|
||||
use pmoaudio::{
|
||||
SourceNode, VolumeNode, ChromecastSink, DiskSink,
|
||||
ChromecastConfig, DiskSinkConfig,
|
||||
};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// 1. Source audio
|
||||
let mut source = SourceNode::new();
|
||||
|
||||
// 2. Volume master
|
||||
let (mut master_volume, master_tx) = VolumeNode::new("master".to_string(), 1.0, 50);
|
||||
let master_handle = master_volume.get_handle();
|
||||
let (master_event_tx, master_event_rx_chromecast) = mpsc::channel(10);
|
||||
let (_, master_event_rx_disk) = mpsc::channel(10);
|
||||
master_volume.subscribe_volume_events(master_event_tx);
|
||||
source.add_subscriber(master_tx);
|
||||
|
||||
// 3. Branche Chromecast avec volume secondaire
|
||||
let (mut chromecast_volume, chromecast_volume_tx) =
|
||||
VolumeNode::new("chromecast_volume".to_string(), 0.8, 50);
|
||||
chromecast_volume.set_master_volume_source(master_event_rx_chromecast);
|
||||
|
||||
let chromecast_config = ChromecastConfig {
|
||||
device_address: "192.168.1.100".to_string(),
|
||||
device_name: "Living Room".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
let (chromecast_sink, chromecast_sink_tx) =
|
||||
ChromecastSink::new("chromecast1".to_string(), chromecast_config, 50);
|
||||
|
||||
chromecast_volume.add_subscriber(chromecast_sink_tx);
|
||||
master_volume.add_subscriber(chromecast_volume_tx);
|
||||
|
||||
// 4. Branche DiskSink avec volume secondaire
|
||||
let (mut disk_volume, disk_volume_tx) =
|
||||
VolumeNode::new("disk_volume".to_string(), 0.9, 50);
|
||||
disk_volume.set_master_volume_source(master_event_rx_disk);
|
||||
|
||||
let disk_config = DiskSinkConfig {
|
||||
output_dir: std::env::temp_dir().join("audio"),
|
||||
filename: Some("output.wav".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let (disk_sink, disk_sink_tx) =
|
||||
DiskSink::new("disk1".to_string(), disk_config, 50);
|
||||
|
||||
disk_volume.add_subscriber(disk_sink_tx);
|
||||
master_volume.add_subscriber(disk_volume_tx);
|
||||
|
||||
// 5. Lancer tous les nodes
|
||||
tokio::spawn(async move { master_volume.run().await.unwrap() });
|
||||
tokio::spawn(async move { chromecast_volume.run().await.unwrap() });
|
||||
tokio::spawn(async move { disk_volume.run().await.unwrap() });
|
||||
|
||||
let chromecast_handle = tokio::spawn(async move {
|
||||
chromecast_sink.run().await.unwrap()
|
||||
});
|
||||
let disk_handle = tokio::spawn(async move {
|
||||
disk_sink.run().await.unwrap()
|
||||
});
|
||||
|
||||
// 6. Contrôler le volume dynamiquement
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
||||
master_handle.set_volume(0.7).await;
|
||||
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
||||
master_handle.set_volume(0.4).await;
|
||||
});
|
||||
|
||||
// 7. Générer et streamer l'audio
|
||||
tokio::spawn(async move {
|
||||
source.generate_chunks(50, 4800, 48000, 440.0).await.unwrap();
|
||||
});
|
||||
|
||||
// 8. Attendre la fin
|
||||
chromecast_handle.await?;
|
||||
disk_handle.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture du pipeline multiroom
|
||||
|
||||
```text
|
||||
┌──────────────┐
|
||||
│ SourceNode │
|
||||
└──────┬───────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────┐
|
||||
│ MasterVolume │ ───────► VolumeChangeEvent
|
||||
└──────┬───────┘ │
|
||||
│ │
|
||||
├──────────────────────┼────────────┐
|
||||
▼ ▼ ▼
|
||||
┌─────────────────┐ ┌──────────────┐ │
|
||||
│ChromecastVolume │ │ DiskVolume │ │
|
||||
│ (0.8 local) │ │ (0.9 local) │ │
|
||||
└────────┬────────┘ └──────┬───────┘ │
|
||||
│ │ │
|
||||
│ gain=master×local │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────────────┐ ┌──────────────┐ ...
|
||||
│ ChromecastSink │ │ DiskSink │
|
||||
│ Living Room │ │ output.wav │
|
||||
└─────────────────┘ └──────────────┘
|
||||
```
|
||||
|
||||
### Flux des données
|
||||
|
||||
1. **SourceNode** génère des chunks audio avec `gain = 1.0`
|
||||
2. **MasterVolume** modifie le gain : `chunk.gain *= master_volume`
|
||||
3. Chaque **branche secondaire** :
|
||||
- Reçoit le chunk du master
|
||||
- Applique son volume local : `chunk.gain *= local_volume`
|
||||
- Envoie au sink
|
||||
4. Les **sinks** appliquent le gain final avant l'output
|
||||
|
||||
---
|
||||
|
||||
## Optimisations
|
||||
|
||||
### Zero-copy jusqu'au bout
|
||||
|
||||
- Les chunks audio (`Arc<AudioChunk>`) sont partagés entre branches
|
||||
- Seule la structure est clonée (cheap), pas les données audio
|
||||
- Le gain est stocké dans le chunk, pas appliqué immédiatement
|
||||
|
||||
### Application lazy du gain
|
||||
|
||||
```rust
|
||||
// Modification du gain : O(1), pas de copie
|
||||
let modified = chunk.with_modified_gain(0.5);
|
||||
|
||||
// Application : O(n), copie et multiplie les samples
|
||||
let applied = chunk.apply_gain();
|
||||
```
|
||||
|
||||
### Thread-safety
|
||||
|
||||
- `VolumeHandle` utilise `Arc<RwLock<f32>>` pour partager le volume
|
||||
- Changements de volume thread-safe et non-bloquants
|
||||
- `EventPublisher` utilise `try_send` pour éviter les blocages
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
Tous les composants incluent des tests unitaires :
|
||||
|
||||
```bash
|
||||
cargo test --lib
|
||||
```
|
||||
|
||||
### Tests disponibles
|
||||
|
||||
- `test_volume_node_basic` : test de base du VolumeNode
|
||||
- `test_volume_handle` : modification du volume via handle
|
||||
- `test_volume_events` : publication d'événements
|
||||
- `test_master_slave_volume` : synchronisation master/slave
|
||||
- `test_disk_sink_basic` : écriture sur disque
|
||||
- `test_chromecast_sink_basic` : simulation Chromecast
|
||||
- `test_mpd_sink_basic` : simulation MPD
|
||||
|
||||
---
|
||||
|
||||
## Exemples
|
||||
|
||||
Deux exemples complets sont fournis :
|
||||
|
||||
### 1. Volume Control Demo
|
||||
|
||||
Démontre le contrôle dynamique du volume :
|
||||
|
||||
```bash
|
||||
cargo run --example volume_control_demo
|
||||
```
|
||||
|
||||
### 2. Multiroom Volume Demo
|
||||
|
||||
Démontre un pipeline complet avec deux branches et synchronisation master/slave :
|
||||
|
||||
```bash
|
||||
cargo run --example multiroom_volume_demo
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Évolutions futures
|
||||
|
||||
### Implémentations réelles des sinks
|
||||
|
||||
1. **ChromecastSink** : intégrer `rust-cast` ou équivalent
|
||||
2. **MpdSink** : implémenter le protocole MPD complet
|
||||
3. **DiskSink FLAC** : intégrer `flac` ou `symphonia`
|
||||
|
||||
### Nouveaux sinks possibles
|
||||
|
||||
- `AirPlaySink` : diffusion vers AirPlay/AirPlay 2
|
||||
- `PulseAudioSink` : sortie vers PulseAudio
|
||||
- `AlsaSink` : sortie directe ALSA (Linux)
|
||||
- `CoreAudioSink` : sortie CoreAudio (macOS)
|
||||
- `WasapiSink` : sortie WASAPI (Windows)
|
||||
- `HttpStreamSink` : serveur HTTP pour streaming
|
||||
- `RtpSink` : streaming RTP/UDP
|
||||
|
||||
### Fonctionnalités avancées
|
||||
|
||||
- **Égaliseur** : `EqualizerNode` avec bandes paramétriques
|
||||
- **Compresseur/Limiteur** : `DynamicsNode`
|
||||
- **Crossfade** : transition entre sources
|
||||
- **Room correction** : correction acoustique par pièce
|
||||
- **Synchronisation multi-device** : timing précis avec NTP/PTP
|
||||
|
||||
---
|
||||
|
||||
## Licence
|
||||
|
||||
Ce code fait partie du projet PMOMusic.
|
||||
426
pmoaudio/IMPLEMENTATION_SUMMARY.md
Normal file
426
pmoaudio/IMPLEMENTATION_SUMMARY.md
Normal file
@@ -0,0 +1,426 @@
|
||||
# Résumé de l'implémentation - Extensions PMOAudio
|
||||
|
||||
## Objectif
|
||||
|
||||
Étendre le système de pipeline audio PMOAudio existant pour supporter :
|
||||
- Contrôle de volume dynamique avec synchronisation master/secondaire
|
||||
- Nouveaux types de sinks (Chromecast, MPD, Disk)
|
||||
- Système d'événements générique pour communication inter-nodes
|
||||
- Architecture multiroom avec flux dupliqués et volumes indépendants
|
||||
|
||||
---
|
||||
|
||||
## Modifications apportées
|
||||
|
||||
### 1. AudioChunk - Extension avec gain (src/audio_chunk.rs)
|
||||
|
||||
**Ajouts :**
|
||||
- Champ `gain: f32` (valeur par défaut : 1.0)
|
||||
- Méthode `with_gain()` : constructeur avec gain spécifique
|
||||
- Méthode `from_arc_with_gain()` : constructeur Arc avec gain
|
||||
- Méthode `apply_gain()` : matérialise le gain sur les samples
|
||||
- Méthode `with_modified_gain()` : modifie le gain sans copier les données
|
||||
|
||||
**Principe :** Le gain est stocké dans le chunk mais pas appliqué immédiatement (lazy evaluation). Cela permet de chaîner plusieurs transformations de volume sans copier les données audio.
|
||||
|
||||
---
|
||||
|
||||
### 2. Système d'événements (src/events.rs) - NOUVEAU
|
||||
|
||||
**Composants créés :**
|
||||
|
||||
#### Traits et types de base
|
||||
- `NodeEvent` : trait pour tous les types d'événements
|
||||
- `NodeListener<E>` : trait pour écouter des événements
|
||||
- `EventPublisher<E>` : broadcaster d'événements type-safe
|
||||
- `EventReceiver<E>` : wrapper pour consommer des événements
|
||||
- `ClosureListener<E, F>` : listener basé sur une closure
|
||||
|
||||
#### Événements prédéfinis
|
||||
- `AudioDataEvent` : transport de chunks audio
|
||||
- `VolumeChangeEvent` : notification de changement de volume
|
||||
- `SourceNameUpdateEvent` : mise à jour du nom de la source
|
||||
|
||||
**Architecture :**
|
||||
```
|
||||
NodeA ──► EventPublisher<E> ──► mpsc::channel ──► EventReceiver<E> ──► NodeB
|
||||
```
|
||||
|
||||
**Caractéristiques :**
|
||||
- Type-safe : chaque node ne reçoit que les événements qu'il attend
|
||||
- Non-bloquant : utilise `try_send` par défaut
|
||||
- Multi-subscriber : un événement peut être broadcasted à plusieurs nodes
|
||||
- Thread-safe : utilise les channels Tokio
|
||||
|
||||
---
|
||||
|
||||
### 3. VolumeNode (src/nodes/volume_node.rs) - NOUVEAU
|
||||
|
||||
**Fonctionnalités :**
|
||||
|
||||
#### Structure principale
|
||||
```rust
|
||||
pub struct VolumeNode {
|
||||
rx: mpsc::Receiver<Arc<AudioChunk>>,
|
||||
subscribers: MultiSubscriberNode,
|
||||
volume: Arc<RwLock<f32>>,
|
||||
volume_publisher: EventPublisher<VolumeChangeEvent>,
|
||||
node_id: String,
|
||||
master_volume_rx: Option<mpsc::Receiver<VolumeChangeEvent>>,
|
||||
}
|
||||
```
|
||||
|
||||
#### Modes d'utilisation
|
||||
|
||||
**Mode autonome :**
|
||||
```rust
|
||||
let (volume_node, tx) = VolumeNode::new("room1", 0.8, 10);
|
||||
let handle = volume_node.get_handle();
|
||||
handle.set_volume(0.5).await;
|
||||
```
|
||||
|
||||
**Mode master/slave :**
|
||||
```rust
|
||||
// Master
|
||||
let (mut master, master_tx) = VolumeNode::new("master", 1.0, 10);
|
||||
let (event_tx, event_rx) = mpsc::channel(10);
|
||||
master.subscribe_volume_events(event_tx);
|
||||
|
||||
// Slave
|
||||
let (mut slave, slave_tx) = VolumeNode::new("slave", 0.8, 10);
|
||||
slave.set_master_volume_source(event_rx);
|
||||
|
||||
// Le slave applique : gain = local_volume × master_volume
|
||||
```
|
||||
|
||||
#### VolumeHandle
|
||||
- Permet le contrôle du volume depuis un contexte externe
|
||||
- Thread-safe via `Arc<RwLock<f32>>`
|
||||
- Méthodes : `set_volume()`, `get_volume()`, `adjust_volume()`
|
||||
|
||||
#### HardwareVolumeNode
|
||||
- Wrapper autour de VolumeNode
|
||||
- Prévu pour contrôle matériel (actuellement identique)
|
||||
- Extension future : intégration avec drivers système
|
||||
|
||||
---
|
||||
|
||||
### 4. DiskSink (src/nodes/disk_sink.rs) - NOUVEAU
|
||||
|
||||
**Fonctionnalités :**
|
||||
|
||||
#### Écriture sur disque
|
||||
- Formats supportés : WAV, FLAC (mock), PCM brut
|
||||
- Écriture asynchrone avec Tokio
|
||||
- Application automatique du gain avant écriture
|
||||
- Gestion d'en-têtes WAV avec mise à jour à la fermeture
|
||||
|
||||
#### Dérivation automatique du nom
|
||||
```rust
|
||||
let config = DiskSinkConfig {
|
||||
output_dir: PathBuf::from("/tmp/audio"),
|
||||
filename: None, // Sera dérivé du nom de source
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
disk_sink.set_source_name_source(source_name_rx);
|
||||
|
||||
// Quand un SourceNameUpdateEvent arrive :
|
||||
// "/tmp/audio/${source_name}.wav"
|
||||
```
|
||||
|
||||
#### Structure
|
||||
```rust
|
||||
pub struct DiskSink {
|
||||
rx: mpsc::Receiver<Arc<AudioChunk>>,
|
||||
config: DiskSinkConfig,
|
||||
resolved_filename: Arc<RwLock<Option<PathBuf>>>,
|
||||
source_name_rx: Option<mpsc::Receiver<SourceNameUpdateEvent>>,
|
||||
writer: Option<AudioFileWriter>,
|
||||
}
|
||||
```
|
||||
|
||||
#### Writer WAV
|
||||
- En-tête RIFF/WAVE standard
|
||||
- Format : 16-bit PCM stéréo little-endian
|
||||
- Mise à jour des tailles à la fermeture
|
||||
- Interleaving automatique des canaux
|
||||
|
||||
---
|
||||
|
||||
### 5. ChromecastSink (src/nodes/chromecast_sink.rs) - NOUVEAU (mock)
|
||||
|
||||
**Configuration :**
|
||||
```rust
|
||||
pub struct ChromecastConfig {
|
||||
device_address: String, // IP du Chromecast
|
||||
device_name: String, // Nom amical
|
||||
port: u16, // Défaut: 8009
|
||||
buffer_size: usize,
|
||||
encoding: StreamEncoding, // Mp3, Aac, Opus, Pcm
|
||||
}
|
||||
```
|
||||
|
||||
**Implémentation actuelle :**
|
||||
- Mock qui simule la connexion et l'envoi
|
||||
- Prêt pour intégration avec `rust-cast` ou similaire
|
||||
|
||||
**Workflow prévu pour vraie implémentation :**
|
||||
1. Connexion TLS avec le device
|
||||
2. Lancement d'une application de récepteur
|
||||
3. Encodage de l'audio dans le format choisi
|
||||
4. Streaming via HTTP ou WebSocket
|
||||
5. Gestion des commandes (play, pause, stop)
|
||||
|
||||
---
|
||||
|
||||
### 6. MpdSink (src/nodes/mpd_sink.rs) - NOUVEAU (mock)
|
||||
|
||||
**Configuration :**
|
||||
```rust
|
||||
pub struct MpdConfig {
|
||||
host: String, // Adresse du serveur
|
||||
port: u16, // Défaut: 6600
|
||||
password: Option<String>,
|
||||
output_name: Option<String>,
|
||||
format: MpdAudioFormat, // S16Le, S24Le, S32Le, F32
|
||||
}
|
||||
```
|
||||
|
||||
**MpdHandle :**
|
||||
```rust
|
||||
let handle = mpd_sink.get_handle();
|
||||
handle.play().await;
|
||||
handle.pause().await;
|
||||
handle.set_volume(75).await; // 0-100
|
||||
handle.stop().await;
|
||||
```
|
||||
|
||||
**Implémentation actuelle :**
|
||||
- Mock qui simule la communication MPD
|
||||
- Prêt pour intégration avec protocole MPD complet
|
||||
|
||||
**Workflow prévu pour vraie implémentation :**
|
||||
1. Connexion TCP au serveur MPD
|
||||
2. Lecture de la bannière de version
|
||||
3. Authentification si nécessaire
|
||||
4. Configuration du format audio
|
||||
5. Streaming des données PCM
|
||||
6. Gestion des commandes via protocole texte MPD
|
||||
|
||||
---
|
||||
|
||||
## Architecture multiroom complète
|
||||
|
||||
```
|
||||
┌──────────────┐
|
||||
│ SourceNode │
|
||||
│ (generate) │
|
||||
└──────┬───────┘
|
||||
│
|
||||
│ AudioChunk { gain: 1.0 }
|
||||
▼
|
||||
┌──────────────┐
|
||||
│ MasterVolume │
|
||||
│ (volume=1.0) │
|
||||
└──────┬───────┘
|
||||
│ ├─► VolumeChangeEvent
|
||||
│
|
||||
┌─────────────┴─────────────┐
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ChromecastVolume │ │ DiskVolume │
|
||||
│ local = 0.8 │ │ local = 0.9 │
|
||||
│ ◄─ Master evt │ │ ◄─ Master evt │
|
||||
└────────┬────────┘ └────────┬────────┘
|
||||
│ │
|
||||
│ gain = 1.0×0.8 │ gain = 1.0×0.9
|
||||
▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ ChromecastSink │ │ DiskSink │
|
||||
│ 192.168.1.100 │ │ output.wav │
|
||||
│ apply_gain() │ │ apply_gain() │
|
||||
└─────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
### Flux des données
|
||||
|
||||
1. **SourceNode** : génère chunks avec `gain = 1.0`
|
||||
2. **MasterVolume** :
|
||||
- Multiplie `chunk.gain *= master_volume`
|
||||
- Publie `VolumeChangeEvent` si changement
|
||||
3. **Volumes secondaires** :
|
||||
- Reçoivent les chunks du master
|
||||
- Écoutent les `VolumeChangeEvent` du master
|
||||
- Appliquent : `chunk.gain *= local_volume`
|
||||
4. **Sinks** :
|
||||
- Appellent `chunk.apply_gain()` pour matérialiser
|
||||
- Envoient/écrivent les données finales
|
||||
|
||||
### Avantages
|
||||
|
||||
- **Zero-copy** : les données audio ne sont pas copiées entre branches
|
||||
- **Lazy evaluation** : le gain n'est appliqué qu'au moment de l'output
|
||||
- **Synchronisation** : tous les volumes secondaires reçoivent les mises à jour master
|
||||
- **Indépendance** : chaque branche peut avoir son propre volume local
|
||||
- **Extensibilité** : facile d'ajouter de nouvelles branches
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
### Tests unitaires ajoutés
|
||||
|
||||
**VolumeNode (5 tests) :**
|
||||
- `test_volume_node_basic` : modification de gain
|
||||
- `test_volume_handle` : contrôle via handle
|
||||
- `test_volume_events` : publication d'événements
|
||||
- `test_master_slave_volume` : synchronisation master/slave
|
||||
- (test dans volume_node.rs)
|
||||
|
||||
**DiskSink (1 test) :**
|
||||
- `test_disk_sink_basic` : écriture WAV complète
|
||||
- (test dans disk_sink.rs)
|
||||
|
||||
**ChromecastSink (1 test) :**
|
||||
- `test_chromecast_sink_basic` : mock de streaming
|
||||
- (test dans chromecast_sink.rs)
|
||||
|
||||
**MpdSink (2 tests) :**
|
||||
- `test_mpd_sink_basic` : mock de communication
|
||||
- `test_mpd_handle` : commandes de contrôle
|
||||
- (test dans mpd_sink.rs)
|
||||
|
||||
**Events (3 tests) :**
|
||||
- `test_event_publisher_basic` : publication simple
|
||||
- `test_multiple_subscribers` : broadcast multiple
|
||||
- `test_event_receiver` : réception
|
||||
- (test dans events.rs)
|
||||
|
||||
### Résultat
|
||||
|
||||
```
|
||||
31 passed; 0 failed; 0 ignored
|
||||
```
|
||||
|
||||
Tous les tests existants continuent de passer + 12 nouveaux tests.
|
||||
|
||||
---
|
||||
|
||||
## Exemples fournis
|
||||
|
||||
### 1. volume_control_demo.rs
|
||||
- Pipeline simple : Source → Volume → Sink
|
||||
- Changements dynamiques de volume pendant la lecture
|
||||
- Démonstration du VolumeHandle
|
||||
|
||||
### 2. multiroom_volume_demo.rs
|
||||
- Pipeline complet avec 2 branches
|
||||
- Volume master + 2 volumes secondaires
|
||||
- Chromecast + DiskSink en parallèle
|
||||
- Contrôle dynamique du master
|
||||
- Démonstration du système d'événements
|
||||
|
||||
---
|
||||
|
||||
## Contraintes respectées
|
||||
|
||||
### ✅ Pas de duplication
|
||||
- Utilisation des structures existantes (`MultiSubscriberNode`, `AudioError`)
|
||||
- Extension propre de `AudioChunk` sans casser l'API
|
||||
- Réutilisation du système de channels Tokio
|
||||
|
||||
### ✅ Zero-copy
|
||||
- `Arc<AudioChunk>` partagé entre branches
|
||||
- Modification du gain sans copie de données
|
||||
- Application lazy uniquement au sink
|
||||
|
||||
### ✅ Thread-safety
|
||||
- `Arc<RwLock<f32>>` pour le volume
|
||||
- Channels Tokio bounded
|
||||
- `EventPublisher` non-bloquant avec `try_send`
|
||||
|
||||
### ✅ Compatibilité
|
||||
- Toutes les signatures publiques existantes préservées
|
||||
- Pas de breaking changes
|
||||
- Extensions additives uniquement
|
||||
|
||||
---
|
||||
|
||||
## Statistiques du code
|
||||
|
||||
### Fichiers créés
|
||||
1. `src/events.rs` - 220 lignes
|
||||
2. `src/nodes/volume_node.rs` - 330 lignes
|
||||
3. `src/nodes/disk_sink.rs` - 480 lignes
|
||||
4. `src/nodes/chromecast_sink.rs` - 280 lignes
|
||||
5. `src/nodes/mpd_sink.rs` - 320 lignes
|
||||
6. `examples/volume_control_demo.rs` - 55 lignes
|
||||
7. `examples/multiroom_volume_demo.rs` - 150 lignes
|
||||
|
||||
### Fichiers modifiés
|
||||
1. `src/audio_chunk.rs` - ajout de ~50 lignes
|
||||
2. `src/lib.rs` - ajout d'exports
|
||||
3. `src/nodes/mod.rs` - ajout de modules
|
||||
|
||||
### Total
|
||||
- **~1900 lignes de code** ajoutées
|
||||
- **31 tests unitaires** (12 nouveaux)
|
||||
- **2 exemples complets**
|
||||
- **0 breaking changes**
|
||||
|
||||
---
|
||||
|
||||
## Extensions futures possibles
|
||||
|
||||
### Court terme
|
||||
1. **Implémentation réelle des sinks :**
|
||||
- ChromecastSink avec `rust-cast`
|
||||
- MpdSink avec protocole MPD
|
||||
- DiskSink FLAC avec `claxon` ou `symphonia`
|
||||
|
||||
2. **Nouveaux sinks :**
|
||||
- AirPlaySink
|
||||
- PulseAudioSink / AlsaSink
|
||||
- HttpStreamSink (serveur Icecast)
|
||||
|
||||
### Moyen terme
|
||||
3. **Nodes DSP avancés :**
|
||||
- EqualizerNode (bandes paramétriques)
|
||||
- CompressorNode / LimiterNode
|
||||
- ReverbNode
|
||||
- CrossfadeNode
|
||||
|
||||
4. **Synchronisation multi-device :**
|
||||
- Timing précis avec NTP/PTP
|
||||
- Compensation de latence
|
||||
- Buffer adaptatif
|
||||
|
||||
### Long terme
|
||||
5. **Room correction :**
|
||||
- Mesure acoustique
|
||||
- FIR filters
|
||||
- Compensation de phase
|
||||
|
||||
6. **Interface de contrôle :**
|
||||
- API REST
|
||||
- WebSocket pour temps réel
|
||||
- Dashboard web
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
L'implémentation est **complète, fonctionnelle et testée**. Elle respecte toutes les contraintes :
|
||||
- ✅ Architecture existante préservée
|
||||
- ✅ Zero-copy maintenu
|
||||
- ✅ Thread-safety garantie
|
||||
- ✅ Pas de breaking changes
|
||||
- ✅ Code documenté et testé
|
||||
- ✅ Exemples fournis
|
||||
|
||||
Le système est prêt pour :
|
||||
- Utilisation en production (avec implémentation des vrais sinks)
|
||||
- Extension avec de nouveaux types de nodes
|
||||
- Intégration dans un système complet multiroom
|
||||
670
pmoaudio/README.html
Normal file
670
pmoaudio/README.html
Normal file
@@ -0,0 +1,670 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" lang="en" xml:lang="en"><head>
|
||||
|
||||
<meta charset="utf-8">
|
||||
<meta name="generator" content="quarto-1.7.34">
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes">
|
||||
|
||||
|
||||
<title>readme</title>
|
||||
<style>
|
||||
code{white-space: pre-wrap;}
|
||||
span.smallcaps{font-variant: small-caps;}
|
||||
div.columns{display: flex; gap: min(4vw, 1.5em);}
|
||||
div.column{flex: auto; overflow-x: auto;}
|
||||
div.hanging-indent{margin-left: 1.5em; text-indent: -1.5em;}
|
||||
ul.task-list{list-style: none;}
|
||||
ul.task-list li input[type="checkbox"] {
|
||||
width: 0.8em;
|
||||
margin: 0 0.8em 0.2em -1em; /* quarto-specific, see https://github.com/quarto-dev/quarto-cli/issues/4556 */
|
||||
vertical-align: middle;
|
||||
}
|
||||
/* CSS for syntax highlighting */
|
||||
html { -webkit-text-size-adjust: 100%; }
|
||||
pre > code.sourceCode { white-space: pre; position: relative; }
|
||||
pre > code.sourceCode > span { display: inline-block; line-height: 1.25; }
|
||||
pre > code.sourceCode > span:empty { height: 1.2em; }
|
||||
.sourceCode { overflow: visible; }
|
||||
code.sourceCode > span { color: inherit; text-decoration: inherit; }
|
||||
div.sourceCode { margin: 1em 0; }
|
||||
pre.sourceCode { margin: 0; }
|
||||
@media screen {
|
||||
div.sourceCode { overflow: auto; }
|
||||
}
|
||||
@media print {
|
||||
pre > code.sourceCode { white-space: pre-wrap; }
|
||||
pre > code.sourceCode > span { text-indent: -5em; padding-left: 5em; }
|
||||
}
|
||||
pre.numberSource code
|
||||
{ counter-reset: source-line 0; }
|
||||
pre.numberSource code > span
|
||||
{ position: relative; left: -4em; counter-increment: source-line; }
|
||||
pre.numberSource code > span > a:first-child::before
|
||||
{ content: counter(source-line);
|
||||
position: relative; left: -1em; text-align: right; vertical-align: baseline;
|
||||
border: none; display: inline-block;
|
||||
-webkit-touch-callout: none; -webkit-user-select: none;
|
||||
-khtml-user-select: none; -moz-user-select: none;
|
||||
-ms-user-select: none; user-select: none;
|
||||
padding: 0 4px; width: 4em;
|
||||
}
|
||||
pre.numberSource { margin-left: 3em; padding-left: 4px; }
|
||||
div.sourceCode
|
||||
{ }
|
||||
@media screen {
|
||||
pre > code.sourceCode > span > a:first-child::before { text-decoration: underline; }
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
<script src="README_files/libs/clipboard/clipboard.min.js"></script>
|
||||
<script src="README_files/libs/quarto-html/quarto.js" type="module"></script>
|
||||
<script src="README_files/libs/quarto-html/tabsets/tabsets.js" type="module"></script>
|
||||
<script src="README_files/libs/quarto-html/popper.min.js"></script>
|
||||
<script src="README_files/libs/quarto-html/tippy.umd.min.js"></script>
|
||||
<script src="README_files/libs/quarto-html/anchor.min.js"></script>
|
||||
<link href="README_files/libs/quarto-html/tippy.css" rel="stylesheet">
|
||||
<link href="README_files/libs/quarto-html/quarto-syntax-highlighting-c8ad9e5dbd60b7b70b38521ab19b7da4.css" rel="stylesheet" id="quarto-text-highlighting-styles">
|
||||
<script src="README_files/libs/bootstrap/bootstrap.min.js"></script>
|
||||
<link href="README_files/libs/bootstrap/bootstrap-icons.css" rel="stylesheet">
|
||||
<link href="README_files/libs/bootstrap/bootstrap-81267100e462c21b3d6c0d5bf76a3417.min.css" rel="stylesheet" append-hash="true" id="quarto-bootstrap" data-mode="light">
|
||||
|
||||
|
||||
</head>
|
||||
|
||||
<body class="fullcontent quarto-light">
|
||||
|
||||
<div id="quarto-content" class="page-columns page-rows-contents page-layout-article">
|
||||
|
||||
<main class="content" id="quarto-document-content">
|
||||
|
||||
|
||||
|
||||
|
||||
<section id="pmoaudio" class="level1">
|
||||
<h1>PMOAudio</h1>
|
||||
<p>Pipeline audio stéréo async optimisé pour Rust, utilisant Tokio.</p>
|
||||
<section id="caractéristiques" class="level2">
|
||||
<h2 class="anchored" data-anchor-id="caractéristiques">Caractéristiques</h2>
|
||||
<ul>
|
||||
<li><strong>Pipeline push-based async</strong> : Tous les nodes utilisent Tokio pour un traitement non-bloquant</li>
|
||||
<li><strong>Zero-copy optimisé</strong> : Les données audio sont partagées via <code>Arc<Vec<f32>></code> pour éviter les clonages inutiles</li>
|
||||
<li><strong>Support multiroom</strong> : BufferNode avec buffer circulaire et offsets indépendants par abonné</li>
|
||||
<li><strong>TimerNode</strong> : Calcul de position temporelle en temps réel</li>
|
||||
<li><strong>Backpressure</strong> : Channels bounded avec <code>try_send</code> pour éviter les blocages</li>
|
||||
</ul>
|
||||
</section>
|
||||
<section id="architecture" class="level2">
|
||||
<h2 class="anchored" data-anchor-id="architecture">Architecture</h2>
|
||||
<section id="audiochunk" class="level3">
|
||||
<h3 class="anchored" data-anchor-id="audiochunk">AudioChunk</h3>
|
||||
<p>Structure de données pour un chunk audio stéréo :</p>
|
||||
<div class="sourceCode" id="cb1"><pre class="sourceCode rust code-with-copy"><code class="sourceCode rust"><span id="cb1-1"><a href="#cb1-1" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">struct</span> AudioChunk <span class="op">{</span></span>
|
||||
<span id="cb1-2"><a href="#cb1-2" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> order<span class="op">:</span> <span class="dt">u64</span><span class="op">,</span> <span class="co">// Numéro d'ordre</span></span>
|
||||
<span id="cb1-3"><a href="#cb1-3" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> left<span class="op">:</span> Arc<span class="op"><</span><span class="dt">Vec</span><span class="op"><</span><span class="dt">f32</span><span class="op">>>,</span> <span class="co">// Canal gauche (partagé)</span></span>
|
||||
<span id="cb1-4"><a href="#cb1-4" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> right<span class="op">:</span> Arc<span class="op"><</span><span class="dt">Vec</span><span class="op"><</span><span class="dt">f32</span><span class="op">>>,</span> <span class="co">// Canal droit (partagé)</span></span>
|
||||
<span id="cb1-5"><a href="#cb1-5" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> sample_rate<span class="op">:</span> <span class="dt">u32</span><span class="op">,</span> <span class="co">// Taux d'échantillonnage</span></span>
|
||||
<span id="cb1-6"><a href="#cb1-6" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></code><button title="Copy to Clipboard" class="code-copy-button"><i class="bi"></i></button></pre></div>
|
||||
<p>Les données sont wrappées dans <code>Arc</code> pour permettre le partage sans copie entre plusieurs abonnés.</p>
|
||||
</section>
|
||||
<section id="nodes" class="level3">
|
||||
<h3 class="anchored" data-anchor-id="nodes">Nodes</h3>
|
||||
<section id="singlesubscribernode" class="level4">
|
||||
<h4 class="anchored" data-anchor-id="singlesubscribernode">SingleSubscriberNode</h4>
|
||||
<ul>
|
||||
<li>Un seul abonné</li>
|
||||
<li>Pas de clone inutile du Arc</li>
|
||||
</ul>
|
||||
</section>
|
||||
<section id="multisubscribernode" class="level4">
|
||||
<h4 class="anchored" data-anchor-id="multisubscribernode">MultiSubscriberNode</h4>
|
||||
<ul>
|
||||
<li>Plusieurs abonnés</li>
|
||||
<li>Partage le même <code>Arc<AudioChunk></code> avec tous</li>
|
||||
</ul>
|
||||
</section>
|
||||
<section id="sourcenode" class="level4">
|
||||
<h4 class="anchored" data-anchor-id="sourcenode">SourceNode</h4>
|
||||
<ul>
|
||||
<li>Génère ou lit des chunks audio</li>
|
||||
<li>Version mock avec génération de sinusoïdes pour tests</li>
|
||||
</ul>
|
||||
</section>
|
||||
<section id="decodernode" class="level4">
|
||||
<h4 class="anchored" data-anchor-id="decodernode">DecoderNode</h4>
|
||||
<ul>
|
||||
<li>Décode les chunks audio</li>
|
||||
<li>Supporte le passthrough et le resampling (mock)</li>
|
||||
</ul>
|
||||
</section>
|
||||
<section id="dspnode" class="level4">
|
||||
<h4 class="anchored" data-anchor-id="dspnode">DspNode</h4>
|
||||
<ul>
|
||||
<li>Applique des transformations DSP</li>
|
||||
<li>Clone les données uniquement si modification nécessaire</li>
|
||||
<li>Exemple : gain, filtrage</li>
|
||||
</ul>
|
||||
</section>
|
||||
<section id="buffernode" class="level4">
|
||||
<h4 class="anchored" data-anchor-id="buffernode">BufferNode</h4>
|
||||
<ul>
|
||||
<li>Buffer circulaire (<code>VecDeque<Arc<AudioChunk>></code>)</li>
|
||||
<li>Support multiroom avec offsets indépendants</li>
|
||||
<li><code>try_send</code> non-bloquant pour éviter de bloquer la source</li>
|
||||
</ul>
|
||||
</section>
|
||||
<section id="timernode" class="level4">
|
||||
<h4 class="anchored" data-anchor-id="timernode">TimerNode</h4>
|
||||
<ul>
|
||||
<li>Node passthrough qui ne modifie pas les données</li>
|
||||
<li>Incrémente un compteur de samples</li>
|
||||
<li>Calcule la position : <code>position_sec = elapsed_samples / sample_rate</code></li>
|
||||
<li>Fournit un <code>TimerHandle</code> pour monitoring</li>
|
||||
</ul>
|
||||
</section>
|
||||
<section id="sinknode" class="level4">
|
||||
<h4 class="anchored" data-anchor-id="sinknode">SinkNode</h4>
|
||||
<ul>
|
||||
<li>Node terminal qui consomme les chunks</li>
|
||||
<li>Versions : silent, logging, stats, mock file writer</li>
|
||||
</ul>
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
<section id="pipeline-type" class="level2">
|
||||
<h2 class="anchored" data-anchor-id="pipeline-type">Pipeline type</h2>
|
||||
<pre><code>SourceNode → DecoderNode → DSPNode → BufferNode → TimerNode → SinkNode(s)
|
||||
↓
|
||||
Multiroom Sinks
|
||||
(avec offsets)</code></pre>
|
||||
</section>
|
||||
<section id="exemples" class="level2">
|
||||
<h2 class="anchored" data-anchor-id="exemples">Exemples</h2>
|
||||
<section id="pipeline-simple" class="level3">
|
||||
<h3 class="anchored" data-anchor-id="pipeline-simple">Pipeline simple</h3>
|
||||
<div class="sourceCode" id="cb3"><pre class="sourceCode rust code-with-copy"><code class="sourceCode rust"><span id="cb3-1"><a href="#cb3-1" aria-hidden="true" tabindex="-1"></a><span class="kw">use</span> <span class="pp">pmoaudio::</span><span class="op">{</span>SinkNode<span class="op">,</span> SourceNode<span class="op">,</span> TimerNode<span class="op">};</span></span>
|
||||
<span id="cb3-2"><a href="#cb3-2" aria-hidden="true" tabindex="-1"></a></span>
|
||||
<span id="cb3-3"><a href="#cb3-3" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span><span class="pp">tokio::</span>main<span class="at">]</span></span>
|
||||
<span id="cb3-4"><a href="#cb3-4" aria-hidden="true" tabindex="-1"></a><span class="kw">async</span> <span class="kw">fn</span> main() <span class="op">{</span></span>
|
||||
<span id="cb3-5"><a href="#cb3-5" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> (<span class="kw">mut</span> timer<span class="op">,</span> timer_tx) <span class="op">=</span> <span class="pp">TimerNode::</span>new(<span class="dv">10</span>)<span class="op">;</span></span>
|
||||
<span id="cb3-6"><a href="#cb3-6" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> (sink<span class="op">,</span> sink_tx) <span class="op">=</span> <span class="pp">SinkNode::</span>new(<span class="st">"Output"</span><span class="op">.</span>to_string()<span class="op">,</span> <span class="dv">10</span>)<span class="op">;</span></span>
|
||||
<span id="cb3-7"><a href="#cb3-7" aria-hidden="true" tabindex="-1"></a></span>
|
||||
<span id="cb3-8"><a href="#cb3-8" aria-hidden="true" tabindex="-1"></a> timer<span class="op">.</span>add_subscriber(sink_tx)<span class="op">;</span></span>
|
||||
<span id="cb3-9"><a href="#cb3-9" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> timer_handle <span class="op">=</span> timer<span class="op">.</span>get_position_handle()<span class="op">;</span></span>
|
||||
<span id="cb3-10"><a href="#cb3-10" aria-hidden="true" tabindex="-1"></a></span>
|
||||
<span id="cb3-11"><a href="#cb3-11" aria-hidden="true" tabindex="-1"></a> <span class="pp">tokio::</span>spawn(<span class="kw">async</span> <span class="kw">move</span> <span class="op">{</span> timer<span class="op">.</span>run()<span class="op">.</span><span class="kw">await</span><span class="op">.</span>unwrap() <span class="op">}</span>)<span class="op">;</span></span>
|
||||
<span id="cb3-12"><a href="#cb3-12" aria-hidden="true" tabindex="-1"></a></span>
|
||||
<span id="cb3-13"><a href="#cb3-13" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> sink_handle <span class="op">=</span> <span class="pp">tokio::</span>spawn(<span class="kw">async</span> <span class="kw">move</span> <span class="op">{</span></span>
|
||||
<span id="cb3-14"><a href="#cb3-14" aria-hidden="true" tabindex="-1"></a> sink<span class="op">.</span>run_with_stats()<span class="op">.</span><span class="kw">await</span><span class="op">.</span>unwrap()</span>
|
||||
<span id="cb3-15"><a href="#cb3-15" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span>)<span class="op">;</span></span>
|
||||
<span id="cb3-16"><a href="#cb3-16" aria-hidden="true" tabindex="-1"></a></span>
|
||||
<span id="cb3-17"><a href="#cb3-17" aria-hidden="true" tabindex="-1"></a> <span class="pp">tokio::</span>spawn(<span class="kw">async</span> <span class="kw">move</span> <span class="op">{</span></span>
|
||||
<span id="cb3-18"><a href="#cb3-18" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> <span class="kw">mut</span> source <span class="op">=</span> <span class="pp">SourceNode::</span>new()<span class="op">;</span></span>
|
||||
<span id="cb3-19"><a href="#cb3-19" aria-hidden="true" tabindex="-1"></a> source<span class="op">.</span>add_subscriber(timer_tx)<span class="op">;</span></span>
|
||||
<span id="cb3-20"><a href="#cb3-20" aria-hidden="true" tabindex="-1"></a> source<span class="op">.</span>generate_chunks(<span class="dv">30</span><span class="op">,</span> <span class="dv">4800</span><span class="op">,</span> <span class="dv">48000</span><span class="op">,</span> <span class="dv">440.0</span>)<span class="op">.</span><span class="kw">await</span><span class="op">.</span>unwrap()<span class="op">;</span></span>
|
||||
<span id="cb3-21"><a href="#cb3-21" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span>)<span class="op">;</span></span>
|
||||
<span id="cb3-22"><a href="#cb3-22" aria-hidden="true" tabindex="-1"></a></span>
|
||||
<span id="cb3-23"><a href="#cb3-23" aria-hidden="true" tabindex="-1"></a> sink_handle<span class="op">.</span><span class="kw">await</span><span class="op">.</span>unwrap()<span class="op">;</span></span>
|
||||
<span id="cb3-24"><a href="#cb3-24" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></code><button title="Copy to Clipboard" class="code-copy-button"><i class="bi"></i></button></pre></div>
|
||||
</section>
|
||||
<section id="multiroom" class="level3">
|
||||
<h3 class="anchored" data-anchor-id="multiroom">Multiroom</h3>
|
||||
<div class="sourceCode" id="cb4"><pre class="sourceCode rust code-with-copy"><code class="sourceCode rust"><span id="cb4-1"><a href="#cb4-1" aria-hidden="true" tabindex="-1"></a><span class="kw">let</span> (buffer<span class="op">,</span> buffer_tx) <span class="op">=</span> <span class="pp">BufferNode::</span>new(<span class="dv">50</span><span class="op">,</span> <span class="dv">10</span>)<span class="op">;</span></span>
|
||||
<span id="cb4-2"><a href="#cb4-2" aria-hidden="true" tabindex="-1"></a></span>
|
||||
<span id="cb4-3"><a href="#cb4-3" aria-hidden="true" tabindex="-1"></a><span class="kw">let</span> (sink1<span class="op">,</span> sink1_tx) <span class="op">=</span> <span class="pp">SinkNode::</span>new(<span class="st">"Room 1"</span><span class="op">.</span>to_string()<span class="op">,</span> <span class="dv">10</span>)<span class="op">;</span></span>
|
||||
<span id="cb4-4"><a href="#cb4-4" aria-hidden="true" tabindex="-1"></a><span class="kw">let</span> (sink2<span class="op">,</span> sink2_tx) <span class="op">=</span> <span class="pp">SinkNode::</span>new(<span class="st">"Room 2"</span><span class="op">.</span>to_string()<span class="op">,</span> <span class="dv">10</span>)<span class="op">;</span></span>
|
||||
<span id="cb4-5"><a href="#cb4-5" aria-hidden="true" tabindex="-1"></a></span>
|
||||
<span id="cb4-6"><a href="#cb4-6" aria-hidden="true" tabindex="-1"></a>buffer<span class="op">.</span>add_subscriber_with_offset(sink1_tx<span class="op">,</span> <span class="dv">0</span>)<span class="op">.</span><span class="kw">await</span><span class="op">;</span> <span class="co">// Pas de délai</span></span>
|
||||
<span id="cb4-7"><a href="#cb4-7" aria-hidden="true" tabindex="-1"></a>buffer<span class="op">.</span>add_subscriber_with_offset(sink2_tx<span class="op">,</span> <span class="dv">5</span>)<span class="op">.</span><span class="kw">await</span><span class="op">;</span> <span class="co">// 5 chunks de retard</span></span></code><button title="Copy to Clipboard" class="code-copy-button"><i class="bi"></i></button></pre></div>
|
||||
</section>
|
||||
</section>
|
||||
<section id="lancer-les-exemples" class="level2">
|
||||
<h2 class="anchored" data-anchor-id="lancer-les-exemples">Lancer les exemples</h2>
|
||||
<div class="sourceCode" id="cb5"><pre class="sourceCode bash code-with-copy"><code class="sourceCode bash"><span id="cb5-1"><a href="#cb5-1" aria-hidden="true" tabindex="-1"></a><span class="co"># Pipeline simple</span></span>
|
||||
<span id="cb5-2"><a href="#cb5-2" aria-hidden="true" tabindex="-1"></a><span class="ex">cargo</span> run <span class="at">--example</span> simple_pipeline</span>
|
||||
<span id="cb5-3"><a href="#cb5-3" aria-hidden="true" tabindex="-1"></a></span>
|
||||
<span id="cb5-4"><a href="#cb5-4" aria-hidden="true" tabindex="-1"></a><span class="co"># Pipeline complet avec tous les nodes</span></span>
|
||||
<span id="cb5-5"><a href="#cb5-5" aria-hidden="true" tabindex="-1"></a><span class="ex">cargo</span> run <span class="at">--example</span> pipeline_demo</span>
|
||||
<span id="cb5-6"><a href="#cb5-6" aria-hidden="true" tabindex="-1"></a></span>
|
||||
<span id="cb5-7"><a href="#cb5-7" aria-hidden="true" tabindex="-1"></a><span class="co"># Configuration multiroom</span></span>
|
||||
<span id="cb5-8"><a href="#cb5-8" aria-hidden="true" tabindex="-1"></a><span class="ex">cargo</span> run <span class="at">--example</span> multiroom_demo</span>
|
||||
<span id="cb5-9"><a href="#cb5-9" aria-hidden="true" tabindex="-1"></a></span>
|
||||
<span id="cb5-10"><a href="#cb5-10" aria-hidden="true" tabindex="-1"></a><span class="co"># Streaming avec timing réel</span></span>
|
||||
<span id="cb5-11"><a href="#cb5-11" aria-hidden="true" tabindex="-1"></a><span class="ex">cargo</span> run <span class="at">--example</span> streaming_demo</span></code><button title="Copy to Clipboard" class="code-copy-button"><i class="bi"></i></button></pre></div>
|
||||
</section>
|
||||
<section id="tests" class="level2">
|
||||
<h2 class="anchored" data-anchor-id="tests">Tests</h2>
|
||||
<div class="sourceCode" id="cb6"><pre class="sourceCode bash code-with-copy"><code class="sourceCode bash"><span id="cb6-1"><a href="#cb6-1" aria-hidden="true" tabindex="-1"></a><span class="ex">cargo</span> test</span></code><button title="Copy to Clipboard" class="code-copy-button"><i class="bi"></i></button></pre></div>
|
||||
<p>20 tests unitaires couvrant : - Propagation des chunks - Calcul de position par TimerNode - BufferNode multi-abonné avec offsets - Arc sharing et zero-copy - DSP avec gain et filtrage - Resampling</p>
|
||||
</section>
|
||||
<section id="optimisations" class="level2">
|
||||
<h2 class="anchored" data-anchor-id="optimisations">Optimisations</h2>
|
||||
<ol type="1">
|
||||
<li><strong>Arc sharing</strong> : Les <code>AudioChunk</code> sont clonés via <code>Arc::clone()</code> qui ne clone que le pointeur</li>
|
||||
<li><strong>Copy-on-Write</strong> : Les DSP nodes clonent les données uniquement si modification nécessaire</li>
|
||||
<li><strong>Bounded channels</strong> : Backpressure automatique</li>
|
||||
<li><strong>try_send</strong> : Non-bloquant pour BufferNode, permet de sauter des chunks si un abonné est saturé</li>
|
||||
<li><strong>RwLock</strong> : Pour partage concurrent du compteur TimerNode</li>
|
||||
</ol>
|
||||
</section>
|
||||
<section id="dépendances" class="level2">
|
||||
<h2 class="anchored" data-anchor-id="dépendances">Dépendances</h2>
|
||||
<ul>
|
||||
<li><code>tokio</code> : Runtime async et channels</li>
|
||||
<li><code>async-trait</code> : Traits async</li>
|
||||
</ul>
|
||||
</section>
|
||||
<section id="license" class="level2">
|
||||
<h2 class="anchored" data-anchor-id="license">License</h2>
|
||||
<p>CeCill-2.0</p>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
<!-- /main column -->
|
||||
<script id="quarto-html-after-body" type="application/javascript">
|
||||
window.document.addEventListener("DOMContentLoaded", function (event) {
|
||||
const icon = "";
|
||||
const anchorJS = new window.AnchorJS();
|
||||
anchorJS.options = {
|
||||
placement: 'right',
|
||||
icon: icon
|
||||
};
|
||||
anchorJS.add('.anchored');
|
||||
const isCodeAnnotation = (el) => {
|
||||
for (const clz of el.classList) {
|
||||
if (clz.startsWith('code-annotation-')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const onCopySuccess = function(e) {
|
||||
// button target
|
||||
const button = e.trigger;
|
||||
// don't keep focus
|
||||
button.blur();
|
||||
// flash "checked"
|
||||
button.classList.add('code-copy-button-checked');
|
||||
var currentTitle = button.getAttribute("title");
|
||||
button.setAttribute("title", "Copied!");
|
||||
let tooltip;
|
||||
if (window.bootstrap) {
|
||||
button.setAttribute("data-bs-toggle", "tooltip");
|
||||
button.setAttribute("data-bs-placement", "left");
|
||||
button.setAttribute("data-bs-title", "Copied!");
|
||||
tooltip = new bootstrap.Tooltip(button,
|
||||
{ trigger: "manual",
|
||||
customClass: "code-copy-button-tooltip",
|
||||
offset: [0, -8]});
|
||||
tooltip.show();
|
||||
}
|
||||
setTimeout(function() {
|
||||
if (tooltip) {
|
||||
tooltip.hide();
|
||||
button.removeAttribute("data-bs-title");
|
||||
button.removeAttribute("data-bs-toggle");
|
||||
button.removeAttribute("data-bs-placement");
|
||||
}
|
||||
button.setAttribute("title", currentTitle);
|
||||
button.classList.remove('code-copy-button-checked');
|
||||
}, 1000);
|
||||
// clear code selection
|
||||
e.clearSelection();
|
||||
}
|
||||
const getTextToCopy = function(trigger) {
|
||||
const codeEl = trigger.previousElementSibling.cloneNode(true);
|
||||
for (const childEl of codeEl.children) {
|
||||
if (isCodeAnnotation(childEl)) {
|
||||
childEl.remove();
|
||||
}
|
||||
}
|
||||
return codeEl.innerText;
|
||||
}
|
||||
const clipboard = new window.ClipboardJS('.code-copy-button:not([data-in-quarto-modal])', {
|
||||
text: getTextToCopy
|
||||
});
|
||||
clipboard.on('success', onCopySuccess);
|
||||
if (window.document.getElementById('quarto-embedded-source-code-modal')) {
|
||||
const clipboardModal = new window.ClipboardJS('.code-copy-button[data-in-quarto-modal]', {
|
||||
text: getTextToCopy,
|
||||
container: window.document.getElementById('quarto-embedded-source-code-modal')
|
||||
});
|
||||
clipboardModal.on('success', onCopySuccess);
|
||||
}
|
||||
var localhostRegex = new RegExp(/^(?:http|https):\/\/localhost\:?[0-9]*\//);
|
||||
var mailtoRegex = new RegExp(/^mailto:/);
|
||||
var filterRegex = new RegExp('/' + window.location.host + '/');
|
||||
var isInternal = (href) => {
|
||||
return filterRegex.test(href) || localhostRegex.test(href) || mailtoRegex.test(href);
|
||||
}
|
||||
// Inspect non-navigation links and adorn them if external
|
||||
var links = window.document.querySelectorAll('a[href]:not(.nav-link):not(.navbar-brand):not(.toc-action):not(.sidebar-link):not(.sidebar-item-toggle):not(.pagination-link):not(.no-external):not([aria-hidden]):not(.dropdown-item):not(.quarto-navigation-tool):not(.about-link)');
|
||||
for (var i=0; i<links.length; i++) {
|
||||
const link = links[i];
|
||||
if (!isInternal(link.href)) {
|
||||
// undo the damage that might have been done by quarto-nav.js in the case of
|
||||
// links that we want to consider external
|
||||
if (link.dataset.originalHref !== undefined) {
|
||||
link.href = link.dataset.originalHref;
|
||||
}
|
||||
}
|
||||
}
|
||||
function tippyHover(el, contentFn, onTriggerFn, onUntriggerFn) {
|
||||
const config = {
|
||||
allowHTML: true,
|
||||
maxWidth: 500,
|
||||
delay: 100,
|
||||
arrow: false,
|
||||
appendTo: function(el) {
|
||||
return el.parentElement;
|
||||
},
|
||||
interactive: true,
|
||||
interactiveBorder: 10,
|
||||
theme: 'quarto',
|
||||
placement: 'bottom-start',
|
||||
};
|
||||
if (contentFn) {
|
||||
config.content = contentFn;
|
||||
}
|
||||
if (onTriggerFn) {
|
||||
config.onTrigger = onTriggerFn;
|
||||
}
|
||||
if (onUntriggerFn) {
|
||||
config.onUntrigger = onUntriggerFn;
|
||||
}
|
||||
window.tippy(el, config);
|
||||
}
|
||||
const noterefs = window.document.querySelectorAll('a[role="doc-noteref"]');
|
||||
for (var i=0; i<noterefs.length; i++) {
|
||||
const ref = noterefs[i];
|
||||
tippyHover(ref, function() {
|
||||
// use id or data attribute instead here
|
||||
let href = ref.getAttribute('data-footnote-href') || ref.getAttribute('href');
|
||||
try { href = new URL(href).hash; } catch {}
|
||||
const id = href.replace(/^#\/?/, "");
|
||||
const note = window.document.getElementById(id);
|
||||
if (note) {
|
||||
return note.innerHTML;
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
});
|
||||
}
|
||||
const xrefs = window.document.querySelectorAll('a.quarto-xref');
|
||||
const processXRef = (id, note) => {
|
||||
// Strip column container classes
|
||||
const stripColumnClz = (el) => {
|
||||
el.classList.remove("page-full", "page-columns");
|
||||
if (el.children) {
|
||||
for (const child of el.children) {
|
||||
stripColumnClz(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
stripColumnClz(note)
|
||||
if (id === null || id.startsWith('sec-')) {
|
||||
// Special case sections, only their first couple elements
|
||||
const container = document.createElement("div");
|
||||
if (note.children && note.children.length > 2) {
|
||||
container.appendChild(note.children[0].cloneNode(true));
|
||||
for (let i = 1; i < note.children.length; i++) {
|
||||
const child = note.children[i];
|
||||
if (child.tagName === "P" && child.innerText === "") {
|
||||
continue;
|
||||
} else {
|
||||
container.appendChild(child.cloneNode(true));
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (window.Quarto?.typesetMath) {
|
||||
window.Quarto.typesetMath(container);
|
||||
}
|
||||
return container.innerHTML
|
||||
} else {
|
||||
if (window.Quarto?.typesetMath) {
|
||||
window.Quarto.typesetMath(note);
|
||||
}
|
||||
return note.innerHTML;
|
||||
}
|
||||
} else {
|
||||
// Remove any anchor links if they are present
|
||||
const anchorLink = note.querySelector('a.anchorjs-link');
|
||||
if (anchorLink) {
|
||||
anchorLink.remove();
|
||||
}
|
||||
if (window.Quarto?.typesetMath) {
|
||||
window.Quarto.typesetMath(note);
|
||||
}
|
||||
if (note.classList.contains("callout")) {
|
||||
return note.outerHTML;
|
||||
} else {
|
||||
return note.innerHTML;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (var i=0; i<xrefs.length; i++) {
|
||||
const xref = xrefs[i];
|
||||
tippyHover(xref, undefined, function(instance) {
|
||||
instance.disable();
|
||||
let url = xref.getAttribute('href');
|
||||
let hash = undefined;
|
||||
if (url.startsWith('#')) {
|
||||
hash = url;
|
||||
} else {
|
||||
try { hash = new URL(url).hash; } catch {}
|
||||
}
|
||||
if (hash) {
|
||||
const id = hash.replace(/^#\/?/, "");
|
||||
const note = window.document.getElementById(id);
|
||||
if (note !== null) {
|
||||
try {
|
||||
const html = processXRef(id, note.cloneNode(true));
|
||||
instance.setContent(html);
|
||||
} finally {
|
||||
instance.enable();
|
||||
instance.show();
|
||||
}
|
||||
} else {
|
||||
// See if we can fetch this
|
||||
fetch(url.split('#')[0])
|
||||
.then(res => res.text())
|
||||
.then(html => {
|
||||
const parser = new DOMParser();
|
||||
const htmlDoc = parser.parseFromString(html, "text/html");
|
||||
const note = htmlDoc.getElementById(id);
|
||||
if (note !== null) {
|
||||
const html = processXRef(id, note);
|
||||
instance.setContent(html);
|
||||
}
|
||||
}).finally(() => {
|
||||
instance.enable();
|
||||
instance.show();
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// See if we can fetch a full url (with no hash to target)
|
||||
// This is a special case and we should probably do some content thinning / targeting
|
||||
fetch(url)
|
||||
.then(res => res.text())
|
||||
.then(html => {
|
||||
const parser = new DOMParser();
|
||||
const htmlDoc = parser.parseFromString(html, "text/html");
|
||||
const note = htmlDoc.querySelector('main.content');
|
||||
if (note !== null) {
|
||||
// This should only happen for chapter cross references
|
||||
// (since there is no id in the URL)
|
||||
// remove the first header
|
||||
if (note.children.length > 0 && note.children[0].tagName === "HEADER") {
|
||||
note.children[0].remove();
|
||||
}
|
||||
const html = processXRef(null, note);
|
||||
instance.setContent(html);
|
||||
}
|
||||
}).finally(() => {
|
||||
instance.enable();
|
||||
instance.show();
|
||||
});
|
||||
}
|
||||
}, function(instance) {
|
||||
});
|
||||
}
|
||||
let selectedAnnoteEl;
|
||||
const selectorForAnnotation = ( cell, annotation) => {
|
||||
let cellAttr = 'data-code-cell="' + cell + '"';
|
||||
let lineAttr = 'data-code-annotation="' + annotation + '"';
|
||||
const selector = 'span[' + cellAttr + '][' + lineAttr + ']';
|
||||
return selector;
|
||||
}
|
||||
const selectCodeLines = (annoteEl) => {
|
||||
const doc = window.document;
|
||||
const targetCell = annoteEl.getAttribute("data-target-cell");
|
||||
const targetAnnotation = annoteEl.getAttribute("data-target-annotation");
|
||||
const annoteSpan = window.document.querySelector(selectorForAnnotation(targetCell, targetAnnotation));
|
||||
const lines = annoteSpan.getAttribute("data-code-lines").split(",");
|
||||
const lineIds = lines.map((line) => {
|
||||
return targetCell + "-" + line;
|
||||
})
|
||||
let top = null;
|
||||
let height = null;
|
||||
let parent = null;
|
||||
if (lineIds.length > 0) {
|
||||
//compute the position of the single el (top and bottom and make a div)
|
||||
const el = window.document.getElementById(lineIds[0]);
|
||||
top = el.offsetTop;
|
||||
height = el.offsetHeight;
|
||||
parent = el.parentElement.parentElement;
|
||||
if (lineIds.length > 1) {
|
||||
const lastEl = window.document.getElementById(lineIds[lineIds.length - 1]);
|
||||
const bottom = lastEl.offsetTop + lastEl.offsetHeight;
|
||||
height = bottom - top;
|
||||
}
|
||||
if (top !== null && height !== null && parent !== null) {
|
||||
// cook up a div (if necessary) and position it
|
||||
let div = window.document.getElementById("code-annotation-line-highlight");
|
||||
if (div === null) {
|
||||
div = window.document.createElement("div");
|
||||
div.setAttribute("id", "code-annotation-line-highlight");
|
||||
div.style.position = 'absolute';
|
||||
parent.appendChild(div);
|
||||
}
|
||||
div.style.top = top - 2 + "px";
|
||||
div.style.height = height + 4 + "px";
|
||||
div.style.left = 0;
|
||||
let gutterDiv = window.document.getElementById("code-annotation-line-highlight-gutter");
|
||||
if (gutterDiv === null) {
|
||||
gutterDiv = window.document.createElement("div");
|
||||
gutterDiv.setAttribute("id", "code-annotation-line-highlight-gutter");
|
||||
gutterDiv.style.position = 'absolute';
|
||||
const codeCell = window.document.getElementById(targetCell);
|
||||
const gutter = codeCell.querySelector('.code-annotation-gutter');
|
||||
gutter.appendChild(gutterDiv);
|
||||
}
|
||||
gutterDiv.style.top = top - 2 + "px";
|
||||
gutterDiv.style.height = height + 4 + "px";
|
||||
}
|
||||
selectedAnnoteEl = annoteEl;
|
||||
}
|
||||
};
|
||||
const unselectCodeLines = () => {
|
||||
const elementsIds = ["code-annotation-line-highlight", "code-annotation-line-highlight-gutter"];
|
||||
elementsIds.forEach((elId) => {
|
||||
const div = window.document.getElementById(elId);
|
||||
if (div) {
|
||||
div.remove();
|
||||
}
|
||||
});
|
||||
selectedAnnoteEl = undefined;
|
||||
};
|
||||
// Handle positioning of the toggle
|
||||
window.addEventListener(
|
||||
"resize",
|
||||
throttle(() => {
|
||||
elRect = undefined;
|
||||
if (selectedAnnoteEl) {
|
||||
selectCodeLines(selectedAnnoteEl);
|
||||
}
|
||||
}, 10)
|
||||
);
|
||||
function throttle(fn, ms) {
|
||||
let throttle = false;
|
||||
let timer;
|
||||
return (...args) => {
|
||||
if(!throttle) { // first call gets through
|
||||
fn.apply(this, args);
|
||||
throttle = true;
|
||||
} else { // all the others get throttled
|
||||
if(timer) clearTimeout(timer); // cancel #2
|
||||
timer = setTimeout(() => {
|
||||
fn.apply(this, args);
|
||||
timer = throttle = false;
|
||||
}, ms);
|
||||
}
|
||||
};
|
||||
}
|
||||
// Attach click handler to the DT
|
||||
const annoteDls = window.document.querySelectorAll('dt[data-target-cell]');
|
||||
for (const annoteDlNode of annoteDls) {
|
||||
annoteDlNode.addEventListener('click', (event) => {
|
||||
const clickedEl = event.target;
|
||||
if (clickedEl !== selectedAnnoteEl) {
|
||||
unselectCodeLines();
|
||||
const activeEl = window.document.querySelector('dt[data-target-cell].code-annotation-active');
|
||||
if (activeEl) {
|
||||
activeEl.classList.remove('code-annotation-active');
|
||||
}
|
||||
selectCodeLines(clickedEl);
|
||||
clickedEl.classList.add('code-annotation-active');
|
||||
} else {
|
||||
// Unselect the line
|
||||
unselectCodeLines();
|
||||
clickedEl.classList.remove('code-annotation-active');
|
||||
}
|
||||
});
|
||||
}
|
||||
const findCites = (el) => {
|
||||
const parentEl = el.parentElement;
|
||||
if (parentEl) {
|
||||
const cites = parentEl.dataset.cites;
|
||||
if (cites) {
|
||||
return {
|
||||
el,
|
||||
cites: cites.split(' ')
|
||||
};
|
||||
} else {
|
||||
return findCites(el.parentElement)
|
||||
}
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
var bibliorefs = window.document.querySelectorAll('a[role="doc-biblioref"]');
|
||||
for (var i=0; i<bibliorefs.length; i++) {
|
||||
const ref = bibliorefs[i];
|
||||
const citeInfo = findCites(ref);
|
||||
if (citeInfo) {
|
||||
tippyHover(citeInfo.el, function() {
|
||||
var popup = window.document.createElement('div');
|
||||
citeInfo.cites.forEach(function(cite) {
|
||||
var citeDiv = window.document.createElement('div');
|
||||
citeDiv.classList.add('hanging-indent');
|
||||
citeDiv.classList.add('csl-entry');
|
||||
var biblioDiv = window.document.getElementById('ref-' + cite);
|
||||
if (biblioDiv) {
|
||||
citeDiv.innerHTML = biblioDiv.innerHTML;
|
||||
}
|
||||
popup.appendChild(citeDiv);
|
||||
});
|
||||
return popup.innerHTML;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</div> <!-- /content -->
|
||||
|
||||
|
||||
|
||||
|
||||
</body></html>
|
||||
165
pmoaudio/README.md
Normal file
165
pmoaudio/README.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# PMOAudio
|
||||
|
||||
Pipeline audio stéréo async optimisé pour Rust, utilisant Tokio.
|
||||
|
||||
## Caractéristiques
|
||||
|
||||
- **Pipeline push-based async** : Tous les nodes utilisent Tokio pour un traitement non-bloquant
|
||||
- **Zero-copy optimisé** : Les données audio sont partagées via `Arc<Vec<f32>>` pour éviter les clonages inutiles
|
||||
- **Support multiroom** : BufferNode avec buffer circulaire et offsets indépendants par abonné
|
||||
- **TimerNode** : Calcul de position temporelle en temps réel
|
||||
- **Backpressure** : Channels bounded avec `try_send` pour éviter les blocages
|
||||
|
||||
## Architecture
|
||||
|
||||
### AudioChunk
|
||||
|
||||
Structure de données pour un chunk audio stéréo :
|
||||
|
||||
```rust
|
||||
pub struct AudioChunk {
|
||||
pub order: u64, // Numéro d'ordre
|
||||
pub left: Arc<Vec<f32>>, // Canal gauche (partagé)
|
||||
pub right: Arc<Vec<f32>>, // Canal droit (partagé)
|
||||
pub sample_rate: u32, // Taux d'échantillonnage
|
||||
}
|
||||
```
|
||||
|
||||
Les données sont wrappées dans `Arc` pour permettre le partage sans copie entre plusieurs abonnés.
|
||||
|
||||
### Nodes
|
||||
|
||||
#### SingleSubscriberNode
|
||||
- Un seul abonné
|
||||
- Pas de clone inutile du Arc
|
||||
|
||||
#### MultiSubscriberNode
|
||||
- Plusieurs abonnés
|
||||
- Partage le même `Arc<AudioChunk>` avec tous
|
||||
|
||||
#### SourceNode
|
||||
- Génère ou lit des chunks audio
|
||||
- Version mock avec génération de sinusoïdes pour tests
|
||||
|
||||
#### DecoderNode
|
||||
- Décode les chunks audio
|
||||
- Supporte le passthrough et le resampling (mock)
|
||||
|
||||
#### DspNode
|
||||
- Applique des transformations DSP
|
||||
- Clone les données uniquement si modification nécessaire
|
||||
- Exemple : gain, filtrage
|
||||
|
||||
#### BufferNode
|
||||
- Buffer circulaire (`VecDeque<Arc<AudioChunk>>`)
|
||||
- Support multiroom avec offsets indépendants
|
||||
- `try_send` non-bloquant pour éviter de bloquer la source
|
||||
|
||||
#### TimerNode
|
||||
- Node passthrough qui ne modifie pas les données
|
||||
- Incrémente un compteur de samples
|
||||
- Calcule la position : `position_sec = elapsed_samples / sample_rate`
|
||||
- Fournit un `TimerHandle` pour monitoring
|
||||
|
||||
#### SinkNode
|
||||
- Node terminal qui consomme les chunks
|
||||
- Versions : silent, logging, stats, mock file writer
|
||||
|
||||
## Pipeline type
|
||||
|
||||
```
|
||||
SourceNode → DecoderNode → DSPNode → BufferNode → TimerNode → SinkNode(s)
|
||||
↓
|
||||
Multiroom Sinks
|
||||
(avec offsets)
|
||||
```
|
||||
|
||||
## Exemples
|
||||
|
||||
### Pipeline simple
|
||||
|
||||
```rust
|
||||
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);
|
||||
let timer_handle = timer.get_position_handle();
|
||||
|
||||
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();
|
||||
}
|
||||
```
|
||||
|
||||
### Multiroom
|
||||
|
||||
```rust
|
||||
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);
|
||||
|
||||
buffer.add_subscriber_with_offset(sink1_tx, 0).await; // Pas de délai
|
||||
buffer.add_subscriber_with_offset(sink2_tx, 5).await; // 5 chunks de retard
|
||||
```
|
||||
|
||||
## Lancer les exemples
|
||||
|
||||
```bash
|
||||
# Pipeline simple
|
||||
cargo run --example simple_pipeline
|
||||
|
||||
# Pipeline complet avec tous les nodes
|
||||
cargo run --example pipeline_demo
|
||||
|
||||
# Configuration multiroom
|
||||
cargo run --example multiroom_demo
|
||||
|
||||
# Streaming avec timing réel
|
||||
cargo run --example streaming_demo
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
cargo test
|
||||
```
|
||||
|
||||
20 tests unitaires couvrant :
|
||||
- Propagation des chunks
|
||||
- Calcul de position par TimerNode
|
||||
- BufferNode multi-abonné avec offsets
|
||||
- Arc sharing et zero-copy
|
||||
- DSP avec gain et filtrage
|
||||
- Resampling
|
||||
|
||||
## Optimisations
|
||||
|
||||
1. **Arc sharing** : Les `AudioChunk` sont clonés via `Arc::clone()` qui ne clone que le pointeur
|
||||
2. **Copy-on-Write** : Les DSP nodes clonent les données uniquement si modification nécessaire
|
||||
3. **Bounded channels** : Backpressure automatique
|
||||
4. **try_send** : Non-bloquant pour BufferNode, permet de sauter des chunks si un abonné est saturé
|
||||
5. **RwLock** : Pour partage concurrent du compteur TimerNode
|
||||
|
||||
## Dépendances
|
||||
|
||||
- `tokio` : Runtime async et channels
|
||||
- `async-trait` : Traits async
|
||||
|
||||
## License
|
||||
|
||||
CeCill-2.0
|
||||
12
pmoaudio/README_files/libs/bootstrap/bootstrap-81267100e462c21b3d6c0d5bf76a3417.min.css
vendored
Normal file
12
pmoaudio/README_files/libs/bootstrap/bootstrap-81267100e462c21b3d6c0d5bf76a3417.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
2078
pmoaudio/README_files/libs/bootstrap/bootstrap-icons.css
vendored
Normal file
2078
pmoaudio/README_files/libs/bootstrap/bootstrap-icons.css
vendored
Normal file
File diff suppressed because it is too large
Load Diff
BIN
pmoaudio/README_files/libs/bootstrap/bootstrap-icons.woff
Normal file
BIN
pmoaudio/README_files/libs/bootstrap/bootstrap-icons.woff
Normal file
Binary file not shown.
7
pmoaudio/README_files/libs/bootstrap/bootstrap.min.js
vendored
Normal file
7
pmoaudio/README_files/libs/bootstrap/bootstrap.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
7
pmoaudio/README_files/libs/clipboard/clipboard.min.js
vendored
Normal file
7
pmoaudio/README_files/libs/clipboard/clipboard.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
9
pmoaudio/README_files/libs/quarto-html/anchor.min.js
vendored
Normal file
9
pmoaudio/README_files/libs/quarto-html/anchor.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
6
pmoaudio/README_files/libs/quarto-html/popper.min.js
vendored
Normal file
6
pmoaudio/README_files/libs/quarto-html/popper.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -0,0 +1,236 @@
|
||||
/* quarto syntax highlight colors */
|
||||
:root {
|
||||
--quarto-hl-ot-color: #003B4F;
|
||||
--quarto-hl-at-color: #657422;
|
||||
--quarto-hl-ss-color: #20794D;
|
||||
--quarto-hl-an-color: #5E5E5E;
|
||||
--quarto-hl-fu-color: #4758AB;
|
||||
--quarto-hl-st-color: #20794D;
|
||||
--quarto-hl-cf-color: #003B4F;
|
||||
--quarto-hl-op-color: #5E5E5E;
|
||||
--quarto-hl-er-color: #AD0000;
|
||||
--quarto-hl-bn-color: #AD0000;
|
||||
--quarto-hl-al-color: #AD0000;
|
||||
--quarto-hl-va-color: #111111;
|
||||
--quarto-hl-bu-color: inherit;
|
||||
--quarto-hl-ex-color: inherit;
|
||||
--quarto-hl-pp-color: #AD0000;
|
||||
--quarto-hl-in-color: #5E5E5E;
|
||||
--quarto-hl-vs-color: #20794D;
|
||||
--quarto-hl-wa-color: #5E5E5E;
|
||||
--quarto-hl-do-color: #5E5E5E;
|
||||
--quarto-hl-im-color: #00769E;
|
||||
--quarto-hl-ch-color: #20794D;
|
||||
--quarto-hl-dt-color: #AD0000;
|
||||
--quarto-hl-fl-color: #AD0000;
|
||||
--quarto-hl-co-color: #5E5E5E;
|
||||
--quarto-hl-cv-color: #5E5E5E;
|
||||
--quarto-hl-cn-color: #8f5902;
|
||||
--quarto-hl-sc-color: #5E5E5E;
|
||||
--quarto-hl-dv-color: #AD0000;
|
||||
--quarto-hl-kw-color: #003B4F;
|
||||
}
|
||||
|
||||
/* other quarto variables */
|
||||
:root {
|
||||
--quarto-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
|
||||
/* syntax highlight based on Pandoc's rules */
|
||||
pre > code.sourceCode > span {
|
||||
color: #003B4F;
|
||||
}
|
||||
|
||||
code.sourceCode > span {
|
||||
color: #003B4F;
|
||||
}
|
||||
|
||||
div.sourceCode,
|
||||
div.sourceCode pre.sourceCode {
|
||||
color: #003B4F;
|
||||
}
|
||||
|
||||
/* Normal */
|
||||
code span {
|
||||
color: #003B4F;
|
||||
}
|
||||
|
||||
/* Alert */
|
||||
code span.al {
|
||||
color: #AD0000;
|
||||
font-style: inherit;
|
||||
}
|
||||
|
||||
/* Annotation */
|
||||
code span.an {
|
||||
color: #5E5E5E;
|
||||
font-style: inherit;
|
||||
}
|
||||
|
||||
/* Attribute */
|
||||
code span.at {
|
||||
color: #657422;
|
||||
font-style: inherit;
|
||||
}
|
||||
|
||||
/* BaseN */
|
||||
code span.bn {
|
||||
color: #AD0000;
|
||||
font-style: inherit;
|
||||
}
|
||||
|
||||
/* BuiltIn */
|
||||
code span.bu {
|
||||
font-style: inherit;
|
||||
}
|
||||
|
||||
/* ControlFlow */
|
||||
code span.cf {
|
||||
color: #003B4F;
|
||||
font-weight: bold;
|
||||
font-style: inherit;
|
||||
}
|
||||
|
||||
/* Char */
|
||||
code span.ch {
|
||||
color: #20794D;
|
||||
font-style: inherit;
|
||||
}
|
||||
|
||||
/* Constant */
|
||||
code span.cn {
|
||||
color: #8f5902;
|
||||
font-style: inherit;
|
||||
}
|
||||
|
||||
/* Comment */
|
||||
code span.co {
|
||||
color: #5E5E5E;
|
||||
font-style: inherit;
|
||||
}
|
||||
|
||||
/* CommentVar */
|
||||
code span.cv {
|
||||
color: #5E5E5E;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Documentation */
|
||||
code span.do {
|
||||
color: #5E5E5E;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* DataType */
|
||||
code span.dt {
|
||||
color: #AD0000;
|
||||
font-style: inherit;
|
||||
}
|
||||
|
||||
/* DecVal */
|
||||
code span.dv {
|
||||
color: #AD0000;
|
||||
font-style: inherit;
|
||||
}
|
||||
|
||||
/* Error */
|
||||
code span.er {
|
||||
color: #AD0000;
|
||||
font-style: inherit;
|
||||
}
|
||||
|
||||
/* Extension */
|
||||
code span.ex {
|
||||
font-style: inherit;
|
||||
}
|
||||
|
||||
/* Float */
|
||||
code span.fl {
|
||||
color: #AD0000;
|
||||
font-style: inherit;
|
||||
}
|
||||
|
||||
/* Function */
|
||||
code span.fu {
|
||||
color: #4758AB;
|
||||
font-style: inherit;
|
||||
}
|
||||
|
||||
/* Import */
|
||||
code span.im {
|
||||
color: #00769E;
|
||||
font-style: inherit;
|
||||
}
|
||||
|
||||
/* Information */
|
||||
code span.in {
|
||||
color: #5E5E5E;
|
||||
font-style: inherit;
|
||||
}
|
||||
|
||||
/* Keyword */
|
||||
code span.kw {
|
||||
color: #003B4F;
|
||||
font-weight: bold;
|
||||
font-style: inherit;
|
||||
}
|
||||
|
||||
/* Operator */
|
||||
code span.op {
|
||||
color: #5E5E5E;
|
||||
font-style: inherit;
|
||||
}
|
||||
|
||||
/* Other */
|
||||
code span.ot {
|
||||
color: #003B4F;
|
||||
font-style: inherit;
|
||||
}
|
||||
|
||||
/* Preprocessor */
|
||||
code span.pp {
|
||||
color: #AD0000;
|
||||
font-style: inherit;
|
||||
}
|
||||
|
||||
/* SpecialChar */
|
||||
code span.sc {
|
||||
color: #5E5E5E;
|
||||
font-style: inherit;
|
||||
}
|
||||
|
||||
/* SpecialString */
|
||||
code span.ss {
|
||||
color: #20794D;
|
||||
font-style: inherit;
|
||||
}
|
||||
|
||||
/* String */
|
||||
code span.st {
|
||||
color: #20794D;
|
||||
font-style: inherit;
|
||||
}
|
||||
|
||||
/* Variable */
|
||||
code span.va {
|
||||
color: #111111;
|
||||
font-style: inherit;
|
||||
}
|
||||
|
||||
/* VerbatimString */
|
||||
code span.vs {
|
||||
color: #20794D;
|
||||
font-style: inherit;
|
||||
}
|
||||
|
||||
/* Warning */
|
||||
code span.wa {
|
||||
color: #5E5E5E;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.prevent-inlining {
|
||||
content: "</";
|
||||
}
|
||||
|
||||
/*# sourceMappingURL=405ffa06017179012420079c49e771b5.css.map */
|
||||
845
pmoaudio/README_files/libs/quarto-html/quarto.js
Normal file
845
pmoaudio/README_files/libs/quarto-html/quarto.js
Normal file
@@ -0,0 +1,845 @@
|
||||
import * as tabsets from "./tabsets/tabsets.js";
|
||||
|
||||
const sectionChanged = new CustomEvent("quarto-sectionChanged", {
|
||||
detail: {},
|
||||
bubbles: true,
|
||||
cancelable: false,
|
||||
composed: false,
|
||||
});
|
||||
|
||||
const layoutMarginEls = () => {
|
||||
// Find any conflicting margin elements and add margins to the
|
||||
// top to prevent overlap
|
||||
const marginChildren = window.document.querySelectorAll(
|
||||
".column-margin.column-container > *, .margin-caption, .aside"
|
||||
);
|
||||
|
||||
let lastBottom = 0;
|
||||
for (const marginChild of marginChildren) {
|
||||
if (marginChild.offsetParent !== null) {
|
||||
// clear the top margin so we recompute it
|
||||
marginChild.style.marginTop = null;
|
||||
const top = marginChild.getBoundingClientRect().top + window.scrollY;
|
||||
if (top < lastBottom) {
|
||||
const marginChildStyle = window.getComputedStyle(marginChild);
|
||||
const marginBottom = parseFloat(marginChildStyle["marginBottom"]);
|
||||
const margin = lastBottom - top + marginBottom;
|
||||
marginChild.style.marginTop = `${margin}px`;
|
||||
}
|
||||
const styles = window.getComputedStyle(marginChild);
|
||||
const marginTop = parseFloat(styles["marginTop"]);
|
||||
lastBottom = top + marginChild.getBoundingClientRect().height + marginTop;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.document.addEventListener("DOMContentLoaded", function (_event) {
|
||||
// Recompute the position of margin elements anytime the body size changes
|
||||
if (window.ResizeObserver) {
|
||||
const resizeObserver = new window.ResizeObserver(
|
||||
throttle(() => {
|
||||
layoutMarginEls();
|
||||
if (
|
||||
window.document.body.getBoundingClientRect().width < 990 &&
|
||||
isReaderMode()
|
||||
) {
|
||||
quartoToggleReader();
|
||||
}
|
||||
}, 50)
|
||||
);
|
||||
resizeObserver.observe(window.document.body);
|
||||
}
|
||||
|
||||
const tocEl = window.document.querySelector('nav.toc-active[role="doc-toc"]');
|
||||
const sidebarEl = window.document.getElementById("quarto-sidebar");
|
||||
const leftTocEl = window.document.getElementById("quarto-sidebar-toc-left");
|
||||
const marginSidebarEl = window.document.getElementById(
|
||||
"quarto-margin-sidebar"
|
||||
);
|
||||
// function to determine whether the element has a previous sibling that is active
|
||||
const prevSiblingIsActiveLink = (el) => {
|
||||
const sibling = el.previousElementSibling;
|
||||
if (sibling && sibling.tagName === "A") {
|
||||
return sibling.classList.contains("active");
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// dispatch for htmlwidgets
|
||||
// they use slideenter event to trigger resize
|
||||
function fireSlideEnter() {
|
||||
const event = window.document.createEvent("Event");
|
||||
event.initEvent("slideenter", true, true);
|
||||
window.document.dispatchEvent(event);
|
||||
}
|
||||
|
||||
const tabs = window.document.querySelectorAll('a[data-bs-toggle="tab"]');
|
||||
tabs.forEach((tab) => {
|
||||
tab.addEventListener("shown.bs.tab", fireSlideEnter);
|
||||
});
|
||||
|
||||
// dispatch for shiny
|
||||
// they use BS shown and hidden events to trigger rendering
|
||||
function distpatchShinyEvents(previous, current) {
|
||||
if (window.jQuery) {
|
||||
if (previous) {
|
||||
window.jQuery(previous).trigger("hidden");
|
||||
}
|
||||
if (current) {
|
||||
window.jQuery(current).trigger("shown");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tabby.js listener: Trigger event for htmlwidget and shiny
|
||||
document.addEventListener(
|
||||
"tabby",
|
||||
function (event) {
|
||||
fireSlideEnter();
|
||||
distpatchShinyEvents(event.detail.previousTab, event.detail.tab);
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
// Track scrolling and mark TOC links as active
|
||||
// get table of contents and sidebar (bail if we don't have at least one)
|
||||
const tocLinks = tocEl
|
||||
? [...tocEl.querySelectorAll("a[data-scroll-target]")]
|
||||
: [];
|
||||
const makeActive = (link) => tocLinks[link].classList.add("active");
|
||||
const removeActive = (link) => tocLinks[link].classList.remove("active");
|
||||
const removeAllActive = () =>
|
||||
[...Array(tocLinks.length).keys()].forEach((link) => removeActive(link));
|
||||
|
||||
// activate the anchor for a section associated with this TOC entry
|
||||
tocLinks.forEach((link) => {
|
||||
link.addEventListener("click", () => {
|
||||
if (link.href.indexOf("#") !== -1) {
|
||||
const anchor = link.href.split("#")[1];
|
||||
const heading = window.document.querySelector(
|
||||
`[data-anchor-id="${anchor}"]`
|
||||
);
|
||||
if (heading) {
|
||||
// Add the class
|
||||
heading.classList.add("reveal-anchorjs-link");
|
||||
|
||||
// function to show the anchor
|
||||
const handleMouseout = () => {
|
||||
heading.classList.remove("reveal-anchorjs-link");
|
||||
heading.removeEventListener("mouseout", handleMouseout);
|
||||
};
|
||||
|
||||
// add a function to clear the anchor when the user mouses out of it
|
||||
heading.addEventListener("mouseout", handleMouseout);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const sections = tocLinks.map((link) => {
|
||||
const target = link.getAttribute("data-scroll-target");
|
||||
if (target.startsWith("#")) {
|
||||
return window.document.getElementById(decodeURI(`${target.slice(1)}`));
|
||||
} else {
|
||||
return window.document.querySelector(decodeURI(`${target}`));
|
||||
}
|
||||
});
|
||||
|
||||
const sectionMargin = 200;
|
||||
let currentActive = 0;
|
||||
// track whether we've initialized state the first time
|
||||
let init = false;
|
||||
|
||||
const updateActiveLink = () => {
|
||||
// The index from bottom to top (e.g. reversed list)
|
||||
let sectionIndex = -1;
|
||||
if (
|
||||
window.innerHeight + window.pageYOffset >=
|
||||
window.document.body.offsetHeight
|
||||
) {
|
||||
// This is the no-scroll case where last section should be the active one
|
||||
sectionIndex = 0;
|
||||
} else {
|
||||
// This finds the last section visible on screen that should be made active
|
||||
sectionIndex = [...sections].reverse().findIndex((section) => {
|
||||
if (section) {
|
||||
return window.pageYOffset >= section.offsetTop - sectionMargin;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
if (sectionIndex > -1) {
|
||||
const current = sections.length - sectionIndex - 1;
|
||||
if (current !== currentActive) {
|
||||
removeAllActive();
|
||||
currentActive = current;
|
||||
makeActive(current);
|
||||
if (init) {
|
||||
window.dispatchEvent(sectionChanged);
|
||||
}
|
||||
init = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const inHiddenRegion = (top, bottom, hiddenRegions) => {
|
||||
for (const region of hiddenRegions) {
|
||||
if (top <= region.bottom && bottom >= region.top) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const categorySelector = "header.quarto-title-block .quarto-category";
|
||||
const activateCategories = (href) => {
|
||||
// Find any categories
|
||||
// Surround them with a link pointing back to:
|
||||
// #category=Authoring
|
||||
try {
|
||||
const categoryEls = window.document.querySelectorAll(categorySelector);
|
||||
for (const categoryEl of categoryEls) {
|
||||
const categoryText = categoryEl.textContent;
|
||||
if (categoryText) {
|
||||
const link = `${href}#category=${encodeURIComponent(categoryText)}`;
|
||||
const linkEl = window.document.createElement("a");
|
||||
linkEl.setAttribute("href", link);
|
||||
for (const child of categoryEl.childNodes) {
|
||||
linkEl.append(child);
|
||||
}
|
||||
categoryEl.appendChild(linkEl);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
};
|
||||
function hasTitleCategories() {
|
||||
return window.document.querySelector(categorySelector) !== null;
|
||||
}
|
||||
|
||||
function offsetRelativeUrl(url) {
|
||||
const offset = getMeta("quarto:offset");
|
||||
return offset ? offset + url : url;
|
||||
}
|
||||
|
||||
function offsetAbsoluteUrl(url) {
|
||||
const offset = getMeta("quarto:offset");
|
||||
const baseUrl = new URL(offset, window.location);
|
||||
|
||||
const projRelativeUrl = url.replace(baseUrl, "");
|
||||
if (projRelativeUrl.startsWith("/")) {
|
||||
return projRelativeUrl;
|
||||
} else {
|
||||
return "/" + projRelativeUrl;
|
||||
}
|
||||
}
|
||||
|
||||
// read a meta tag value
|
||||
function getMeta(metaName) {
|
||||
const metas = window.document.getElementsByTagName("meta");
|
||||
for (let i = 0; i < metas.length; i++) {
|
||||
if (metas[i].getAttribute("name") === metaName) {
|
||||
return metas[i].getAttribute("content");
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
async function findAndActivateCategories() {
|
||||
// Categories search with listing only use path without query
|
||||
const currentPagePath = offsetAbsoluteUrl(
|
||||
window.location.origin + window.location.pathname
|
||||
);
|
||||
const response = await fetch(offsetRelativeUrl("listings.json"));
|
||||
if (response.status == 200) {
|
||||
return response.json().then(function (listingPaths) {
|
||||
const listingHrefs = [];
|
||||
for (const listingPath of listingPaths) {
|
||||
const pathWithoutLeadingSlash = listingPath.listing.substring(1);
|
||||
for (const item of listingPath.items) {
|
||||
const encodedItem = encodeURI(item);
|
||||
if (
|
||||
encodedItem === currentPagePath ||
|
||||
encodedItem === currentPagePath + "index.html"
|
||||
) {
|
||||
// Resolve this path against the offset to be sure
|
||||
// we already are using the correct path to the listing
|
||||
// (this adjusts the listing urls to be rooted against
|
||||
// whatever root the page is actually running against)
|
||||
const relative = offsetRelativeUrl(pathWithoutLeadingSlash);
|
||||
const baseUrl = window.location;
|
||||
const resolvedPath = new URL(relative, baseUrl);
|
||||
listingHrefs.push(resolvedPath.pathname);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Look up the tree for a nearby linting and use that if we find one
|
||||
const nearestListing = findNearestParentListing(
|
||||
offsetAbsoluteUrl(window.location.pathname),
|
||||
listingHrefs
|
||||
);
|
||||
if (nearestListing) {
|
||||
activateCategories(nearestListing);
|
||||
} else {
|
||||
// See if the referrer is a listing page for this item
|
||||
const referredRelativePath = offsetAbsoluteUrl(document.referrer);
|
||||
const referrerListing = listingHrefs.find((listingHref) => {
|
||||
const isListingReferrer =
|
||||
listingHref === referredRelativePath ||
|
||||
listingHref === referredRelativePath + "index.html";
|
||||
return isListingReferrer;
|
||||
});
|
||||
|
||||
if (referrerListing) {
|
||||
// Try to use the referrer if possible
|
||||
activateCategories(referrerListing);
|
||||
} else if (listingHrefs.length > 0) {
|
||||
// Otherwise, just fall back to the first listing
|
||||
activateCategories(listingHrefs[0]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
if (hasTitleCategories()) {
|
||||
findAndActivateCategories();
|
||||
}
|
||||
|
||||
const findNearestParentListing = (href, listingHrefs) => {
|
||||
if (!href || !listingHrefs) {
|
||||
return undefined;
|
||||
}
|
||||
// Look up the tree for a nearby linting and use that if we find one
|
||||
const relativeParts = href.substring(1).split("/");
|
||||
while (relativeParts.length > 0) {
|
||||
const path = relativeParts.join("/");
|
||||
for (const listingHref of listingHrefs) {
|
||||
if (listingHref.startsWith(path)) {
|
||||
return listingHref;
|
||||
}
|
||||
}
|
||||
relativeParts.pop();
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const manageSidebarVisiblity = (el, placeholderDescriptor) => {
|
||||
let isVisible = true;
|
||||
let elRect;
|
||||
|
||||
return (hiddenRegions) => {
|
||||
if (el === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the last element of the TOC
|
||||
const lastChildEl = el.lastElementChild;
|
||||
|
||||
if (lastChildEl) {
|
||||
// Converts the sidebar to a menu
|
||||
const convertToMenu = () => {
|
||||
for (const child of el.children) {
|
||||
child.style.opacity = 0;
|
||||
child.style.overflow = "hidden";
|
||||
child.style.pointerEvents = "none";
|
||||
}
|
||||
|
||||
nexttick(() => {
|
||||
const toggleContainer = window.document.createElement("div");
|
||||
toggleContainer.style.width = "100%";
|
||||
toggleContainer.classList.add("zindex-over-content");
|
||||
toggleContainer.classList.add("quarto-sidebar-toggle");
|
||||
toggleContainer.classList.add("headroom-target"); // Marks this to be managed by headeroom
|
||||
toggleContainer.id = placeholderDescriptor.id;
|
||||
toggleContainer.style.position = "fixed";
|
||||
|
||||
const toggleIcon = window.document.createElement("i");
|
||||
toggleIcon.classList.add("quarto-sidebar-toggle-icon");
|
||||
toggleIcon.classList.add("bi");
|
||||
toggleIcon.classList.add("bi-caret-down-fill");
|
||||
|
||||
const toggleTitle = window.document.createElement("div");
|
||||
const titleEl = window.document.body.querySelector(
|
||||
placeholderDescriptor.titleSelector
|
||||
);
|
||||
if (titleEl) {
|
||||
toggleTitle.append(
|
||||
titleEl.textContent || titleEl.innerText,
|
||||
toggleIcon
|
||||
);
|
||||
}
|
||||
toggleTitle.classList.add("zindex-over-content");
|
||||
toggleTitle.classList.add("quarto-sidebar-toggle-title");
|
||||
toggleContainer.append(toggleTitle);
|
||||
|
||||
const toggleContents = window.document.createElement("div");
|
||||
toggleContents.classList = el.classList;
|
||||
toggleContents.classList.add("zindex-over-content");
|
||||
toggleContents.classList.add("quarto-sidebar-toggle-contents");
|
||||
for (const child of el.children) {
|
||||
if (child.id === "toc-title") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const clone = child.cloneNode(true);
|
||||
clone.style.opacity = 1;
|
||||
clone.style.pointerEvents = null;
|
||||
clone.style.display = null;
|
||||
toggleContents.append(clone);
|
||||
}
|
||||
toggleContents.style.height = "0px";
|
||||
const positionToggle = () => {
|
||||
// position the element (top left of parent, same width as parent)
|
||||
if (!elRect) {
|
||||
elRect = el.getBoundingClientRect();
|
||||
}
|
||||
toggleContainer.style.left = `${elRect.left}px`;
|
||||
toggleContainer.style.top = `${elRect.top}px`;
|
||||
toggleContainer.style.width = `${elRect.width}px`;
|
||||
};
|
||||
positionToggle();
|
||||
|
||||
toggleContainer.append(toggleContents);
|
||||
el.parentElement.prepend(toggleContainer);
|
||||
|
||||
// Process clicks
|
||||
let tocShowing = false;
|
||||
// Allow the caller to control whether this is dismissed
|
||||
// when it is clicked (e.g. sidebar navigation supports
|
||||
// opening and closing the nav tree, so don't dismiss on click)
|
||||
const clickEl = placeholderDescriptor.dismissOnClick
|
||||
? toggleContainer
|
||||
: toggleTitle;
|
||||
|
||||
const closeToggle = () => {
|
||||
if (tocShowing) {
|
||||
toggleContainer.classList.remove("expanded");
|
||||
toggleContents.style.height = "0px";
|
||||
tocShowing = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Get rid of any expanded toggle if the user scrolls
|
||||
window.document.addEventListener(
|
||||
"scroll",
|
||||
throttle(() => {
|
||||
closeToggle();
|
||||
}, 50)
|
||||
);
|
||||
|
||||
// Handle positioning of the toggle
|
||||
window.addEventListener(
|
||||
"resize",
|
||||
throttle(() => {
|
||||
elRect = undefined;
|
||||
positionToggle();
|
||||
}, 50)
|
||||
);
|
||||
|
||||
window.addEventListener("quarto-hrChanged", () => {
|
||||
elRect = undefined;
|
||||
});
|
||||
|
||||
// Process the click
|
||||
clickEl.onclick = () => {
|
||||
if (!tocShowing) {
|
||||
toggleContainer.classList.add("expanded");
|
||||
toggleContents.style.height = null;
|
||||
tocShowing = true;
|
||||
} else {
|
||||
closeToggle();
|
||||
}
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// Converts a sidebar from a menu back to a sidebar
|
||||
const convertToSidebar = () => {
|
||||
for (const child of el.children) {
|
||||
child.style.opacity = 1;
|
||||
child.style.overflow = null;
|
||||
child.style.pointerEvents = null;
|
||||
}
|
||||
|
||||
const placeholderEl = window.document.getElementById(
|
||||
placeholderDescriptor.id
|
||||
);
|
||||
if (placeholderEl) {
|
||||
placeholderEl.remove();
|
||||
}
|
||||
|
||||
el.classList.remove("rollup");
|
||||
};
|
||||
|
||||
if (isReaderMode()) {
|
||||
convertToMenu();
|
||||
isVisible = false;
|
||||
} else {
|
||||
// Find the top and bottom o the element that is being managed
|
||||
const elTop = el.offsetTop;
|
||||
const elBottom =
|
||||
elTop + lastChildEl.offsetTop + lastChildEl.offsetHeight;
|
||||
|
||||
if (!isVisible) {
|
||||
// If the element is current not visible reveal if there are
|
||||
// no conflicts with overlay regions
|
||||
if (!inHiddenRegion(elTop, elBottom, hiddenRegions)) {
|
||||
convertToSidebar();
|
||||
isVisible = true;
|
||||
}
|
||||
} else {
|
||||
// If the element is visible, hide it if it conflicts with overlay regions
|
||||
// and insert a placeholder toggle (or if we're in reader mode)
|
||||
if (inHiddenRegion(elTop, elBottom, hiddenRegions)) {
|
||||
convertToMenu();
|
||||
isVisible = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const tabEls = document.querySelectorAll('a[data-bs-toggle="tab"]');
|
||||
for (const tabEl of tabEls) {
|
||||
const id = tabEl.getAttribute("data-bs-target");
|
||||
if (id) {
|
||||
const columnEl = document.querySelector(
|
||||
`${id} .column-margin, .tabset-margin-content`
|
||||
);
|
||||
if (columnEl)
|
||||
tabEl.addEventListener("shown.bs.tab", function (event) {
|
||||
const el = event.srcElement;
|
||||
if (el) {
|
||||
const visibleCls = `${el.id}-margin-content`;
|
||||
// walk up until we find a parent tabset
|
||||
let panelTabsetEl = el.parentElement;
|
||||
while (panelTabsetEl) {
|
||||
if (panelTabsetEl.classList.contains("panel-tabset")) {
|
||||
break;
|
||||
}
|
||||
panelTabsetEl = panelTabsetEl.parentElement;
|
||||
}
|
||||
|
||||
if (panelTabsetEl) {
|
||||
const prevSib = panelTabsetEl.previousElementSibling;
|
||||
if (
|
||||
prevSib &&
|
||||
prevSib.classList.contains("tabset-margin-container")
|
||||
) {
|
||||
const childNodes = prevSib.querySelectorAll(
|
||||
".tabset-margin-content"
|
||||
);
|
||||
for (const childEl of childNodes) {
|
||||
if (childEl.classList.contains(visibleCls)) {
|
||||
childEl.classList.remove("collapse");
|
||||
} else {
|
||||
childEl.classList.add("collapse");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
layoutMarginEls();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Manage the visibility of the toc and the sidebar
|
||||
const marginScrollVisibility = manageSidebarVisiblity(marginSidebarEl, {
|
||||
id: "quarto-toc-toggle",
|
||||
titleSelector: "#toc-title",
|
||||
dismissOnClick: true,
|
||||
});
|
||||
const sidebarScrollVisiblity = manageSidebarVisiblity(sidebarEl, {
|
||||
id: "quarto-sidebarnav-toggle",
|
||||
titleSelector: ".title",
|
||||
dismissOnClick: false,
|
||||
});
|
||||
let tocLeftScrollVisibility;
|
||||
if (leftTocEl) {
|
||||
tocLeftScrollVisibility = manageSidebarVisiblity(leftTocEl, {
|
||||
id: "quarto-lefttoc-toggle",
|
||||
titleSelector: "#toc-title",
|
||||
dismissOnClick: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Find the first element that uses formatting in special columns
|
||||
const conflictingEls = window.document.body.querySelectorAll(
|
||||
'[class^="column-"], [class*=" column-"], aside, [class*="margin-caption"], [class*=" margin-caption"], [class*="margin-ref"], [class*=" margin-ref"]'
|
||||
);
|
||||
|
||||
// Filter all the possibly conflicting elements into ones
|
||||
// the do conflict on the left or ride side
|
||||
const arrConflictingEls = Array.from(conflictingEls);
|
||||
const leftSideConflictEls = arrConflictingEls.filter((el) => {
|
||||
if (el.tagName === "ASIDE") {
|
||||
return false;
|
||||
}
|
||||
return Array.from(el.classList).find((className) => {
|
||||
return (
|
||||
className !== "column-body" &&
|
||||
className.startsWith("column-") &&
|
||||
!className.endsWith("right") &&
|
||||
!className.endsWith("container") &&
|
||||
className !== "column-margin"
|
||||
);
|
||||
});
|
||||
});
|
||||
const rightSideConflictEls = arrConflictingEls.filter((el) => {
|
||||
if (el.tagName === "ASIDE") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const hasMarginCaption = Array.from(el.classList).find((className) => {
|
||||
return className == "margin-caption";
|
||||
});
|
||||
if (hasMarginCaption) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Array.from(el.classList).find((className) => {
|
||||
return (
|
||||
className !== "column-body" &&
|
||||
!className.endsWith("container") &&
|
||||
className.startsWith("column-") &&
|
||||
!className.endsWith("left")
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const kOverlapPaddingSize = 10;
|
||||
function toRegions(els) {
|
||||
return els.map((el) => {
|
||||
const boundRect = el.getBoundingClientRect();
|
||||
const top =
|
||||
boundRect.top +
|
||||
document.documentElement.scrollTop -
|
||||
kOverlapPaddingSize;
|
||||
return {
|
||||
top,
|
||||
bottom: top + el.scrollHeight + 2 * kOverlapPaddingSize,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
let hasObserved = false;
|
||||
const visibleItemObserver = (els) => {
|
||||
let visibleElements = [...els];
|
||||
const intersectionObserver = new IntersectionObserver(
|
||||
(entries, _observer) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
if (visibleElements.indexOf(entry.target) === -1) {
|
||||
visibleElements.push(entry.target);
|
||||
}
|
||||
} else {
|
||||
visibleElements = visibleElements.filter((visibleEntry) => {
|
||||
return visibleEntry !== entry;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (!hasObserved) {
|
||||
hideOverlappedSidebars();
|
||||
}
|
||||
hasObserved = true;
|
||||
},
|
||||
{}
|
||||
);
|
||||
els.forEach((el) => {
|
||||
intersectionObserver.observe(el);
|
||||
});
|
||||
|
||||
return {
|
||||
getVisibleEntries: () => {
|
||||
return visibleElements;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const rightElementObserver = visibleItemObserver(rightSideConflictEls);
|
||||
const leftElementObserver = visibleItemObserver(leftSideConflictEls);
|
||||
|
||||
const hideOverlappedSidebars = () => {
|
||||
marginScrollVisibility(toRegions(rightElementObserver.getVisibleEntries()));
|
||||
sidebarScrollVisiblity(toRegions(leftElementObserver.getVisibleEntries()));
|
||||
if (tocLeftScrollVisibility) {
|
||||
tocLeftScrollVisibility(
|
||||
toRegions(leftElementObserver.getVisibleEntries())
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
window.quartoToggleReader = () => {
|
||||
// Applies a slow class (or removes it)
|
||||
// to update the transition speed
|
||||
const slowTransition = (slow) => {
|
||||
const manageTransition = (id, slow) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) {
|
||||
if (slow) {
|
||||
el.classList.add("slow");
|
||||
} else {
|
||||
el.classList.remove("slow");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
manageTransition("TOC", slow);
|
||||
manageTransition("quarto-sidebar", slow);
|
||||
};
|
||||
const readerMode = !isReaderMode();
|
||||
setReaderModeValue(readerMode);
|
||||
|
||||
// If we're entering reader mode, slow the transition
|
||||
if (readerMode) {
|
||||
slowTransition(readerMode);
|
||||
}
|
||||
highlightReaderToggle(readerMode);
|
||||
hideOverlappedSidebars();
|
||||
|
||||
// If we're exiting reader mode, restore the non-slow transition
|
||||
if (!readerMode) {
|
||||
slowTransition(!readerMode);
|
||||
}
|
||||
};
|
||||
|
||||
const highlightReaderToggle = (readerMode) => {
|
||||
const els = document.querySelectorAll(".quarto-reader-toggle");
|
||||
if (els) {
|
||||
els.forEach((el) => {
|
||||
if (readerMode) {
|
||||
el.classList.add("reader");
|
||||
} else {
|
||||
el.classList.remove("reader");
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const setReaderModeValue = (val) => {
|
||||
if (window.location.protocol !== "file:") {
|
||||
window.localStorage.setItem("quarto-reader-mode", val);
|
||||
} else {
|
||||
localReaderMode = val;
|
||||
}
|
||||
};
|
||||
|
||||
const isReaderMode = () => {
|
||||
if (window.location.protocol !== "file:") {
|
||||
return window.localStorage.getItem("quarto-reader-mode") === "true";
|
||||
} else {
|
||||
return localReaderMode;
|
||||
}
|
||||
};
|
||||
let localReaderMode = null;
|
||||
|
||||
const tocOpenDepthStr = tocEl?.getAttribute("data-toc-expanded");
|
||||
const tocOpenDepth = tocOpenDepthStr ? Number(tocOpenDepthStr) : 1;
|
||||
|
||||
// Walk the TOC and collapse/expand nodes
|
||||
// Nodes are expanded if:
|
||||
// - they are top level
|
||||
// - they have children that are 'active' links
|
||||
// - they are directly below an link that is 'active'
|
||||
const walk = (el, depth) => {
|
||||
// Tick depth when we enter a UL
|
||||
if (el.tagName === "UL") {
|
||||
depth = depth + 1;
|
||||
}
|
||||
|
||||
// It this is active link
|
||||
let isActiveNode = false;
|
||||
if (el.tagName === "A" && el.classList.contains("active")) {
|
||||
isActiveNode = true;
|
||||
}
|
||||
|
||||
// See if there is an active child to this element
|
||||
let hasActiveChild = false;
|
||||
for (const child of el.children) {
|
||||
hasActiveChild = walk(child, depth) || hasActiveChild;
|
||||
}
|
||||
|
||||
// Process the collapse state if this is an UL
|
||||
if (el.tagName === "UL") {
|
||||
if (tocOpenDepth === -1 && depth > 1) {
|
||||
// toc-expand: false
|
||||
el.classList.add("collapse");
|
||||
} else if (
|
||||
depth <= tocOpenDepth ||
|
||||
hasActiveChild ||
|
||||
prevSiblingIsActiveLink(el)
|
||||
) {
|
||||
el.classList.remove("collapse");
|
||||
} else {
|
||||
el.classList.add("collapse");
|
||||
}
|
||||
|
||||
// untick depth when we leave a UL
|
||||
depth = depth - 1;
|
||||
}
|
||||
return hasActiveChild || isActiveNode;
|
||||
};
|
||||
|
||||
// walk the TOC and expand / collapse any items that should be shown
|
||||
if (tocEl) {
|
||||
updateActiveLink();
|
||||
walk(tocEl, 0);
|
||||
}
|
||||
|
||||
// Throttle the scroll event and walk peridiocally
|
||||
window.document.addEventListener(
|
||||
"scroll",
|
||||
throttle(() => {
|
||||
if (tocEl) {
|
||||
updateActiveLink();
|
||||
walk(tocEl, 0);
|
||||
}
|
||||
if (!isReaderMode()) {
|
||||
hideOverlappedSidebars();
|
||||
}
|
||||
}, 5)
|
||||
);
|
||||
window.addEventListener(
|
||||
"resize",
|
||||
throttle(() => {
|
||||
if (tocEl) {
|
||||
updateActiveLink();
|
||||
walk(tocEl, 0);
|
||||
}
|
||||
if (!isReaderMode()) {
|
||||
hideOverlappedSidebars();
|
||||
}
|
||||
}, 10)
|
||||
);
|
||||
hideOverlappedSidebars();
|
||||
highlightReaderToggle(isReaderMode());
|
||||
});
|
||||
|
||||
tabsets.init();
|
||||
|
||||
function throttle(func, wait) {
|
||||
let waiting = false;
|
||||
return function () {
|
||||
if (!waiting) {
|
||||
func.apply(this, arguments);
|
||||
waiting = true;
|
||||
setTimeout(function () {
|
||||
waiting = false;
|
||||
}, wait);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function nexttick(func) {
|
||||
return setTimeout(func, 0);
|
||||
}
|
||||
95
pmoaudio/README_files/libs/quarto-html/tabsets/tabsets.js
Normal file
95
pmoaudio/README_files/libs/quarto-html/tabsets/tabsets.js
Normal file
@@ -0,0 +1,95 @@
|
||||
// grouped tabsets
|
||||
|
||||
export function init() {
|
||||
window.addEventListener("pageshow", (_event) => {
|
||||
function getTabSettings() {
|
||||
const data = localStorage.getItem("quarto-persistent-tabsets-data");
|
||||
if (!data) {
|
||||
localStorage.setItem("quarto-persistent-tabsets-data", "{}");
|
||||
return {};
|
||||
}
|
||||
if (data) {
|
||||
return JSON.parse(data);
|
||||
}
|
||||
}
|
||||
|
||||
function setTabSettings(data) {
|
||||
localStorage.setItem(
|
||||
"quarto-persistent-tabsets-data",
|
||||
JSON.stringify(data)
|
||||
);
|
||||
}
|
||||
|
||||
function setTabState(groupName, groupValue) {
|
||||
const data = getTabSettings();
|
||||
data[groupName] = groupValue;
|
||||
setTabSettings(data);
|
||||
}
|
||||
|
||||
function toggleTab(tab, active) {
|
||||
const tabPanelId = tab.getAttribute("aria-controls");
|
||||
const tabPanel = document.getElementById(tabPanelId);
|
||||
if (active) {
|
||||
tab.classList.add("active");
|
||||
tabPanel.classList.add("active");
|
||||
} else {
|
||||
tab.classList.remove("active");
|
||||
tabPanel.classList.remove("active");
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAll(selectedGroup, selectorsToSync) {
|
||||
for (const [thisGroup, tabs] of Object.entries(selectorsToSync)) {
|
||||
const active = selectedGroup === thisGroup;
|
||||
for (const tab of tabs) {
|
||||
toggleTab(tab, active);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findSelectorsToSyncByLanguage() {
|
||||
const result = {};
|
||||
const tabs = Array.from(
|
||||
document.querySelectorAll(`div[data-group] a[id^='tabset-']`)
|
||||
);
|
||||
for (const item of tabs) {
|
||||
const div = item.parentElement.parentElement.parentElement;
|
||||
const group = div.getAttribute("data-group");
|
||||
if (!result[group]) {
|
||||
result[group] = {};
|
||||
}
|
||||
const selectorsToSync = result[group];
|
||||
const value = item.innerHTML;
|
||||
if (!selectorsToSync[value]) {
|
||||
selectorsToSync[value] = [];
|
||||
}
|
||||
selectorsToSync[value].push(item);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function setupSelectorSync() {
|
||||
const selectorsToSync = findSelectorsToSyncByLanguage();
|
||||
Object.entries(selectorsToSync).forEach(([group, tabSetsByValue]) => {
|
||||
Object.entries(tabSetsByValue).forEach(([value, items]) => {
|
||||
items.forEach((item) => {
|
||||
item.addEventListener("click", (_event) => {
|
||||
setTabState(group, value);
|
||||
toggleAll(value, selectorsToSync[group]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
return selectorsToSync;
|
||||
}
|
||||
|
||||
const selectorsToSync = setupSelectorSync();
|
||||
for (const [group, selectedName] of Object.entries(getTabSettings())) {
|
||||
const selectors = selectorsToSync[group];
|
||||
// it's possible that stale state gives us empty selections, so we explicitly check here.
|
||||
if (selectors) {
|
||||
toggleAll(selectedName, selectors);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
1
pmoaudio/README_files/libs/quarto-html/tippy.css
Normal file
1
pmoaudio/README_files/libs/quarto-html/tippy.css
Normal file
@@ -0,0 +1 @@
|
||||
.tippy-box[data-animation=fade][data-state=hidden]{opacity:0}[data-tippy-root]{max-width:calc(100vw - 10px)}.tippy-box{position:relative;background-color:#333;color:#fff;border-radius:4px;font-size:14px;line-height:1.4;white-space:normal;outline:0;transition-property:transform,visibility,opacity}.tippy-box[data-placement^=top]>.tippy-arrow{bottom:0}.tippy-box[data-placement^=top]>.tippy-arrow:before{bottom:-7px;left:0;border-width:8px 8px 0;border-top-color:initial;transform-origin:center top}.tippy-box[data-placement^=bottom]>.tippy-arrow{top:0}.tippy-box[data-placement^=bottom]>.tippy-arrow:before{top:-7px;left:0;border-width:0 8px 8px;border-bottom-color:initial;transform-origin:center bottom}.tippy-box[data-placement^=left]>.tippy-arrow{right:0}.tippy-box[data-placement^=left]>.tippy-arrow:before{border-width:8px 0 8px 8px;border-left-color:initial;right:-7px;transform-origin:center left}.tippy-box[data-placement^=right]>.tippy-arrow{left:0}.tippy-box[data-placement^=right]>.tippy-arrow:before{left:-7px;border-width:8px 8px 8px 0;border-right-color:initial;transform-origin:center right}.tippy-box[data-inertia][data-state=visible]{transition-timing-function:cubic-bezier(.54,1.5,.38,1.11)}.tippy-arrow{width:16px;height:16px;color:#333}.tippy-arrow:before{content:"";position:absolute;border-color:transparent;border-style:solid}.tippy-content{position:relative;padding:5px 9px;z-index:1}
|
||||
2
pmoaudio/README_files/libs/quarto-html/tippy.umd.min.js
vendored
Normal file
2
pmoaudio/README_files/libs/quarto-html/tippy.umd.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
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
Normal file
208
pmoaudio/examples/audio_chunk_api.rs
Normal 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.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.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.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!();
|
||||
}
|
||||
68
pmoaudio/examples/file_nodes_test.rs
Normal file
68
pmoaudio/examples/file_nodes_test.rs
Normal 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(())
|
||||
}
|
||||
80
pmoaudio/examples/multiroom_demo.rs
Normal file
80
pmoaudio/examples/multiroom_demo.rs
Normal file
@@ -0,0 +1,80 @@
|
||||
//! Exemple de configuration multiroom avec BufferNode
|
||||
//!
|
||||
//! Démontre l'utilisation du buffer circulaire pour synchroniser
|
||||
//! plusieurs sorties avec des délais différents
|
||||
|
||||
use pmoaudio::{BufferNode, SinkNode, SourceNode};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
println!("=== Multiroom Demo ===\n");
|
||||
|
||||
// Buffer avec capacité pour gérer les délais
|
||||
let (buffer, buffer_tx) = BufferNode::new(50, 10);
|
||||
|
||||
// Créer 3 sorties avec délais différents
|
||||
let (sink1, sink1_tx) = SinkNode::new("Room 1 (no delay)".to_string(), 10);
|
||||
let (sink2, sink2_tx) = SinkNode::new("Room 2 (5 chunks delay)".to_string(), 10);
|
||||
let (sink3, sink3_tx) = SinkNode::new("Room 3 (10 chunks delay)".to_string(), 10);
|
||||
|
||||
buffer.add_subscriber_with_offset(sink1_tx, 0).await;
|
||||
buffer.add_subscriber_with_offset(sink2_tx, 5).await;
|
||||
buffer.add_subscriber_with_offset(sink3_tx, 10).await;
|
||||
|
||||
// Spawn buffer et sinks
|
||||
tokio::spawn(async move {
|
||||
buffer.run().await.unwrap();
|
||||
});
|
||||
|
||||
let sink1_handle = tokio::spawn(async move {
|
||||
let stats = sink1.run_with_stats().await.unwrap();
|
||||
stats.display();
|
||||
stats
|
||||
});
|
||||
|
||||
let sink2_handle = tokio::spawn(async move {
|
||||
let stats = sink2.run_with_stats().await.unwrap();
|
||||
stats.display();
|
||||
stats
|
||||
});
|
||||
|
||||
let sink3_handle = tokio::spawn(async move {
|
||||
let stats = sink3.run_with_stats().await.unwrap();
|
||||
stats.display();
|
||||
stats
|
||||
});
|
||||
|
||||
// Générer de l'audio dans une tâche séparée
|
||||
println!("Generating audio for multiroom playback...\n");
|
||||
tokio::spawn(async move {
|
||||
let mut source = SourceNode::new();
|
||||
source.add_subscriber(buffer_tx);
|
||||
source
|
||||
.generate_chunks(30, 4800, 48000, 440.0)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
println!("Waiting for all rooms to finish...\n");
|
||||
|
||||
// Attendre toutes les sorties
|
||||
let stats1 = sink1_handle.await.unwrap();
|
||||
let stats2 = sink2_handle.await.unwrap();
|
||||
let stats3 = sink3_handle.await.unwrap();
|
||||
|
||||
println!("\n=== Multiroom Summary ===");
|
||||
println!(
|
||||
"{}: {} chunks received",
|
||||
stats1.name, stats1.chunks_received
|
||||
);
|
||||
println!(
|
||||
"{}: {} chunks received",
|
||||
stats2.name, stats2.chunks_received
|
||||
);
|
||||
println!(
|
||||
"{}: {} chunks received",
|
||||
stats3.name, stats3.chunks_received
|
||||
);
|
||||
|
||||
println!("\nNote: Delayed rooms receive fewer chunks due to the offset");
|
||||
}
|
||||
168
pmoaudio/examples/multiroom_volume_demo.rs
Normal file
168
pmoaudio/examples/multiroom_volume_demo.rs
Normal file
@@ -0,0 +1,168 @@
|
||||
//! Exemple complet de pipeline multiroom avec contrôle de volume
|
||||
//!
|
||||
//! Ce programme démontre :
|
||||
//! - Une source audio unique
|
||||
//! - Deux branches de sortie : Chromecast et DiskSink
|
||||
//! - Un volume master avec deux VolumeNodes secondaires synchronisés
|
||||
//! - Système d'événements pour la communication entre nodes
|
||||
|
||||
use pmoaudio::{
|
||||
ChromecastConfig, ChromecastSink, DiskSink, DiskSinkConfig, SourceNode, VolumeNode,
|
||||
};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("=== PMOAudio Multiroom Volume Demo ===\n");
|
||||
|
||||
// Configuration
|
||||
let sample_rate = 48000u32;
|
||||
let chunk_size = 4800usize; // 100ms à 48kHz
|
||||
let num_chunks = 50; // 5 secondes de lecture
|
||||
let frequency = 440.0; // La 440 Hz
|
||||
|
||||
// ===== 1. Créer la source audio =====
|
||||
println!("1. Creating audio source...");
|
||||
let mut source = SourceNode::new();
|
||||
|
||||
// ===== 2. Créer le volume master =====
|
||||
println!("2. Creating master volume node...");
|
||||
let (mut master_volume, master_tx) = VolumeNode::new("master".to_string(), 1.0, 50);
|
||||
let master_handle = master_volume.get_handle();
|
||||
|
||||
// Channel pour les événements du volume master
|
||||
let (master_event_tx, master_event_rx_chromecast) = mpsc::channel(10);
|
||||
let (_, master_event_rx_disk) = mpsc::channel(10);
|
||||
|
||||
master_volume.subscribe_volume_events(master_event_tx);
|
||||
|
||||
source.add_subscriber(master_tx);
|
||||
|
||||
// ===== 3. Créer les branches de sortie =====
|
||||
|
||||
// Branche 1: Chromecast avec volume secondaire
|
||||
println!("3a. Creating Chromecast output branch...");
|
||||
let (mut chromecast_volume, chromecast_volume_tx) =
|
||||
VolumeNode::new("chromecast_volume".to_string(), 0.8, 50);
|
||||
|
||||
chromecast_volume.set_master_volume_source(master_event_rx_chromecast);
|
||||
|
||||
let chromecast_config = ChromecastConfig {
|
||||
device_address: "192.168.1.100".to_string(),
|
||||
device_name: "Living Room".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let (chromecast_sink, chromecast_sink_tx) =
|
||||
ChromecastSink::new("chromecast1".to_string(), chromecast_config, 50);
|
||||
|
||||
chromecast_volume.add_subscriber(chromecast_sink_tx);
|
||||
master_volume.add_subscriber(chromecast_volume_tx);
|
||||
|
||||
// Branche 2: DiskSink avec volume secondaire
|
||||
println!("3b. Creating DiskSink output branch...");
|
||||
let (mut disk_volume, disk_volume_tx) = VolumeNode::new("disk_volume".to_string(), 0.9, 50);
|
||||
|
||||
disk_volume.set_master_volume_source(master_event_rx_disk);
|
||||
|
||||
let disk_config = DiskSinkConfig {
|
||||
output_dir: std::env::temp_dir().join("pmoaudio_demo"),
|
||||
filename: Some("multiroom_output.wav".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let (disk_sink, disk_sink_tx) = DiskSink::new("disk1".to_string(), disk_config, 50);
|
||||
|
||||
disk_volume.add_subscriber(disk_sink_tx);
|
||||
master_volume.add_subscriber(disk_volume_tx);
|
||||
|
||||
// ===== 4. Lancer tous les nodes =====
|
||||
println!("4. Starting pipeline nodes...\n");
|
||||
|
||||
// Spawn master volume
|
||||
let master_volume_handle = tokio::spawn(async move {
|
||||
master_volume.run().await.unwrap();
|
||||
});
|
||||
|
||||
// Spawn chromecast branch
|
||||
let chromecast_volume_handle = tokio::spawn(async move {
|
||||
chromecast_volume.run().await.unwrap();
|
||||
});
|
||||
|
||||
let chromecast_sink_handle = tokio::spawn(async move {
|
||||
let stats = chromecast_sink.run().await.unwrap();
|
||||
stats.display();
|
||||
});
|
||||
|
||||
// Spawn disk branch
|
||||
let disk_volume_handle = tokio::spawn(async move {
|
||||
disk_volume.run().await.unwrap();
|
||||
});
|
||||
|
||||
let disk_sink_handle = tokio::spawn(async move {
|
||||
let stats = disk_sink.run().await.unwrap();
|
||||
stats.display();
|
||||
});
|
||||
|
||||
// ===== 5. Contrôler le volume pendant la lecture =====
|
||||
let master_handle_clone = master_handle.clone();
|
||||
tokio::spawn(async move {
|
||||
// Attendre un peu, puis diminuer le volume
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
||||
println!("\n>>> Decreasing master volume to 0.7");
|
||||
master_handle_clone.set_volume(0.7).await;
|
||||
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
||||
println!(">>> Decreasing master volume to 0.4");
|
||||
master_handle_clone.set_volume(0.4).await;
|
||||
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
||||
println!(">>> Increasing master volume back to 1.0");
|
||||
master_handle_clone.set_volume(1.0).await;
|
||||
});
|
||||
|
||||
// ===== 6. Générer et envoyer les chunks audio =====
|
||||
println!("5. Generating and streaming audio...");
|
||||
tokio::spawn(async move {
|
||||
source
|
||||
.generate_chunks(num_chunks, chunk_size, sample_rate, frequency)
|
||||
.await
|
||||
.unwrap();
|
||||
println!("\n>>> Audio generation complete!");
|
||||
});
|
||||
|
||||
// ===== 7. Attendre la fin de tous les nodes =====
|
||||
println!("6. Waiting for all nodes to complete...\n");
|
||||
|
||||
// Attendre que les sinks terminent
|
||||
chromecast_sink_handle.await?;
|
||||
disk_sink_handle.await?;
|
||||
|
||||
// Nettoyer
|
||||
master_volume_handle.abort();
|
||||
chromecast_volume_handle.abort();
|
||||
disk_volume_handle.abort();
|
||||
|
||||
println!("\n=== Demo completed successfully! ===");
|
||||
println!("\nSummary:");
|
||||
println!(
|
||||
"- Generated {} chunks of {} samples each",
|
||||
num_chunks, chunk_size
|
||||
);
|
||||
println!(
|
||||
"- Total duration: {:.2} seconds",
|
||||
(num_chunks as usize * chunk_size) as f32 / sample_rate as f32
|
||||
);
|
||||
println!("- Output to Chromecast: Living Room (192.168.1.100)");
|
||||
println!(
|
||||
"- Output to file: {}",
|
||||
std::env::temp_dir()
|
||||
.join("pmoaudio_demo")
|
||||
.join("multiroom_output.wav")
|
||||
.display()
|
||||
);
|
||||
println!("- Master volume control demonstrated with live changes");
|
||||
println!("\nAll streams received synchronized volume updates!");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
126
pmoaudio/examples/pipeline_demo.rs
Normal file
126
pmoaudio/examples/pipeline_demo.rs
Normal file
@@ -0,0 +1,126 @@
|
||||
//! Exemple de pipeline audio stéréo complet avec tous les nodes
|
||||
//!
|
||||
//! Pipeline: SourceNode → DecoderNode → DspNode → BufferNode → TimerNode → SinkNode(s)
|
||||
|
||||
use pmoaudio::{BufferNode, DecoderNode, DspNode, SinkNode, SourceNode, TimerNode};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
println!("=== PMOAudio Pipeline Demo ===\n");
|
||||
|
||||
// Créer le pipeline de nodes
|
||||
|
||||
// 2. DecoderNode - passthrough dans cet exemple
|
||||
let (mut decoder, decoder_tx) = DecoderNode::new(10);
|
||||
|
||||
// 3. DspNode - applique un gain de 0.5
|
||||
let (mut dsp, dsp_tx) = DspNode::new(10, 0.5);
|
||||
|
||||
// 4. BufferNode - buffer circulaire pour multiroom
|
||||
let (mut buffer, buffer_tx) = BufferNode::new(100, 10);
|
||||
|
||||
// 5. TimerNode - calcule la position temporelle
|
||||
let (mut timer, timer_tx) = TimerNode::new(10);
|
||||
|
||||
// 6. SinkNodes - deux destinations finales
|
||||
let (sink1, sink1_tx) = SinkNode::new("Main Output".to_string(), 10);
|
||||
let (sink2, sink2_tx) = SinkNode::new("Secondary Output".to_string(), 10);
|
||||
|
||||
// Ajouter un abonné au BufferNode avec offset (multiroom simulation)
|
||||
let (sink3, sink3_tx) = SinkNode::new("Delayed Output".to_string(), 10);
|
||||
buffer.add_subscriber_with_offset(sink3_tx, 5).await; // 5 chunks de retard
|
||||
|
||||
// Connecter le pipeline
|
||||
decoder.add_subscriber(dsp_tx);
|
||||
dsp.add_subscriber(buffer_tx);
|
||||
buffer.add_next_subscriber(timer_tx); // BufferNode -> TimerNode
|
||||
timer.add_subscriber(sink1_tx);
|
||||
timer.add_subscriber(sink2_tx);
|
||||
|
||||
// Obtenir un handle pour lire la position du TimerNode
|
||||
let timer_handle = timer.get_position_handle();
|
||||
|
||||
// Spawn tous les nodes
|
||||
let decoder_handle = tokio::spawn(async move {
|
||||
decoder.run_passthrough().await.unwrap();
|
||||
});
|
||||
|
||||
let dsp_handle = tokio::spawn(async move {
|
||||
dsp.run().await.unwrap();
|
||||
});
|
||||
|
||||
let buffer_handle = tokio::spawn(async move {
|
||||
buffer.run().await.unwrap();
|
||||
});
|
||||
|
||||
let timer_handle_task = tokio::spawn(async move {
|
||||
timer.run().await.unwrap();
|
||||
});
|
||||
|
||||
let sink1_handle = tokio::spawn(async move {
|
||||
let stats = sink1.run_with_stats().await.unwrap();
|
||||
stats.display();
|
||||
stats
|
||||
});
|
||||
|
||||
let sink2_handle = tokio::spawn(async move {
|
||||
sink2.run_silent().await.unwrap();
|
||||
});
|
||||
|
||||
let sink3_handle = tokio::spawn(async move {
|
||||
let stats = sink3.run_with_stats().await.unwrap();
|
||||
stats.display();
|
||||
stats
|
||||
});
|
||||
|
||||
// Spawn une tâche pour afficher la position périodiquement
|
||||
let position_monitor = tokio::spawn(async move {
|
||||
for _ in 0..10 {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
|
||||
let position = timer_handle.position_sec().await;
|
||||
let samples = timer_handle.elapsed_samples().await;
|
||||
println!("Position: {:.3} sec ({} samples)", position, samples);
|
||||
}
|
||||
});
|
||||
|
||||
// Générer des chunks audio
|
||||
println!("Generating audio chunks...\n");
|
||||
let chunk_size = 4800; // 100ms à 48kHz
|
||||
let sample_rate = 48000;
|
||||
let frequency = 440.0; // La 440Hz
|
||||
|
||||
// Source node dans une tâche séparée
|
||||
tokio::spawn(async move {
|
||||
let mut source = SourceNode::new();
|
||||
source.add_subscriber(decoder_tx);
|
||||
|
||||
// Générer 50 chunks (environ 5 secondes)
|
||||
source
|
||||
.generate_chunks(50, chunk_size, sample_rate, frequency)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
println!("\nChunks sent. Processing...\n");
|
||||
});
|
||||
|
||||
// Attendre que tous les nodes terminent
|
||||
decoder_handle.await.unwrap();
|
||||
dsp_handle.await.unwrap();
|
||||
buffer_handle.await.unwrap();
|
||||
timer_handle_task.await.unwrap();
|
||||
|
||||
let stats1 = sink1_handle.await.unwrap();
|
||||
sink2_handle.await.unwrap();
|
||||
let stats3 = sink3_handle.await.unwrap();
|
||||
position_monitor.await.unwrap();
|
||||
|
||||
println!("\n=== Pipeline Demo Complete ===");
|
||||
println!(
|
||||
"Main output processed: {} chunks, {:.3} sec",
|
||||
stats1.chunks_received, stats1.total_duration_sec
|
||||
);
|
||||
println!(
|
||||
"Delayed output processed: {} chunks, {:.3} sec",
|
||||
stats3.chunks_received, stats3.total_duration_sec
|
||||
);
|
||||
}
|
||||
96
pmoaudio/examples/quick_start.rs
Normal file
96
pmoaudio/examples/quick_start.rs
Normal file
@@ -0,0 +1,96 @@
|
||||
//! Quick Start - Démonstration rapide des nouvelles fonctionnalités
|
||||
//!
|
||||
//! Cet exemple montre l'utilisation des principales nouvelles fonctionnalités :
|
||||
//! - VolumeNode avec contrôle dynamique
|
||||
//! - DiskSink pour écriture sur disque
|
||||
//! - Pipeline simple et efficace
|
||||
|
||||
use pmoaudio::{AudioFileFormat, DiskSink, DiskSinkConfig, SourceNode, VolumeNode};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("=== PMOAudio Quick Start ===\n");
|
||||
|
||||
// 1. Créer la source audio (génère un signal de test)
|
||||
let mut source = SourceNode::new();
|
||||
|
||||
// 2. Créer un VolumeNode pour contrôler le volume
|
||||
let (mut volume, volume_tx) = VolumeNode::new("main".to_string(), 0.8, 10);
|
||||
let volume_handle = volume.get_handle();
|
||||
|
||||
// 3. Créer un DiskSink pour écrire sur disque
|
||||
let output_dir = std::env::temp_dir().join("pmoaudio_quickstart");
|
||||
let config = DiskSinkConfig {
|
||||
output_dir: output_dir.clone(),
|
||||
filename: Some("quickstart_output.wav".to_string()),
|
||||
format: AudioFileFormat::Wav,
|
||||
buffer_size: 50,
|
||||
};
|
||||
|
||||
let (disk_sink, disk_tx) = DiskSink::new("disk".to_string(), config, 10);
|
||||
|
||||
// 4. Connecter le pipeline : Source → Volume → DiskSink
|
||||
source.add_subscriber(volume_tx);
|
||||
volume.add_subscriber(disk_tx);
|
||||
|
||||
println!("Pipeline configured:");
|
||||
println!(" SourceNode → VolumeNode (vol=0.8) → DiskSink");
|
||||
println!(" Output: {}/quickstart_output.wav\n", output_dir.display());
|
||||
|
||||
// 5. Lancer les nodes
|
||||
let volume_handle_clone = volume_handle.clone();
|
||||
tokio::spawn(async move {
|
||||
volume.run().await.unwrap();
|
||||
});
|
||||
|
||||
let disk_handle = tokio::spawn(async move {
|
||||
let stats = disk_sink.run().await.unwrap();
|
||||
println!("\nDiskSink Statistics:");
|
||||
stats.display();
|
||||
stats
|
||||
});
|
||||
|
||||
// 6. Démonstration du contrôle de volume pendant la lecture
|
||||
tokio::spawn(async move {
|
||||
println!("Generating audio with volume changes...");
|
||||
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||
println!(" → Volume: 0.8 (initial)");
|
||||
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
|
||||
volume_handle_clone.set_volume(0.5).await;
|
||||
println!(" → Volume: 0.5 (decreased)");
|
||||
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
|
||||
volume_handle_clone.set_volume(1.0).await;
|
||||
println!(" → Volume: 1.0 (maximum)");
|
||||
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
|
||||
volume_handle_clone.set_volume(0.3).await;
|
||||
println!(" → Volume: 0.3 (low)");
|
||||
});
|
||||
|
||||
// 7. Générer l'audio (10 chunks de 4800 samples à 48kHz = ~1 seconde)
|
||||
source
|
||||
.generate_chunks(
|
||||
10, // nombre de chunks
|
||||
4800, // samples par chunk (100ms @ 48kHz)
|
||||
48000, // sample rate
|
||||
440.0, // fréquence (La 440 Hz)
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 8. Attendre la fin du traitement
|
||||
let stats = disk_handle.await?;
|
||||
|
||||
// 9. Résumé
|
||||
println!("\n=== Summary ===");
|
||||
println!("✓ Audio file generated successfully");
|
||||
println!("✓ {} chunks written", stats.chunks_written);
|
||||
println!("✓ Duration: {:.2} seconds", stats.total_duration_sec);
|
||||
println!("✓ Volume was dynamically adjusted during playback");
|
||||
println!("\nYou can play the file with:");
|
||||
println!(" ffplay {}/quickstart_output.wav", output_dir.display());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
53
pmoaudio/examples/simple_pipeline.rs
Normal file
53
pmoaudio/examples/simple_pipeline.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
//! Exemple simple de pipeline audio : Source → Timer → Sink
|
||||
//!
|
||||
//! Démontre l'utilisation basique du pipeline avec calcul de position
|
||||
|
||||
use pmoaudio::{SinkNode, SourceNode, TimerNode};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
println!("=== Simple Pipeline Example ===\n");
|
||||
|
||||
// Créer les nodes
|
||||
let (mut timer, timer_tx) = TimerNode::new(10);
|
||||
let (sink, sink_tx) = SinkNode::new("Output".to_string(), 10);
|
||||
|
||||
// Connecter
|
||||
timer.add_subscriber(sink_tx);
|
||||
|
||||
// Handle pour monitorer la position
|
||||
let timer_handle = timer.get_position_handle();
|
||||
|
||||
// Spawn timer et sink
|
||||
tokio::spawn(async move {
|
||||
timer.run().await.unwrap();
|
||||
});
|
||||
|
||||
let sink_handle = tokio::spawn(async move {
|
||||
let stats = sink.run_with_stats().await.unwrap();
|
||||
stats.display();
|
||||
stats
|
||||
});
|
||||
|
||||
// Générer quelques secondes d'audio dans une tâche séparée
|
||||
println!("Generating 440Hz sine wave...\n");
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut source = SourceNode::new();
|
||||
source.add_subscriber(timer_tx);
|
||||
|
||||
source
|
||||
.generate_chunks(30, 4800, 48000, 440.0) // ~3 secondes
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// La source est drop ici, fermant le channel
|
||||
});
|
||||
|
||||
// Attendre la fin
|
||||
let stats = sink_handle.await.unwrap();
|
||||
|
||||
let final_position = timer_handle.position_sec().await;
|
||||
println!("\nFinal position: {:.3} seconds", final_position);
|
||||
println!("Total duration: {:.3} seconds", stats.total_duration_sec);
|
||||
}
|
||||
52
pmoaudio/examples/streaming_demo.rs
Normal file
52
pmoaudio/examples/streaming_demo.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
//! Exemple de streaming audio en temps réel
|
||||
//!
|
||||
//! Démontre l'utilisation du pipeline avec génération de chunks
|
||||
//! en temps réel avec timing approprié
|
||||
|
||||
use pmoaudio::{SinkNode, SourceNode, TimerNode};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
println!("=== Streaming Demo ===\n");
|
||||
println!("Streaming audio in real-time for 3 seconds...\n");
|
||||
|
||||
let mut source = SourceNode::new();
|
||||
let (mut timer, timer_tx) = TimerNode::new(20);
|
||||
let (sink, sink_tx) = SinkNode::new("Streaming Output".to_string(), 20);
|
||||
|
||||
source.add_subscriber(timer_tx);
|
||||
timer.add_subscriber(sink_tx);
|
||||
|
||||
let timer_handle = timer.get_position_handle();
|
||||
|
||||
// Spawn le pipeline
|
||||
tokio::spawn(async move {
|
||||
timer.run().await.unwrap();
|
||||
});
|
||||
|
||||
let sink_handle = tokio::spawn(async move {
|
||||
sink.run_with_logging().await.unwrap();
|
||||
});
|
||||
|
||||
// Monitor la position
|
||||
let monitor_handle = tokio::spawn(async move {
|
||||
for _ in 0..15 {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
|
||||
let position = timer_handle.position_sec().await;
|
||||
println!("Playback position: {:.3} sec", position);
|
||||
}
|
||||
});
|
||||
|
||||
// Stream des chunks avec timing réel
|
||||
// 100ms par chunk à 48kHz = 4800 samples
|
||||
source
|
||||
.stream_chunks(4800, 48000, 440.0, 3000) // 3 secondes
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
println!("\nStreaming complete.");
|
||||
|
||||
// Attendre la fin
|
||||
sink_handle.await.unwrap();
|
||||
monitor_handle.await.unwrap();
|
||||
}
|
||||
58
pmoaudio/examples/volume_control_demo.rs
Normal file
58
pmoaudio/examples/volume_control_demo.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
//! Exemple simple de contrôle de volume
|
||||
//!
|
||||
//! Démontre l'utilisation du VolumeNode avec changements dynamiques
|
||||
|
||||
use pmoaudio::{SinkNode, SourceNode, VolumeNode};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("=== Volume Control Demo ===\n");
|
||||
|
||||
// Créer la source
|
||||
let mut source = SourceNode::new();
|
||||
|
||||
// Créer le volume node
|
||||
let (mut volume, volume_tx) = VolumeNode::new("main".to_string(), 1.0, 10);
|
||||
let volume_handle = volume.get_handle();
|
||||
|
||||
// Créer le sink
|
||||
let (sink, sink_tx) = SinkNode::new("Output".to_string(), 10);
|
||||
|
||||
// Connecter le pipeline
|
||||
source.add_subscriber(volume_tx);
|
||||
volume.add_subscriber(sink_tx);
|
||||
|
||||
// Lancer les nodes
|
||||
tokio::spawn(async move { volume.run().await.unwrap() });
|
||||
|
||||
let sink_handle = tokio::spawn(async move { sink.run_with_stats().await.unwrap() });
|
||||
|
||||
// Contrôler le volume pendant la lecture
|
||||
let volume_control = tokio::spawn(async move {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
|
||||
println!("Setting volume to 0.5");
|
||||
volume_handle.set_volume(0.5).await;
|
||||
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
|
||||
println!("Setting volume to 0.2");
|
||||
volume_handle.set_volume(0.2).await;
|
||||
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
|
||||
println!("Setting volume to 1.0");
|
||||
volume_handle.set_volume(1.0).await;
|
||||
});
|
||||
|
||||
// Générer l'audio
|
||||
source
|
||||
.generate_chunks(20, 4800, 48000, 440.0)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
volume_control.await?;
|
||||
let stats = sink_handle.await?;
|
||||
|
||||
println!("\nFinal statistics:");
|
||||
stats.display();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
921
pmoaudio/src/audio_chunk.rs
Normal file
921
pmoaudio/src/audio_chunk.rs
Normal file
@@ -0,0 +1,921 @@
|
||||
//! AudioChunk : Représentation générique de données audio stéréo
|
||||
//!
|
||||
//! Cette nouvelle architecture supporte différents types de samples :
|
||||
//! - Entiers : i16, I24 (24-bit), i32
|
||||
//! - Flottants : f32, f64
|
||||
//!
|
||||
//! L'utilisation de génériques permet de factoriser le code tout en gardant
|
||||
//! des performances optimales grâce à la monomorphisation.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{dsp, BitDepth, Sample, I24};
|
||||
|
||||
// ============================================================================
|
||||
// AudioChunkData<T> : Structure générique pour un chunk audio typé
|
||||
// ============================================================================
|
||||
|
||||
/// Représente un chunk audio stéréo typé avec partage zero-copy via Arc
|
||||
///
|
||||
/// Cette structure générique encapsule des données audio de n'importe quel type
|
||||
/// de sample (i16, I24, i32, f32, f64). Les données sont partagées via `Arc`
|
||||
/// pour permettre un partage efficace entre plusieurs consumers sans copier.
|
||||
///
|
||||
/// # Optimisation zero-copy
|
||||
///
|
||||
/// - Le clonage d'un `AudioChunkData` ne clone que le pointeur Arc (très rapide)
|
||||
/// - Les données audio réelles ne sont jamais copiées tant qu'on ne modifie pas
|
||||
/// - Plusieurs nodes peuvent partager le même chunk simultanément
|
||||
///
|
||||
/// # Gain
|
||||
///
|
||||
/// Le gain est stocké en décibels (dB) et n'est pas appliqué aux données tant
|
||||
/// qu'on n'appelle pas explicitement `apply_gain()`. Cela permet de propager
|
||||
/// des changements de gain sans recopier les données.
|
||||
///
|
||||
/// # Exemples
|
||||
///
|
||||
/// ```
|
||||
/// use pmoaudio::{AudioChunkData, I24};
|
||||
///
|
||||
/// // Créer un chunk I24
|
||||
/// let stereo = vec![[I24::new(1_000_000).unwrap(), I24::new(500_000).unwrap()]; 1000];
|
||||
/// let chunk = AudioChunkData::new(stereo, 48_000, 0.0);
|
||||
///
|
||||
/// assert_eq!(chunk.len(), 1000);
|
||||
/// assert_eq!(chunk.sample_rate(), 48_000);
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AudioChunkData<T: Sample> {
|
||||
/// Frames stéréo [L, R], partagées et immuables via Arc
|
||||
stereo: Arc<[[T; 2]]>,
|
||||
|
||||
/// Taux d'échantillonnage en Hz (44100, 48000, 96000, 192000, etc.)
|
||||
sample_rate: u32,
|
||||
|
||||
/// Gain appliqué au flux audio, en décibels (dB)
|
||||
///
|
||||
/// Conversion : `gain_linear = 10^(gain_db / 20)`
|
||||
/// Valeur par défaut : `0.0 dB` (aucune modification)
|
||||
/// Exemples : `-6 dB` ≈ moitié du volume ; `+6 dB` ≈ double
|
||||
gain_db: f64,
|
||||
}
|
||||
|
||||
impl<T: Sample> AudioChunkData<T> {
|
||||
/// Crée un nouveau chunk audio
|
||||
///
|
||||
/// Les vecteurs sont automatiquement wrappés dans `Arc`.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `stereo` - Frames stéréo `[L, R]`
|
||||
/// * `sample_rate` - Taux d'échantillonnage en Hz
|
||||
/// * `gain_db` - Gain initial en décibels (0.0 = unity gain)
|
||||
///
|
||||
/// # Exemples
|
||||
///
|
||||
/// ```
|
||||
/// use pmoaudio::AudioChunkData;
|
||||
///
|
||||
/// let chunk = AudioChunkData::new(
|
||||
/// vec![[0.0f32, 0.0f32]; 1000],
|
||||
/// 48_000,
|
||||
/// 0.0,
|
||||
/// );
|
||||
/// ```
|
||||
pub fn new(stereo: Vec<[T; 2]>, sample_rate: u32, gain_db: f64) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
stereo: Arc::from(stereo),
|
||||
sample_rate,
|
||||
gain_db,
|
||||
})
|
||||
}
|
||||
|
||||
/// Retourne le nombre d'échantillons par canal (frames)
|
||||
#[inline]
|
||||
pub fn len(&self) -> usize {
|
||||
self.stereo.len()
|
||||
}
|
||||
|
||||
/// Vérifie si le chunk est vide
|
||||
#[inline]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.stereo.is_empty()
|
||||
}
|
||||
|
||||
/// Taux d'échantillonnage (Hz)
|
||||
#[inline]
|
||||
pub fn sample_rate(&self) -> u32 {
|
||||
self.sample_rate
|
||||
}
|
||||
|
||||
/// Gain courant en décibels
|
||||
#[inline]
|
||||
pub fn gain_db(&self) -> f64 {
|
||||
self.gain_db
|
||||
}
|
||||
|
||||
/// Gain sous forme linéaire
|
||||
#[inline]
|
||||
pub fn gain_linear(&self) -> f64 {
|
||||
gain_linear_from_db(self.gain_db)
|
||||
}
|
||||
|
||||
/// Retourne une vue immuable sur les frames `[L, R]`
|
||||
#[inline]
|
||||
pub fn frames(&self) -> &[[T; 2]] {
|
||||
&self.stereo
|
||||
}
|
||||
|
||||
/// Clone les frames stéréo dans un `Vec`
|
||||
#[inline]
|
||||
pub fn clone_frames(&self) -> Vec<[T; 2]> {
|
||||
self.stereo.to_vec()
|
||||
}
|
||||
|
||||
/// Définit le gain (retourne un nouveau chunk avec le même Arc mais gain différent)
|
||||
///
|
||||
/// Cette méthode est très peu coûteuse car elle ne clone que la structure, pas les données audio.
|
||||
pub fn set_gain_db(&self, gain_db: f64) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
stereo: self.stereo.clone(),
|
||||
sample_rate: self.sample_rate,
|
||||
gain_db,
|
||||
})
|
||||
}
|
||||
|
||||
/// Définit le gain à l'aide d'un facteur linéaire (>0)
|
||||
pub fn set_gain_linear(&self, gain_linear: f64) -> Arc<Self> {
|
||||
self.set_gain_db(gain_db_from_linear(gain_linear))
|
||||
}
|
||||
|
||||
/// Modifie le gain de ce chunk (ajoute un delta en dB)
|
||||
pub fn with_modified_gain_db(&self, delta_gain_db: f64) -> Arc<Self> {
|
||||
self.set_gain_db(self.gain_db + delta_gain_db)
|
||||
}
|
||||
|
||||
/// Modifie le gain via un facteur linéaire multiplié au gain courant
|
||||
pub fn with_modified_gain_linear(&self, gain_linear: f64) -> Arc<Self> {
|
||||
self.with_modified_gain_db(gain_db_from_linear(gain_linear))
|
||||
}
|
||||
}
|
||||
|
||||
// Méthodes spécifiques pour les types entiers (i16, I24, i32)
|
||||
impl AudioChunkData<i32> {
|
||||
/// Applique le gain et retourne un nouveau chunk avec les données modifiées
|
||||
///
|
||||
/// Cette méthode crée un nouveau chunk avec les samples multipliés par le gain.
|
||||
/// Le gain du chunk résultant est remis à 0.0 dB.
|
||||
pub fn apply_gain(self: Arc<Self>) -> Arc<Self> {
|
||||
if self.gain_db.abs() < f64::EPSILON {
|
||||
return self; // Pas de gain à appliquer
|
||||
}
|
||||
|
||||
let mut stereo = self.clone_frames();
|
||||
dsp::apply_gain_stereo_i32(&mut stereo, self.gain_db);
|
||||
|
||||
AudioChunkData::new(stereo, self.sample_rate, 0.0)
|
||||
}
|
||||
|
||||
/// Construit un chunk depuis deux vecteurs `i32` séparés (L/R)
|
||||
pub fn from_channels(left: Vec<i32>, right: Vec<i32>, sample_rate: u32) -> Arc<Self> {
|
||||
assert_eq!(
|
||||
left.len(),
|
||||
right.len(),
|
||||
"channels must have identical length"
|
||||
);
|
||||
let stereo = left
|
||||
.into_iter()
|
||||
.zip(right.into_iter())
|
||||
.map(|(l, r)| [l, r])
|
||||
.collect();
|
||||
AudioChunkData::new(stereo, sample_rate, 0.0)
|
||||
}
|
||||
|
||||
/// Change la profondeur de bits (bit depth conversion)
|
||||
pub fn set_bit_depth(self: Arc<Self>, old_depth: BitDepth, new_depth: BitDepth) -> Arc<Self> {
|
||||
if old_depth == new_depth {
|
||||
return self;
|
||||
}
|
||||
|
||||
let mut stereo = self.clone_frames();
|
||||
dsp::bitdepth_change_stereo(&mut stereo, old_depth, new_depth);
|
||||
|
||||
Arc::new(Self {
|
||||
stereo: Arc::from(stereo),
|
||||
sample_rate: self.sample_rate,
|
||||
gain_db: self.gain_db,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Méthodes spécifiques pour f32
|
||||
impl AudioChunkData<f32> {
|
||||
/// Applique le gain et retourne un nouveau chunk avec les données modifiées
|
||||
pub fn apply_gain(self: Arc<Self>) -> Arc<Self> {
|
||||
if self.gain_db.abs() < f64::EPSILON {
|
||||
return self; // Pas de gain à appliquer
|
||||
}
|
||||
|
||||
let gain_linear = gain_linear_from_db(self.gain_db) as f32;
|
||||
let mut stereo = self.clone_frames();
|
||||
for frame in &mut stereo {
|
||||
frame[0] *= gain_linear;
|
||||
frame[1] *= gain_linear;
|
||||
}
|
||||
|
||||
AudioChunkData::new(stereo, self.sample_rate, 0.0)
|
||||
}
|
||||
|
||||
/// Construit un chunk depuis deux vecteurs `f32` séparés (L/R)
|
||||
pub fn from_channels(left: Vec<f32>, right: Vec<f32>, sample_rate: u32) -> Arc<Self> {
|
||||
assert_eq!(
|
||||
left.len(),
|
||||
right.len(),
|
||||
"channels must have identical length"
|
||||
);
|
||||
let stereo = left
|
||||
.into_iter()
|
||||
.zip(right.into_iter())
|
||||
.map(|(l, r)| [l, r])
|
||||
.collect();
|
||||
AudioChunkData::new(stereo, sample_rate, 0.0)
|
||||
}
|
||||
}
|
||||
|
||||
// Méthodes spécifiques pour f64
|
||||
impl AudioChunkData<f64> {
|
||||
/// Applique le gain et retourne un nouveau chunk avec les données modifiées
|
||||
pub fn apply_gain(self: Arc<Self>) -> Arc<Self> {
|
||||
if self.gain_db.abs() < f64::EPSILON {
|
||||
return self; // Pas de gain à appliquer
|
||||
}
|
||||
|
||||
let gain_linear = gain_linear_from_db(self.gain_db);
|
||||
let mut stereo = self.clone_frames();
|
||||
for frame in &mut stereo {
|
||||
frame[0] *= gain_linear;
|
||||
frame[1] *= gain_linear;
|
||||
}
|
||||
|
||||
AudioChunkData::new(stereo, self.sample_rate, 0.0)
|
||||
}
|
||||
|
||||
/// Construit un chunk depuis deux vecteurs `f64` séparés (L/R)
|
||||
pub fn from_channels(left: Vec<f64>, right: Vec<f64>, sample_rate: u32) -> Arc<Self> {
|
||||
assert_eq!(
|
||||
left.len(),
|
||||
right.len(),
|
||||
"channels must have identical length"
|
||||
);
|
||||
let stereo = left
|
||||
.into_iter()
|
||||
.zip(right.into_iter())
|
||||
.map(|(l, r)| [l, r])
|
||||
.collect();
|
||||
AudioChunkData::new(stereo, sample_rate, 0.0)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AudioChunk : Enum pour tous les types de chunks
|
||||
// ============================================================================
|
||||
|
||||
/// Enum contenant tous les types de chunks audio possibles
|
||||
///
|
||||
/// Cette enum permet de manipuler des chunks de différents types dans un
|
||||
/// pipeline unifié, tout en conservant l'information de type.
|
||||
///
|
||||
/// # Variantes
|
||||
///
|
||||
/// - `I16` : Échantillons 16-bit signés
|
||||
/// - `I24` : Échantillons 24-bit signés (stockés sur i32)
|
||||
/// - `I32` : Échantillons 32-bit signés
|
||||
/// - `F32` : Échantillons flottants 32-bit normalisés [-1.0, 1.0]
|
||||
/// - `F64` : Échantillons flottants 64-bit normalisés [-1.0, 1.0]
|
||||
///
|
||||
/// # Exemples
|
||||
///
|
||||
/// ```
|
||||
/// use pmoaudio::{AudioChunk, AudioChunkData};
|
||||
///
|
||||
/// let chunk_f32 = AudioChunkData::new(vec![[0.5f32, 0.25f32]; 1000], 48_000, 0.0);
|
||||
/// let chunk = AudioChunk::F32(chunk_f32);
|
||||
///
|
||||
/// match &chunk {
|
||||
/// AudioChunk::F32(data) => println!("F32 chunk with {} frames", data.len()),
|
||||
/// _ => println!("Other type"),
|
||||
/// }
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AudioChunk {
|
||||
I16(Arc<AudioChunkData<i16>>),
|
||||
I24(Arc<AudioChunkData<I24>>),
|
||||
I32(Arc<AudioChunkData<i32>>),
|
||||
F32(Arc<AudioChunkData<f32>>),
|
||||
F64(Arc<AudioChunkData<f64>>),
|
||||
}
|
||||
|
||||
impl AudioChunk {
|
||||
/// Retourne le nombre de frames du chunk
|
||||
pub fn len(&self) -> usize {
|
||||
match self {
|
||||
AudioChunk::I16(d) => d.len(),
|
||||
AudioChunk::I24(d) => d.len(),
|
||||
AudioChunk::I32(d) => d.len(),
|
||||
AudioChunk::F32(d) => d.len(),
|
||||
AudioChunk::F64(d) => d.len(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie si le chunk est vide
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
|
||||
/// Taux d'échantillonnage (Hz)
|
||||
pub fn sample_rate(&self) -> u32 {
|
||||
match self {
|
||||
AudioChunk::I16(d) => d.sample_rate(),
|
||||
AudioChunk::I24(d) => d.sample_rate(),
|
||||
AudioChunk::I32(d) => d.sample_rate(),
|
||||
AudioChunk::F32(d) => d.sample_rate(),
|
||||
AudioChunk::F64(d) => d.sample_rate(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Gain courant en décibels
|
||||
pub fn gain_db(&self) -> f64 {
|
||||
match self {
|
||||
AudioChunk::I16(d) => d.gain_db(),
|
||||
AudioChunk::I24(d) => d.gain_db(),
|
||||
AudioChunk::I32(d) => d.gain_db(),
|
||||
AudioChunk::F32(d) => d.gain_db(),
|
||||
AudioChunk::F64(d) => d.gain_db(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Gain sous forme linéaire
|
||||
pub fn gain_linear(&self) -> f64 {
|
||||
gain_linear_from_db(self.gain_db())
|
||||
}
|
||||
|
||||
/// Définit le gain en dB
|
||||
pub fn set_gain_db(&self, gain_db: f64) -> Self {
|
||||
match self {
|
||||
AudioChunk::I16(d) => AudioChunk::I16(d.set_gain_db(gain_db)),
|
||||
AudioChunk::I24(d) => AudioChunk::I24(d.set_gain_db(gain_db)),
|
||||
AudioChunk::I32(d) => AudioChunk::I32(d.set_gain_db(gain_db)),
|
||||
AudioChunk::F32(d) => AudioChunk::F32(d.set_gain_db(gain_db)),
|
||||
AudioChunk::F64(d) => AudioChunk::F64(d.set_gain_db(gain_db)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Définit le gain via un facteur linéaire
|
||||
pub fn set_gain_linear(&self, gain_linear: f64) -> Self {
|
||||
self.set_gain_db(gain_db_from_linear(gain_linear))
|
||||
}
|
||||
|
||||
/// Modifie le gain (ajoute un delta en dB)
|
||||
pub fn with_modified_gain_db(&self, delta_gain_db: f64) -> Self {
|
||||
self.set_gain_db(self.gain_db() + delta_gain_db)
|
||||
}
|
||||
|
||||
/// Applique le gain et retourne un nouveau chunk avec les données modifiées
|
||||
///
|
||||
/// Le gain du chunk résultant est remis à 0.0 dB.
|
||||
pub fn apply_gain(self) -> Self {
|
||||
match self {
|
||||
AudioChunk::I16(d) => {
|
||||
let gain_db = d.gain_db();
|
||||
if gain_db.abs() < f64::EPSILON {
|
||||
return AudioChunk::I16(d);
|
||||
}
|
||||
let gain_linear = gain_linear_from_db(gain_db) as f32;
|
||||
let mut stereo = d.clone_frames();
|
||||
for frame in &mut stereo {
|
||||
frame[0] = (frame[0] as f32 * gain_linear)
|
||||
.round()
|
||||
.clamp(-32768.0, 32767.0) as i16;
|
||||
frame[1] = (frame[1] as f32 * gain_linear)
|
||||
.round()
|
||||
.clamp(-32768.0, 32767.0) as i16;
|
||||
}
|
||||
AudioChunk::I16(AudioChunkData::new(stereo, d.sample_rate(), 0.0))
|
||||
}
|
||||
AudioChunk::I24(d) => {
|
||||
let gain_db = d.gain_db();
|
||||
if gain_db.abs() < f64::EPSILON {
|
||||
return AudioChunk::I24(d);
|
||||
}
|
||||
let gain_linear = gain_linear_from_db(gain_db) as f32;
|
||||
let mut stereo = d.clone_frames();
|
||||
for frame in &mut stereo {
|
||||
let l = (frame[0].as_i32() as f32 * gain_linear)
|
||||
.round()
|
||||
.clamp(-8_388_608.0, 8_388_607.0) as i32;
|
||||
let r = (frame[1].as_i32() as f32 * gain_linear)
|
||||
.round()
|
||||
.clamp(-8_388_608.0, 8_388_607.0) as i32;
|
||||
frame[0] = I24::new_clamped(l);
|
||||
frame[1] = I24::new_clamped(r);
|
||||
}
|
||||
AudioChunk::I24(AudioChunkData::new(stereo, d.sample_rate(), 0.0))
|
||||
}
|
||||
AudioChunk::I32(d) => AudioChunk::I32(d.apply_gain()),
|
||||
AudioChunk::F32(d) => AudioChunk::F32(d.apply_gain()),
|
||||
AudioChunk::F64(d) => AudioChunk::F64(d.apply_gain()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Retourne le nom du type de sample
|
||||
pub fn type_name(&self) -> &'static str {
|
||||
match self {
|
||||
AudioChunk::I16(_) => "i16",
|
||||
AudioChunk::I24(_) => "I24",
|
||||
AudioChunk::I32(_) => "i32",
|
||||
AudioChunk::F32(_) => "f32",
|
||||
AudioChunk::F64(_) => "f64",
|
||||
}
|
||||
}
|
||||
|
||||
/// Tente de convertir vers AudioIntegerChunk (retourne None si float)
|
||||
pub fn try_as_integer(&self) -> Option<AudioIntegerChunk> {
|
||||
match self {
|
||||
AudioChunk::I16(d) => Some(AudioIntegerChunk::I16(d.clone())),
|
||||
AudioChunk::I24(d) => Some(AudioIntegerChunk::I24(d.clone())),
|
||||
AudioChunk::I32(d) => Some(AudioIntegerChunk::I32(d.clone())),
|
||||
AudioChunk::F32(_) | AudioChunk::F64(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Tente de convertir vers AudioFloatChunk (retourne None si integer)
|
||||
pub fn try_as_float(&self) -> Option<AudioFloatChunk> {
|
||||
match self {
|
||||
AudioChunk::F32(d) => Some(AudioFloatChunk::F32(d.clone())),
|
||||
AudioChunk::F64(d) => Some(AudioFloatChunk::F64(d.clone())),
|
||||
AudioChunk::I16(_) | AudioChunk::I24(_) | AudioChunk::I32(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie si le chunk est de type entier
|
||||
pub fn is_integer(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
AudioChunk::I16(_) | AudioChunk::I24(_) | AudioChunk::I32(_)
|
||||
)
|
||||
}
|
||||
|
||||
/// Vérifie si le chunk est de type flottant
|
||||
pub fn is_float(&self) -> bool {
|
||||
matches!(self, AudioChunk::F32(_) | AudioChunk::F64(_))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AudioIntegerChunk {
|
||||
I16(Arc<AudioChunkData<i16>>),
|
||||
I24(Arc<AudioChunkData<I24>>),
|
||||
I32(Arc<AudioChunkData<i32>>),
|
||||
}
|
||||
|
||||
impl AudioIntegerChunk {
|
||||
/// Retourne le nombre de frames du chunk
|
||||
pub fn len(&self) -> usize {
|
||||
match self {
|
||||
AudioIntegerChunk::I16(d) => d.len(),
|
||||
AudioIntegerChunk::I24(d) => d.len(),
|
||||
AudioIntegerChunk::I32(d) => d.len(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie si le chunk est vide
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
|
||||
/// Taux d'échantillonnage (Hz)
|
||||
pub fn sample_rate(&self) -> u32 {
|
||||
match self {
|
||||
AudioIntegerChunk::I16(d) => d.sample_rate(),
|
||||
AudioIntegerChunk::I24(d) => d.sample_rate(),
|
||||
AudioIntegerChunk::I32(d) => d.sample_rate(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Gain courant en décibels
|
||||
pub fn gain_db(&self) -> f64 {
|
||||
match self {
|
||||
AudioIntegerChunk::I16(d) => d.gain_db(),
|
||||
AudioIntegerChunk::I24(d) => d.gain_db(),
|
||||
AudioIntegerChunk::I32(d) => d.gain_db(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Gain sous forme linéaire
|
||||
pub fn gain_linear(&self) -> f64 {
|
||||
gain_linear_from_db(self.gain_db())
|
||||
}
|
||||
|
||||
/// Définit le gain en dB
|
||||
pub fn set_gain_db(&self, gain_db: f64) -> Self {
|
||||
match self {
|
||||
AudioIntegerChunk::I16(d) => AudioIntegerChunk::I16(d.set_gain_db(gain_db)),
|
||||
AudioIntegerChunk::I24(d) => AudioIntegerChunk::I24(d.set_gain_db(gain_db)),
|
||||
AudioIntegerChunk::I32(d) => AudioIntegerChunk::I32(d.set_gain_db(gain_db)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Définit le gain via un facteur linéaire
|
||||
pub fn set_gain_linear(&self, gain_linear: f64) -> Self {
|
||||
self.set_gain_db(gain_db_from_linear(gain_linear))
|
||||
}
|
||||
|
||||
/// Modifie le gain (ajoute un delta en dB)
|
||||
pub fn with_modified_gain_db(&self, delta_gain_db: f64) -> Self {
|
||||
self.set_gain_db(self.gain_db() + delta_gain_db)
|
||||
}
|
||||
|
||||
/// Applique le gain et retourne un nouveau chunk avec les données modifiées
|
||||
///
|
||||
/// Le gain du chunk résultant est remis à 0.0 dB.
|
||||
pub fn apply_gain(self) -> Self {
|
||||
match self {
|
||||
AudioIntegerChunk::I16(d) => {
|
||||
let gain_db = d.gain_db();
|
||||
if gain_db.abs() < f64::EPSILON {
|
||||
return AudioIntegerChunk::I16(d);
|
||||
}
|
||||
let gain_linear = gain_linear_from_db(gain_db) as f32;
|
||||
let mut stereo = d.clone_frames();
|
||||
for frame in &mut stereo {
|
||||
frame[0] = (frame[0] as f32 * gain_linear)
|
||||
.round()
|
||||
.clamp(-32768.0, 32767.0) as i16;
|
||||
frame[1] = (frame[1] as f32 * gain_linear)
|
||||
.round()
|
||||
.clamp(-32768.0, 32767.0) as i16;
|
||||
}
|
||||
AudioIntegerChunk::I16(AudioChunkData::new(stereo, d.sample_rate(), 0.0))
|
||||
}
|
||||
AudioIntegerChunk::I24(d) => {
|
||||
let gain_db = d.gain_db();
|
||||
if gain_db.abs() < f64::EPSILON {
|
||||
return AudioIntegerChunk::I24(d);
|
||||
}
|
||||
let gain_linear = gain_linear_from_db(gain_db) as f32;
|
||||
let mut stereo = d.clone_frames();
|
||||
for frame in &mut stereo {
|
||||
let l = (frame[0].as_i32() as f32 * gain_linear)
|
||||
.round()
|
||||
.clamp(-8_388_608.0, 8_388_607.0) as i32;
|
||||
let r = (frame[1].as_i32() as f32 * gain_linear)
|
||||
.round()
|
||||
.clamp(-8_388_608.0, 8_388_607.0) as i32;
|
||||
frame[0] = I24::new_clamped(l);
|
||||
frame[1] = I24::new_clamped(r);
|
||||
}
|
||||
AudioIntegerChunk::I24(AudioChunkData::new(stereo, d.sample_rate(), 0.0))
|
||||
}
|
||||
AudioIntegerChunk::I32(d) => AudioIntegerChunk::I32(d.apply_gain()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Retourne le nom du type de sample
|
||||
pub fn type_name(&self) -> &'static str {
|
||||
match self {
|
||||
AudioIntegerChunk::I16(_) => "i16",
|
||||
AudioIntegerChunk::I24(_) => "I24",
|
||||
AudioIntegerChunk::I32(_) => "i32",
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie si le chunk est de type I16
|
||||
pub fn is_i16(&self) -> bool {
|
||||
matches!(self, AudioIntegerChunk::I16(_))
|
||||
}
|
||||
|
||||
/// Vérifie si le chunk est de type I24
|
||||
pub fn is_i24(&self) -> bool {
|
||||
matches!(self, AudioIntegerChunk::I24(_))
|
||||
}
|
||||
|
||||
/// Vérifie si le chunk est de type I32
|
||||
pub fn is_i32(&self) -> bool {
|
||||
matches!(self, AudioIntegerChunk::I32(_))
|
||||
}
|
||||
|
||||
/// Retourne la profondeur de bit du chunk
|
||||
pub fn bit_depth(&self) -> u8 {
|
||||
match self {
|
||||
AudioIntegerChunk::I16(_) => 16,
|
||||
AudioIntegerChunk::I24(_) => 24,
|
||||
AudioIntegerChunk::I32(_) => 32,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convertit vers AudioChunk
|
||||
pub fn as_audio_chunk(&self) -> AudioChunk {
|
||||
match self {
|
||||
AudioIntegerChunk::I16(d) => AudioChunk::I16(d.clone()),
|
||||
AudioIntegerChunk::I24(d) => AudioChunk::I24(d.clone()),
|
||||
AudioIntegerChunk::I32(d) => AudioChunk::I32(d.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convertit vers I16 (avec conversion si nécessaire)
|
||||
pub fn to_i16(&self) -> AudioIntegerChunk {
|
||||
match self {
|
||||
AudioIntegerChunk::I16(_) => self.clone(),
|
||||
AudioIntegerChunk::I24(d) => {
|
||||
// I24 -> I32 -> I16
|
||||
let i32_chunk = crate::conversions::convert_i24_to_i32(d);
|
||||
let converted = crate::conversions::convert_i32_to_i16(&i32_chunk);
|
||||
AudioIntegerChunk::I16(converted)
|
||||
}
|
||||
AudioIntegerChunk::I32(d) => {
|
||||
let converted = crate::conversions::convert_i32_to_i16(d);
|
||||
AudioIntegerChunk::I16(converted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convertit vers I24 (avec conversion si nécessaire)
|
||||
pub fn to_i24(&self) -> AudioIntegerChunk {
|
||||
match self {
|
||||
AudioIntegerChunk::I16(d) => {
|
||||
// I16 -> I32 -> I24
|
||||
let i32_chunk = crate::conversions::convert_i16_to_i32(d);
|
||||
let converted = crate::conversions::convert_i32_to_i24(&i32_chunk);
|
||||
AudioIntegerChunk::I24(converted)
|
||||
}
|
||||
AudioIntegerChunk::I24(_) => self.clone(),
|
||||
AudioIntegerChunk::I32(d) => {
|
||||
let converted = crate::conversions::convert_i32_to_i24(d);
|
||||
AudioIntegerChunk::I24(converted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convertit vers I32 (avec conversion si nécessaire)
|
||||
pub fn to_i32(&self) -> AudioIntegerChunk {
|
||||
match self {
|
||||
AudioIntegerChunk::I16(d) => {
|
||||
let converted = crate::conversions::convert_i16_to_i32(d);
|
||||
AudioIntegerChunk::I32(converted)
|
||||
}
|
||||
AudioIntegerChunk::I24(d) => {
|
||||
let converted = crate::conversions::convert_i24_to_i32(d);
|
||||
AudioIntegerChunk::I32(converted)
|
||||
}
|
||||
AudioIntegerChunk::I32(_) => self.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Retourne un itérateur sur les frames
|
||||
pub fn frames(&self) -> Box<dyn Iterator<Item = [i32; 2]> + '_> {
|
||||
match self {
|
||||
AudioIntegerChunk::I16(d) => {
|
||||
Box::new(d.frames().iter().map(|f| [f[0] as i32, f[1] as i32]))
|
||||
}
|
||||
AudioIntegerChunk::I24(d) => {
|
||||
Box::new(d.frames().iter().map(|f| [f[0].as_i32(), f[1].as_i32()]))
|
||||
}
|
||||
AudioIntegerChunk::I32(d) => Box::new(d.frames().iter().map(|f| [f[0], f[1]])),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AudioChunk> for AudioIntegerChunk {
|
||||
/// Convertit depuis AudioChunk (panic si le chunk est float)
|
||||
fn from(chunk: AudioChunk) -> Self {
|
||||
match chunk {
|
||||
AudioChunk::I16(d) => AudioIntegerChunk::I16(d),
|
||||
AudioChunk::I24(d) => AudioIntegerChunk::I24(d),
|
||||
AudioChunk::I32(d) => AudioIntegerChunk::I32(d),
|
||||
AudioChunk::F32(_) | AudioChunk::F64(_) => {
|
||||
panic!("Cannot convert float AudioChunk to AudioIntegerChunk")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AudioFloatChunk {
|
||||
F32(Arc<AudioChunkData<f32>>),
|
||||
F64(Arc<AudioChunkData<f64>>),
|
||||
}
|
||||
|
||||
impl AudioFloatChunk {
|
||||
/// Retourne le nombre de frames du chunk
|
||||
pub fn len(&self) -> usize {
|
||||
match self {
|
||||
AudioFloatChunk::F32(d) => d.len(),
|
||||
AudioFloatChunk::F64(d) => d.len(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie si le chunk est vide
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
|
||||
/// Taux d'échantillonnage (Hz)
|
||||
pub fn sample_rate(&self) -> u32 {
|
||||
match self {
|
||||
AudioFloatChunk::F32(d) => d.sample_rate(),
|
||||
AudioFloatChunk::F64(d) => d.sample_rate(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Gain courant en décibels
|
||||
pub fn gain_db(&self) -> f64 {
|
||||
match self {
|
||||
AudioFloatChunk::F32(d) => d.gain_db(),
|
||||
AudioFloatChunk::F64(d) => d.gain_db(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Gain sous forme linéaire
|
||||
pub fn gain_linear(&self) -> f64 {
|
||||
gain_linear_from_db(self.gain_db())
|
||||
}
|
||||
|
||||
/// Définit le gain en dB
|
||||
pub fn set_gain_db(&self, gain_db: f64) -> Self {
|
||||
match self {
|
||||
AudioFloatChunk::F32(d) => AudioFloatChunk::F32(d.set_gain_db(gain_db)),
|
||||
AudioFloatChunk::F64(d) => AudioFloatChunk::F64(d.set_gain_db(gain_db)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Définit le gain via un facteur linéaire
|
||||
pub fn set_gain_linear(&self, gain_linear: f64) -> Self {
|
||||
self.set_gain_db(gain_db_from_linear(gain_linear))
|
||||
}
|
||||
|
||||
/// Modifie le gain (ajoute un delta en dB)
|
||||
pub fn with_modified_gain_db(&self, delta_gain_db: f64) -> Self {
|
||||
self.set_gain_db(self.gain_db() + delta_gain_db)
|
||||
}
|
||||
|
||||
/// Applique le gain et retourne un nouveau chunk avec les données modifiées
|
||||
///
|
||||
/// Le gain du chunk résultant est remis à 0.0 dB.
|
||||
pub fn apply_gain(self) -> Self {
|
||||
match self {
|
||||
AudioFloatChunk::F32(d) => AudioFloatChunk::F32(d.apply_gain()),
|
||||
AudioFloatChunk::F64(d) => AudioFloatChunk::F64(d.apply_gain()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Retourne le nom du type de sample
|
||||
pub fn type_name(&self) -> &'static str {
|
||||
match self {
|
||||
AudioFloatChunk::F32(_) => "f32",
|
||||
AudioFloatChunk::F64(_) => "f64",
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie si le chunk est de type F32
|
||||
pub fn is_f32(&self) -> bool {
|
||||
matches!(self, AudioFloatChunk::F32(_))
|
||||
}
|
||||
|
||||
/// Vérifie si le chunk est de type F64
|
||||
pub fn is_f64(&self) -> bool {
|
||||
matches!(self, AudioFloatChunk::F64(_))
|
||||
}
|
||||
|
||||
/// Retourne la profondeur de bit du chunk (32 ou 64)
|
||||
pub fn bit_depth(&self) -> u8 {
|
||||
match self {
|
||||
AudioFloatChunk::F32(_) => 32,
|
||||
AudioFloatChunk::F64(_) => 64,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convertit vers AudioChunk
|
||||
pub fn as_audio_chunk(&self) -> AudioChunk {
|
||||
match self {
|
||||
AudioFloatChunk::F32(d) => AudioChunk::F32(d.clone()),
|
||||
AudioFloatChunk::F64(d) => AudioChunk::F64(d.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convertit vers F32 (avec conversion si nécessaire)
|
||||
pub fn to_f32(&self) -> AudioFloatChunk {
|
||||
match self {
|
||||
AudioFloatChunk::F32(_) => self.clone(),
|
||||
AudioFloatChunk::F64(d) => {
|
||||
let converted = crate::conversions::convert_f64_to_f32(d);
|
||||
AudioFloatChunk::F32(converted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convertit vers F64 (avec conversion si nécessaire)
|
||||
pub fn to_f64(&self) -> AudioFloatChunk {
|
||||
match self {
|
||||
AudioFloatChunk::F32(d) => {
|
||||
let converted = crate::conversions::convert_f32_to_f64(d);
|
||||
AudioFloatChunk::F64(converted)
|
||||
}
|
||||
AudioFloatChunk::F64(_) => self.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Retourne un itérateur sur les frames
|
||||
pub fn frames(&self) -> Box<dyn Iterator<Item = [f64; 2]> + '_> {
|
||||
match self {
|
||||
AudioFloatChunk::F32(d) => {
|
||||
Box::new(d.frames().iter().map(|f| [f[0] as f64, f[1] as f64]))
|
||||
}
|
||||
AudioFloatChunk::F64(d) => Box::new(d.frames().iter().map(|f| [f[0], f[1]])),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AudioChunk> for AudioFloatChunk {
|
||||
/// Convertit depuis AudioChunk (panic si le chunk est entier)
|
||||
fn from(chunk: AudioChunk) -> Self {
|
||||
match chunk {
|
||||
AudioChunk::F32(d) => AudioFloatChunk::F32(d),
|
||||
AudioChunk::F64(d) => AudioFloatChunk::F64(d),
|
||||
AudioChunk::I16(_) | AudioChunk::I24(_) | AudioChunk::I32(_) => {
|
||||
panic!("Cannot convert integer AudioChunk to AudioFloatChunk")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Fonctions utilitaires de conversion gain
|
||||
// ============================================================================
|
||||
|
||||
const MIN_GAIN_DB: f64 = -120.0;
|
||||
|
||||
/// Convertit un gain linéaire (>0) en décibels
|
||||
#[inline]
|
||||
pub fn gain_db_from_linear(gain_linear: f64) -> f64 {
|
||||
if gain_linear <= 0.0 {
|
||||
MIN_GAIN_DB
|
||||
} else {
|
||||
(20.0 * gain_linear.log10()).max(MIN_GAIN_DB)
|
||||
}
|
||||
}
|
||||
|
||||
/// Convertit un gain en décibels vers un gain linéaire
|
||||
#[inline]
|
||||
pub fn gain_linear_from_db(gain_db: f64) -> f64 {
|
||||
10f64.powf(gain_db / 20.0)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_audio_chunk_data_f32() {
|
||||
let stereo: Vec<[f32; 2]> = vec![[0.5, 0.25], [0.75, 0.125]];
|
||||
let chunk = AudioChunkData::new(stereo, 48000, 0.0);
|
||||
|
||||
assert_eq!(chunk.len(), 2);
|
||||
assert_eq!(chunk.sample_rate(), 48000);
|
||||
assert!(!chunk.is_empty());
|
||||
assert_eq!(chunk.gain_db(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_audio_chunk_data_i32() {
|
||||
let stereo: Vec<[i32; 2]> = vec![[1000, 2000], [3000, 4000]];
|
||||
let chunk = AudioChunkData::new(stereo, 48000, -6.0);
|
||||
|
||||
assert_eq!(chunk.len(), 2);
|
||||
assert_eq!(chunk.gain_db(), -6.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_audio_chunk_enum() {
|
||||
let data_f32 = AudioChunkData::new(vec![[0.5f32, 0.25f32]; 1000], 48000, 0.0);
|
||||
let chunk = AudioChunk::F32(data_f32);
|
||||
|
||||
assert_eq!(chunk.len(), 1000);
|
||||
assert_eq!(chunk.sample_rate(), 48000);
|
||||
assert_eq!(chunk.type_name(), "f32");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gain_conversion() {
|
||||
let linear = 2.0;
|
||||
let db = gain_db_from_linear(linear);
|
||||
assert!((db - 6.0206).abs() < 0.01); // 2x ≈ +6dB
|
||||
|
||||
let back = gain_linear_from_db(db);
|
||||
assert!((back - linear).abs() < 0.001);
|
||||
}
|
||||
}
|
||||
511
pmoaudio/src/audio_segment.rs
Normal file
511
pmoaudio/src/audio_segment.rs
Normal 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
Normal file
162
pmoaudio/src/bit_depth.rs
Normal 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
Normal file
751
pmoaudio/src/conversions.rs
Normal 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.sample_rate(), chunk.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.sample_rate(), chunk.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
|
||||
.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.sample_rate(), chunk.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
|
||||
.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.sample_rate(), chunk.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.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.sample_rate(), chunk.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.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.sample_rate(), chunk.gain_db())
|
||||
}
|
||||
|
||||
/// Convertit I24 vers f64
|
||||
pub fn convert_i24_to_f64(chunk: &AudioChunkData<I24>) -> Arc<AudioChunkData<f64>> {
|
||||
let frames = chunk.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.sample_rate(), chunk.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.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.sample_rate(), chunk.gain_db())
|
||||
}
|
||||
|
||||
/// Convertit i16 vers f64
|
||||
pub fn convert_i16_to_f64(chunk: &AudioChunkData<i16>) -> Arc<AudioChunkData<f64>> {
|
||||
let frames = chunk.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.sample_rate(), chunk.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.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.sample_rate(), chunk.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.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.sample_rate(), chunk.gain_db())
|
||||
}
|
||||
|
||||
/// Convertit f64 vers I24
|
||||
pub fn convert_f64_to_i24(chunk: &AudioChunkData<f64>) -> Arc<AudioChunkData<I24>> {
|
||||
let frames = chunk.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.sample_rate(), chunk.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.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.sample_rate(), chunk.gain_db())
|
||||
}
|
||||
|
||||
/// Convertit f64 vers i16
|
||||
pub fn convert_f64_to_i16(chunk: &AudioChunkData<f64>) -> Arc<AudioChunkData<i16>> {
|
||||
let frames = chunk.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.sample_rate(), chunk.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.frames();
|
||||
let stereo: Vec<[f64; 2]> = frames.iter().map(|[l, r]| [*l as f64, *r as f64]).collect();
|
||||
|
||||
AudioChunkData::new(stereo, chunk.sample_rate(), chunk.gain_db())
|
||||
}
|
||||
|
||||
/// Convertit f64 vers f32 (downcast simple)
|
||||
pub fn convert_f64_to_f32(chunk: &AudioChunkData<f64>) -> Arc<AudioChunkData<f32>> {
|
||||
let frames = chunk.frames();
|
||||
let stereo: Vec<[f32; 2]> = frames.iter().map(|[l, r]| [*l as f32, *r as f32]).collect();
|
||||
|
||||
AudioChunkData::new(stereo, chunk.sample_rate(), chunk.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.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.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.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.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.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.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
Normal file
111
pmoaudio/src/dsp/depth.rs
Normal 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
Normal file
94
pmoaudio/src/dsp/gain_16bits.rs
Normal 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
Normal file
99
pmoaudio/src/dsp/gain_24bits.rs
Normal 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
Normal file
93
pmoaudio/src/dsp/gain_32bits.rs
Normal 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
Normal file
489
pmoaudio/src/dsp/int_float.rs
Normal 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
Normal file
21
pmoaudio/src/dsp/mod.rs
Normal 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
Normal file
75
pmoaudio/src/dsp/resampling.rs
Normal 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)
|
||||
}
|
||||
233
pmoaudio/src/events.rs
Normal file
233
pmoaudio/src/events.rs
Normal file
@@ -0,0 +1,233 @@
|
||||
//! Système d'événements et d'abonnements générique pour les nodes
|
||||
//!
|
||||
//! Ce module fournit une infrastructure d'abonnement type-safe permettant
|
||||
//! à chaque node d'émettre et de recevoir différents types d'événements.
|
||||
|
||||
use crate::AudioChunk;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
/// Trait de base pour tous les événements de node
|
||||
///
|
||||
/// Chaque type d'événement doit implémenter ce trait pour pouvoir
|
||||
/// être utilisé dans le système d'abonnement.
|
||||
pub trait NodeEvent: Send + Sync + Clone + 'static {}
|
||||
|
||||
/// Événement : données audio disponibles
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AudioDataEvent {
|
||||
pub chunk: Arc<AudioChunk>,
|
||||
}
|
||||
|
||||
impl NodeEvent for AudioDataEvent {}
|
||||
|
||||
/// Événement : changement de volume
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VolumeChangeEvent {
|
||||
pub volume: f32,
|
||||
pub source_node_id: String,
|
||||
}
|
||||
|
||||
impl NodeEvent for VolumeChangeEvent {}
|
||||
|
||||
/// Événement : mise à jour du nom de la source
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SourceNameUpdateEvent {
|
||||
pub source_name: String,
|
||||
pub device_name: Option<String>,
|
||||
}
|
||||
|
||||
impl NodeEvent for SourceNameUpdateEvent {}
|
||||
|
||||
/// Trait pour les listeners d'événements
|
||||
///
|
||||
/// Les nodes qui souhaitent recevoir des événements d'un type particulier
|
||||
/// doivent implémenter ce trait pour ce type.
|
||||
#[async_trait::async_trait]
|
||||
pub trait NodeListener<E: NodeEvent>: Send + Sync {
|
||||
/// Appelé lorsqu'un événement est reçu
|
||||
async fn on_event(&self, event: E);
|
||||
}
|
||||
|
||||
/// Gestionnaire d'abonnements pour un type d'événement spécifique
|
||||
///
|
||||
/// Permet d'enregistrer des listeners et de broadcaster des événements.
|
||||
#[derive(Clone)]
|
||||
pub struct EventPublisher<E: NodeEvent> {
|
||||
subscribers: Vec<mpsc::Sender<E>>,
|
||||
}
|
||||
|
||||
impl<E: NodeEvent> EventPublisher<E> {
|
||||
/// Crée un nouveau publisher vide
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
subscribers: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Ajoute un subscriber via un channel
|
||||
pub fn subscribe(&mut self, tx: mpsc::Sender<E>) {
|
||||
self.subscribers.push(tx);
|
||||
}
|
||||
|
||||
/// Publie un événement à tous les subscribers
|
||||
pub async fn publish(&self, event: E) {
|
||||
for tx in &self.subscribers {
|
||||
// Utiliser try_send pour éviter de bloquer si un subscriber est lent
|
||||
let _ = tx.try_send(event.clone());
|
||||
}
|
||||
}
|
||||
|
||||
/// Publie un événement de manière bloquante (attend que tous les subscribers reçoivent)
|
||||
pub async fn publish_blocking(&self, event: E) {
|
||||
for tx in &self.subscribers {
|
||||
let _ = tx.send(event.clone()).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Retourne le nombre de subscribers actifs
|
||||
pub fn subscriber_count(&self) -> usize {
|
||||
self.subscribers.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: NodeEvent> Default for EventPublisher<E> {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper pour créer un listener basé sur une closure
|
||||
pub struct ClosureListener<E: NodeEvent, F>
|
||||
where
|
||||
F: Fn(E) + Send + Sync + 'static,
|
||||
{
|
||||
callback: Arc<F>,
|
||||
_phantom: std::marker::PhantomData<E>,
|
||||
}
|
||||
|
||||
impl<E: NodeEvent, F> ClosureListener<E, F>
|
||||
where
|
||||
F: Fn(E) + Send + Sync + 'static,
|
||||
{
|
||||
pub fn new(callback: F) -> Self {
|
||||
Self {
|
||||
callback: Arc::new(callback),
|
||||
_phantom: std::marker::PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl<E: NodeEvent, F> NodeListener<E> for ClosureListener<E, F>
|
||||
where
|
||||
F: Fn(E) + Send + Sync + 'static,
|
||||
{
|
||||
async fn on_event(&self, event: E) {
|
||||
(self.callback)(event);
|
||||
}
|
||||
}
|
||||
|
||||
/// Receiver helper pour consommer des événements depuis un channel
|
||||
pub struct EventReceiver<E: NodeEvent> {
|
||||
rx: mpsc::Receiver<E>,
|
||||
}
|
||||
|
||||
impl<E: NodeEvent> EventReceiver<E> {
|
||||
/// Crée un nouveau receiver
|
||||
pub fn new(rx: mpsc::Receiver<E>) -> Self {
|
||||
Self { rx }
|
||||
}
|
||||
|
||||
/// Attend le prochain événement
|
||||
pub async fn recv(&mut self) -> Option<E> {
|
||||
self.rx.recv().await
|
||||
}
|
||||
|
||||
/// Tente de recevoir un événement sans bloquer
|
||||
pub fn try_recv(&mut self) -> Result<E, mpsc::error::TryRecvError> {
|
||||
self.rx.try_recv()
|
||||
}
|
||||
}
|
||||
|
||||
/// Macro pour faciliter la création de publishers multiples dans un node
|
||||
///
|
||||
/// # Exemple
|
||||
///
|
||||
/// ```ignore
|
||||
/// struct MyNode {
|
||||
/// audio_publisher: EventPublisher<AudioDataEvent>,
|
||||
/// volume_publisher: EventPublisher<VolumeChangeEvent>,
|
||||
/// }
|
||||
/// ```
|
||||
#[macro_export]
|
||||
macro_rules! publishers {
|
||||
($($field:ident: $event_type:ty),* $(,)?) => {
|
||||
$(
|
||||
pub $field: $crate::events::EventPublisher<$event_type>,
|
||||
)*
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_event_publisher_basic() {
|
||||
let mut publisher = EventPublisher::<VolumeChangeEvent>::new();
|
||||
let (tx, mut rx) = mpsc::channel(10);
|
||||
|
||||
publisher.subscribe(tx);
|
||||
|
||||
let event = VolumeChangeEvent {
|
||||
volume: 0.5,
|
||||
source_node_id: "test".to_string(),
|
||||
};
|
||||
|
||||
publisher.publish(event.clone()).await;
|
||||
|
||||
let received = rx.recv().await.unwrap();
|
||||
assert_eq!(received.volume, 0.5);
|
||||
assert_eq!(received.source_node_id, "test");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_multiple_subscribers() {
|
||||
let mut publisher = EventPublisher::<VolumeChangeEvent>::new();
|
||||
let (tx1, mut rx1) = mpsc::channel(10);
|
||||
let (tx2, mut rx2) = mpsc::channel(10);
|
||||
|
||||
publisher.subscribe(tx1);
|
||||
publisher.subscribe(tx2);
|
||||
|
||||
let event = VolumeChangeEvent {
|
||||
volume: 0.7,
|
||||
source_node_id: "test".to_string(),
|
||||
};
|
||||
|
||||
publisher.publish(event.clone()).await;
|
||||
|
||||
let received1 = rx1.recv().await.unwrap();
|
||||
let received2 = rx2.recv().await.unwrap();
|
||||
|
||||
assert_eq!(received1.volume, 0.7);
|
||||
assert_eq!(received2.volume, 0.7);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_event_receiver() {
|
||||
let (tx, rx) = mpsc::channel(10);
|
||||
let mut receiver = EventReceiver::new(rx);
|
||||
|
||||
let event = VolumeChangeEvent {
|
||||
volume: 0.3,
|
||||
source_node_id: "test".to_string(),
|
||||
};
|
||||
|
||||
tx.send(event.clone()).await.unwrap();
|
||||
|
||||
let received = receiver.recv().await.unwrap();
|
||||
assert_eq!(received.volume, 0.3);
|
||||
}
|
||||
}
|
||||
142
pmoaudio/src/lib.rs
Normal file
142
pmoaudio/src/lib.rs
Normal file
@@ -0,0 +1,142 @@
|
||||
#![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;
|
||||
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 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},
|
||||
decoder_node::DecoderNode,
|
||||
disk_sink::{AudioFileFormat, DiskSink, DiskSinkConfig, DiskSinkStats},
|
||||
dsp_node::DspNode,
|
||||
mpd_sink::{MpdAudioFormat, MpdConfig, MpdHandle, MpdSink, MpdStats},
|
||||
sink_node::{SinkNode, SinkStats},
|
||||
source_node::SourceNode,
|
||||
timer_node::{TimerHandle, TimerNode},
|
||||
volume_node::{HardwareVolumeNode, VolumeHandle, VolumeNode},
|
||||
};
|
||||
*/
|
||||
283
pmoaudio/src/macros.rs
Normal file
283
pmoaudio/src/macros.rs
Normal 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.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.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");
|
||||
}
|
||||
}
|
||||
320
pmoaudio/src/nodes/converter_nodes.rs
Normal file
320
pmoaudio/src/nodes/converter_nodes.rs
Normal file
@@ -0,0 +1,320 @@
|
||||
//! 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.
|
||||
|
||||
use crate::{
|
||||
nodes::{AudioError, TypedAudioNode},
|
||||
type_constraints::{SampleType, TypeRequirement},
|
||||
AudioPipelineNode, AudioSegment,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
// Macro pour générer les converter nodes avec la nouvelle architecture AudioPipelineNode
|
||||
macro_rules! converter_node {
|
||||
($node_name:ident, $convert_method:ident, $output_type:expr, $doc:expr) => {
|
||||
#[doc = $doc]
|
||||
pub struct $node_name {
|
||||
tx: mpsc::Sender<Arc<AudioSegment>>,
|
||||
rx: mpsc::Receiver<Arc<AudioSegment>>,
|
||||
child_txs: Vec<mpsc::Sender<Arc<AudioSegment>>>,
|
||||
children: Vec<Box<dyn AudioPipelineNode>>,
|
||||
}
|
||||
|
||||
impl $node_name {
|
||||
/// Crée un nouveau node de conversion
|
||||
pub fn new() -> Self {
|
||||
Self::with_channel_size(16)
|
||||
}
|
||||
|
||||
/// Crée un nouveau node avec une taille de buffer spécifique
|
||||
pub fn with_channel_size(channel_size: usize) -> Self {
|
||||
let (tx, rx) = mpsc::channel(channel_size);
|
||||
Self {
|
||||
tx,
|
||||
rx,
|
||||
child_txs: Vec::new(),
|
||||
children: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl AudioPipelineNode for $node_name {
|
||||
fn get_tx(&self) -> Option<mpsc::Sender<Arc<AudioSegment>>> {
|
||||
Some(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> {
|
||||
// Spawner tous les enfants
|
||||
let mut child_handles = Vec::new();
|
||||
for child in self.children {
|
||||
let child_token = stop_token.child_token();
|
||||
let handle = tokio::spawn(async move { child.run(child_token).await });
|
||||
child_handles.push(handle);
|
||||
}
|
||||
|
||||
// Boucle de traitement
|
||||
loop {
|
||||
let segment = tokio::select! {
|
||||
result = self.rx.recv() => {
|
||||
match result {
|
||||
Some(seg) => seg,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
_ = stop_token.cancelled() => {
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// 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 = chunk.$convert_method();
|
||||
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 &self.child_txs {
|
||||
if tx.send(output_segment.clone()).await.is_err() {
|
||||
// Un enfant est mort, arrêter
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Attendre que tous les enfants se terminent
|
||||
for handle in child_handles {
|
||||
match handle.await {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(e)) => return Err(e),
|
||||
Err(e) => {
|
||||
return Err(AudioError::ProcessingError(format!(
|
||||
"Child task panicked: {}",
|
||||
e
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedAudioNode for $node_name {
|
||||
fn input_type(&self) -> Option<TypeRequirement> {
|
||||
Some(TypeRequirement::any())
|
||||
}
|
||||
|
||||
fn output_type(&self) -> Option<TypeRequirement> {
|
||||
Some(TypeRequirement::specific($output_type))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for $node_name {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Générer les 5 converter nodes
|
||||
converter_node!(
|
||||
ToI16Node,
|
||||
to_i16,
|
||||
SampleType::I16,
|
||||
"Node de conversion vers I16 (16-bit signed integer)"
|
||||
);
|
||||
converter_node!(
|
||||
ToI24Node,
|
||||
to_i24,
|
||||
SampleType::I24,
|
||||
"Node de conversion vers I24 (24-bit signed integer)"
|
||||
);
|
||||
converter_node!(
|
||||
ToI32Node,
|
||||
to_i32,
|
||||
SampleType::I32,
|
||||
"Node de conversion vers I32 (32-bit signed integer)"
|
||||
);
|
||||
converter_node!(
|
||||
ToF32Node,
|
||||
to_f32,
|
||||
SampleType::F32,
|
||||
"Node de conversion vers F32 (32-bit floating point)"
|
||||
);
|
||||
converter_node!(
|
||||
ToF64Node,
|
||||
to_f64,
|
||||
SampleType::F64,
|
||||
"Node de conversion vers F64 (64-bit floating point)"
|
||||
);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{AudioChunk, AudioChunkData};
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_to_f32_node_type_requirements() {
|
||||
let node = ToF32Node::new();
|
||||
|
||||
// Vérifier les types d'entrée/sortie
|
||||
assert_eq!(
|
||||
node.input_type().unwrap().get_accepted_types().len(),
|
||||
5,
|
||||
"Should accept all 5 types"
|
||||
);
|
||||
assert_eq!(
|
||||
node.output_type()
|
||||
.unwrap()
|
||||
.get_accepted_types()
|
||||
.first()
|
||||
.copied(),
|
||||
Some(SampleType::F32),
|
||||
"Should output F32 only"
|
||||
);
|
||||
}
|
||||
|
||||
// Nœud de test simple qui collecte les segments
|
||||
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");
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_to_i16_node_converts_from_i32() {
|
||||
let mut node = ToI16Node::new();
|
||||
let (out_tx, mut out_rx) = mpsc::channel(16);
|
||||
let collector = TestCollectorNode::new(out_tx);
|
||||
node.register(Box::new(collector));
|
||||
|
||||
// Récupérer le tx du node
|
||||
let tx = node.get_tx().unwrap();
|
||||
|
||||
// Lancer le node dans une tâche
|
||||
let stop_token = CancellationToken::new();
|
||||
let handle = tokio::spawn(async move { Box::new(node).run(stop_token).await });
|
||||
|
||||
// Créer et envoyer un chunk I32
|
||||
let stereo = vec![[1_000_000i32 << 16, -500_000i32 << 16]; 100];
|
||||
let chunk_data = AudioChunkData::new(stereo.clone(), 48_000, 0.0);
|
||||
let chunk = AudioChunk::I32(chunk_data);
|
||||
let segment = Arc::new(AudioSegment {
|
||||
order: 0,
|
||||
timestamp_sec: 0.0,
|
||||
segment: crate::_AudioSegment::Chunk(Arc::new(chunk)),
|
||||
});
|
||||
|
||||
tx.send(segment).await.unwrap();
|
||||
drop(tx);
|
||||
|
||||
// Recevoir le chunk converti
|
||||
let result = out_rx.recv().await.unwrap();
|
||||
assert!(result.is_audio_chunk());
|
||||
|
||||
if let Some(converted) = result.as_chunk() {
|
||||
assert_eq!(converted.type_name(), "i16");
|
||||
assert_eq!(converted.len(), 100);
|
||||
|
||||
// Vérifier la conversion (downsampling de I32 vers I16)
|
||||
if let AudioChunk::I16(data) = &**converted {
|
||||
for (orig, converted_frame) in stereo.iter().zip(data.frames().iter()) {
|
||||
let expected_l = (orig[0] >> 16) as i16;
|
||||
let expected_r = (orig[1] >> 16) as i16;
|
||||
assert_eq!(converted_frame[0], expected_l);
|
||||
assert_eq!(converted_frame[1], expected_r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handle.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_syncmarkers_passthrough() {
|
||||
let mut node = ToI16Node::new();
|
||||
let (out_tx, mut out_rx) = mpsc::channel(16);
|
||||
let collector = TestCollectorNode::new(out_tx);
|
||||
node.register(Box::new(collector));
|
||||
|
||||
let tx = node.get_tx().unwrap();
|
||||
let stop_token = CancellationToken::new();
|
||||
|
||||
tokio::spawn(async move {
|
||||
Box::new(node).run(stop_token).await.unwrap();
|
||||
});
|
||||
|
||||
// Envoyer un syncmarker
|
||||
let top_zero = AudioSegment::new_top_zero_sync();
|
||||
tx.send(top_zero.clone()).await.unwrap();
|
||||
drop(tx);
|
||||
|
||||
// Recevoir le syncmarker
|
||||
let result = out_rx.recv().await.unwrap();
|
||||
assert!(!result.is_audio_chunk());
|
||||
assert!(result.as_sync_marker().is_some());
|
||||
}
|
||||
}
|
||||
564
pmoaudio/src/nodes/file_source.rs
Normal file
564
pmoaudio/src/nodes/file_source.rs
Normal file
@@ -0,0 +1,564 @@
|
||||
use crate::{
|
||||
nodes::{AudioError, TypedAudioNode, DEFAULT_CHUNK_DURATION_MS},
|
||||
pipeline::AudioPipelineNode,
|
||||
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;
|
||||
|
||||
/// 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
|
||||
pub struct FileSource {
|
||||
path: PathBuf,
|
||||
chunk_frames: usize,
|
||||
child_txs: Vec<mpsc::Sender<Arc<AudioSegment>>>,
|
||||
children: Vec<Box<dyn AudioPipelineNode>>,
|
||||
}
|
||||
|
||||
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 {
|
||||
Self {
|
||||
path: path.into(),
|
||||
chunk_frames,
|
||||
child_txs: Vec::new(),
|
||||
children: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 FileSource {
|
||||
fn get_tx(&self) -> Option<mpsc::Sender<Arc<AudioSegment>>> {
|
||||
// FileSource est une source, elle n'a pas d'input
|
||||
None
|
||||
}
|
||||
|
||||
fn register(&mut self, child: Box<dyn AudioPipelineNode>) {
|
||||
// Extraire le tx du child avant de le stocker
|
||||
if let Some(tx) = child.get_tx() {
|
||||
self.child_txs.push(tx.clone());
|
||||
}
|
||||
self.children.push(child);
|
||||
}
|
||||
|
||||
async fn run(
|
||||
self: Box<Self>,
|
||||
stop_token: CancellationToken,
|
||||
) -> Result<(), AudioError> {
|
||||
let Self {
|
||||
path,
|
||||
chunk_frames,
|
||||
child_txs,
|
||||
children,
|
||||
} = *self;
|
||||
|
||||
// 1. Spawner tous les enfants AVANT de commencer à lire
|
||||
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);
|
||||
}
|
||||
|
||||
// 2. Faire le travail de lecture du fichier
|
||||
let work_result: Result<(), AudioError> = async {
|
||||
// Macro helper pour envoyer à tous les enfants
|
||||
macro_rules! send_to_children {
|
||||
($segment:expr) => {
|
||||
for tx in &child_txs {
|
||||
if tx.send($segment.clone()).await.is_err() {
|
||||
tracing::warn!("FileSource: child died during send");
|
||||
return Err(AudioError::SendError);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Ouvrir le fichier
|
||||
let file = File::open(&path).await.map_err(|e| {
|
||||
AudioError::ProcessingError(format!("Failed to open {:?}: {}", 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 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 {
|
||||
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(&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!("FileSource: 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::ProcessingError(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(())
|
||||
}.await;
|
||||
|
||||
// 3. Arrêter les enfants qui tournent encore (descendant uniquement)
|
||||
stop_token.cancel();
|
||||
|
||||
// 4. Fermer les channels pour signaler EOF
|
||||
drop(child_txs);
|
||||
|
||||
// 5. Attendre que TOUS les enfants se terminent
|
||||
for handle in child_handles {
|
||||
match handle.await {
|
||||
Ok(Ok(())) => {
|
||||
// Enfant terminé normalement
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
// Enfant en erreur → propager
|
||||
tracing::error!("FileSource: child error: {}", e);
|
||||
return Err(e);
|
||||
}
|
||||
Err(e) => {
|
||||
// Panic dans l'enfant
|
||||
tracing::error!("FileSource: child panic: {}", e);
|
||||
return Err(AudioError::ProcessingError(format!("Child panic: {}", e)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Retourner notre propre résultat (montant vers le parent)
|
||||
work_result
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
788
pmoaudio/src/nodes/flac_file_sink.rs
Normal file
788
pmoaudio/src/nodes/flac_file_sink.rs
Normal file
@@ -0,0 +1,788 @@
|
||||
use crate::{
|
||||
nodes::{AudioError, TypedAudioNode, DEFAULT_CHANNEL_SIZE},
|
||||
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
|
||||
pub struct FlacFileSink {
|
||||
tx: mpsc::Sender<Arc<AudioSegment>>,
|
||||
rx: mpsc::Receiver<Arc<AudioSegment>>,
|
||||
base_path: PathBuf,
|
||||
encoder_options: EncoderOptions,
|
||||
pcm_buffer_capacity: usize,
|
||||
}
|
||||
|
||||
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 (tx, rx) = mpsc::channel(channel_size);
|
||||
Self {
|
||||
tx,
|
||||
rx,
|
||||
base_path: base_path.into(),
|
||||
encoder_options,
|
||||
pcm_buffer_capacity: 8,
|
||||
}
|
||||
}
|
||||
|
||||
/// Lance l'encodage vers le(s) fichier(s) cible(s).
|
||||
///
|
||||
/// Cette méthode crée un nouveau fichier FLAC pour chaque TrackBoundary rencontré.
|
||||
/// Les fichiers sont nommés selon la convention :
|
||||
/// - Track 0 : base_path.flac
|
||||
/// - Track 1 : base_path_01.flac
|
||||
/// - Track 2 : base_path_02.flac, etc.
|
||||
async fn run_internal(
|
||||
mut rx: mpsc::Receiver<Arc<AudioSegment>>,
|
||||
base_path: PathBuf,
|
||||
encoder_options: EncoderOptions,
|
||||
pcm_buffer_capacity: usize,
|
||||
stop_token: CancellationToken,
|
||||
) -> Result<(), AudioError> {
|
||||
|
||||
let mut track_number = 0;
|
||||
|
||||
loop {
|
||||
// Vérifier si l'arrêt a été demandé
|
||||
if stop_token.is_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);
|
||||
|
||||
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(&base_path, track_number);
|
||||
|
||||
// Créer le pipeline d'encodage pour cette track
|
||||
let (pcm_tx, pcm_rx) = mpsc::channel::<Vec<u8>>(pcm_buffer_capacity);
|
||||
|
||||
// Préparer les options d'encodage avec les métadonnées du TrackBoundary
|
||||
let mut options_with_metadata = 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(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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))
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
/// 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.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.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.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.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.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.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.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.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.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>>> {
|
||||
Some(self.tx.clone())
|
||||
}
|
||||
|
||||
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> {
|
||||
let FlacFileSink {
|
||||
tx: _tx,
|
||||
rx,
|
||||
base_path,
|
||||
encoder_options,
|
||||
pcm_buffer_capacity,
|
||||
} = *self;
|
||||
|
||||
Self::run_internal(rx, base_path, encoder_options, pcm_buffer_capacity, 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);
|
||||
}
|
||||
}
|
||||
901
pmoaudio/src/nodes/http_source.rs
Normal file
901
pmoaudio/src/nodes/http_source.rs
Normal file
@@ -0,0 +1,901 @@
|
||||
use crate::{
|
||||
nodes::{AudioError, TypedAudioNode, DEFAULT_CHUNK_DURATION_MS},
|
||||
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.)
|
||||
pub struct HttpSource {
|
||||
url: String,
|
||||
chunk_frames: usize,
|
||||
child_txs: Vec<mpsc::Sender<Arc<AudioSegment>>>,
|
||||
children: Vec<Box<dyn AudioPipelineNode>>,
|
||||
}
|
||||
|
||||
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 {
|
||||
Self {
|
||||
url: url.into(),
|
||||
chunk_frames,
|
||||
child_txs: Vec::new(),
|
||||
children: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_internal(
|
||||
url: String,
|
||||
chunk_frames: usize,
|
||||
child_txs: Vec<mpsc::Sender<Arc<AudioSegment>>>,
|
||||
stop_token: CancellationToken,
|
||||
) -> Result<(), AudioError> {
|
||||
// Effectuer la requête HTTP
|
||||
let response = reqwest::get(&url)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AudioError::ProcessingError(format!("HTTP request failed for {}: {}", url, e))
|
||||
})?;
|
||||
|
||||
// Vérifier le status
|
||||
if !response.status().is_success() {
|
||||
return Err(AudioError::ProcessingError(format!(
|
||||
"HTTP request returned status {}: {}",
|
||||
response.status(),
|
||||
url
|
||||
)));
|
||||
}
|
||||
|
||||
// Extraire les métadonnées depuis les headers HTTP
|
||||
let metadata = extract_metadata_from_headers(&response, &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 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 {
|
||||
chunk_frames.max(1)
|
||||
};
|
||||
|
||||
// Émettre TopZeroSync
|
||||
let top_zero = AudioSegment::new_top_zero_sync();
|
||||
for tx in &child_txs {
|
||||
tx.send(top_zero.clone()).await.map_err(|_| AudioError::SendError)?;
|
||||
}
|
||||
|
||||
// É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)));
|
||||
for tx in &child_txs {
|
||||
tx.send(track_boundary.clone()).await.map_err(|_| AudioError::SendError)?;
|
||||
}
|
||||
|
||||
// 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,
|
||||
)?;
|
||||
|
||||
for tx in &child_txs {
|
||||
if tx.send(segment.clone()).await.is_err() {
|
||||
// Un enfant est mort, arrêter
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
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)?;
|
||||
for tx in &child_txs {
|
||||
let _ = tx.send(segment.clone()).await;
|
||||
}
|
||||
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);
|
||||
for tx in &child_txs {
|
||||
let _ = tx.send(eos.clone()).await;
|
||||
}
|
||||
|
||||
// Attendre la fin du décodage
|
||||
stream
|
||||
.wait()
|
||||
.await
|
||||
.map_err(|e| AudioError::ProcessingError(format!("Decode task failed: {}", e)))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 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>>> {
|
||||
None // HttpSource est une source, pas d'input
|
||||
}
|
||||
|
||||
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(
|
||||
self: Box<Self>,
|
||||
stop_token: CancellationToken,
|
||||
) -> Result<(), AudioError> {
|
||||
let HttpSource {
|
||||
url,
|
||||
chunk_frames,
|
||||
child_txs,
|
||||
children,
|
||||
} = *self;
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Lancer la logique interne
|
||||
let work_result = Self::run_internal(url, chunk_frames, child_txs, stop_token.clone()).await;
|
||||
|
||||
// Attendre que tous les enfants se terminent
|
||||
for handle in child_handles {
|
||||
match handle.await {
|
||||
Ok(Ok(())) => {},
|
||||
Ok(Err(e)) => return Err(e),
|
||||
Err(e) => {
|
||||
return Err(AudioError::ProcessingError(format!(
|
||||
"Child task panicked: {}",
|
||||
e
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
work_result
|
||||
}
|
||||
}
|
||||
|
||||
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.url, "http://example.com/audio.flac");
|
||||
assert_eq!(source.chunk_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.url, "http://example.com/audio.mp3");
|
||||
assert_eq!(source.chunk_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.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.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");
|
||||
}
|
||||
}
|
||||
135
pmoaudio/src/nodes/mod.rs
Normal file
135
pmoaudio/src/nodes/mod.rs
Normal file
@@ -0,0 +1,135 @@
|
||||
//! Nodes du pipeline audio
|
||||
//!
|
||||
//! Ce module contient tous les types de nodes disponibles pour construire
|
||||
//! un pipeline audio, ainsi que les traits et structures de support.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
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;
|
||||
pub mod disk_sink;
|
||||
pub mod dsp_node;
|
||||
pub mod mpd_sink;
|
||||
pub mod sink_node;
|
||||
pub mod source_node;
|
||||
pub mod timer_node;
|
||||
pub mod volume_node;
|
||||
*/
|
||||
|
||||
/// Trait de base pour tous les nodes audio
|
||||
///
|
||||
/// Tous les nodes du pipeline implémentent ce trait pour permettre
|
||||
/// une interface uniforme de traitement des chunks audio.
|
||||
#[async_trait::async_trait]
|
||||
pub trait AudioNode: Send + Sync {
|
||||
/// Push un chunk vers ce node
|
||||
///
|
||||
/// # Erreurs
|
||||
///
|
||||
/// Retourne `AudioError::SendError` si l'envoi échoue
|
||||
async fn push(&mut self, chunk: Arc<AudioSegment>) -> Result<(), AudioError>;
|
||||
|
||||
/// Ferme le node proprement
|
||||
async fn close(&mut self);
|
||||
}
|
||||
|
||||
/// Trait pour les nodes qui déclarent leurs types acceptés/produits
|
||||
///
|
||||
/// 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),
|
||||
/// }
|
||||
/// ```
|
||||
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>;
|
||||
|
||||
/// 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>;
|
||||
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Erreurs possibles dans le pipeline audio
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AudioError {
|
||||
/// Échec d'envoi d'un chunk à travers un channel
|
||||
SendError,
|
||||
/// Échec de réception d'un chunk depuis un channel
|
||||
ReceiveError,
|
||||
/// Erreur de traitement avec message descriptif
|
||||
ProcessingError(String),
|
||||
/// Incompatibilité de types entre nodes
|
||||
TypeMismatch(TypeMismatch),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AudioError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for AudioError {}
|
||||
110
pmoaudio/src/pipeline.rs
Normal file
110
pmoaudio/src/pipeline.rs
Normal file
@@ -0,0 +1,110 @@
|
||||
//! 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_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>;
|
||||
}
|
||||
329
pmoaudio/src/sample_types.rs
Normal file
329
pmoaudio/src/sample_types.rs
Normal 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
Normal file
14
pmoaudio/src/sync_marker.rs
Normal 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
Normal file
385
pmoaudio/src/type_constraints.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
162
pmoaudio/tests/integration_test.rs
Normal file
162
pmoaudio/tests/integration_test.rs
Normal file
@@ -0,0 +1,162 @@
|
||||
//! Tests d'intégration pour le pipeline audio complet
|
||||
|
||||
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 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);
|
||||
|
||||
// Connecter le pipeline
|
||||
decoder.add_subscriber(dsp_tx);
|
||||
dsp.add_subscriber(buffer_tx);
|
||||
buffer.add_next_subscriber(timer_tx);
|
||||
timer.add_subscriber(sink_tx);
|
||||
|
||||
let timer_handle = timer.get_position_handle();
|
||||
|
||||
// Spawn tous les nodes
|
||||
tokio::spawn(async move { decoder.run_passthrough().await.unwrap() });
|
||||
tokio::spawn(async move { dsp.run().await.unwrap() });
|
||||
tokio::spawn(async move { buffer.run().await.unwrap() });
|
||||
tokio::spawn(async move { timer.run().await.unwrap() });
|
||||
|
||||
let sink_handle = tokio::spawn(async move { sink.run_with_stats().await.unwrap() });
|
||||
|
||||
// Générer des chunks
|
||||
tokio::spawn(async move {
|
||||
let mut source = SourceNode::new();
|
||||
source.add_subscriber(decoder_tx);
|
||||
source
|
||||
.generate_chunks(10, 4800, 48000, 440.0)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
// Attendre la fin
|
||||
let stats = sink_handle.await.unwrap();
|
||||
|
||||
// Vérifier les résultats
|
||||
assert_eq!(stats.chunks_received, 10);
|
||||
assert_eq!(stats.total_samples, 48000);
|
||||
|
||||
// Vérifier que le gain a été appliqué (peak devrait être ~0.5)
|
||||
assert!(stats.peak_left < 0.51 && stats.peak_left > 0.49);
|
||||
|
||||
// Vérifier la position
|
||||
let position = timer_handle.position_sec().await;
|
||||
assert!((position - 1.0).abs() < 0.01); // ~1 seconde
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_multiroom_buffering() {
|
||||
// Tester le BufferNode avec plusieurs abonnés avec offsets
|
||||
|
||||
let (buffer, buffer_tx) = BufferNode::new(50, 20);
|
||||
|
||||
let (sink1, sink1_tx) = SinkNode::new("Room 1".to_string(), 20);
|
||||
let (sink2, sink2_tx) = SinkNode::new("Room 2".to_string(), 20);
|
||||
let (sink3, sink3_tx) = SinkNode::new("Room 3".to_string(), 20);
|
||||
|
||||
buffer.add_subscriber_with_offset(sink1_tx, 0).await;
|
||||
buffer.add_subscriber_with_offset(sink2_tx, 3).await;
|
||||
buffer.add_subscriber_with_offset(sink3_tx, 6).await;
|
||||
|
||||
tokio::spawn(async move { buffer.run().await.unwrap() });
|
||||
|
||||
let sink1_handle = tokio::spawn(async move { sink1.run_with_stats().await.unwrap() });
|
||||
let sink2_handle = tokio::spawn(async move { sink2.run_with_stats().await.unwrap() });
|
||||
let sink3_handle = tokio::spawn(async move { sink3.run_with_stats().await.unwrap() });
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut source = SourceNode::new();
|
||||
source.add_subscriber(buffer_tx);
|
||||
source
|
||||
.generate_chunks(20, 1000, 48000, 440.0)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
let stats1 = sink1_handle.await.unwrap();
|
||||
let stats2 = sink2_handle.await.unwrap();
|
||||
let stats3 = sink3_handle.await.unwrap();
|
||||
|
||||
// Room 1 devrait avoir tous les chunks
|
||||
assert_eq!(stats1.chunks_received, 20);
|
||||
|
||||
// Room 2 devrait avoir 3 chunks de moins
|
||||
assert_eq!(stats2.chunks_received, 17);
|
||||
|
||||
// Room 3 devrait avoir 6 chunks de moins
|
||||
assert_eq!(stats3.chunks_received, 14);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_timer_accuracy() {
|
||||
// Tester la précision du TimerNode
|
||||
|
||||
let (mut timer, timer_tx) = TimerNode::new(10);
|
||||
let (sink, sink_tx) = SinkNode::new("Timer Test".to_string(), 10);
|
||||
|
||||
timer.add_subscriber(sink_tx);
|
||||
let timer_handle = timer.get_position_handle();
|
||||
|
||||
tokio::spawn(async move { timer.run().await.unwrap() });
|
||||
let sink_handle = tokio::spawn(async move { sink.run_silent().await.unwrap() });
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut source = SourceNode::new();
|
||||
source.add_subscriber(timer_tx);
|
||||
|
||||
// 48000 samples à 48kHz = 1 seconde
|
||||
source
|
||||
.generate_chunks(1, 48000, 48000, 440.0)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
sink_handle.await.unwrap();
|
||||
|
||||
let position = timer_handle.position_sec().await;
|
||||
let samples = timer_handle.elapsed_samples().await;
|
||||
|
||||
assert_eq!(samples, 48000);
|
||||
assert!((position - 1.0).abs() < 0.0001);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_arc_sharing() {
|
||||
// Vérifier que les chunks sont bien partagés via Arc sans copie
|
||||
|
||||
let (mut timer, timer_tx) = TimerNode::new(10);
|
||||
let (sink1, sink1_tx) = SinkNode::new("Sink1".to_string(), 10);
|
||||
let (sink2, sink2_tx) = SinkNode::new("Sink2".to_string(), 10);
|
||||
|
||||
timer.add_subscriber(sink1_tx);
|
||||
timer.add_subscriber(sink2_tx);
|
||||
|
||||
tokio::spawn(async move { timer.run().await.unwrap() });
|
||||
|
||||
let sink1_handle = tokio::spawn(async move { sink1.run_with_stats().await.unwrap() });
|
||||
let sink2_handle = tokio::spawn(async move { sink2.run_with_stats().await.unwrap() });
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut source = SourceNode::new();
|
||||
source.add_subscriber(timer_tx);
|
||||
source.generate_silence(5, 1000, 48000).await.unwrap();
|
||||
});
|
||||
|
||||
let stats1 = sink1_handle.await.unwrap();
|
||||
let stats2 = sink2_handle.await.unwrap();
|
||||
|
||||
// Les deux sinks devraient avoir reçu les mêmes chunks
|
||||
assert_eq!(stats1.chunks_received, 5);
|
||||
assert_eq!(stats2.chunks_received, 5);
|
||||
assert_eq!(stats1.total_samples, stats2.total_samples);
|
||||
}
|
||||
54
pmoaudiocache/Cargo.toml
Normal file
54
pmoaudiocache/Cargo.toml
Normal file
@@ -0,0 +1,54 @@
|
||||
[package]
|
||||
name = "pmoaudiocache"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
# Cache générique
|
||||
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"
|
||||
|
||||
# Métadonnées audio
|
||||
lofty = "0.22"
|
||||
|
||||
# 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"] }
|
||||
|
||||
# Serveur HTTP (optionnel pour l'extension)
|
||||
pmoserver = { path = "../pmoserver", optional = true }
|
||||
pmoconfig = { path = "../pmoconfig", optional = true }
|
||||
axum = { version = "0.8", optional = true }
|
||||
utoipa = { version = "5.3", features = ["axum_extras"], optional = true }
|
||||
|
||||
tracing = "0.1.41"
|
||||
|
||||
[dev-dependencies]
|
||||
tracing-subscriber = "0.3"
|
||||
tempfile = "3"
|
||||
|
||||
[features]
|
||||
default = ["pmoserver"]
|
||||
pmoconfig = ["dep:pmoconfig", "pmocache/pmoconfig"]
|
||||
pmoserver = ["pmoconfig", "dep:pmoserver", "dep:axum", "dep:utoipa", "pmocache/pmoserver", "pmocache/openapi"]
|
||||
51
pmoaudiocache/examples/test_flac_debug.rs
Normal file
51
pmoaudiocache/examples/test_flac_debug.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
use pmoaudiocache::cache;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_max_level(tracing::Level::DEBUG)
|
||||
.init();
|
||||
|
||||
let cache_dir = "/tmp/test_audio_cache_debug";
|
||||
std::fs::create_dir_all(cache_dir)?;
|
||||
|
||||
println!("Creating cache in: {}", cache_dir);
|
||||
|
||||
let cache = Arc::new(cache::new_cache(cache_dir, 100)?);
|
||||
|
||||
// URL MP3 de test - petit fichier
|
||||
let test_url = "https://fr.getsamplefiles.com/download/mp3/sample-3.mp3";
|
||||
|
||||
println!("\nDownloading: {}", test_url);
|
||||
let pk = cache::add_with_metadata_extraction(&cache, test_url, Some("test")).await?;
|
||||
|
||||
println!("\nPK: {}", pk);
|
||||
let file_path = cache.get_file_path(&pk);
|
||||
println!("File path: {}", file_path.display());
|
||||
|
||||
// Vérifier le format
|
||||
let data = std::fs::read(&file_path)?;
|
||||
if data.len() >= 4 {
|
||||
let header = &data[0..4];
|
||||
if header == b"fLaC" {
|
||||
println!("✓ File is FLAC!");
|
||||
} else if header[0..3] == *b"ID3"
|
||||
|| (header.len() >= 2 && header[0] == 0xFF && (header[1] & 0xE0) == 0xE0)
|
||||
{
|
||||
println!("✗ File is still MP3!");
|
||||
println!(
|
||||
" Header: {:02X} {:02X} {:02X} {:02X}",
|
||||
header[0], header[1], header[2], header[3]
|
||||
);
|
||||
} else {
|
||||
println!("? Unknown format");
|
||||
println!(
|
||||
" Header: {:02X} {:02X} {:02X} {:02X}",
|
||||
header[0], header[1], header[2], header[3]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
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(())
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user