Stabilize live playback audio
Some checks are pending
ci-gates / checks (push) Waiting to run
deploy-cloudflare / checks (push) Waiting to run
deploy-cloudflare / deploy (push) Blocked by required conditions

This commit is contained in:
every.channel 2026-05-03 20:52:41 -07:00
parent 0d86104762
commit 64e5ee3965
No known key found for this signature in database
4 changed files with 122 additions and 6 deletions

View file

@ -751,14 +751,16 @@ async fn start_stream(
let stream_id_clone = stream_id.clone(); let stream_id_clone = stream_id.clone();
let output_dir_clone = output_dir.clone(); let output_dir_clone = output_dir.clone();
let app_clone = app.clone(); let app_clone = app.clone();
let variants = local_playback_cmaf_variants();
let process = tokio::task::spawn_blocking(move || { let process = tokio::task::spawn_blocking(move || {
spawn_ffmpeg_cmaf_ladder_for_source( spawn_ffmpeg_cmaf_ladder_for_source_with_variants(
&app_clone, &app_clone,
&source, &source,
&output_dir_clone, &output_dir_clone,
DEFAULT_SEGMENT_MS, DEFAULT_SEGMENT_MS,
6, 6,
true, true,
variants,
) )
}) })
.await .await
@ -3651,6 +3653,13 @@ fn default_cmaf_variants() -> Vec<CmafVariantSpec> {
] ]
} }
fn local_playback_cmaf_variants() -> Vec<CmafVariantSpec> {
default_cmaf_variants()
.into_iter()
.filter(|variant| variant.id == "720p")
.collect()
}
fn write_hls_master_playlist( fn write_hls_master_playlist(
output_dir: &Path, output_dir: &Path,
variants: &[CmafVariantSpec], variants: &[CmafVariantSpec],
@ -4730,6 +4739,26 @@ fn spawn_ffmpeg_cmaf_ladder_for_source(
chunk_ms: u64, chunk_ms: u64,
hls_list_size: usize, hls_list_size: usize,
delete_segments: bool, delete_segments: bool,
) -> Result<Child> {
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<CmafVariantSpec>,
) -> Result<Child> { ) -> Result<Child> {
if is_nbc_source(source) { if is_nbc_source(source) {
let reader = spawn_nbc_frame_reader(app, source)?; let reader = spawn_nbc_frame_reader(app, source)?;
@ -4739,6 +4768,7 @@ fn spawn_ffmpeg_cmaf_ladder_for_source(
chunk_ms, chunk_ms,
hls_list_size, hls_list_size,
delete_segments, delete_segments,
&variants,
); );
} }
@ -4748,6 +4778,7 @@ fn spawn_ffmpeg_cmaf_ladder_for_source(
chunk_ms, chunk_ms,
hls_list_size, hls_list_size,
delete_segments, delete_segments,
variants,
) )
} }
@ -4757,8 +4788,8 @@ fn spawn_ffmpeg_cmaf_ladder(
chunk_ms: u64, chunk_ms: u64,
hls_list_size: usize, hls_list_size: usize,
delete_segments: bool, delete_segments: bool,
variants: Vec<CmafVariantSpec>,
) -> Result<Child> { ) -> Result<Child> {
let variants = default_cmaf_variants();
let segment_time = format!("{:.3}", chunk_ms as f64 / 1000.0); let segment_time = format!("{:.3}", chunk_ms as f64 / 1000.0);
let _ = fs::remove_dir_all(output_dir); let _ = fs::remove_dir_all(output_dir);
@ -4802,17 +4833,17 @@ fn spawn_ffmpeg_cmaf_ladder_from_mjpeg_reader<R: Read + Send + 'static>(
chunk_ms: u64, chunk_ms: u64,
hls_list_size: usize, hls_list_size: usize,
delete_segments: bool, delete_segments: bool,
variants: &[CmafVariantSpec],
) -> Result<Child> { ) -> Result<Child> {
let segment_time = format!("{:.3}", chunk_ms as f64 / 1000.0); let segment_time = format!("{:.3}", chunk_ms as f64 / 1000.0);
let variants = default_cmaf_variants();
let _ = fs::remove_dir_all(output_dir); let _ = fs::remove_dir_all(output_dir);
fs::create_dir_all(output_dir) fs::create_dir_all(output_dir)
.with_context(|| format!("failed to create {}", output_dir.display()))?; .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))?; 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( spawn_ffmpeg_cmaf_ladder_with_input(
vec![ vec![
@ -4826,7 +4857,7 @@ fn spawn_ffmpeg_cmaf_ladder_from_mjpeg_reader<R: Read + Send + 'static>(
Some(Box::new(reader)), Some(Box::new(reader)),
output_dir, output_dir,
&segment_time, &segment_time,
&variants, variants,
hls_list_size, hls_list_size,
delete_segments, delete_segments,
) )
@ -5203,12 +5234,18 @@ fn default_encoder_args() -> Vec<&'static str> {
vec![ vec![
"-c:a", "-c:a",
"aac", "aac",
"-profile:a",
"aac_low",
"-b:a", "-b:a",
"128k", "128k",
"-ac", "-ac",
"2", "2",
"-ar", "-ar",
"48000", "48000",
"-af",
ec_chopper::LIVE_AUDIO_RESAMPLE_FILTER,
"-max_muxing_queue_size",
"2048",
"-pix_fmt", "-pix_fmt",
"yuv420p", "yuv420p",
"-g", "-g",
@ -5321,6 +5358,28 @@ mod tests {
let _ = fs::remove_dir_all(&dir); 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] #[test]
fn derive_variant_stream_id_is_stable() { fn derive_variant_stream_id_is_stable() {
assert_eq!( assert_eq!(

View file

@ -19,6 +19,8 @@ use std::process::{Child, Command, Stdio};
#[cfg(feature = "ffmpeg-ffi")] #[cfg(feature = "ffmpeg-ffi")]
use std::time::Duration; 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StreamProbe { pub struct StreamProbe {
pub index: usize, pub index: usize,
@ -804,12 +806,18 @@ pub fn deterministic_h264_profile() -> DeterminismProfile {
encoder_args: vec![ encoder_args: vec![
"-c:a".to_string(), "-c:a".to_string(),
"aac".to_string(), "aac".to_string(),
"-profile:a".to_string(),
"aac_low".to_string(),
"-b:a".to_string(), "-b:a".to_string(),
"128k".to_string(), "128k".to_string(),
"-ac".to_string(), "-ac".to_string(),
"2".to_string(), "2".to_string(),
"-ar".to_string(), "-ar".to_string(),
"48000".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(), "-pix_fmt".to_string(),
"yuv420p".to_string(), "yuv420p".to_string(),
"-g".to_string(), "-g".to_string(),
@ -876,6 +884,15 @@ mod tests {
assert!(args.iter().any(|a| a == "1")); assert!(args.iter().any(|a| a == "1"));
assert!(args.iter().any(|a| a == "+bitexact")); assert!(args.iter().any(|a| a == "+bitexact"));
assert!(args.iter().any(|a| a == "libx264")); 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] #[test]

View file

@ -2292,12 +2292,18 @@ async fn moq_publish(args: MoqPublishArgs) -> Result<()> {
.arg(&bufsize) .arg(&bufsize)
.arg("-c:a") .arg("-c:a")
.arg("aac") .arg("aac")
.arg("-profile:a")
.arg("aac_low")
.arg("-b:a") .arg("-b:a")
.arg("128k") .arg("128k")
.arg("-ac") .arg("-ac")
.arg("2") .arg("2")
.arg("-ar") .arg("-ar")
.arg("48000") .arg("48000")
.arg("-af")
.arg(ec_chopper::LIVE_AUDIO_RESAMPLE_FILTER)
.arg("-max_muxing_queue_size")
.arg("2048")
.arg("-pix_fmt") .arg("-pix_fmt")
.arg("yuv420p") .arg("yuv420p")
.arg("-g") .arg("-g")
@ -6814,6 +6820,10 @@ async fn wt_publish(args: WtPublishArgs) -> Result<()> {
"2", "2",
"-ar", "-ar",
"48000", "48000",
"-af",
ec_chopper::LIVE_AUDIO_RESAMPLE_FILTER,
"-max_muxing_queue_size",
"2048",
]); ]);
} else { } else {
cmd.args(["-c", "copy"]); cmd.args(["-c", "copy"]);

View file

@ -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.