From 274d5de45b663bab951f0fa56167747cdc35c79a Mon Sep 17 00:00:00 2001 From: FedyaLight Date: Wed, 11 Nov 2026 22:49:49 +0300 Subject: [PATCH] Trim MP3 encoder delay as FFmpeg does MP3 encoders prime their output: LAME's delay plus the decoder's adds 1105 samples at the start, recorded in the LAME header. FFmpeg trims them; Align's in-process decoder did not, so MP3 audio sat 23 to 25 ms early and synchronized that far off. The delay Symphonia reports now becomes the stream's lead, which every decode path already skips for MP4 edit lists. Only the start is trimmed. Symphonia's gapless mode would also cut the end at a frame count that files without a Xing header merely estimate, which dropped audio after a quiet stretch, and it underflows on an inconsistent Xing frame count. The encoder priming test now covers MP3, and a new test keeps headerless VBR files and contradictory frame counts decoding in full. --- crates/align-decode/src/sym.rs | 152 ++++++++++++++++++++++++++++----- 1 file changed, 129 insertions(+), 23 deletions(-) diff --git a/crates/align-decode/src/sym.rs b/crates/align-decode/src/sym.rs index 7f74f76..8945896 100644 --- a/crates/align-decode/src/sym.rs +++ b/crates/align-decode/src/sym.rs @@ -327,12 +327,20 @@ fn open_stream(path: &Path, stream_index: usize) -> Result .map(|c| c.count()) .or_else(|| Some(movie_track.as_ref()?.audio.as_ref()?.channels)) .unwrap_or(0); - let lead = movie_track - .as_ref() - .filter(|t| t.timescale > 0) - .map_or(0, |t| { - (t.media_start as f64 * sample_rate as f64 / t.timescale as f64).round() as u64 - }); + // MP3's LAME header records the encoder and decoder delay, which + // Symphonia reports and FFmpeg trims. Only the start is trimmed here: + // Symphonia's gapless mode would also cut the end at a frame count that + // files without a Xing header merely estimate. + let lead = if is_iso_media(path) { + movie_track + .as_ref() + .filter(|t| t.timescale > 0) + .map_or(0, |t| { + (t.media_start as f64 * sample_rate as f64 / t.timescale as f64).round() as u64 + }) + } else { + u64::from(track.codec_params.delay.unwrap_or(0)) + }; let is_pcm = symphonia::default::get_codecs() .get_codec(track.codec_params.codec) .is_some_and(|codec| codec.short_name.starts_with("pcm")); @@ -759,26 +767,104 @@ mod edit_list_tests { samples.iter().position(|s| s.abs() > 0.3) } - /// AAC in MP4 and M4A carries encoder priming in the edit list. - /// In-process decoding must place audio exactly where FFmpeg (which - /// applies the edit list) does, both from the head and after a seek. + /// Encoders prime their output: AAC in MP4 and M4A records it in the + /// edit list, MP3 in its LAME header. In-process decoding must place + /// audio exactly where FFmpeg (which applies both) does, from the head + /// and after a seek. #[test] - fn aac_priming_matches_ffmpeg_timing() { - for name in ["camera.mp4", "voice.m4a"] { - aac_priming_case(name); + fn encoder_priming_matches_ffmpeg_timing() { + let click = "aevalsrc='if(between(t,1.5,1.502),0.9,0)':s=48000:d=4"; + let dir = tempfile::tempdir().unwrap(); + let clips = [ + super::test_media::aac_clip(dir.path(), "camera.mp4", click), + super::test_media::aac_clip(dir.path(), "voice.m4a", click), + super::test_media::encoded_clip( + dir.path(), + "voice.mp3", + click, + &["-c:a", "libmp3lame", "-b:a", "192k"], + ), + ]; + for clip in clips.iter().flatten() { + priming_case(clip); } } - fn aac_priming_case(name: &str) { + fn native_length(path: &Path) -> usize { + let mut frames = 0; + decode_native(path, 0, 0.0, None, &mut |block| { + frames += block.frames[0].len(); + Ok(()) + }) + .unwrap(); + frames + } + + fn ffmpeg_length(path: &Path) -> usize { + let mut frames = 0; + let mut pipe = crate::ff::AudioPipe::spawn(path, 0, None, 2).unwrap(); + pipe.pump(&mut |samples| { + frames += samples.len() / 2; + Ok(()) + }) + .unwrap(); + frames + } + + /// Without a Xing header the frame count of a VBR MP3 is only an + /// estimate from its first frames. Trimming the end by that estimate + /// dropped the audio after a quiet stretch; the whole file must decode, + /// and a frame count that contradicts the LAME header must not break it. + #[test] + fn mp3_frame_counts_never_cut_audio() { let dir = tempfile::tempdir().unwrap(); - let Some(clip) = super::test_media::aac_clip( + // Loud, then silent, then loud: the estimate from the loud opening + // is far too short. + let source = "anoisesrc=d=4.5:r=44100:a=0.5,volume='if(between(t,0.5,3.8),0,1)':eval=frame"; + let Some(headerless) = super::test_media::encoded_clip( dir.path(), - name, - "aevalsrc='if(between(t,1.5,1.502),0.9,0)':s=48000:d=4", + "headerless.mp3", + source, + &["-c:a", "libmp3lame", "-q:a", "2", "-write_xing", "0"], + ) else { + return; + }; + let expected = ffmpeg_length(&headerless); + let decoded = native_length(&headerless); + assert!( + decoded.abs_diff(expected) <= 1152, + "{decoded} of {expected} frames" + ); + + let Some(tagged) = super::test_media::encoded_clip( + dir.path(), + "tagged.mp3", + source, + &["-c:a", "libmp3lame", "-q:a", "2"], ) else { return; }; - assert!(can_decode(&clip, 0), "AAC must decode in-process"); + let full = native_length(&tagged); + // The Xing/Info tag: identifier, flags, then the frame count. + let mut data = std::fs::read(&tagged).unwrap(); + let tag = data + .windows(4) + .position(|w| w == b"Xing" || w == b"Info") + .expect("Xing/Info tag"); + assert_eq!(data[tag + 7] & 1, 1, "the tag records a frame count"); + data[tag + 8..tag + 12].copy_from_slice(&1u32.to_be_bytes()); + std::fs::write(&tagged, data).unwrap(); + assert!(inspect(&tagged).is_ok()); + assert_eq!(native_length(&tagged), full); + } + + fn priming_case(clip: &Path) { + let clip = clip.to_path_buf(); + assert!( + can_decode(&clip, 0), + "{} must decode in-process", + clip.display() + ); let reference = { let mut out = Vec::new(); let mut pipe = crate::ff::AudioPipe::spawn(&clip, 0, None, 2).unwrap(); @@ -820,19 +906,39 @@ pub(crate) mod test_media { /// Stereo AAC in MP4 from an FFmpeg `lavfi` source, or `None` without an /// AAC encoder. pub(crate) fn aac_clip(dir: &Path, name: &str, source: &str) -> Option { + // Without noise substitution, whose generator state would let one + // skipped packet change every later packet. + let codec = [ + "-movflags", + "+faststart", + "-c:a", + "aac", + "-b:a", + "192k", + "-aac_pns", + "0", + ]; + encoded_clip(dir, name, source, &codec) + } + + /// Stereo audio from an FFmpeg `lavfi` source with the given codec + /// arguments, or `None` without that encoder. + pub(crate) fn encoded_clip( + dir: &Path, + name: &str, + source: &str, + codec: &[&str], + ) -> Option { let ffmpeg = crate::ff::ffmpeg_bin()?; let clip = dir.join(name); let ok = std::process::Command::new(ffmpeg) - .args(["-v", "error", "-y", "-f", "lavfi", "-i", source]) - .args(["-movflags", "+faststart"]) - // Without noise substitution, whose generator state would let - // one skipped packet change every later packet. - .args(["-ac", "2", "-c:a", "aac", "-b:a", "192k", "-aac_pns", "0"]) + .args(["-v", "error", "-y", "-f", "lavfi", "-i", source, "-ac", "2"]) + .args(codec) .arg(&clip) .status() .is_ok_and(|s| s.success()); if !ok { - eprintln!("SKIP: no AAC encoder"); + eprintln!("SKIP: no encoder for {name}"); } ok.then_some(clip) }