diff --git a/apps/tauri/src/main.rs b/apps/tauri/src/main.rs index 9e7de0e..72258fe 100644 --- a/apps/tauri/src/main.rs +++ b/apps/tauri/src/main.rs @@ -751,14 +751,16 @@ async fn start_stream( let stream_id_clone = stream_id.clone(); let output_dir_clone = output_dir.clone(); let app_clone = app.clone(); + let variants = local_playback_cmaf_variants(); let process = tokio::task::spawn_blocking(move || { - spawn_ffmpeg_cmaf_ladder_for_source( + spawn_ffmpeg_cmaf_ladder_for_source_with_variants( &app_clone, &source, &output_dir_clone, DEFAULT_SEGMENT_MS, 6, true, + variants, ) }) .await @@ -3651,6 +3653,13 @@ fn default_cmaf_variants() -> Vec { ] } +fn local_playback_cmaf_variants() -> Vec { + default_cmaf_variants() + .into_iter() + .filter(|variant| variant.id == "720p") + .collect() +} + fn write_hls_master_playlist( output_dir: &Path, variants: &[CmafVariantSpec], @@ -4730,6 +4739,26 @@ fn spawn_ffmpeg_cmaf_ladder_for_source( chunk_ms: u64, hls_list_size: usize, delete_segments: bool, +) -> Result { + spawn_ffmpeg_cmaf_ladder_for_source_with_variants( + app, + source, + output_dir, + chunk_ms, + hls_list_size, + delete_segments, + default_cmaf_variants(), + ) +} + +fn spawn_ffmpeg_cmaf_ladder_for_source_with_variants( + app: &AppHandle, + source: &StreamSource, + output_dir: &Path, + chunk_ms: u64, + hls_list_size: usize, + delete_segments: bool, + variants: Vec, ) -> Result { if is_nbc_source(source) { let reader = spawn_nbc_frame_reader(app, source)?; @@ -4739,6 +4768,7 @@ fn spawn_ffmpeg_cmaf_ladder_for_source( chunk_ms, hls_list_size, delete_segments, + &variants, ); } @@ -4748,6 +4778,7 @@ fn spawn_ffmpeg_cmaf_ladder_for_source( chunk_ms, hls_list_size, delete_segments, + variants, ) } @@ -4757,8 +4788,8 @@ fn spawn_ffmpeg_cmaf_ladder( chunk_ms: u64, hls_list_size: usize, delete_segments: bool, + variants: Vec, ) -> Result { - let variants = default_cmaf_variants(); let segment_time = format!("{:.3}", chunk_ms as f64 / 1000.0); let _ = fs::remove_dir_all(output_dir); @@ -4802,17 +4833,17 @@ fn spawn_ffmpeg_cmaf_ladder_from_mjpeg_reader( chunk_ms: u64, hls_list_size: usize, delete_segments: bool, + variants: &[CmafVariantSpec], ) -> Result { let segment_time = format!("{:.3}", chunk_ms as f64 / 1000.0); - let variants = default_cmaf_variants(); let _ = fs::remove_dir_all(output_dir); fs::create_dir_all(output_dir) .with_context(|| format!("failed to create {}", output_dir.display()))?; - for v in &variants { + for v in variants { fs::create_dir_all(output_dir.join(v.id))?; } - write_hls_master_playlist(output_dir, &variants, 0)?; + write_hls_master_playlist(output_dir, variants, 0)?; spawn_ffmpeg_cmaf_ladder_with_input( vec![ @@ -4826,7 +4857,7 @@ fn spawn_ffmpeg_cmaf_ladder_from_mjpeg_reader( Some(Box::new(reader)), output_dir, &segment_time, - &variants, + variants, hls_list_size, delete_segments, ) @@ -5203,12 +5234,18 @@ fn default_encoder_args() -> Vec<&'static str> { vec![ "-c:a", "aac", + "-profile:a", + "aac_low", "-b:a", "128k", "-ac", "2", "-ar", "48000", + "-af", + ec_chopper::LIVE_AUDIO_RESAMPLE_FILTER, + "-max_muxing_queue_size", + "2048", "-pix_fmt", "yuv420p", "-g", @@ -5321,6 +5358,28 @@ mod tests { let _ = fs::remove_dir_all(&dir); } + #[test] + fn local_playback_uses_single_720p_variant() { + let variants = local_playback_cmaf_variants(); + assert_eq!(variants.len(), 1); + assert_eq!(variants[0].id, "720p"); + assert_eq!(variants[0].video_bitrate_kbps, 3000); + } + + #[test] + fn default_encoder_args_stabilize_live_audio() { + let args = default_encoder_args(); + assert!(args + .windows(2) + .any(|w| w[0] == "-profile:a" && w[1] == "aac_low")); + assert!(args + .windows(2) + .any(|w| w[0] == "-af" && w[1] == ec_chopper::LIVE_AUDIO_RESAMPLE_FILTER)); + assert!(args + .windows(2) + .any(|w| w[0] == "-max_muxing_queue_size" && w[1] == "2048")); + } + #[test] fn derive_variant_stream_id_is_stable() { assert_eq!( diff --git a/crates/ec-chopper/src/lib.rs b/crates/ec-chopper/src/lib.rs index b760dd7..098d241 100644 --- a/crates/ec-chopper/src/lib.rs +++ b/crates/ec-chopper/src/lib.rs @@ -19,6 +19,8 @@ use std::process::{Child, Command, Stdio}; #[cfg(feature = "ffmpeg-ffi")] use std::time::Duration; +pub const LIVE_AUDIO_RESAMPLE_FILTER: &str = "aresample=async=1000:min_hard_comp=0.100:first_pts=0"; + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StreamProbe { pub index: usize, @@ -804,12 +806,18 @@ pub fn deterministic_h264_profile() -> DeterminismProfile { encoder_args: vec![ "-c:a".to_string(), "aac".to_string(), + "-profile:a".to_string(), + "aac_low".to_string(), "-b:a".to_string(), "128k".to_string(), "-ac".to_string(), "2".to_string(), "-ar".to_string(), "48000".to_string(), + "-af".to_string(), + LIVE_AUDIO_RESAMPLE_FILTER.to_string(), + "-max_muxing_queue_size".to_string(), + "2048".to_string(), "-pix_fmt".to_string(), "yuv420p".to_string(), "-g".to_string(), @@ -876,6 +884,15 @@ mod tests { assert!(args.iter().any(|a| a == "1")); assert!(args.iter().any(|a| a == "+bitexact")); assert!(args.iter().any(|a| a == "libx264")); + assert!(args + .windows(2) + .any(|w| w[0] == "-profile:a" && w[1] == "aac_low")); + assert!(args + .windows(2) + .any(|w| w[0] == "-af" && w[1] == LIVE_AUDIO_RESAMPLE_FILTER)); + assert!(args + .windows(2) + .any(|w| w[0] == "-max_muxing_queue_size" && w[1] == "2048")); } #[test] diff --git a/crates/ec-node/src/main.rs b/crates/ec-node/src/main.rs index 4bd4e3a..cd18afb 100644 --- a/crates/ec-node/src/main.rs +++ b/crates/ec-node/src/main.rs @@ -2292,12 +2292,18 @@ async fn moq_publish(args: MoqPublishArgs) -> Result<()> { .arg(&bufsize) .arg("-c:a") .arg("aac") + .arg("-profile:a") + .arg("aac_low") .arg("-b:a") .arg("128k") .arg("-ac") .arg("2") .arg("-ar") .arg("48000") + .arg("-af") + .arg(ec_chopper::LIVE_AUDIO_RESAMPLE_FILTER) + .arg("-max_muxing_queue_size") + .arg("2048") .arg("-pix_fmt") .arg("yuv420p") .arg("-g") @@ -6814,6 +6820,10 @@ async fn wt_publish(args: WtPublishArgs) -> Result<()> { "2", "-ar", "48000", + "-af", + ec_chopper::LIVE_AUDIO_RESAMPLE_FILTER, + "-max_muxing_queue_size", + "2048", ]); } else { cmd.args(["-c", "copy"]); diff --git a/evolution/proposals/ECP-0114-live-playback-audio-stability.md b/evolution/proposals/ECP-0114-live-playback-audio-stability.md new file mode 100644 index 0000000..191be2d --- /dev/null +++ b/evolution/proposals/ECP-0114-live-playback-audio-stability.md @@ -0,0 +1,30 @@ +# ECP-0114: Live Playback Audio Stability + +Status: Draft + +## Problem / context + +Local HDHomeRun playback can sound choppy even while video continues. The current desktop bridge starts a full three-rung CMAF/HLS ladder for local watching, and each variant carries its own AAC encode. OTA MPEG-TS timestamps can also jitter enough that straight AAC transcoding preserves audible gaps or corrections. + +## Decision + +Use a playback-specific encoding profile for local watching: + +- local desktop playback encodes only the 720p rendition instead of the full ABR ladder; +- all live AAC transcode paths force AAC-LC stereo and resample with timestamp compensation; +- keep the full multi-variant ladder for publishing and sharing paths. + +## Consequences + +- Local watching spends less CPU and avoids variant-switch audio discontinuities. +- AAC output gets a continuous 48 kHz stereo timeline even when OTA timestamps jitter. +- Published streams remain multi-variant and manifest-compatible. + +## Alternatives Considered + +- Keep full ABR for local playback. Rejected because the local player does not need ABR to watch a LAN tuner and the separate AAC timelines make audible switching more likely. +- Fix only the player buffer. Rejected because the source fragments should already have stable audio timestamps before they reach HLS or MSE. + +## Rollout / teardown + +Remove the playback-specific variant selection and the audio resample filter from the ffmpeg profiles.