Prblen enxt chanson en flac
This commit is contained in:
@@ -6,13 +6,14 @@
|
|||||||
//! - Paces broadcast to match audio playback rate
|
//! - Paces broadcast to match audio playback rate
|
||||||
|
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
use tracing::{trace, warn};
|
use tracing::trace;
|
||||||
|
|
||||||
/// Error returned when a frame should be skipped (too late)
|
/// Error returned when a frame should be skipped (too late)
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct SkipFrame;
|
pub struct SkipFrame;
|
||||||
|
|
||||||
/// Manages broadcast pacing with TopZeroSync detection
|
/// Manages broadcast pacing with TopZeroSync detection
|
||||||
|
#[allow(dead_code)]
|
||||||
pub struct BroadcastPacer {
|
pub struct BroadcastPacer {
|
||||||
/// Start time (reset on TopZeroSync)
|
/// Start time (reset on TopZeroSync)
|
||||||
start_time: Instant,
|
start_time: Instant,
|
||||||
|
|||||||
@@ -42,4 +42,4 @@ mod streaming_ogg_flac_sink;
|
|||||||
pub use streaming_ogg_flac_sink::{OggFlacClientStream, OggFlacStreamHandle, StreamingOggFlacSink};
|
pub use streaming_ogg_flac_sink::{OggFlacClientStream, OggFlacStreamHandle, StreamingOggFlacSink};
|
||||||
|
|
||||||
#[cfg(feature = "http-stream")]
|
#[cfg(feature = "http-stream")]
|
||||||
pub use streaming_sink_common::MetadataSnapshot;
|
pub use streaming_sink_common::{MetadataSnapshot, StreamingSinkOptions};
|
||||||
|
|||||||
@@ -54,7 +54,6 @@
|
|||||||
//! }
|
//! }
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
use std::collections::VecDeque;
|
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
@@ -65,7 +64,7 @@ use std::time::Duration;
|
|||||||
use super::{
|
use super::{
|
||||||
broadcast_pacing::BroadcastPacer,
|
broadcast_pacing::BroadcastPacer,
|
||||||
flac_frame_utils,
|
flac_frame_utils,
|
||||||
timed_broadcast::{self, SendError, TryRecvError},
|
timed_broadcast::{self, SendError},
|
||||||
};
|
};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
@@ -77,12 +76,13 @@ use pmoflac::{EncoderOptions, FlacEncodedStream};
|
|||||||
use tokio::io::{AsyncRead, AsyncReadExt, ReadBuf};
|
use tokio::io::{AsyncRead, AsyncReadExt, ReadBuf};
|
||||||
use tokio::sync::{mpsc, RwLock};
|
use tokio::sync::{mpsc, RwLock};
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
use tracing::{debug, error, info, trace, warn};
|
use tracing::{debug, error, trace, warn};
|
||||||
|
|
||||||
use crate::byte_stream_reader::{PcmChunk};
|
use crate::byte_stream_reader::{PcmChunk};
|
||||||
use crate::chunk_to_pcm::chunk_to_pcm_bytes;
|
use crate::chunk_to_pcm::chunk_to_pcm_bytes;
|
||||||
use crate::sinks::streaming_sink_common::{
|
use crate::sinks::streaming_sink_common::{
|
||||||
MetadataSnapshot, SharedClientStream, SharedSinkContext, SharedStreamHandleInner,
|
MetadataSnapshot, SharedClientStream, SharedSinkContext, SharedStreamHandleInner,
|
||||||
|
StreamingSinkOptions,
|
||||||
};
|
};
|
||||||
use crate::sinks::timed_broadcast::{
|
use crate::sinks::timed_broadcast::{
|
||||||
calculate_broadcast_capacity, DEFAULT_BROADCAST_MAX_LEAD_TIME,
|
calculate_broadcast_capacity, DEFAULT_BROADCAST_MAX_LEAD_TIME,
|
||||||
@@ -214,8 +214,8 @@ impl NodeLogic for StreamingFlacSinkLogic {
|
|||||||
self.ctx.first_chunk_timestamp_checked = true;
|
self.ctx.first_chunk_timestamp_checked = true;
|
||||||
if seg.timestamp_sec.abs() > 1e-6 {
|
if seg.timestamp_sec.abs() > 1e-6 {
|
||||||
warn!(
|
warn!(
|
||||||
"StreamingFlacSink: first chunk timestamp is {:.6}s (expected 0.0)",
|
"StreamingFlacSink: first chunk timestamp is {:.3}ms (expected 0.0)",
|
||||||
seg.timestamp_sec
|
seg.timestamp_sec * 1000.0
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
trace!("StreamingFlacSink: first chunk timestamp verified at 0.0s");
|
trace!("StreamingFlacSink: first chunk timestamp verified at 0.0s");
|
||||||
@@ -318,8 +318,11 @@ impl NodeLogic for StreamingFlacSinkLogic {
|
|||||||
|
|
||||||
debug!("StreamingFlacSink: SyncMarker::TrackBoundary {:?}",metadata.read().await.get_duration().await);
|
debug!("StreamingFlacSink: SyncMarker::TrackBoundary {:?}",metadata.read().await.get_duration().await);
|
||||||
|
|
||||||
|
if self.ctx.restart_encoder_on_track_boundary {
|
||||||
// Only restart encoder if it's already initialized (not the first track)
|
// Only restart encoder if it's already initialized (not the first track)
|
||||||
if self.ctx.sample_rate.is_some() && self.ctx.encoder_state.is_some() {
|
if self.ctx.sample_rate.is_some()
|
||||||
|
&& self.ctx.encoder_state.is_some()
|
||||||
|
{
|
||||||
// Restart encoder to emit new header and reset timestamps
|
// Restart encoder to emit new header and reset timestamps
|
||||||
if let Err(e) = self
|
if let Err(e) = self
|
||||||
.ctx
|
.ctx
|
||||||
@@ -346,12 +349,23 @@ impl NodeLogic for StreamingFlacSinkLogic {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
error!("Failed to restart encoder for new track: {}", e);
|
error!(
|
||||||
|
"Failed to restart encoder for new track: {}",
|
||||||
|
e
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
trace!("Skipping encoder restart for first track (encoder not yet initialized)");
|
trace!("Skipping encoder restart for first track (encoder not yet initialized)");
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// For raw FLAC streaming we keep a single continuous encoder.
|
||||||
|
// Restarting would insert a new STREAMINFO header mid-stream and many
|
||||||
|
// clients treat that as end-of-file.
|
||||||
|
trace!(
|
||||||
|
"StreamingFlacSink: keeping encoder alive across track boundary"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Update metadata for the new track
|
// Update metadata for the new track
|
||||||
if let Err(e) = self.ctx.update_metadata(metadata, seg.timestamp_sec).await {
|
if let Err(e) = self.ctx.update_metadata(metadata, seg.timestamp_sec).await {
|
||||||
@@ -422,6 +436,21 @@ impl StreamingFlacSink {
|
|||||||
encoder_options: EncoderOptions,
|
encoder_options: EncoderOptions,
|
||||||
bits_per_sample: u8,
|
bits_per_sample: u8,
|
||||||
broadcast_max_lead_time: f64,
|
broadcast_max_lead_time: f64,
|
||||||
|
) -> (Self, StreamHandle) {
|
||||||
|
Self::with_options(
|
||||||
|
encoder_options,
|
||||||
|
bits_per_sample,
|
||||||
|
broadcast_max_lead_time,
|
||||||
|
StreamingSinkOptions::flac_defaults(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a sink with a custom broadcast pacing limit and options.
|
||||||
|
pub fn with_options(
|
||||||
|
encoder_options: EncoderOptions,
|
||||||
|
bits_per_sample: u8,
|
||||||
|
broadcast_max_lead_time: f64,
|
||||||
|
options: StreamingSinkOptions,
|
||||||
) -> (Self, StreamHandle) {
|
) -> (Self, StreamHandle) {
|
||||||
// Validate bit depth
|
// Validate bit depth
|
||||||
if ![16, 24, 32].contains(&bits_per_sample) {
|
if ![16, 24, 32].contains(&bits_per_sample) {
|
||||||
@@ -465,6 +494,11 @@ impl StreamingFlacSink {
|
|||||||
ctx: SharedSinkContext {
|
ctx: SharedSinkContext {
|
||||||
encoder_options,
|
encoder_options,
|
||||||
bits_per_sample,
|
bits_per_sample,
|
||||||
|
enable_total_samples: options.enable_total_samples,
|
||||||
|
restart_encoder_on_track_boundary: options.restart_encoder_on_track_boundary,
|
||||||
|
default_title: options.default_title.clone(),
|
||||||
|
default_artist: options.default_artist.clone(),
|
||||||
|
use_only_default_metadata: options.use_only_default_metadata,
|
||||||
pcm_tx: Some(pcm_tx),
|
pcm_tx: Some(pcm_tx),
|
||||||
pcm_rx: Some(pcm_rx),
|
pcm_rx: Some(pcm_rx),
|
||||||
metadata,
|
metadata,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use crate::{MetadataSnapshot, sinks::{flac_frame_utils::FlacStreamState, streami
|
|||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use std::io;
|
use std::io;
|
||||||
|
|
||||||
use tracing::{debug, error, info, trace, warn};
|
use tracing::{debug, warn};
|
||||||
|
|
||||||
/// ICY-wrapped FLAC client stream (implements AsyncRead).
|
/// ICY-wrapped FLAC client stream (implements AsyncRead).
|
||||||
///
|
///
|
||||||
@@ -97,6 +97,7 @@ impl IcyClientStream {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get metadata block if it needs to be inserted.
|
/// Get metadata block if it needs to be inserted.
|
||||||
|
#[allow(dead_code)]
|
||||||
async fn get_metadata_if_changed(&mut self) -> Option<Bytes> {
|
async fn get_metadata_if_changed(&mut self) -> Option<Bytes> {
|
||||||
let meta = self.metadata.read().await;
|
let meta = self.metadata.read().await;
|
||||||
if meta.version > self.current_metadata_version {
|
if meta.version > self.current_metadata_version {
|
||||||
|
|||||||
@@ -46,7 +46,6 @@
|
|||||||
//! - Pages are broadcast immediately to connected clients
|
//! - Pages are broadcast immediately to connected clients
|
||||||
//! - TrackBoundary only triggers encoder flush (no data accumulation)
|
//! - TrackBoundary only triggers encoder flush (no data accumulation)
|
||||||
|
|
||||||
use std::collections::VecDeque;
|
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
@@ -56,7 +55,7 @@ use std::task::{Context, Poll};
|
|||||||
use super::{
|
use super::{
|
||||||
broadcast_pacing::BroadcastPacer,
|
broadcast_pacing::BroadcastPacer,
|
||||||
flac_frame_utils,
|
flac_frame_utils,
|
||||||
timed_broadcast::{self, SendError, TryRecvError},
|
timed_broadcast::{self, SendError},
|
||||||
};
|
};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
@@ -68,13 +67,14 @@ use pmoflac::{EncoderOptions, FlacEncodedStream};
|
|||||||
use tokio::io::{AsyncRead, AsyncReadExt, ReadBuf};
|
use tokio::io::{AsyncRead, AsyncReadExt, ReadBuf};
|
||||||
use tokio::sync::{mpsc, RwLock};
|
use tokio::sync::{mpsc, RwLock};
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
use tracing::{debug, error, info, trace, warn};
|
use tracing::{debug, error, trace, warn};
|
||||||
|
|
||||||
use crate::byte_stream_reader::{PcmChunk};
|
use crate::byte_stream_reader::{PcmChunk};
|
||||||
use crate::chunk_to_pcm::chunk_to_pcm_bytes;
|
use crate::chunk_to_pcm::chunk_to_pcm_bytes;
|
||||||
use crate::sinks::flac_frame_utils::{extract_sample_rate_from_streaminfo, read_flac_header};
|
use crate::sinks::flac_frame_utils::{extract_sample_rate_from_streaminfo, read_flac_header};
|
||||||
use crate::sinks::streaming_sink_common::{
|
use crate::sinks::streaming_sink_common::{
|
||||||
MetadataSnapshot, SharedClientStream, SharedSinkContext, SharedStreamHandleInner,
|
MetadataSnapshot, SharedClientStream, SharedSinkContext, SharedStreamHandleInner,
|
||||||
|
StreamingSinkOptions,
|
||||||
};
|
};
|
||||||
use crate::sinks::timed_broadcast::{
|
use crate::sinks::timed_broadcast::{
|
||||||
calculate_broadcast_capacity, DEFAULT_BROADCAST_MAX_LEAD_TIME,
|
calculate_broadcast_capacity, DEFAULT_BROADCAST_MAX_LEAD_TIME,
|
||||||
@@ -210,7 +210,7 @@ impl NodeLogic for StreamingOggFlacSinkLogic {
|
|||||||
current_timestamp,
|
current_timestamp,
|
||||||
current_duration,
|
current_duration,
|
||||||
max_lead,
|
max_lead,
|
||||||
sample_rate,
|
_sample_rate,
|
||||||
timestamp_offset_sec| {
|
timestamp_offset_sec| {
|
||||||
broadcast_ogg_flac_stream(
|
broadcast_ogg_flac_stream(
|
||||||
flac_stream,
|
flac_stream,
|
||||||
@@ -273,8 +273,11 @@ impl NodeLogic for StreamingOggFlacSinkLogic {
|
|||||||
error!("Failed to prepare encoder options for new track: {}", e);
|
error!("Failed to prepare encoder options for new track: {}", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if self.ctx.restart_encoder_on_track_boundary {
|
||||||
// Only restart encoder if it's already initialized (not the first track)
|
// Only restart encoder if it's already initialized (not the first track)
|
||||||
if self.ctx.sample_rate.is_some() && self.ctx.encoder_state.is_some() {
|
if self.ctx.sample_rate.is_some()
|
||||||
|
&& self.ctx.encoder_state.is_some()
|
||||||
|
{
|
||||||
// Restart encoder to emit new OGG stream header and reset timestamps
|
// Restart encoder to emit new OGG stream header and reset timestamps
|
||||||
if let Err(e) = self
|
if let Err(e) = self
|
||||||
.ctx
|
.ctx
|
||||||
@@ -285,7 +288,7 @@ impl NodeLogic for StreamingOggFlacSinkLogic {
|
|||||||
current_timestamp,
|
current_timestamp,
|
||||||
current_duration,
|
current_duration,
|
||||||
max_lead,
|
max_lead,
|
||||||
sample_rate,
|
_sample_rate,
|
||||||
timestamp_offset_sec| {
|
timestamp_offset_sec| {
|
||||||
broadcast_ogg_flac_stream(
|
broadcast_ogg_flac_stream(
|
||||||
flac_stream,
|
flac_stream,
|
||||||
@@ -306,6 +309,9 @@ impl NodeLogic for StreamingOggFlacSinkLogic {
|
|||||||
} else {
|
} else {
|
||||||
trace!("Skipping OGG encoder restart for first track (encoder not yet initialized)");
|
trace!("Skipping OGG encoder restart for first track (encoder not yet initialized)");
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
trace!("StreamingOggFlacSink: restart disabled; continuing encoder across track boundary");
|
||||||
|
}
|
||||||
|
|
||||||
// Update metadata for the new track
|
// Update metadata for the new track
|
||||||
if let Err(e) = self.ctx.update_metadata(metadata, seg.timestamp_sec).await {
|
if let Err(e) = self.ctx.update_metadata(metadata, seg.timestamp_sec).await {
|
||||||
@@ -379,6 +385,21 @@ impl StreamingOggFlacSink {
|
|||||||
encoder_options: EncoderOptions,
|
encoder_options: EncoderOptions,
|
||||||
bits_per_sample: u8,
|
bits_per_sample: u8,
|
||||||
broadcast_max_lead_time: f64,
|
broadcast_max_lead_time: f64,
|
||||||
|
) -> (Self, OggFlacStreamHandle) {
|
||||||
|
Self::with_options(
|
||||||
|
encoder_options,
|
||||||
|
bits_per_sample,
|
||||||
|
broadcast_max_lead_time,
|
||||||
|
StreamingSinkOptions::ogg_defaults(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a sink with a custom broadcast pacing limit and options.
|
||||||
|
pub fn with_options(
|
||||||
|
encoder_options: EncoderOptions,
|
||||||
|
bits_per_sample: u8,
|
||||||
|
broadcast_max_lead_time: f64,
|
||||||
|
options: StreamingSinkOptions,
|
||||||
) -> (Self, OggFlacStreamHandle) {
|
) -> (Self, OggFlacStreamHandle) {
|
||||||
// Validate bit depth
|
// Validate bit depth
|
||||||
if ![16, 24, 32].contains(&bits_per_sample) {
|
if ![16, 24, 32].contains(&bits_per_sample) {
|
||||||
@@ -420,6 +441,11 @@ impl StreamingOggFlacSink {
|
|||||||
ctx: SharedSinkContext {
|
ctx: SharedSinkContext {
|
||||||
encoder_options,
|
encoder_options,
|
||||||
bits_per_sample,
|
bits_per_sample,
|
||||||
|
enable_total_samples: options.enable_total_samples,
|
||||||
|
restart_encoder_on_track_boundary: options.restart_encoder_on_track_boundary,
|
||||||
|
default_title: options.default_title.clone(),
|
||||||
|
default_artist: options.default_artist.clone(),
|
||||||
|
use_only_default_metadata: options.use_only_default_metadata,
|
||||||
pcm_tx: Some(pcm_tx),
|
pcm_tx: Some(pcm_tx),
|
||||||
pcm_rx: Some(pcm_rx),
|
pcm_rx: Some(pcm_rx),
|
||||||
metadata,
|
metadata,
|
||||||
@@ -494,9 +520,7 @@ async fn broadcast_ogg_flac_stream(
|
|||||||
let mut ogg_writer = OggPageWriter::new(stream_serial);
|
let mut ogg_writer = OggPageWriter::new(stream_serial);
|
||||||
|
|
||||||
let mut total_bytes = 0u64;
|
let mut total_bytes = 0u64;
|
||||||
let mut header_captured = false;
|
|
||||||
let mut pacer = BroadcastPacer::new(broadcast_max_lead_time, "OGG");
|
let mut pacer = BroadcastPacer::new(broadcast_max_lead_time, "OGG");
|
||||||
let mut last_granule_update_time = 0.0f64;
|
|
||||||
|
|
||||||
// Timing instrumentation for burst detection
|
// Timing instrumentation for burst detection
|
||||||
let mut last_broadcast_time = std::time::Instant::now();
|
let mut last_broadcast_time = std::time::Instant::now();
|
||||||
@@ -538,7 +562,6 @@ async fn broadcast_ogg_flac_stream(
|
|||||||
cached_header.extend_from_slice(&bos_bytes);
|
cached_header.extend_from_slice(&bos_bytes);
|
||||||
cached_header.extend_from_slice(&comment_bytes);
|
cached_header.extend_from_slice(&comment_bytes);
|
||||||
*header_cache.write().await = Some(Bytes::from(cached_header));
|
*header_cache.write().await = Some(Bytes::from(cached_header));
|
||||||
header_captured = true;
|
|
||||||
trace!(
|
trace!(
|
||||||
"OGG-FLAC header cached ({} bytes: BOS + Vorbis Comment)",
|
"OGG-FLAC header cached ({} bytes: BOS + Vorbis Comment)",
|
||||||
bos_bytes.len() + comment_bytes.len()
|
bos_bytes.len() + comment_bytes.len()
|
||||||
|
|||||||
@@ -45,6 +45,63 @@ pub struct MetadataSnapshot {
|
|||||||
pub version: u64,
|
pub version: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Configuration options shared by streaming sinks.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct StreamingSinkOptions {
|
||||||
|
pub restart_encoder_on_track_boundary: bool,
|
||||||
|
pub enable_total_samples: bool,
|
||||||
|
pub default_title: Option<String>,
|
||||||
|
pub default_artist: Option<String>,
|
||||||
|
pub use_only_default_metadata: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StreamingSinkOptions {
|
||||||
|
pub fn flac_defaults() -> Self {
|
||||||
|
Self {
|
||||||
|
restart_encoder_on_track_boundary: false,
|
||||||
|
enable_total_samples: false,
|
||||||
|
default_title: None,
|
||||||
|
default_artist: None,
|
||||||
|
use_only_default_metadata: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ogg_defaults() -> Self {
|
||||||
|
Self {
|
||||||
|
restart_encoder_on_track_boundary: true,
|
||||||
|
enable_total_samples: true,
|
||||||
|
default_title: None,
|
||||||
|
default_artist: None,
|
||||||
|
use_only_default_metadata: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_restart(mut self, restart: bool) -> Self {
|
||||||
|
self.restart_encoder_on_track_boundary = restart;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_total_samples(mut self, enable: bool) -> Self {
|
||||||
|
self.enable_total_samples = enable;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_default_title(mut self, title: impl Into<Option<String>>) -> Self {
|
||||||
|
self.default_title = title.into();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_default_artist(mut self, artist: impl Into<Option<String>>) -> Self {
|
||||||
|
self.default_artist = artist.into();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_only_default_metadata(mut self, only_default: bool) -> Self {
|
||||||
|
self.use_only_default_metadata = only_default;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Shared handle state for streaming sinks.
|
/// Shared handle state for streaming sinks.
|
||||||
pub struct SharedStreamHandleInner {
|
pub struct SharedStreamHandleInner {
|
||||||
pub broadcast: timed_broadcast::Sender<Bytes>,
|
pub broadcast: timed_broadcast::Sender<Bytes>,
|
||||||
@@ -202,6 +259,14 @@ pub struct EncoderState {
|
|||||||
pub struct SharedSinkContext {
|
pub struct SharedSinkContext {
|
||||||
pub encoder_options: EncoderOptions,
|
pub encoder_options: EncoderOptions,
|
||||||
pub bits_per_sample: u8,
|
pub bits_per_sample: u8,
|
||||||
|
/// Whether to propagate total_samples into STREAMINFO.
|
||||||
|
/// For unbounded live streams (raw FLAC), this must stay false to avoid
|
||||||
|
/// players stopping after they reach the advertised length.
|
||||||
|
pub enable_total_samples: bool,
|
||||||
|
pub restart_encoder_on_track_boundary: bool,
|
||||||
|
pub default_title: Option<String>,
|
||||||
|
pub default_artist: Option<String>,
|
||||||
|
pub use_only_default_metadata: bool,
|
||||||
pub pcm_tx: Option<mpsc::Sender<PcmChunk>>,
|
pub pcm_tx: Option<mpsc::Sender<PcmChunk>>,
|
||||||
pub pcm_rx: Option<mpsc::Receiver<PcmChunk>>,
|
pub pcm_rx: Option<mpsc::Receiver<PcmChunk>>,
|
||||||
pub metadata: Arc<RwLock<MetadataSnapshot>>,
|
pub metadata: Arc<RwLock<MetadataSnapshot>>,
|
||||||
@@ -311,6 +376,15 @@ impl SharedSinkContext {
|
|||||||
// Always pass the metadata handle to the encoder so Vorbis comments are emitted.
|
// Always pass the metadata handle to the encoder so Vorbis comments are emitted.
|
||||||
self.encoder_options.metadata = Some(metadata_lock.clone());
|
self.encoder_options.metadata = Some(metadata_lock.clone());
|
||||||
|
|
||||||
|
// In raw FLAC live streaming we must NOT advertise a total_samples value,
|
||||||
|
// otherwise players think the stream ends after the first track.
|
||||||
|
if !self.enable_total_samples {
|
||||||
|
self.pending_track_duration = None;
|
||||||
|
self.pending_total_samples = None;
|
||||||
|
self.encoder_options.total_samples = None;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
// Capture duration (if any) to set total_samples.
|
// Capture duration (if any) to set total_samples.
|
||||||
let duration_opt = {
|
let duration_opt = {
|
||||||
let metadata = metadata_lock.read().await;
|
let metadata = metadata_lock.read().await;
|
||||||
@@ -339,6 +413,12 @@ impl SharedSinkContext {
|
|||||||
self.sample_rate
|
self.sample_rate
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if !self.enable_total_samples {
|
||||||
|
self.encoder_options.total_samples = None;
|
||||||
|
info!("Encoder metadata: total_samples disabled for live streaming");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(total) = self.pending_total_samples {
|
if let Some(total) = self.pending_total_samples {
|
||||||
self.encoder_options.total_samples = Some(total);
|
self.encoder_options.total_samples = Some(total);
|
||||||
info!(
|
info!(
|
||||||
@@ -430,8 +510,24 @@ impl SharedSinkContext {
|
|||||||
let metadata = metadata_lock.read().await;
|
let metadata = metadata_lock.read().await;
|
||||||
let mut snapshot = self.metadata.write().await;
|
let mut snapshot = self.metadata.write().await;
|
||||||
|
|
||||||
snapshot.title = metadata.get_title().await.ok().flatten();
|
// Title / artist with default fallback or forced default.
|
||||||
snapshot.artist = metadata.get_artist().await.ok().flatten();
|
if self.use_only_default_metadata {
|
||||||
|
snapshot.title = self.default_title.clone();
|
||||||
|
snapshot.artist = self.default_artist.clone();
|
||||||
|
} else {
|
||||||
|
snapshot.title = metadata
|
||||||
|
.get_title()
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.or_else(|| self.default_title.clone());
|
||||||
|
snapshot.artist = metadata
|
||||||
|
.get_artist()
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.or_else(|| self.default_artist.clone());
|
||||||
|
}
|
||||||
snapshot.album = metadata.get_album().await.ok().flatten();
|
snapshot.album = metadata.get_album().await.ok().flatten();
|
||||||
snapshot.duration = metadata.get_duration().await.ok().flatten();
|
snapshot.duration = metadata.get_duration().await.ok().flatten();
|
||||||
snapshot.cover_url = metadata.get_cover_url().await.ok().flatten();
|
snapshot.cover_url = metadata.get_cover_url().await.ok().flatten();
|
||||||
@@ -443,6 +539,9 @@ impl SharedSinkContext {
|
|||||||
snapshot.track_number = extra
|
snapshot.track_number = extra
|
||||||
.get("track_number")
|
.get("track_number")
|
||||||
.and_then(|s| s.parse::<u32>().ok());
|
.and_then(|s| s.parse::<u32>().ok());
|
||||||
|
} else {
|
||||||
|
snapshot.genre = None;
|
||||||
|
snapshot.track_number = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
snapshot.audio_timestamp_sec = timestamp_sec;
|
snapshot.audio_timestamp_sec = timestamp_sec;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
collections::VecDeque,
|
collections::VecDeque,
|
||||||
fmt, string,
|
fmt,
|
||||||
sync::{
|
sync::{
|
||||||
atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering},
|
atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering},
|
||||||
Arc, Mutex, Weak,
|
Arc, Mutex, Weak,
|
||||||
@@ -15,7 +15,7 @@ use std::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use tokio::sync::Notify;
|
use tokio::sync::Notify;
|
||||||
use tracing::{debug, info, trace, warn};
|
use tracing::{info, trace, warn};
|
||||||
|
|
||||||
/// Tolérance pour détecter un timestamp à zéro (TopZero).
|
/// Tolérance pour détecter un timestamp à zéro (TopZero).
|
||||||
const TOP_ZERO_EPSILON: f64 = 1e-9;
|
const TOP_ZERO_EPSILON: f64 = 1e-9;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ use pmoparadise::{
|
|||||||
channels::{ChannelDescriptor, ALL_CHANNELS},
|
channels::{ChannelDescriptor, ALL_CHANNELS},
|
||||||
ParadiseHistoryBuilder, ParadiseStreamChannel, ParadiseStreamChannelConfig,
|
ParadiseHistoryBuilder, ParadiseStreamChannel, ParadiseStreamChannelConfig,
|
||||||
};
|
};
|
||||||
|
use pmoaudio_ext::StreamingSinkOptions;
|
||||||
use pmoplaylist::register_audio_cache as register_playlist_audio_cache;
|
use pmoplaylist::register_audio_cache as register_playlist_audio_cache;
|
||||||
use std::{fs, net::SocketAddr, sync::Arc};
|
use std::{fs, net::SocketAddr, sync::Arc};
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
@@ -62,10 +63,16 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
history_builder.collection_prefix = Some(format!("single-channel-{}", descriptor.slug));
|
history_builder.collection_prefix = Some(format!("single-channel-{}", descriptor.slug));
|
||||||
let history_opts = history_builder.build_for_channel(&descriptor).await?;
|
let history_opts = history_builder.build_for_channel(&descriptor).await?;
|
||||||
|
|
||||||
|
let mut channel_config = ParadiseStreamChannelConfig::default();
|
||||||
|
channel_config.flac_options = StreamingSinkOptions::flac_defaults()
|
||||||
|
.with_default_artist(Some("Radio Paradise".to_string()))
|
||||||
|
.with_default_title(descriptor.display_name.to_string())
|
||||||
|
.with_only_default_metadata(true);
|
||||||
|
|
||||||
let channel = Arc::new(
|
let channel = Arc::new(
|
||||||
ParadiseStreamChannel::new(
|
ParadiseStreamChannel::new(
|
||||||
descriptor,
|
descriptor,
|
||||||
ParadiseStreamChannelConfig::default(),
|
channel_config,
|
||||||
Some(cover_cache),
|
Some(cover_cache),
|
||||||
Some(history_opts),
|
Some(history_opts),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ use pmoaudio::{AudioError, AudioPipelineNode};
|
|||||||
use pmoaudio_ext::{
|
use pmoaudio_ext::{
|
||||||
FlacClientStream, IcyClientStream, MetadataSnapshot, OggFlacClientStream, OggFlacStreamHandle,
|
FlacClientStream, IcyClientStream, MetadataSnapshot, OggFlacClientStream, OggFlacStreamHandle,
|
||||||
PlaylistSource, StreamHandle, StreamingFlacSink, StreamingOggFlacSink, TrackBoundaryCoverNode,
|
PlaylistSource, StreamHandle, StreamingFlacSink, StreamingOggFlacSink, TrackBoundaryCoverNode,
|
||||||
|
StreamingSinkOptions,
|
||||||
};
|
};
|
||||||
use pmoaudiocache::{get_audio_cache, Cache as AudioCache};
|
use pmoaudiocache::{get_audio_cache, Cache as AudioCache};
|
||||||
use pmocovers::{get_cover_cache, Cache as CoverCache};
|
use pmocovers::{get_cover_cache, Cache as CoverCache};
|
||||||
@@ -43,12 +44,18 @@ use tracing::{error, info, warn};
|
|||||||
pub struct ParadiseStreamChannelConfig {
|
pub struct ParadiseStreamChannelConfig {
|
||||||
/// Durée maximale (en secondes) d'avance acceptée par le broadcast.
|
/// Durée maximale (en secondes) d'avance acceptée par le broadcast.
|
||||||
pub max_lead_seconds: f64,
|
pub max_lead_seconds: f64,
|
||||||
|
/// Options pour le flux FLAC pur.
|
||||||
|
pub flac_options: StreamingSinkOptions,
|
||||||
|
/// Options pour le flux OGG-FLAC.
|
||||||
|
pub ogg_options: StreamingSinkOptions,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ParadiseStreamChannelConfig {
|
impl Default for ParadiseStreamChannelConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
max_lead_seconds: 1.0,
|
max_lead_seconds: 1.0,
|
||||||
|
flac_options: StreamingSinkOptions::flac_defaults(),
|
||||||
|
ogg_options: StreamingSinkOptions::ogg_defaults(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -136,6 +143,8 @@ impl ParadiseStreamChannelConfig {
|
|||||||
if let Some(v) = num.as_f64() {
|
if let Some(v) = num.as_f64() {
|
||||||
Self {
|
Self {
|
||||||
max_lead_seconds: v.max(0.1),
|
max_lead_seconds: v.max(0.1),
|
||||||
|
flac_options: StreamingSinkOptions::flac_defaults(),
|
||||||
|
ogg_options: StreamingSinkOptions::ogg_defaults(),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let default = Self::default();
|
let default = Self::default();
|
||||||
@@ -148,6 +157,8 @@ impl ParadiseStreamChannelConfig {
|
|||||||
if let Ok(v) = s.parse::<f64>() {
|
if let Ok(v) = s.parse::<f64>() {
|
||||||
Self {
|
Self {
|
||||||
max_lead_seconds: v.max(0.1),
|
max_lead_seconds: v.max(0.1),
|
||||||
|
flac_options: StreamingSinkOptions::flac_defaults(),
|
||||||
|
ogg_options: StreamingSinkOptions::ogg_defaults(),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let default = Self::default();
|
let default = Self::default();
|
||||||
@@ -238,15 +249,17 @@ impl ParadiseStreamChannel {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 4. Créer les sinks de broadcast (FLAC + OGG)
|
// 4. Créer les sinks de broadcast (FLAC + OGG)
|
||||||
let (flac_sink, stream_handle) = StreamingFlacSink::with_max_broadcast_lead(
|
let (flac_sink, stream_handle) = StreamingFlacSink::with_options(
|
||||||
EncoderOptions::default(),
|
EncoderOptions::default(),
|
||||||
16,
|
16,
|
||||||
config.max_lead_seconds,
|
config.max_lead_seconds,
|
||||||
|
config.flac_options.clone(),
|
||||||
);
|
);
|
||||||
let (ogg_sink, ogg_handle) = StreamingOggFlacSink::with_max_broadcast_lead(
|
let (ogg_sink, ogg_handle) = StreamingOggFlacSink::with_options(
|
||||||
EncoderOptions::default(),
|
EncoderOptions::default(),
|
||||||
16,
|
16,
|
||||||
config.max_lead_seconds,
|
config.max_lead_seconds,
|
||||||
|
config.ogg_options.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut downstream_children: Vec<Box<dyn AudioPipelineNode>> = Vec::new();
|
let mut downstream_children: Vec<Box<dyn AudioPipelineNode>> = Vec::new();
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ use pmoaudio::{nodes::DEFAULT_CHANNEL_SIZE, AudioPipelineNode};
|
|||||||
use pmoaudio_ext::{
|
use pmoaudio_ext::{
|
||||||
FlacCacheSink, FlacClientStream, IcyClientStream, MetadataSnapshot, OggFlacClientStream,
|
FlacCacheSink, FlacClientStream, IcyClientStream, MetadataSnapshot, OggFlacClientStream,
|
||||||
OggFlacStreamHandle, PlaylistSource, StreamHandle, StreamingFlacSink, StreamingOggFlacSink,
|
OggFlacStreamHandle, PlaylistSource, StreamHandle, StreamingFlacSink, StreamingOggFlacSink,
|
||||||
TrackBoundaryCoverNode,
|
TrackBoundaryCoverNode, StreamingSinkOptions,
|
||||||
};
|
};
|
||||||
use pmoaudiocache::Cache as AudioCache;
|
use pmoaudiocache::Cache as AudioCache;
|
||||||
use pmocovers::Cache as CoverCache;
|
use pmocovers::Cache as CoverCache;
|
||||||
@@ -37,12 +37,16 @@ use tracing::{error, info, warn};
|
|||||||
pub struct ParadiseStreamChannelConfig {
|
pub struct ParadiseStreamChannelConfig {
|
||||||
/// Durée maximale (en secondes) d'avance acceptée par le broadcast.
|
/// Durée maximale (en secondes) d'avance acceptée par le broadcast.
|
||||||
pub max_lead_seconds: f64,
|
pub max_lead_seconds: f64,
|
||||||
|
pub flac_options: StreamingSinkOptions,
|
||||||
|
pub ogg_options: StreamingSinkOptions,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ParadiseStreamChannelConfig {
|
impl Default for ParadiseStreamChannelConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
max_lead_seconds: 1.0,
|
max_lead_seconds: 1.0,
|
||||||
|
flac_options: StreamingSinkOptions::flac_defaults(),
|
||||||
|
ogg_options: StreamingSinkOptions::ogg_defaults(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -189,15 +193,17 @@ impl ParadiseStreamChannel {
|
|||||||
let mut source = RadioParadiseStreamSource::new(client.clone());
|
let mut source = RadioParadiseStreamSource::new(client.clone());
|
||||||
let block_handle = source.block_handle();
|
let block_handle = source.block_handle();
|
||||||
|
|
||||||
let (flac_sink, stream_handle) = StreamingFlacSink::with_max_broadcast_lead(
|
let (flac_sink, stream_handle) = StreamingFlacSink::with_options(
|
||||||
EncoderOptions::default(),
|
EncoderOptions::default(),
|
||||||
16,
|
16,
|
||||||
config.max_lead_seconds,
|
config.max_lead_seconds,
|
||||||
|
config.flac_options.clone(),
|
||||||
);
|
);
|
||||||
let (ogg_sink, ogg_handle) = StreamingOggFlacSink::with_max_broadcast_lead(
|
let (ogg_sink, ogg_handle) = StreamingOggFlacSink::with_options(
|
||||||
EncoderOptions::default(),
|
EncoderOptions::default(),
|
||||||
16,
|
16,
|
||||||
config.max_lead_seconds,
|
config.max_lead_seconds,
|
||||||
|
config.ogg_options.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut downstream_children: Vec<Box<dyn AudioPipelineNode>> = Vec::new();
|
let mut downstream_children: Vec<Box<dyn AudioPipelineNode>> = Vec::new();
|
||||||
@@ -361,10 +367,11 @@ impl ParadiseStreamChannel {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| HistoryStreamError::Playlist(e.to_string()))?;
|
.map_err(|e| HistoryStreamError::Playlist(e.to_string()))?;
|
||||||
let mut source = PlaylistSource::new(reader, history.audio_cache.clone());
|
let mut source = PlaylistSource::new(reader, history.audio_cache.clone());
|
||||||
let (flac_sink, handle) = StreamingFlacSink::with_max_broadcast_lead(
|
let (flac_sink, handle) = StreamingFlacSink::with_options(
|
||||||
EncoderOptions::default(),
|
EncoderOptions::default(),
|
||||||
16,
|
16,
|
||||||
history.replay_max_lead_seconds,
|
history.replay_max_lead_seconds,
|
||||||
|
self.state.config.flac_options.clone(),
|
||||||
);
|
);
|
||||||
source.register(Box::new(flac_sink));
|
source.register(Box::new(flac_sink));
|
||||||
let stop_token = CancellationToken::new();
|
let stop_token = CancellationToken::new();
|
||||||
@@ -397,10 +404,11 @@ impl ParadiseStreamChannel {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| HistoryStreamError::Playlist(e.to_string()))?;
|
.map_err(|e| HistoryStreamError::Playlist(e.to_string()))?;
|
||||||
let mut source = PlaylistSource::new(reader, history.audio_cache.clone());
|
let mut source = PlaylistSource::new(reader, history.audio_cache.clone());
|
||||||
let (ogg_sink, handle) = StreamingOggFlacSink::with_max_broadcast_lead(
|
let (ogg_sink, handle) = StreamingOggFlacSink::with_options(
|
||||||
EncoderOptions::default(),
|
EncoderOptions::default(),
|
||||||
16,
|
16,
|
||||||
history.replay_max_lead_seconds,
|
history.replay_max_lead_seconds,
|
||||||
|
self.state.config.ogg_options.clone(),
|
||||||
);
|
);
|
||||||
source.register(Box::new(ogg_sink));
|
source.register(Box::new(ogg_sink));
|
||||||
let stop_token = CancellationToken::new();
|
let stop_token = CancellationToken::new();
|
||||||
|
|||||||
Reference in New Issue
Block a user