mux: fix non-monotonic audio DTS (TrueHD + DTS-HD MA) and stamp builds with git hash

TrueHD: when the PES PTS lags the access-unit cadence, resync to the PTS
but never snap the running timestamp backward, so the emitted DTS stays
monotonic across the resync (next_pts_ns = max(next_pts_ns, pts)).

DTS-HD MA: size each EXSS extension substream exactly from its header
(exss_frame_size) and skip it as a unit, so a false 0x7FFE8001 core sync
inside the lossless extension payload can no longer split the access unit
and truncate the extension. Falls back to a bounded scan when the header
is unparseable.

Provenance: build.rs bakes the git short hash into GIT_SUFFIX; the muxing/
writing-application field and the FVI generator tag now record the exact
build (e.g. "freemkv 1.1.0-beta.1 (g835cc99)"), so any output file is
traceable to the revision that produced it.
This commit is contained in:
Matthew Jackson
2026-06-26 19:26:24 -07:00
parent afa218fc8f
commit c49a180ce7
5 changed files with 234 additions and 12 deletions
+35
View File
@@ -1,4 +1,6 @@
fn main() {
emit_git_suffix();
let target = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
if target == "macos" {
println!("cargo:rustc-link-lib=framework=IOKit");
@@ -48,3 +50,36 @@ fn main() {
println!("cargo:rerun-if-changed=src/scsi/macos_shim.c");
}
}
/// Bake the git short hash into the build as `GIT_SUFFIX` so any muxed MKV or
/// FVI index is traceable to the exact source revision (e.g. ` (g835cc99)`).
/// Empty when git or the repo is unavailable (e.g. a crates.io tarball build),
/// leaving just the package version. Always emitted so `env!("GIT_SUFFIX")`
/// resolves on every target.
fn emit_git_suffix() {
let suffix = git_short_hash()
.map(|h| format!(" (g{h})"))
.unwrap_or_default();
println!("cargo:rustc-env=GIT_SUFFIX={suffix}");
// Re-run when HEAD (or the branch it points at) moves so the stamp stays
// current without a clean rebuild.
println!("cargo:rerun-if-changed=.git/HEAD");
if let Ok(head) = std::fs::read_to_string(".git/HEAD") {
if let Some(ref_path) = head.strip_prefix("ref: ") {
println!("cargo:rerun-if-changed=.git/{}", ref_path.trim());
}
}
}
fn git_short_hash() -> Option<String> {
let out = std::process::Command::new("git")
.args(["rev-parse", "--short=7", "HEAD"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let h = String::from_utf8(out.stdout).ok()?.trim().to_string();
if h.is_empty() { None } else { Some(h) }
}
+154 -9
View File
@@ -5,14 +5,14 @@
//! Buffers across PES boundaries so frames spanning two PES packets
//! are emitted complete.
use super::startcode::BitReader;
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
const DTS_CORE_SYNC: [u8; 4] = [0x7F, 0xFE, 0x80, 0x01];
/// DTS-HD extension substream syncword. The parser delimits an access unit by
/// the next CORE sync (so every extension between two cores is captured), and
/// never needs to locate or size the extension itself — so this is referenced
/// only by the tests that synthesize extension substreams.
#[cfg(test)]
/// DTS-HD extension substream syncword. An access unit is delimited by the next
/// CORE sync; the parser locates and exactly sizes each extension substream (via
/// `exss_frame_size`) so a false core sync inside the EXSS payload can't split
/// the AU and truncate the lossless extension.
const DTS_HD_EXT_SYNC: [u8; 4] = [0x64, 0x58, 0x20, 0x25];
/// DTS / DTS-HD elementary-stream parser. Buffers DTS across PES boundaries so
@@ -336,11 +336,108 @@ enum NextCore {
/// match the core syncword, so each candidate is validated by decoding its
/// core size: a match whose decoded size is implausible (< MIN_CORE_FRAME_BYTES
/// or > MAX_AU_BYTES) is a false sync and is skipped, continuing the search.
/// Both DTS syncwords (core `0x7FFE8001`, EXSS `0x64582025`) are 32-bit words.
const SYNCWORD_BYTES: usize = DTS_CORE_SYNC.len();
/// DTS-HD extension-substream (EXSS) header field bit widths (ETSI TS 102 114,
/// ExtSS header). `bHeaderSizeType` selects the short form (`nuExtSSHeaderSize`
/// 8 bits, `nuExtSSFsize` 16 bits) or, for larger substreams, the long form
/// (12 / 20 bits).
const EXSS_USER_DEFINED_BITS: u32 = 8;
const EXSS_INDEX_BITS: u32 = 2;
const EXSS_HEADER_SIZE_TYPE_BITS: u32 = 1;
const EXSS_HDRSIZE_BITS_SHORT: u32 = 8;
const EXSS_FSIZE_BITS_SHORT: u32 = 16;
const EXSS_HDRSIZE_BITS_LONG: u32 = 12;
const EXSS_FSIZE_BITS_LONG: u32 = 20;
/// `bHeaderSizeType == 1` selects the long-form field widths.
const EXSS_HEADER_SIZE_TYPE_LONG: u32 = 1;
/// Bytes that must be buffered to read the EXSS size fields in the worst case
/// (long form): the 4-byte sync plus the bits up through `nuExtSSFsize`.
const EXSS_HEADER_MIN_BYTES: usize = SYNCWORD_BYTES
+ (EXSS_USER_DEFINED_BITS
+ EXSS_INDEX_BITS
+ EXSS_HEADER_SIZE_TYPE_BITS
+ EXSS_HDRSIZE_BITS_LONG
+ EXSS_FSIZE_BITS_LONG)
.div_ceil(u8::BITS) as usize;
/// DTS-HD extension substream (EXSS) total byte size — INCLUDING the
/// `0x64582025` syncword — read precisely from its header. `buf` must begin with
/// `DTS_HD_EXT_SYNC`. `None` when the size fields aren't fully buffered.
///
/// `nuExtSSFsize` is the total frame size in bytes minus one. Parsing it lets the
/// AU framer skip the extension by its exact length instead of scanning its
/// (arbitrary) payload for a core sync.
fn exss_frame_size(buf: &[u8]) -> Option<usize> {
if buf.len() < EXSS_HEADER_MIN_BYTES {
return None;
}
let mut r = BitReader::new(&buf[SYNCWORD_BYTES..]);
let _user = r.read_bits(EXSS_USER_DEFINED_BITS)?; // nUserDefinedBits
let _idx = r.read_bits(EXSS_INDEX_BITS)?; // nExtSSIndex
let large = r.read_bits(EXSS_HEADER_SIZE_TYPE_BITS)? == EXSS_HEADER_SIZE_TYPE_LONG;
let (hbits, fbits) = if large {
(EXSS_HDRSIZE_BITS_LONG, EXSS_FSIZE_BITS_LONG)
} else {
(EXSS_HDRSIZE_BITS_SHORT, EXSS_FSIZE_BITS_SHORT)
};
let _hdr = r.read_bits(hbits)?; // nuExtSSHeaderSize (not needed for framing)
let fsize_minus_one = r.read_bits(fbits)?; // nuExtSSFsize = total bytes - 1
Some(fsize_minus_one as usize + 1)
}
/// Offset where the current access unit ends (the start of the next core
/// frame). The AU is the core frame plus its trailing DTS-HD extension
/// substreams, which are skipped PRECISELY by their declared size — so a chance
/// core syncword inside the XLL lossless payload can never be mistaken for the
/// next AU boundary (the bug that truncated the extension and produced the
/// "Failed to decode block code(s)" class). Falls back to the heuristic core-sync
/// scan only when an extension can't be sized (malformed / truncated input).
fn next_core_boundary(buf: &[u8], core_size: usize) -> NextCore {
let mut from = core_size;
let mut pos = core_size;
loop {
if buf.len() < pos + SYNCWORD_BYTES {
return NextCore::NeedMore; // need a syncword to identify the next chunk
}
if buf[pos..].starts_with(&DTS_HD_EXT_SYNC) {
match exss_frame_size(&buf[pos..]) {
Some(sz) if sz >= SYNCWORD_BYTES => {
if buf.len() < pos + sz {
return NextCore::NeedMore; // extension not fully buffered
}
pos += sz; // skip the whole extension substream precisely
}
// Couldn't size it (truncated/garbage header) — heuristic fallback.
_ => return scan_for_next_core(buf, pos),
}
} else if buf[pos..].starts_with(&DTS_CORE_SYNC) {
// The bytes right after the precisely-skipped extensions are the next
// core frame — the AU boundary.
if buf.len() - pos < CORE_HEADER_MIN_BYTES {
return NextCore::NeedMore;
}
let sz = dts_core_frame_size(&buf[pos..]);
if (MIN_CORE_FRAME_BYTES..=MAX_AU_BYTES).contains(&sz) {
return NextCore::Found(pos);
}
return scan_for_next_core(buf, pos); // implausible core here — fall back
} else {
// Neither a known extension nor a core sync at the precise boundary
// (padding / junk) — fall back to the heuristic scan.
return scan_for_next_core(buf, pos);
}
}
}
/// Heuristic fallback (the pre-fix behaviour): scan forward for the next core
/// syncword whose decoded size is plausible. Used only when precise extension
/// skipping can't proceed; a chance core syncword in extension payload usually
/// decodes to an implausible size and is skipped.
fn scan_for_next_core(buf: &[u8], from: usize) -> NextCore {
let mut from = from;
while let Some(rel) = find_sync(&buf[from..], &DTS_CORE_SYNC) {
let pos = from + rel;
// Need the candidate's core header to judge it.
if buf.len() - pos < CORE_HEADER_MIN_BYTES {
return NextCore::NeedMore;
}
@@ -348,8 +445,7 @@ fn next_core_boundary(buf: &[u8], core_size: usize) -> NextCore {
if (MIN_CORE_FRAME_BYTES..=MAX_AU_BYTES).contains(&sz) {
return NextCore::Found(pos);
}
// False sync inside extension payload — skip it and keep searching.
from = pos + 4;
from = pos + SYNCWORD_BYTES;
}
NextCore::None
}
@@ -399,6 +495,55 @@ mod tests {
data
}
/// A real DTS-HD EXSS substream of `total` bytes (short header form), with an
/// optional false DTS core syncword embedded in its payload (decoding to a
/// plausible core size) — to prove precise sizing, not a payload scan, bounds
/// the extension.
fn make_exss(total: usize, false_core_at: Option<usize>) -> Vec<u8> {
let mut d = vec![0u8; total];
d[0..4].copy_from_slice(&DTS_HD_EXT_SYNC);
// Short form: all header fields 0 except nuExtSSFsize = total - 1, laid
// out at bit 19 after the sync (byte 6 low 5 bits, byte 7, byte 8 top 3).
let fsize = (total - 1) as u32;
d[6] = ((fsize >> 11) & 0x1F) as u8;
d[7] = ((fsize >> 3) & 0xFF) as u8;
d[8] = ((fsize & 0x07) << 5) as u8;
if let Some(at) = false_core_at {
d[at..at + 4].copy_from_slice(&DTS_CORE_SYNC);
let fcs = 512u32 - 1; // decode to a plausible core size — fools the heuristic
d[at + 5] = (d[at + 5] & 0xFC) | ((fcs >> 12) & 0x03) as u8;
d[at + 6] = ((fcs >> 4) & 0xFF) as u8;
d[at + 7] = (d[at + 7] & 0x0F) | (((fcs & 0x0F) << 4) as u8);
}
d
}
#[test]
fn plausible_false_core_sync_inside_real_exss_does_not_split_au() {
// EXSS size parse round-trips.
assert_eq!(exss_frame_size(&make_exss(600, None)), Some(600));
// AU = core(512) + a REAL EXSS substream whose XLL payload embeds a DTS
// core syncword decoding to a plausible size (512). The heuristic-only
// framer would split here and truncate the lossless extension (the
// Dunkirk `dca` "Failed to decode block code(s)" class). Precise EXSS
// sizing spans the whole extension to the REAL next core.
let core = make_dts_core(512);
let exss = make_exss(600, Some(40));
let next = make_dts_core(512);
let mut buf = core.clone();
buf.extend_from_slice(&exss);
buf.extend_from_slice(&next);
assert!(
matches!(
next_core_boundary(&buf, core.len()),
NextCore::Found(end) if end == core.len() + exss.len()
),
"AU must end at the REAL next core (after the full EXSS), not the false sync inside it"
);
}
#[test]
fn parse_empty_pes() {
let mut parser = DtsParser::new();
+42 -1
View File
@@ -138,7 +138,18 @@ impl CodecParser for TrueHdParser {
// the next PES legitimately begins a new AU and seeds the base.
if self.buf.is_empty() {
if let Some(pts) = pes.pts {
self.next_pts_ns = pts_to_ns(pts);
// Resync to the authoritative PES PTS, but NEVER snap backward.
// TrueHD AUs are a fixed sample count (40 @ 48 kHz), so the
// per-AU `+AU_DURATION_NS` cadence is sample-accurate — more so
// than the disc's per-PES PTS, which carries the source muxer's
// own rounding jitter. When the buffer empties exactly on a PES
// boundary and that PES's PTS lands a few ticks *below* the
// running cadence, an unconditional reset would set the next
// AU's timestamp below the AU just emitted, producing the
// non-monotonic block timestamps a muxer rejects. Clamp to the
// running position so output stays strictly monotonic; a
// genuine forward gap/discontinuity is still adopted.
self.next_pts_ns = self.next_pts_ns.max(pts_to_ns(pts));
}
}
@@ -449,6 +460,36 @@ mod tests {
assert_eq!(frames[1].pts_ns - frames[0].pts_ns, AU_DURATION_NS);
}
#[test]
fn pes_pts_lagging_the_au_cadence_never_emits_backward() {
// Regression: the per-AU cadence is sample-accurate, but a PES boundary
// can carry a PTS that lags it slightly (source-muxer rounding jitter).
// When the buffer empties exactly on that boundary, an unconditional
// reset to the PES PTS snapped the next AU's timestamp BELOW the AU just
// emitted — the non-monotonic block timestamps a muxer rejects (the
// Top Gun / Dune: Part Two case). The reset must clamp forward-only.
let mut parser = TrueHdParser::new();
let au = make_truehd_unit(100);
// PES1: three complete AUs at pts 90000 — buffer empties, cadence runs
// ahead to 90000_ns + 3*AU_DURATION_NS.
let mut d1 = au.clone();
d1.extend_from_slice(&au);
d1.extend_from_slice(&au);
let f1 = parser.parse(&make_pes(d1, Some(90000)));
assert_eq!(f1.len(), 3);
let last1 = f1.last().unwrap().pts_ns;
// PES2's PTS (90001) maps to fewer ns than the running cadence — pre-fix
// this snapped backward.
let f2 = parser.parse(&make_pes(au.clone(), Some(90001)));
assert_eq!(f2.len(), 1);
assert!(
f2[0].pts_ns >= last1,
"AU pts must not go backward when PES PTS lags the cadence: got {} after {}",
f2[0].pts_ns,
last1
);
}
#[test]
fn skip_interleaved_ac3() {
let mut parser = TrueHdParser::new();
+2 -1
View File
@@ -773,7 +773,8 @@ impl<W: Write + Seek> MkvMuxer<W> {
}
// Stamp the freemkv version so any muxed file is traceable to the build
// that produced it (MediaInfo "Writing application"/"library").
const FREEMKV_MUX_APP: &str = concat!("freemkv ", env!("CARGO_PKG_VERSION"));
const FREEMKV_MUX_APP: &str =
concat!("freemkv ", env!("CARGO_PKG_VERSION"), env!("GIT_SUFFIX"));
ebml::write_string(&mut writer, ebml::MUXING_APP, FREEMKV_MUX_APP)?;
ebml::write_string(&mut writer, ebml::WRITING_APP, FREEMKV_MUX_APP)?;
if let Some(t) = title {
+1 -1
View File
@@ -37,7 +37,7 @@ pub const FVI_VERSION: u32 = 1;
/// Producing tool tag for the header `"generator"` member
/// (`docs/FVI_FORMAT.md` §6).
pub const FVI_GENERATOR: &str = concat!("freemkv/", env!("CARGO_PKG_VERSION"));
pub const FVI_GENERATOR: &str = concat!("freemkv/", env!("CARGO_PKG_VERSION"), env!("GIT_SUFFIX"));
/// Header `"timescale"` for all `pts`/`dts` ticks (`docs/FVI_FORMAT.md` §10).
/// The highway carries presentation timestamps in nanoseconds, so the timescale