Compare commits

...
6 Commits
Author SHA1 Message Date
Matthew Jackson 2cb6bcfebe v1.6.10: bump version (freemkv-unlock git-pinned for the tag) 2026-08-24 01:45:51 +00:00
Matthew Jackson e98e98c8fd TrueHD: resync forward to next major sync on a source discontinuity
MLP/TrueHD carries predictor + restart state across access units, so after a source transport-stream discontinuity (continuity-counter gap) the parser was splicing post-gap audio mid-stream, decoding against stale state and producing a decoder-choking seam (a burst of "restart header sync incorrect" / "Invalid blocksize" errors that could flag the whole track corrupt).

Honor the source's own gap signal: drop forward to the next CRC-valid major-sync AU (a clean re-init point) before resuming, guarded so it never arms before a validated baseline exists (no stream-head whole-track drop). Mirrors the video keyframe resync and the ResyncGate design. Corrects the "audio has no inter-frame refs" comments in truehd/dts/resync. Adds a regression test.
2026-08-23 16:30:29 -07:00
Matthew Jackson 7c085db133 restore freemkv-unlock path dep for local dev (post-v1.6.9) 2026-08-22 08:34:31 -07:00
Matthew Jackson 587e26353c v1.6.9: bump version (freemkv-unlock git-pinned for the tag) 2026-08-22 08:34:26 -07:00
Matthew Jackson 3eb3e649b1 changelog: 1.6.9 unified-release entry 2026-08-22 08:24:55 -07:00
Matthew Jackson b01505983c restore freemkv-unlock path dep for local dev (post-v1.6.8) 2026-08-21 12:54:53 -07:00
5 changed files with 134 additions and 7 deletions
+28
View File
@@ -1,5 +1,33 @@
# Changelog
## [1.6.10] — 2026-08-23
### Fixed
- TrueHD/MLP audio: on a source transport-stream discontinuity (lost or gapped
packets, signalled by a continuity-counter break), the parser now drops forward
to the next major-sync access unit before resuming, instead of splicing the
post-gap audio mid-stream. MLP carries predictor + restart state across access
units, so resuming mid-stream decoded against stale state and produced a
decoder-choking seam — streaming decoders emitted a burst of "restart header
sync incorrect" / "Invalid blocksize" errors and could flag the whole track
corrupt. This mirrors the video path (resync to the next keyframe after a gap)
and how the independent-frame audio codecs (AC-3, DTS, FLAC, AAC) already
recover; the fix is guarded so it never arms before a validated major-sync
baseline exists (no whole-track drop at stream head). The gapped instant itself
is in the source and cannot be recovered, but the muxed track is now
decoder-clean.
## [1.6.9] — 2026-08-22
### Changed
- Version aligned to 1.6.9 for the unified release. No functional changes to
this crate; the release was driven by autorip (automatic per-episode TV
ripping — each episode named `S{NN}E{MM}`, with TMDB runtime-aligned episode
numbering across multi-disc seasons — a Manual Rename option, and a unified
per-disc staging state file — see the autorip 1.6.9 notes).
## [1.6.8] — 2026-08-21
### Changed
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "libfreemkv"
version = "1.6.8"
version = "1.6.10"
edition = "2024"
rust-version = "1.97"
license = "MIT"
@@ -26,7 +26,7 @@ aes = "0.9"
# Interim path dep for local cross-repo dev; the release script re-pins this to
# `{ git = ".../freemkv-unlock", tag = "vX.Y.Z" }` before tagging libfreemkv (so
# the released tag resolves freemkv-unlock from git, not a sibling path).
freemkv-unlock = { git = "https://github.com/freemkv/freemkv-unlock", tag = "v1.6.8" }
freemkv-unlock = { git = "https://github.com/freemkv/freemkv-unlock", tag = "v1.6.10" }
rand = "0.10"
zip = { version = "8", default-features = false, features = ["deflate"] }
base64 = "0.23"
+6 -2
View File
@@ -205,8 +205,12 @@ impl CodecParser for DtsParser {
// TRUNCATED. Splicing post-gap bytes onto it corrupts the core/extension
// framing (→ "Failed to decode block code(s)" / "Invalid data found").
// Drop the partial AU and its PTS marks; the next PES re-bases a fresh
// unit. (Audio has no inter-frame refs — dropping the spliced partial is
// the whole fix; the video ResyncGate handles video.)
// unit. (DTS core/extension frames are independently decodable — every
// frame re-inits on its own syncword — so dropping the truncated partial
// and resuming on the next frame is sufficient here. This is NOT true of
// every audio codec: TrueHD/MLP carries predictor + restart state across
// access units and must additionally drop forward to its next major sync;
// see `codec::truehd`. The video ResyncGate handles inter-coded video.)
//
// Handle the discontinuity BEFORE the empty-data guard so the signal can
// never be stranded by an empty post-gap PES (defensive; the demuxer only
+89
View File
@@ -367,6 +367,28 @@ impl CodecParser for TrueHdParser {
// emits non-empty PES today).
if pes.discontinuity {
self.acc.clear();
// Unlike AC-3/DTS (independent frames), MLP/TrueHD carries filter +
// predictor + restart state ACROSS access units. A demux-signalled
// discontinuity (lost TS packets / a concealed source gap) leaves that
// state stale, so resuming mid-stream on the next parity-OK AU decodes
// against broken state and desyncs a conformant decoder ("restart header
// sync incorrect" / "Invalid blocksize"). Arm the same drop-forward the
// corruption path uses: keep dropping until the next CRC-valid major sync
// (a clean re-init point), then resume. This mirrors the video path's
// "resync on the next keyframe" after a gap — the gapped audio can't be
// recovered either way, but the output stays decoder-clean instead of
// shipping a decoder-choking seam.
//
// Only once a validated major sync has established a baseline: before
// that there is no CRC-validatable re-init point to resume on, so arming
// here would collateral-drop the WHOLE track waiting for a major sync we
// cannot yet validate — the exact stream-head catastrophe `au_check`
// guards (a checksum-failed head major sync is kept, not nuked). At
// stream head we therefore keep the current behaviour: drop the truncated
// partial (above) and let the fresh post-gap unit through.
if self.num_substreams.is_some() {
self.resync_pending = true;
}
}
if pes.data.is_empty() {
return Vec::new();
@@ -913,6 +935,73 @@ mod tests {
);
}
#[test]
fn discontinuity_resyncs_forward_to_next_major_sync() {
// A source-signalled discontinuity (lost TS packets / a concealed gap)
// leaves MLP's cross-AU predictor + restart state stale. Resuming
// mid-stream on the next parity-OK AU decodes against broken state and
// desyncs a conformant decoder ("restart header sync incorrect" /
// "Invalid blocksize"). So a discontinuity must arm the SAME drop-forward
// the corruption path uses: drop every post-gap AU until the next
// CRC-valid major sync (the clean re-init point), then resume there.
//
// Regression for a real disc (John Wick: Chapter 2) whose transport
// stream has a multi-PID continuity-counter gap at ~6:49: before this
// fix the TrueHD path cleared its buffer and resumed on the next NON-major
// AU, shipping a decoder-choking seam (559 `restart header sync` errors),
// while AC-3/DTS on the same disc were fine because THEIR frames are
// independently decodable. Video already resyncs to a keyframe after a
// gap (see `mux::resync`); this gives TrueHD the equivalent, keyed on the
// codec's own re-init point (a major-sync AU).
let mut parser = TrueHdParser::new();
// Establish a baseline so num_substreams is known and the gate is live.
let mut pre = valid_major_sync();
pre.extend_from_slice(&valid_normal_au());
let pre_frames = parser.parse(&make_pes(pre, Some(90000)));
assert_eq!(pre_frames.len(), 2, "baseline: major sync + one normal AU");
// Post-gap PES: two normal AUs, then a valid major sync, then a normal AU.
// `discontinuity: true` says packets were lost before this PES.
let mut post = valid_normal_au();
post.extend_from_slice(&valid_normal_au());
post.extend_from_slice(&valid_major_sync());
post.extend_from_slice(&valid_normal_au());
let post_pes = PesPacket {
source: None,
pid: 0x1100,
pts: Some(90000 + 4 * 900),
dts: None,
data: post,
discontinuity: true,
};
let mut frames = parser.parse(&post_pes);
frames.extend(parser.flush());
// Only the major sync and the AU after it survive; the two leading
// post-gap normal AUs are dropped for lack of a re-init point.
assert_eq!(
frames.len(),
2,
"resume only at the major sync + what follows it (got {})",
frames.len()
);
assert!(
frames[0].keyframe,
"the first surviving post-gap frame MUST be a major sync (re-init \
point), never a mid-stream AU"
);
assert!(
!frames[1].keyframe,
"the AU after the re-init point is a normal AU"
);
assert_eq!(
parser.dropped_frames(),
2,
"the two post-gap AUs before the major sync are dropped as collateral"
);
}
#[test]
fn crc_failed_head_major_sync_is_kept_not_track_killed() {
// REGRESSION (every TrueHD title muxed after the checksum gate landed): a
+9 -3
View File
@@ -11,9 +11,15 @@
//! and resume cleanly there. The gap rounds up to (at most) one GOP — the price
//! of never emitting a dangling reference; it is logged.
//!
//! Audio and subtitle frames are independent (no inter-frame references), so a
//! gap there costs only the single already-dropped frame; the gate is a no-op
//! for non-video tracks (it always admits).
//! Most audio and all subtitle frames are independently decodable (each frame
//! re-inits on its own header), so a gap there costs only the single already-
//! dropped frame and this gate is a no-op for them (it always admits). The one
//! exception is TrueHD/MLP, whose predictor + restart state spans access units:
//! its re-init point is a codec-specific major-sync AU (not a generic keyframe),
//! so it runs the equivalent drop-forward-to-major-sync inside `codec::truehd`
//! rather than through this gate. In short: this gate is NOT keyed on "audio vs
//! video" but on "needs a re-init point after a gap" — video here, TrueHD in its
//! own parser, everything else genuinely independent.
/// Per-track keyframe-resync state. One gate per elementary stream; a video
/// track's gate stays "armed" from a discontinuity until the next keyframe.