mux/codec: drop undecodable audio frames, keep A/V sync
A damaged audio access unit is now dropped rather than muxed as a
decoder-choking glitch. Sync is preserved — a drop becomes a silence
gap, never a shift — and every drop is logged. Detection is per-codec,
each mirroring the format's authoritative integrity check:
DTS core-header validity gates
AC-3/E-AC-3 native frame CRC-16 + bitstream-id range
FLAC whole-frame CRC-16 residue
MP2/MP3 header sanity + free-format reject
AAC-ADTS header sanity (raw AAC passes through untouched)
TrueHD/MLP major-sync CRC-16 + AU parity; corrupt AUs drop forward
to the next major sync, since decode state carries
across access units
LPCM and video are excluded by design (no in-frame integrity data;
inter-frame prediction). A shared DropTally handles counting, logging,
and a whole-track fallback for a mostly-undecodable track.
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
//! Shared "keep what decodes, drop what doesn't" bookkeeping for the audio
|
||||
//! codec parsers.
|
||||
//!
|
||||
//! The user's rule: a clean mux keeps every frame it can and drops the ones it
|
||||
//! can't — video always survives (it's inter-frame predicted; a per-frame drop
|
||||
//! would cascade, so video resyncs/conceals instead), audio keeps every
|
||||
//! decodable access unit, and a damaged audio AU is dropped rather than shipped
|
||||
//! as a decoder-choking glitch.
|
||||
//!
|
||||
//! The DETECTION is inherently per-codec — each format carries its own
|
||||
//! authoritative corruption check (DTS: ffmpeg's core-header parse; AC-3: the
|
||||
//! header CRC; FLAC: the frame CRC-16; …). This type only carries the UNIFORM
|
||||
//! response so every audio parser behaves identically:
|
||||
//!
|
||||
//! 1. **Count** kept vs dropped AUs and the dropped duration.
|
||||
//! 2. **Log** every drop (fail-loud, never silent) — a per-drop trace plus a
|
||||
//! once-per-track aggregate at `warn` so it surfaces without debug logging.
|
||||
//! 3. **Whole-track fallback**: once a track is judged mostly undecodable, latch
|
||||
//! a poison flag so the remainder is dropped too (a track that damaged isn't
|
||||
//! worth muxing).
|
||||
//!
|
||||
//! **Sync preservation is the caller's responsibility**, not this type's: the
|
||||
//! parser must advance its PTS clock across a dropped AU exactly as it would for
|
||||
//! an emitted one, so a drop becomes a silence gap and never a shift of the
|
||||
//! following audio. See `DtsParser`'s `stamp_pts` call ordering for the pattern.
|
||||
|
||||
/// Minimum access units observed before the whole-track drop verdict can fire.
|
||||
/// Below this, a short damaged burst can't poison an otherwise-good track.
|
||||
const TRACK_VERDICT_MIN_AUS: u64 = 200;
|
||||
|
||||
/// Per-track drop bookkeeping shared by the audio codec parsers.
|
||||
pub(crate) struct DropTally {
|
||||
/// Static codec label for log lines (e.g. `"dts"`, `"ac3"`).
|
||||
codec: &'static str,
|
||||
kept: u64,
|
||||
dropped: u64,
|
||||
dropped_dur_ns: u64,
|
||||
poisoned: bool,
|
||||
}
|
||||
|
||||
impl DropTally {
|
||||
pub(crate) fn new(codec: &'static str) -> Self {
|
||||
Self {
|
||||
codec,
|
||||
kept: 0,
|
||||
dropped: 0,
|
||||
dropped_dur_ns: 0,
|
||||
poisoned: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the track has been judged too damaged to mux. Once `true`, the
|
||||
/// caller should drop every remaining AU (passing them to [`record_drop`]
|
||||
/// with a poison reason) rather than emit them.
|
||||
pub(crate) fn is_poisoned(&self) -> bool {
|
||||
self.poisoned
|
||||
}
|
||||
|
||||
/// Access units dropped as undecodable so far — surfaced to the CLI/mux.
|
||||
pub(crate) fn dropped_frames(&self) -> u64 {
|
||||
self.dropped
|
||||
}
|
||||
|
||||
/// Total decoded duration (ns) of dropped AUs — the audio silence introduced.
|
||||
pub(crate) fn dropped_duration_ns(&self) -> u64 {
|
||||
self.dropped_dur_ns
|
||||
}
|
||||
|
||||
/// Record an emitted (decodable) access unit.
|
||||
pub(crate) fn record_kept(&mut self) {
|
||||
self.kept += 1;
|
||||
}
|
||||
|
||||
/// Record a dropped (undecodable) access unit and log it. `reason` is a
|
||||
/// short static label for the specific corruption check that failed.
|
||||
pub(crate) fn record_drop(&mut self, pts_ns: i64, dur_ns: i64, bytes: usize, reason: &str) {
|
||||
self.dropped += 1;
|
||||
self.dropped_dur_ns += dur_ns.max(0) as u64;
|
||||
tracing::debug!(
|
||||
target: "mux",
|
||||
"{}: dropped undecodable AU #{} pts_ns={} dur_ns={} bytes={} reason={}",
|
||||
self.codec,
|
||||
self.dropped,
|
||||
pts_ns,
|
||||
dur_ns,
|
||||
bytes,
|
||||
reason
|
||||
);
|
||||
self.maybe_poison();
|
||||
}
|
||||
|
||||
/// Whole-track fallback: after enough AUs to judge, if more than half were
|
||||
/// dropped the track is too damaged to be worth muxing — latch `poisoned`
|
||||
/// and log it loudly once. The minimum-sample gate keeps a short damaged
|
||||
/// burst from poisoning an otherwise-good track.
|
||||
fn maybe_poison(&mut self) {
|
||||
if self.poisoned {
|
||||
return;
|
||||
}
|
||||
let total = self.kept + self.dropped;
|
||||
if total >= TRACK_VERDICT_MIN_AUS && self.dropped * 2 > total {
|
||||
self.poisoned = true;
|
||||
tracing::warn!(
|
||||
target: "mux",
|
||||
"{}: track too damaged to mux — {}/{} AUs undecodable (>50%); dropping the whole track",
|
||||
self.codec,
|
||||
self.dropped,
|
||||
total
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// End-of-stream aggregate report, logged at `warn` so a track's dropped
|
||||
/// audio is never hidden even without debug logging. No-op if nothing was
|
||||
/// dropped.
|
||||
pub(crate) fn log_summary(&self) {
|
||||
if self.dropped > 0 {
|
||||
tracing::warn!(
|
||||
target: "mux",
|
||||
"{}: dropped {} undecodable AU(s) totaling {} ns of audio ({} kept)",
|
||||
self.codec,
|
||||
self.dropped,
|
||||
self.dropped_dur_ns,
|
||||
self.kept
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn counts_kept_and_dropped() {
|
||||
let mut t = DropTally::new("test");
|
||||
t.record_kept();
|
||||
t.record_drop(0, 1000, 512, "bad");
|
||||
t.record_kept();
|
||||
assert_eq!(t.dropped_frames(), 1);
|
||||
assert_eq!(t.dropped_duration_ns(), 1000);
|
||||
assert!(!t.is_poisoned());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poisons_after_min_aus_over_half_dropped() {
|
||||
let mut t = DropTally::new("test");
|
||||
// 199 AUs, all dropped: below the min-AU gate, must NOT poison yet.
|
||||
for _ in 0..199 {
|
||||
t.record_drop(0, 1000, 512, "bad");
|
||||
}
|
||||
assert!(!t.is_poisoned(), "below the 200-AU minimum, no verdict");
|
||||
// The 200th drop reaches the minimum with >50% dropped → poison.
|
||||
t.record_drop(0, 1000, 512, "bad");
|
||||
assert!(t.is_poisoned());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_poison_a_mostly_good_track() {
|
||||
let mut t = DropTally::new("test");
|
||||
// 400 AUs, 1 dropped: nowhere near 50%.
|
||||
t.record_drop(0, 1000, 512, "bad");
|
||||
for _ in 0..399 {
|
||||
t.record_kept();
|
||||
}
|
||||
assert!(!t.is_poisoned());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user