Stabilize live playback audio
This commit is contained in:
parent
0d86104762
commit
64e5ee3965
4 changed files with 122 additions and 6 deletions
|
|
@ -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!(
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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"]);
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue