Migrate instance ID storage from localStorage to sessionStorage

Switch instance ID storage from localStorage to sessionStorage in useWebRenderer composable to ensure better session management and prevent data persistence across browser sessions.

Also update the DirectOggFlacSink implementation to improve backpressure handling, streamline the OGG chaining logic, and add proper Safari range header support for the stream endpoint.
This commit is contained in:
2026-02-28 14:10:38 +01:00
parent ca8702fd7f
commit de74c7b431
5 changed files with 187 additions and 271 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@@ -34,10 +34,10 @@ function generateUUID(): string {
function getOrCreateInstanceId(): string { function getOrCreateInstanceId(): string {
try { try {
let id = localStorage.getItem(INSTANCE_ID_KEY); let id = sessionStorage.getItem(INSTANCE_ID_KEY);
if (!id) { if (!id) {
id = generateUUID(); id = generateUUID();
localStorage.setItem(INSTANCE_ID_KEY, id); sessionStorage.setItem(INSTANCE_ID_KEY, id);
} }
return id; return id;
} catch { } catch {

View File

@@ -1,35 +1,28 @@
//! DirectOggFlacSink — nœud puits OGG-FLAC pour un seul client HTTP. //! DirectOggFlacSink — nœud puits OGG-FLAC pour un seul client HTTP.
//! //!
//! Combine la logique de backpressure/reconnexion de `DirectFlacSink`
//! avec l'encodage OGG-FLAC de `StreamingOggFlacSink`.
//!
//! # Cycle de vie
//!
//! - **Play** : le navigateur appelle `GET /stream`. `connect()` crée un channel
//! Bytes (bytes_tx/rx), une task de forwarding qui copie les Bytes dans un
//! DuplexStream, et lance le premier encodeur OGG-FLAC. Le `bytes_tx` est
//! stocké dans le sink pour le chaining TrackBoundary.
//! - **Stop** : le navigateur ferme la connexion. Le DuplexStream se rompt,
//! la task de forwarding se termine, le bytes_tx devient invalide. Le sink
//! voit `pcm_tx.send()` échouer et bloque sur `client_notify`.
//! - **TrackBoundary** : le sink ferme le `pcm_tx` courant (EOF → encodeur écrit
//! EOS OGG), attend la fin de l'encodeur, puis relance un nouvel encodeur
//! dans le même `bytes_tx` (OGG chaining : nouvelle BOS OGG dans le même flux HTTP).
//! - **Play suivant** : `connect()` → nouveau DuplexStream + channel → nouveau pipe.
//!
//! # Architecture //! # Architecture
//! //!
//! ```text //! ```text
//! AudioSegment I24 @ 96 kHz //! AudioSegment I24 @ 96 kHz
//! ↓ NodeLogic::process() [bloque si pas de client] //! ↓ NodeLogic::process()
//! chunk_to_pcm_bytes() → PCM 24-bit LE //! chunk_to_pcm_bytes() → PCM 24-bit LE
//! ↓ SharedPcmTx //! ↓ pcm_tx (cap=2)
//! ByteStreamReader → encode_flac_stream() → OGG pages //! ByteStreamReader → encode_flac_stream() → OGG pages (Bytes)
//! ↓ SharedBytesTx (mpsc::Sender<Bytes>) ← persistant entre encodeurs //! ↓ ogg_tx mpsc::Sender<Bytes> (cap=8, backpressure naturelle)
//! [forwarding task] → tokio::io::DuplexStream //! Arc<Mutex<Receiver<Bytes>>> → DirectOggFlacStream (AsyncRead) → HTTP → Safari
//! ↓ DirectOggFlacStream (AsyncRead) → Body HTTP
//! ``` //! ```
//!
//! # Backpressure
//!
//! Safari lent → ogg_rx plein → ogg_tx.send() bloque → encodeur bloque
//! → pcm_tx.send() bloque → pipeline audio bloque.
//!
//! # OGG chaining (TrackBoundary)
//!
//! Fermer pcm_tx → encodeur termine (EOS) → lancer nouvel encodeur.
//! Le même ogg_tx est réutilisé : le stream HTTP est continu.
use std::collections::VecDeque;
use std::io; use std::io;
use std::pin::Pin; use std::pin::Pin;
use std::sync::Arc; use std::sync::Arc;
@@ -46,7 +39,7 @@ use tokio::io::{AsyncRead, ReadBuf};
use tokio::sync::{mpsc, watch, Mutex}; use tokio::sync::{mpsc, watch, Mutex};
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use tracing::{debug, warn}; use tracing::{debug, trace, warn};
use crate::sinks::byte_stream_reader::{ByteStreamReader, PcmChunk}; use crate::sinks::byte_stream_reader::{ByteStreamReader, PcmChunk};
use crate::sinks::chunk_to_pcm::chunk_to_pcm_bytes; use crate::sinks::chunk_to_pcm::chunk_to_pcm_bytes;
@@ -57,129 +50,54 @@ pub const DIRECT_OGG_FLAC_SAMPLE_RATE: u32 = 96_000;
pub const DIRECT_OGG_FLAC_CHANNELS: u8 = 2; pub const DIRECT_OGG_FLAC_CHANNELS: u8 = 2;
pub const DIRECT_OGG_FLAC_BITS_PER_SAMPLE: u8 = 24; pub const DIRECT_OGG_FLAC_BITS_PER_SAMPLE: u8 = 24;
/// Capacité du pipe duplex (~256 KB). /// Capacité du canal OGG → HTTP (en chunks de ~8 KB).
const PIPE_CAPACITY: usize = 256 * 1024; /// Fournit un petit buffer sans casser la backpressure TCP.
/// Capacité du channel Bytes intermédiaire. const OGG_CHANNEL_CAPACITY: usize = 8;
const BYTES_CHANNEL_CAPACITY: usize = 64;
// ─── Shared state ───────────────────────────────────────────────────────────── // ─── Types partagés ───────────────────────────────────────────────────────────
type SharedPcmTx = Arc<Mutex<Option<mpsc::Sender<PcmChunk>>>>; type SharedPcmTx = Arc<Mutex<Option<mpsc::Sender<PcmChunk>>>>;
/// Canal Bytes persistant entre les encodeurs successifs (OGG chaining).
/// Le sink y envoie les pages OGG ; une task de forwarding les copie dans le DuplexStream.
type SharedBytesTx = Arc<Mutex<Option<mpsc::Sender<Bytes>>>>;
/// Handle de la task encodeur courante.
type SharedEncoderTask = Arc<Mutex<Option<JoinHandle<()>>>>; type SharedEncoderTask = Arc<Mutex<Option<JoinHandle<()>>>>;
type SharedOggRx = Arc<Mutex<mpsc::Receiver<Bytes>>>;
// ─── Handle public ──────────────────────────────────────────────────────────── // ─── Handle public ────────────────────────────────────────────────────────────
/// Handle vers le sink, cloneable, reconnectable à chaque Play.
#[derive(Clone)] #[derive(Clone)]
pub struct DirectOggFlacHandle { pub struct DirectOggFlacHandle {
pcm_tx: SharedPcmTx, pcm_tx: SharedPcmTx,
client_connect_tx: Arc<watch::Sender<u64>>, encoder_task: SharedEncoderTask,
client_notify_internal: Arc<tokio::sync::Notify>,
first_byte_tx: Arc<watch::Sender<bool>>,
encoder_options: EncoderOptions, encoder_options: EncoderOptions,
current_timestamp: Arc<tokio::sync::RwLock<f64>>, current_timestamp: Arc<tokio::sync::RwLock<f64>>,
/// Canal Bytes persistant partagé avec la logic du sink pour le OGG chaining. /// Canal OGG partagé avec le stream HTTP.
bytes_tx: SharedBytesTx, ogg_tx: mpsc::Sender<Bytes>,
/// Task encodeur courante partagée avec la logic du sink. ogg_rx: SharedOggRx,
encoder_task: SharedEncoderTask, /// Notifié quand get_stream() est appelé (premier client).
client_notify: Arc<tokio::sync::Notify>,
} }
impl DirectOggFlacHandle { impl DirectOggFlacHandle {
/// Crée un nouveau flux OGG-FLAC pour le client HTTP. /// Retourne le stream OGG-FLAC pour le handler HTTP.
/// Remplace toute connexion précédente. /// Lance le premier encodeur au premier appel.
pub async fn connect(&self) -> DirectOggFlacStream { pub fn get_stream(&self) -> DirectOggFlacStream {
let connect_count_before = *self.client_connect_tx.borrow(); debug!("DirectOggFlacHandle::get_stream()");
debug!("DirectOggFlacHandle::connect() called, connect_count={}", connect_count_before); self.client_notify.notify_one();
// Annuler l'encodeur précédent s'il tourne encore
if let Some(old_task) = self.encoder_task.lock().await.take() {
old_task.abort();
}
// Créer le channel Bytes persistant (OGG chaining)
let (bytes_tx, bytes_rx) = mpsc::channel::<Bytes>(BYTES_CHANNEL_CAPACITY);
*self.bytes_tx.lock().await = Some(bytes_tx.clone());
// Créer le DuplexStream vers le client HTTP
let (mut pipe_writer, pipe_reader) = tokio::io::duplex(PIPE_CAPACITY);
// Task de forwarding : Bytes → DuplexStream
// Se termine quand bytes_rx est fermé (bytes_tx droppé) ou pipe cassé
tokio::spawn(async move {
use tokio::io::AsyncWriteExt;
let mut rx = bytes_rx;
while let Some(bytes) = rx.recv().await {
if pipe_writer.write_all(&bytes).await.is_err() {
debug!("DirectOggFlacStream forwarder: pipe broken, client disconnected");
break;
}
}
debug!("DirectOggFlacStream forwarder: done");
});
// Réinitialiser les signaux
let _ = self.first_byte_tx.send(false);
*self.current_timestamp.write().await = 0.0;
// Créer le premier pcm_tx + encodeur
let (pcm_tx, pcm_rx) = mpsc::channel::<PcmChunk>(8);
let current_dur = Arc::new(tokio::sync::RwLock::new(0.0f64));
let pcm_reader = ByteStreamReader::new(pcm_rx, self.current_timestamp.clone(), current_dur);
*self.pcm_tx.lock().await = Some(pcm_tx);
let new_count = connect_count_before.wrapping_add(1);
let _ = self.client_connect_tx.send(new_count);
self.client_notify_internal.notify_one();
debug!("DirectOggFlacHandle::connect() client_connect_count -> {}", new_count);
let options = self.encoder_options.clone();
let current_timestamp = self.current_timestamp.clone();
let shared_bytes_tx = self.bytes_tx.clone();
let handle = tokio::spawn(async move {
debug!("DirectOggFlacHandle: initial encoder task started");
if let Err(e) = run_ogg_encoder(pcm_reader, bytes_tx, shared_bytes_tx, options, current_timestamp).await {
debug!("DirectOggFlacHandle: initial encoder stopped: {}", e);
}
debug!("DirectOggFlacHandle: initial encoder task ended");
});
*self.encoder_task.lock().await = Some(handle);
debug!("DirectOggFlacHandle::connect() returning DirectOggFlacStream");
DirectOggFlacStream { DirectOggFlacStream {
inner: pipe_reader, ogg_rx: self.ogg_rx.clone(),
first_byte_tx: Some(self.first_byte_tx.clone()), buffer: VecDeque::new(),
} }
} }
pub fn first_byte_ready(&self) -> watch::Receiver<bool> {
self.first_byte_tx.subscribe()
}
pub async fn current_position_sec(&self) -> f64 { pub async fn current_position_sec(&self) -> f64 {
*self.current_timestamp.read().await *self.current_timestamp.read().await
} }
pub async fn wait_for_client(&self) {
let seen = *self.client_connect_tx.borrow();
debug!("DirectOggFlacHandle::wait_for_client() called, seen connect_count={}", seen);
let mut rx = self.client_connect_tx.subscribe();
let _ = rx.wait_for(|v| *v > seen).await;
debug!("DirectOggFlacHandle::wait_for_client() unblocked");
}
} }
// ─── Stream public ──────────────────────────────────────────────────────────── // ─── Stream public ────────────────────────────────────────────────────────────
/// AsyncRead sur le canal OGG-FLAC.
pub struct DirectOggFlacStream { pub struct DirectOggFlacStream {
inner: tokio::io::DuplexStream, ogg_rx: SharedOggRx,
first_byte_tx: Option<Arc<watch::Sender<bool>>>, buffer: VecDeque<u8>,
} }
impl AsyncRead for DirectOggFlacStream { impl AsyncRead for DirectOggFlacStream {
@@ -188,18 +106,36 @@ impl AsyncRead for DirectOggFlacStream {
cx: &mut Context<'_>, cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>, buf: &mut ReadBuf<'_>,
) -> Poll<io::Result<()>> { ) -> Poll<io::Result<()>> {
let filled_before = buf.filled().len(); // Vider le buffer interne en premier
let result = Pin::new(&mut self.inner).poll_read(cx, buf); if !self.buffer.is_empty() {
if let Poll::Ready(Ok(())) = &result { let to_copy = self.buffer.len().min(buf.remaining());
let filled_after = buf.filled().len(); let chunk: Vec<u8> = self.buffer.drain(..to_copy).collect();
if filled_after > filled_before { buf.put_slice(&chunk);
if let Some(tx) = self.first_byte_tx.take() { return Poll::Ready(Ok(()));
debug!("DirectOggFlacStream: first {} bytes sent to HTTP client", filled_after - filled_before);
let _ = tx.send(true);
} }
// Tenter de recevoir le prochain chunk OGG
let mut guard = match self.ogg_rx.try_lock() {
Ok(g) => g,
Err(_) => {
cx.waker().wake_by_ref();
return Poll::Pending;
} }
};
match guard.poll_recv(cx) {
Poll::Ready(Some(bytes)) => {
drop(guard);
let to_copy = bytes.len().min(buf.remaining());
buf.put_slice(&bytes[..to_copy]);
if to_copy < bytes.len() {
self.buffer.extend(&bytes[to_copy..]);
}
Poll::Ready(Ok(()))
}
Poll::Ready(None) => Poll::Ready(Ok(())), // EOF
Poll::Pending => Poll::Pending,
} }
result
} }
} }
@@ -207,13 +143,11 @@ impl AsyncRead for DirectOggFlacStream {
struct DirectOggFlacSinkLogic { struct DirectOggFlacSinkLogic {
pcm_tx: SharedPcmTx, pcm_tx: SharedPcmTx,
client_notify: Arc<tokio::sync::Notify>,
encoder_options: EncoderOptions,
encoder_task: SharedEncoderTask, encoder_task: SharedEncoderTask,
bytes_tx: SharedBytesTx, encoder_options: EncoderOptions,
current_timestamp: Arc<tokio::sync::RwLock<f64>>, current_timestamp: Arc<tokio::sync::RwLock<f64>>,
/// Vrai dès qu'au moins un chunk audio a été encodé dans le stream courant. ogg_tx: mpsc::Sender<Bytes>,
/// Empêche le OGG chaining sur le TrackBoundary initial (avant tout audio). client_notify: Arc<tokio::sync::Notify>,
has_encoded_frames: bool, has_encoded_frames: bool,
} }
@@ -229,6 +163,15 @@ impl NodeLogic for DirectOggFlacSinkLogic {
AudioError::ProcessingError("DirectOggFlacSink requires an input".into()) AudioError::ProcessingError("DirectOggFlacSink requires an input".into())
})?; })?;
// Attendre le premier client Safari avant de démarrer l'encodeur
debug!("DirectOggFlacSink: waiting for first client...");
tokio::select! {
_ = stop_token.cancelled() => return Ok(()),
_ = self.client_notify.notified() => {}
}
debug!("DirectOggFlacSink: first client connected, starting encoder");
self.start_encoder().await;
loop { loop {
tokio::select! { tokio::select! {
_ = stop_token.cancelled() => { _ = stop_token.cancelled() => {
@@ -244,25 +187,9 @@ impl NodeLogic for DirectOggFlacSinkLogic {
} }
Some(seg) => match &seg.segment { Some(seg) => match &seg.segment {
_AudioSegment::Chunk(chunk) => { _AudioSegment::Chunk(chunk) => {
// Attendre un client si nécessaire (backpressure quand pas de Play) let tx = self.pcm_tx.lock().await.clone();
loop { let Some(tx) = tx else { continue; };
let tx_opt = self.pcm_tx.lock().await.clone();
if tx_opt.is_some() {
break;
}
debug!("DirectOggFlacSink: no pcm_tx, waiting for client_notify...");
tokio::select! {
_ = stop_token.cancelled() => {
debug!("DirectOggFlacSink: cancelled while waiting for client");
return Ok(());
}
_ = self.client_notify.notified() => {
debug!("DirectOggFlacSink: client_notify received, rechecking pcm_tx");
}
}
}
let tx = self.pcm_tx.lock().await.clone().unwrap();
let pcm_bytes = chunk_to_pcm_bytes(chunk, DIRECT_OGG_FLAC_BITS_PER_SAMPLE)?; let pcm_bytes = chunk_to_pcm_bytes(chunk, DIRECT_OGG_FLAC_BITS_PER_SAMPLE)?;
let duration_sec = chunk.len() as f64 / DIRECT_OGG_FLAC_SAMPLE_RATE as f64; let duration_sec = chunk.len() as f64 / DIRECT_OGG_FLAC_SAMPLE_RATE as f64;
let pcm_chunk = PcmChunk { let pcm_chunk = PcmChunk {
@@ -271,11 +198,7 @@ impl NodeLogic for DirectOggFlacSinkLogic {
duration_sec, duration_sec,
}; };
if tx.send(pcm_chunk).await.is_err() { if tx.send(pcm_chunk).await.is_err() {
warn!( warn!("DirectOggFlacSink: encoder gone at {:.3}s", seg.timestamp_sec);
ts = seg.timestamp_sec,
"DirectOggFlacSink: chunk dropped (client disconnected at {:.3}s), waiting for reconnect",
seg.timestamp_sec,
);
*self.pcm_tx.lock().await = None; *self.pcm_tx.lock().await = None;
self.has_encoded_frames = false; self.has_encoded_frames = false;
} else { } else {
@@ -289,11 +212,12 @@ impl NodeLogic for DirectOggFlacSinkLogic {
self.has_encoded_frames = false; self.has_encoded_frames = false;
self.do_track_boundary().await; self.do_track_boundary().await;
} else { } else {
debug!("DirectOggFlacSink: TrackBoundary ignored (no frames encoded yet)"); debug!("DirectOggFlacSink: TrackBoundary ignored (no frames)");
} }
} }
SyncMarker::EndOfStream => { SyncMarker::EndOfStream => {
debug!("DirectOggFlacSink: EndOfStream"); debug!("DirectOggFlacSink: EndOfStream");
self.stop_encoder().await;
} }
_ => {} _ => {}
}, },
@@ -303,6 +227,7 @@ impl NodeLogic for DirectOggFlacSinkLogic {
} }
} }
self.stop_encoder().await;
Ok(()) Ok(())
} }
@@ -312,71 +237,56 @@ impl NodeLogic for DirectOggFlacSinkLogic {
} }
impl DirectOggFlacSinkLogic { impl DirectOggFlacSinkLogic {
/// OGG chaining : ferme l'encodeur courant (EOF → EOS OGG), attend sa fin, async fn start_encoder(&mut self) {
/// puis relance un nouvel encodeur dans le même channel Bytes (nouvelle BOS OGG). let (pcm_tx, pcm_rx) = mpsc::channel::<PcmChunk>(2);
async fn do_track_boundary(&mut self) {
// 1. Fermer le pcm_tx courant → EOF dans ByteStreamReader → encodeur écrit EOS OGG
{
let mut guard = self.pcm_tx.lock().await;
*guard = None;
}
// 2. Attendre la fin de la task encodeur courante
let old_task = self.encoder_task.lock().await.take();
if let Some(handle) = old_task {
let _ = handle.await;
debug!("DirectOggFlacSink: previous encoder task joined");
}
// 3. Vérifier que le bytes_tx est encore valide (client pas déconnecté)
let bytes_tx = {
let guard = self.bytes_tx.lock().await;
guard.clone()
};
let Some(bytes_tx) = bytes_tx else {
debug!("DirectOggFlacSink: bytes_tx gone (client disconnected), skip OGG chaining");
return;
};
// 4. Nouveau pcm_tx + ByteStreamReader
let (pcm_tx, pcm_rx) = mpsc::channel::<PcmChunk>(8);
let current_dur = Arc::new(tokio::sync::RwLock::new(0.0f64)); let current_dur = Arc::new(tokio::sync::RwLock::new(0.0f64));
let pcm_reader = ByteStreamReader::new( let pcm_reader = ByteStreamReader::new(pcm_rx, self.current_timestamp.clone(), current_dur);
pcm_rx,
self.current_timestamp.clone(),
current_dur,
);
*self.pcm_tx.lock().await = Some(pcm_tx); *self.pcm_tx.lock().await = Some(pcm_tx);
// 5. Relancer l'encodeur dans le même bytes_tx (OGG chaining : nouvelle BOS OGG) let ogg_tx = self.ogg_tx.clone();
let options = self.encoder_options.clone(); let options = self.encoder_options.clone();
let current_timestamp = self.current_timestamp.clone(); let current_timestamp = self.current_timestamp.clone();
let shared_bytes_tx = self.bytes_tx.clone();
let handle = tokio::spawn(async move { let handle = tokio::spawn(async move {
debug!("DirectOggFlacSink: chained encoder task started"); debug!("DirectOggFlacSink: encoder task started");
if let Err(e) = run_ogg_encoder(pcm_reader, bytes_tx, shared_bytes_tx, options, current_timestamp).await { if let Err(e) = run_ogg_encoder(pcm_reader, ogg_tx, options, current_timestamp).await {
debug!("DirectOggFlacSink: chained encoder stopped: {}", e); debug!("DirectOggFlacSink: encoder stopped: {}", e);
} }
debug!("DirectOggFlacSink: chained encoder task ended"); debug!("DirectOggFlacSink: encoder task ended");
}); });
*self.encoder_task.lock().await = Some(handle); *self.encoder_task.lock().await = Some(handle);
debug!("DirectOggFlacSink: OGG chaining complete, new encoder started"); }
async fn stop_encoder(&mut self) {
*self.pcm_tx.lock().await = None;
if let Some(handle) = self.encoder_task.lock().await.take() {
let _ = handle.await;
}
self.has_encoded_frames = false;
}
async fn do_track_boundary(&mut self) {
// Fermer pcm_tx → encodeur écrit EOS, se termine
*self.pcm_tx.lock().await = None;
if let Some(handle) = self.encoder_task.lock().await.take() {
let _ = handle.await;
debug!("DirectOggFlacSink: previous encoder joined");
}
// Démarrer le nouvel encodeur sur le même ogg_tx
self.start_encoder().await;
debug!("DirectOggFlacSink: OGG chaining complete");
} }
} }
// ─── Encodeur FLAC + wrapper OGG ───────────────────────────────────────────── // ─── Encodeur FLAC + wrapper OGG ─────────────────────────────────────────────
/// Encode PCM → FLAC → OGG et envoie les pages OGG dans `bytes_tx`.
/// Quand le channel devient invalide (client déconnecté), nettoie `shared_bytes_tx`.
async fn run_ogg_encoder( async fn run_ogg_encoder(
pcm_reader: ByteStreamReader, pcm_reader: ByteStreamReader,
bytes_tx: mpsc::Sender<Bytes>, ogg_tx: mpsc::Sender<Bytes>,
shared_bytes_tx: SharedBytesTx,
options: EncoderOptions, options: EncoderOptions,
_current_timestamp: Arc<tokio::sync::RwLock<f64>>, _current_timestamp: Arc<tokio::sync::RwLock<f64>>,
) -> Result<(), AudioError> { ) -> Result<(), AudioError> {
use tokio::io::AsyncReadExt;
let format = PcmFormat { let format = PcmFormat {
sample_rate: DIRECT_OGG_FLAC_SAMPLE_RATE, sample_rate: DIRECT_OGG_FLAC_SAMPLE_RATE,
channels: DIRECT_OGG_FLAC_CHANNELS, channels: DIRECT_OGG_FLAC_CHANNELS,
@@ -394,36 +304,34 @@ async fn run_ogg_encoder(
let mut ogg = OggPageWriter::new(stream_serial); let mut ogg = OggPageWriter::new(stream_serial);
let ogg_flac_id = create_ogg_flac_identification(&flac_header)?; let ogg_flac_id = create_ogg_flac_identification(&flac_header)?;
let bos_page = Bytes::from(ogg.create_page(&ogg_flac_id, true, false, false)); let bos_page = ogg.create_page(&ogg_flac_id, true, false, false);
let vorbis_comment = create_empty_vorbis_comment(); let vorbis_comment = create_empty_vorbis_comment();
let comment_page = Bytes::from(ogg.create_page(&vorbis_comment, false, false, false)); let comment_page = ogg.create_page(&vorbis_comment, false, false, false);
macro_rules! send_or_cleanup { let mut header = Vec::new();
($page:expr) => { header.extend_from_slice(&bos_page);
if bytes_tx.send($page).await.is_err() { header.extend_from_slice(&comment_page);
debug!("DirectOggFlacSink: bytes_tx broken, client disconnected"); if ogg_tx.send(Bytes::from(header)).await.is_err() {
*shared_bytes_tx.lock().await = None; debug!("DirectOggFlacSink: ogg_tx closed on headers");
return Ok(()); return Ok(());
} }
};
}
send_or_cleanup!(bos_page);
send_or_cleanup!(comment_page);
use tokio::io::AsyncReadExt;
use crate::sinks::flac_frame_utils::{validate_frame_header_crc, parse_flac_block_size}; use crate::sinks::flac_frame_utils::{validate_frame_header_crc, parse_flac_block_size};
let mut encoded_samples = 0u64; let mut encoded_samples = 0u64;
let mut read_buffer = vec![0u8; 16384]; let mut read_buffer = vec![0u8; 65536];
let mut accumulator: Vec<u8> = Vec::with_capacity(32768); let mut accumulator: Vec<u8> = Vec::with_capacity(32768);
loop { loop {
match flac_stream.read(&mut read_buffer).await { match flac_stream.read(&mut read_buffer).await {
Ok(0) => { Ok(0) => {
// EOF : page EOS finale trace!(
let eos_page = Bytes::from(ogg.create_page(&accumulator, false, true, false)); "OggEncoder: EOF — accum={} B, encoded={:.3}s",
let _ = bytes_tx.send(eos_page).await; accumulator.len(),
encoded_samples as f64 / DIRECT_OGG_FLAC_SAMPLE_RATE as f64,
);
let eos_page = ogg.create_page(&accumulator, false, true, false);
let _ = ogg_tx.send(Bytes::from(eos_page)).await;
break; break;
} }
Ok(n) => { Ok(n) => {
@@ -460,14 +368,16 @@ async fn run_ogg_encoder(
encoded_samples = encoded_samples.saturating_add(first_samples as u64); encoded_samples = encoded_samples.saturating_add(first_samples as u64);
ogg.add_samples(first_samples as u64); ogg.add_samples(first_samples as u64);
let ogg_page = Bytes::from(ogg.create_page(&frame, false, false, false)); trace!(
if bytes_tx.send(ogg_page).await.is_err() { "OggEncoder: frame {} B, {:.3}s total",
warn!( frame.len(),
"DirectOggFlacSink: bytes_tx broken after {} samples ({:.3}s), client disconnected",
encoded_samples,
encoded_samples as f64 / DIRECT_OGG_FLAC_SAMPLE_RATE as f64, encoded_samples as f64 / DIRECT_OGG_FLAC_SAMPLE_RATE as f64,
); );
*shared_bytes_tx.lock().await = None;
let ogg_page = ogg.create_page(&frame, false, false, false);
if ogg_tx.send(Bytes::from(ogg_page)).await.is_err() {
warn!("DirectOggFlacSink: ogg_tx closed after {:.3}s",
encoded_samples as f64 / DIRECT_OGG_FLAC_SAMPLE_RATE as f64);
return Ok(()); return Ok(());
} }
} }
@@ -624,22 +534,20 @@ pub struct DirectOggFlacSink {
impl DirectOggFlacSink { impl DirectOggFlacSink {
pub fn new(encoder_options: EncoderOptions) -> (Self, DirectOggFlacHandle) { pub fn new(encoder_options: EncoderOptions) -> (Self, DirectOggFlacHandle) {
let pcm_tx: SharedPcmTx = Arc::new(Mutex::new(None)); let pcm_tx: SharedPcmTx = Arc::new(Mutex::new(None));
let client_notify_internal = Arc::new(tokio::sync::Notify::new());
let (client_connect_tx, _) = watch::channel(0u64);
let client_connect_tx = Arc::new(client_connect_tx);
let (first_byte_tx, _) = watch::channel(false);
let first_byte_tx = Arc::new(first_byte_tx);
let current_timestamp = Arc::new(tokio::sync::RwLock::new(0.0f64));
let bytes_tx: SharedBytesTx = Arc::new(Mutex::new(None));
let encoder_task: SharedEncoderTask = Arc::new(Mutex::new(None)); let encoder_task: SharedEncoderTask = Arc::new(Mutex::new(None));
let current_timestamp = Arc::new(tokio::sync::RwLock::new(0.0f64));
let client_notify = Arc::new(tokio::sync::Notify::new());
let (ogg_tx, ogg_rx) = mpsc::channel::<Bytes>(OGG_CHANNEL_CAPACITY);
let ogg_rx = Arc::new(Mutex::new(ogg_rx));
let logic = DirectOggFlacSinkLogic { let logic = DirectOggFlacSinkLogic {
pcm_tx: pcm_tx.clone(), pcm_tx: pcm_tx.clone(),
client_notify: client_notify_internal.clone(),
encoder_options: encoder_options.clone(),
encoder_task: encoder_task.clone(), encoder_task: encoder_task.clone(),
bytes_tx: bytes_tx.clone(), encoder_options: encoder_options.clone(),
current_timestamp: current_timestamp.clone(), current_timestamp: current_timestamp.clone(),
ogg_tx: ogg_tx.clone(),
client_notify: client_notify.clone(),
has_encoded_frames: false, has_encoded_frames: false,
}; };
@@ -649,13 +557,12 @@ impl DirectOggFlacSink {
let handle = DirectOggFlacHandle { let handle = DirectOggFlacHandle {
pcm_tx, pcm_tx,
client_connect_tx, encoder_task,
client_notify_internal,
first_byte_tx,
encoder_options, encoder_options,
current_timestamp, current_timestamp,
bytes_tx, ogg_tx,
encoder_task, ogg_rx,
client_notify,
}; };
(sink, handle) (sink, handle)

View File

@@ -116,15 +116,16 @@ impl RendererRegistry {
Ok((stream_url, udn)) Ok((stream_url, udn))
} }
/// Retourne le DirectOggFlacHandle pour l'endpoint /stream (clonable). /// Retourne un DirectOggFlacStream pour l'endpoint /stream.
pub fn get_flac_handle( /// Chaque appel retourne un wrapper sur le même reader persistant.
pub fn get_stream(
&self, &self,
instance_id: &str, instance_id: &str,
) -> Option<pmoaudio_ext::sinks::DirectOggFlacHandle> { ) -> Option<pmoaudio_ext::sinks::DirectOggFlacStream> {
self.instances self.instances
.read() .read()
.get(instance_id) .get(instance_id)
.map(|i| i.flac_handle.clone()) .map(|i| i.flac_handle.get_stream())
} }
/// Retourne le PipelineHandle par UDN (pour les handlers UPnP) /// Retourne le PipelineHandle par UDN (pour les handlers UPnP)

View File

@@ -1,15 +1,17 @@
//! Handler HTTP GET /api/webrenderer/{id}/stream //! Handler HTTP GET /api/webrenderer/{id}/stream
//! //!
//! Sert le flux FLAC d'une instance WebRenderer via DirectFlacSink. //! Sert le flux OGG-FLAC d'une instance WebRenderer.
//! //!
//! Reconnectable : appelé à chaque Play, crée un nouveau pipe + encodeur. //! Safari fait systématiquement une requête Range: bytes=0-1 avant de jouer.
//! On répond 206 avec 2 octets factices pour satisfaire la sonde,
//! puis la vraie requête (sans Range) reçoit le stream persistant.
use axum::{ use axum::{
body::Body, body::Body,
extract::{Path, State}, extract::{Path, State},
http::{ http::{
StatusCode, HeaderMap, StatusCode,
header::{CACHE_CONTROL, CONNECTION, CONTENT_TYPE}, header::{CACHE_CONTROL, CONNECTION, CONTENT_TYPE, CONTENT_RANGE, CONTENT_LENGTH, ACCEPT_RANGES},
}, },
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
@@ -20,21 +22,31 @@ use tracing::info;
use crate::registry::RendererRegistry; use crate::registry::RendererRegistry;
/// GET /api/webrenderer/{id}/stream /// GET /api/webrenderer/{id}/stream
///
/// Crée un nouveau pipe FLAC à chaque connexion (chaque Play).
/// Le flux reste ouvert jusqu'à ce que le client se déconnecte (Stop).
/// Les morceaux s'enchaînent en gapless dans le même flux.
///
/// Safari envoie un Range header (bytes=0-) et exige Accept-Ranges: bytes.
/// On répond 206 Partial Content si un Range header est présent, sinon 200.
pub async fn stream_handler( pub async fn stream_handler(
State(registry): State<Arc<RendererRegistry>>, State(registry): State<Arc<RendererRegistry>>,
Path(instance_id): Path<String>, Path(instance_id): Path<String>,
headers: HeaderMap,
) -> impl IntoResponse { ) -> impl IntoResponse {
info!(instance_id = %instance_id, "FLAC stream client connecting"); info!(instance_id = %instance_id, "FLAC stream client connecting");
let handle = match registry.get_flac_handle(&instance_id) { // Détecter la sonde Range: bytes=0-1 de Safari
Some(h) => h, if let Some(range) = headers.get("range") {
if range.as_bytes() == b"bytes=0-1" {
info!(instance_id = %instance_id, "Safari range probe — responding 206");
return Response::builder()
.status(StatusCode::PARTIAL_CONTENT)
.header(CONTENT_TYPE, "audio/ogg; codecs=flac")
.header(ACCEPT_RANGES, "bytes")
.header(CONTENT_RANGE, "bytes 0-1/*")
.header(CONTENT_LENGTH, "2")
.body(Body::from(vec![0u8, 0u8]))
.unwrap()
.into_response();
}
}
let stream = match registry.get_stream(&instance_id) {
Some(s) => s,
None => { None => {
return ( return (
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
@@ -44,18 +56,14 @@ pub async fn stream_handler(
} }
}; };
let stream = handle.connect().await;
info!(instance_id = %instance_id, "FLAC stream started"); info!(instance_id = %instance_id, "FLAC stream started");
// Toujours 200 OK pour un flux live de taille inconnue.
// Les navigateurs (Safari inclus) acceptent 200 pour l'audio streaming.
// Un Content-Range invalide (bytes 0-*/*) ferait rejeter le flux.
Response::builder() Response::builder()
.status(StatusCode::OK) .status(StatusCode::OK)
.header(CONTENT_TYPE, "audio/ogg; codecs=flac") .header(CONTENT_TYPE, "audio/ogg; codecs=flac")
.header(CACHE_CONTROL, "no-store, no-transform") .header(CACHE_CONTROL, "no-store, no-transform")
.header(CONNECTION, "keep-alive") .header(CONNECTION, "keep-alive")
.header(ACCEPT_RANGES, "bytes")
.header("X-Content-Type-Options", "nosniff") .header("X-Content-Type-Options", "nosniff")
.body(Body::from_stream(ReaderStream::new(stream))) .body(Body::from_stream(ReaderStream::new(stream)))
.unwrap() .unwrap()