2026-02-26 19:58:37 +01:00
|
|
|
//! Pipeline audio serveur par instance WebRenderer
|
|
|
|
|
//!
|
|
|
|
|
//! Chaque instance WebRenderer possède un pipeline indépendant :
|
2026-03-01 23:40:07 +01:00
|
|
|
//! - Une `PlayerSource` qui gère le cycle de vie AVTransport (Play/Pause/Stop/Seek/LoadUri)
|
|
|
|
|
//! - Un `StreamingOggFlacSink` qui encode et diffuse le flux OGG-FLAC aux clients HTTP
|
|
|
|
|
//! - Des nœuds de normalisation (resampling → 96 kHz, conversion → I24)
|
2026-02-26 19:58:37 +01:00
|
|
|
|
|
|
|
|
use std::sync::Arc;
|
|
|
|
|
|
2026-03-01 23:40:07 +01:00
|
|
|
use pmoaudio::{AudioPipelineNode, ResamplingNode, ToI24Node};
|
|
|
|
|
use pmoaudio_ext::{PlayerCommand, PlayerHandle, PlayerSource};
|
|
|
|
|
use pmoaudio_ext::sinks::{OggFlacStreamHandle, StreamingOggFlacSink};
|
2026-02-26 19:58:37 +01:00
|
|
|
use pmoflac::EncoderOptions;
|
|
|
|
|
use tokio_util::sync::CancellationToken;
|
2026-03-01 23:40:07 +01:00
|
|
|
use tracing::{debug, warn};
|
2026-02-26 19:58:37 +01:00
|
|
|
|
|
|
|
|
use crate::state::SharedState;
|
|
|
|
|
|
2026-03-01 23:40:07 +01:00
|
|
|
// ─── Ré-export des commandes pour les handlers ────────────────────────────────
|
2026-02-26 19:58:37 +01:00
|
|
|
|
2026-03-01 23:40:07 +01:00
|
|
|
/// Commandes de transport — alias vers PlayerCommand pour compatibilité handlers
|
|
|
|
|
pub use pmoaudio_ext::PlayerCommand as PipelineControl;
|
2026-02-26 19:58:37 +01:00
|
|
|
|
|
|
|
|
// ─── Handle vers le pipeline ─────────────────────────────────────────────────
|
|
|
|
|
|
2026-03-01 23:40:07 +01:00
|
|
|
/// Handle partageable vers le pipeline audio d'une instance.
|
|
|
|
|
///
|
|
|
|
|
/// Expose le `PlayerHandle` pour les commandes AVTransport et le `CancellationToken`
|
|
|
|
|
/// pour l'arrêt complet du pipeline.
|
2026-02-26 19:58:37 +01:00
|
|
|
#[derive(Clone)]
|
|
|
|
|
pub struct PipelineHandle {
|
2026-03-01 23:40:07 +01:00
|
|
|
pub player: PlayerHandle,
|
2026-02-26 19:58:37 +01:00
|
|
|
pub stop_token: CancellationToken,
|
2026-03-01 23:40:07 +01:00
|
|
|
/// Volume courant (géré localement, pas dans PlayerSource)
|
|
|
|
|
state: SharedState,
|
2026-02-26 19:58:37 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl PipelineHandle {
|
2026-03-01 23:40:07 +01:00
|
|
|
/// Envoie une commande de transport. Gère SetVolume/SetMute localement.
|
2026-02-26 19:58:37 +01:00
|
|
|
pub async fn send(&self, cmd: PipelineControl) {
|
2026-03-01 23:40:07 +01:00
|
|
|
match cmd {
|
|
|
|
|
PlayerCommand::LoadUri(uri) => self.player.load_uri(uri).await,
|
|
|
|
|
PlayerCommand::LoadNextUri(uri) => self.player.load_next_uri(uri).await,
|
|
|
|
|
PlayerCommand::Play => self.player.play().await,
|
|
|
|
|
PlayerCommand::Pause => self.player.pause().await,
|
|
|
|
|
PlayerCommand::Stop => self.player.stop().await,
|
|
|
|
|
PlayerCommand::Seek(pos) => self.player.seek(pos).await,
|
|
|
|
|
}
|
2026-02-26 19:58:37 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Pipeline instancié ──────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// Pipeline audio complet pour une instance WebRenderer.
|
|
|
|
|
///
|
|
|
|
|
/// Créé au `POST /register`. Le flux OGG-FLAC est accessible via `flac_handle`
|
2026-03-01 22:46:00 +01:00
|
|
|
/// (multi-client broadcast, chaque `subscribe()` crée un flux indépendant).
|
2026-02-26 19:58:37 +01:00
|
|
|
pub struct InstancePipeline {
|
2026-03-01 22:46:00 +01:00
|
|
|
/// Handle vers le sink OGG-FLAC — clonable, subscribe() crée un flux indépendant par client.
|
|
|
|
|
pub flac_handle: OggFlacStreamHandle,
|
2026-02-26 19:58:37 +01:00
|
|
|
pub pipeline_handle: PipelineHandle,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl InstancePipeline {
|
|
|
|
|
/// Crée et démarre le pipeline en background.
|
|
|
|
|
/// Retourne immédiatement avec les handles nécessaires.
|
|
|
|
|
pub fn start(
|
|
|
|
|
state: SharedState,
|
|
|
|
|
#[cfg(feature = "pmoserver")]
|
|
|
|
|
control_point: Arc<pmocontrol::ControlPoint>,
|
|
|
|
|
udn: String,
|
|
|
|
|
) -> Self {
|
|
|
|
|
let stop_token = CancellationToken::new();
|
|
|
|
|
|
|
|
|
|
use pmoaudio::pipeline::AudioPipelineNode;
|
|
|
|
|
|
2026-03-01 23:40:07 +01:00
|
|
|
// Sink broadcast OGG-FLAC (multi-client, pacé à 0.5s max d'avance)
|
2026-03-01 22:46:00 +01:00
|
|
|
let (sink, flac_handle) = StreamingOggFlacSink::new(EncoderOptions::default(), 24);
|
2026-02-26 19:58:37 +01:00
|
|
|
|
|
|
|
|
// Nœud de conversion de profondeur : tout type entier → I24
|
|
|
|
|
let mut to_i24 = ToI24Node::new();
|
2026-02-28 12:45:55 +01:00
|
|
|
to_i24.register(sink.boxed());
|
2026-02-26 19:58:37 +01:00
|
|
|
|
|
|
|
|
// Nœud de rééchantillonnage : n'importe quel sample rate → 96 kHz
|
2026-03-01 22:46:00 +01:00
|
|
|
let mut resampler = ResamplingNode::new(96_000);
|
2026-02-26 20:18:14 +01:00
|
|
|
resampler.register(to_i24.boxed());
|
2026-02-26 19:58:37 +01:00
|
|
|
|
2026-03-01 23:40:07 +01:00
|
|
|
// Source avec contrôle AVTransport complet
|
|
|
|
|
let (mut player_source, player_handle) = PlayerSource::new();
|
|
|
|
|
player_source.register(resampler.boxed());
|
2026-02-26 19:58:37 +01:00
|
|
|
|
2026-03-01 23:40:07 +01:00
|
|
|
// Lancer le pipeline en background
|
2026-02-26 19:58:37 +01:00
|
|
|
let sink_stop = stop_token.clone();
|
|
|
|
|
tokio::spawn(async move {
|
2026-03-01 23:40:07 +01:00
|
|
|
if let Err(e) = player_source.boxed().run(sink_stop).await {
|
2026-02-26 19:58:37 +01:00
|
|
|
warn!("Audio pipeline error: {:?}", e);
|
|
|
|
|
}
|
2026-03-01 23:40:07 +01:00
|
|
|
debug!("Pipeline task terminated");
|
2026-02-26 19:58:37 +01:00
|
|
|
});
|
|
|
|
|
|
2026-03-01 23:40:07 +01:00
|
|
|
// Écouter les événements PlayerSource pour mettre à jour le state UPnP
|
|
|
|
|
let event_rx = player_handle.subscribe_events();
|
|
|
|
|
let state_clone = state.clone();
|
|
|
|
|
let udn_clone = udn.clone();
|
|
|
|
|
#[cfg(feature = "pmoserver")]
|
|
|
|
|
let cp_clone = control_point.clone();
|
2026-02-26 19:58:37 +01:00
|
|
|
tokio::spawn(async move {
|
2026-03-01 23:40:07 +01:00
|
|
|
run_event_listener(
|
|
|
|
|
event_rx,
|
|
|
|
|
state_clone,
|
|
|
|
|
udn_clone,
|
2026-02-26 19:58:37 +01:00
|
|
|
#[cfg(feature = "pmoserver")]
|
2026-03-01 23:40:07 +01:00
|
|
|
cp_clone,
|
|
|
|
|
).await;
|
2026-02-26 19:58:37 +01:00
|
|
|
});
|
|
|
|
|
|
2026-03-01 23:40:07 +01:00
|
|
|
let pipeline_handle = PipelineHandle {
|
|
|
|
|
player: player_handle,
|
|
|
|
|
stop_token: stop_token.clone(),
|
|
|
|
|
state,
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-26 19:58:37 +01:00
|
|
|
Self {
|
|
|
|
|
flac_handle,
|
|
|
|
|
pipeline_handle,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-01 23:40:07 +01:00
|
|
|
// ─── Listener d'événements ────────────────────────────────────────────────────
|
2026-02-26 19:58:37 +01:00
|
|
|
|
2026-03-01 23:40:07 +01:00
|
|
|
async fn run_event_listener(
|
|
|
|
|
mut event_rx: tokio::sync::broadcast::Receiver<pmoaudio_ext::PlayerEvent>,
|
2026-02-26 19:58:37 +01:00
|
|
|
state: SharedState,
|
|
|
|
|
udn: String,
|
|
|
|
|
#[cfg(feature = "pmoserver")]
|
|
|
|
|
control_point: Arc<pmocontrol::ControlPoint>,
|
|
|
|
|
) {
|
2026-03-01 23:40:07 +01:00
|
|
|
use pmoaudio_ext::PlayerEvent;
|
|
|
|
|
use crate::messages::PlaybackState;
|
2026-02-26 19:58:37 +01:00
|
|
|
|
|
|
|
|
loop {
|
2026-03-01 23:40:07 +01:00
|
|
|
match event_rx.recv().await {
|
|
|
|
|
Ok(event) => match event {
|
|
|
|
|
PlayerEvent::Playing { uri, duration_sec } => {
|
|
|
|
|
let mut s = state.write();
|
|
|
|
|
s.playback_state = PlaybackState::Playing;
|
|
|
|
|
s.current_uri = Some(uri);
|
|
|
|
|
s.duration = duration_sec.map(seconds_to_upnp_time);
|
|
|
|
|
s.position = None;
|
|
|
|
|
// Effacer next_uri/next_metadata : la nouvelle piste est maintenant courante
|
|
|
|
|
s.next_uri = None;
|
|
|
|
|
s.next_metadata = None;
|
2026-02-26 19:58:37 +01:00
|
|
|
}
|
2026-03-01 23:40:07 +01:00
|
|
|
PlayerEvent::Paused { position_sec } => {
|
|
|
|
|
let mut s = state.write();
|
|
|
|
|
s.playback_state = PlaybackState::Paused;
|
|
|
|
|
s.position = Some(seconds_to_upnp_time(position_sec));
|
|
|
|
|
}
|
|
|
|
|
PlayerEvent::Stopped => {
|
|
|
|
|
let mut s = state.write();
|
|
|
|
|
s.playback_state = PlaybackState::Stopped;
|
|
|
|
|
s.position = None;
|
|
|
|
|
}
|
|
|
|
|
PlayerEvent::TrackEnded => {
|
|
|
|
|
#[cfg(feature = "pmoserver")]
|
|
|
|
|
{
|
2026-02-26 19:58:37 +01:00
|
|
|
let cp = control_point.clone();
|
2026-03-01 23:40:07 +01:00
|
|
|
let udn_c = udn.clone();
|
2026-02-26 19:58:37 +01:00
|
|
|
tokio::spawn(async move {
|
2026-03-01 23:40:07 +01:00
|
|
|
cp.advance_queue_and_prefetch(&pmocontrol::DeviceId(udn_c));
|
2026-02-26 19:58:37 +01:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-01 23:40:07 +01:00
|
|
|
PlayerEvent::Error(e) => {
|
|
|
|
|
tracing::warn!(udn = %udn, "PlayerSource error: {}", e);
|
2026-02-26 19:58:37 +01:00
|
|
|
state.write().playback_state = PlaybackState::Stopped;
|
|
|
|
|
}
|
2026-03-01 23:40:07 +01:00
|
|
|
},
|
|
|
|
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
|
|
|
|
tracing::warn!(udn = %udn, "Event listener lagged {} events", n);
|
|
|
|
|
}
|
|
|
|
|
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
|
|
|
|
|
break;
|
2026-02-26 19:58:37 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
pub fn seconds_to_upnp_time(s: f64) -> String {
|
|
|
|
|
let s = s as u64;
|
|
|
|
|
let h = s / 3600;
|
|
|
|
|
let m = (s % 3600) / 60;
|
|
|
|
|
let sec = s % 60;
|
|
|
|
|
format!("{}:{:02}:{:02}", h, m, sec)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn upnp_time_to_seconds(t: &str) -> f64 {
|
|
|
|
|
let parts: Vec<f64> = t.split(':').filter_map(|p| p.parse().ok()).collect();
|
|
|
|
|
match parts.as_slice() {
|
|
|
|
|
[h, m, s] => h * 3600.0 + m * 60.0 + s,
|
|
|
|
|
[m, s] => m * 60.0 + s,
|
|
|
|
|
[s] => *s,
|
|
|
|
|
_ => 0.0,
|
|
|
|
|
}
|
|
|
|
|
}
|