Files
libfreemkv/docs/ac3.md
T
Matthew Jackson 6e14fc0c0c libfreemkv: 1.7.0 groundwork — sector-recycle soundness + CI hardening
Security
- Remove the unsafe Vec::set_len over recycled sector buffers — UB reachable
  from safe code (GHSA-j8ww-f5fg-9pmh, low). Uses Vec::resize; pooling unchanged.

CI / tooling
- clippy on stable with the MSRV single-sourced from Cargo.toml; cargo-deny
  dependency-audit gate with first-party git sources allowed (#15); audience-based
  comment-guard (#17).

Docs
- README (keydb ownership, BD-J parser count), drive-access transport/reset
  notes, mux timeline module-doc links.
2026-09-02 12:40:55 -07:00

11 KiB
Raw Blame History

AC3 / E-AC3 parser notes (src/mux/codec/ac3.rs)

Why a frame set is one sample, never split into separate tracks

A legacy AC-3 syncframe is a complete access unit on its own, but an E-AC-3 access unit (ETSI TS 102 366 / ATSC A/52 Annex E) is a whole FRAME SET: the mandatory independent substream (substreamid 0) plus every DEPENDENT substream frame that follows it, plus any ADDITIONAL independent substreams (substreamid 1..7) and their own dependents. A decoder needs the whole set to reconstruct the programme (a 5.1 independent substream plus a dependent substream carrying the extra channels of a 7.1 programme, and the AC-3-core + E-AC-3-dependent arrangement used for backwards-compatible Dolby Digital Plus), and every substream of the frame set covers the SAME time period.

WHY the whole frame set is ONE sample, rather than splitting an additional independent substream (an associated / commentary service) into a track of its own: Annex E orders the substreams of a frame set inside a single elementary stream, all covering one time period, and a decoder handed a frame set renders the programme it is asked for from it — that is exactly what the container's E-AC-3 sample and its dec3/num_ind_sub description denote. Splitting would mean rewriting the bitstream (re-numbering substreamid so the extracted service becomes substream 0, and rebuilding its frame sets), because a substream numbered 1..7 with no substream 0 is not a conforming stream. That is a transcode, not a remux; this parser is lossless, so the frame set stays whole and player-side programme selection decides what is heard.

SAMPLE_RATES / fscod2

Sample rates indexed by fscod (0=48kHz, 1=44.1kHz, 2=32kHz). fscod=3 is reserved in AC-3; in E-AC-3 it signals "fscod2" (reduced rates: 24/22.05/16 kHz, selected by byte-4 bits [5:4]). frame_sample_rate decodes fscod2 in the E-AC-3 case; the table's index-3 entry (48 kHz) is only the fallback when the header is too short to read fscod2.

MAX_AC3_BUF derivation

An AC-3/E-AC-3 syncframe is at most 8192 bytes (the frame_size > 8192 reject) and an access unit is a whole frame set: up to 8 independent substreams, each with up to 8 dependent substreams (ETSI TS 102 366 Annex E) — 72 syncframes worst case. A cap of one straddling frame set plus slack therefore has to exceed 72 × 8192 = 576 KiB. If the buffer grows past the cap without yielding a frame (pathological / never-syncing input) it is dropped and resync begins, rather than accumulating one PES worth of data per call for the whole title.

scan_access_units

Per ETSI TS 102 366 (ATSC A/52) Annex E an access unit is one FRAME SET: the mandatory independent substream (substreamid 0) plus every DEPENDENT substream that follows it, plus any ADDITIONAL independent substreams (substreamid 1..7 — associated/commentary services) with their own dependents. The access unit therefore closes only when the next substreamid-0 independent substream (or, with at_eos, the end of the stream) is seen, and it carries THAT substream's PTS and duration: every other substream of the frame set describes the SAME time period and adds no duration of its own.

base_pts_ns times the access unit that begins at data[0] — i.e. the running cadence carried over from the previous call. anchor re-anchors the running PTS to this PES's own timestamp at the first access unit that STARTS in the newly-appended bytes: a PES timestamp applies to the first access unit beginning in that PES, never to one that began in an earlier PES and is only being completed (or was held) here.

Returns the emitted access units, the offset in data from which bytes must be carried over to the next call, and the PTS to stamp on the access unit that begins that carry-over.

substream_role

Byte 2 of an E-AC-3 syncframe is strmtyp(2) | substreamid(3) | frmsiz[10:8] (ETSI TS 102 366 Annex E BSI). strmtyp 0 and 2 are independent substreams (type 2 being an independent substream that is not the first of the bit stream); strmtyp 1 is the dependent substream.

Annex E defines a FRAME SET as independent substream 0 — mandatory, always first — with its dependent substreams, followed by the OPTIONAL additional independent substreams 1..7, each with their own dependents; all of them carry the same time period. So the access-unit boundary is an independent substream with substreamid == 0, and NOT merely "an independent substream": an additional independent substream (an associated or commentary service) sits INSIDE the frame set already open. Keying the boundary on strmtyp alone made every such substream close the access unit and advance the running PTS a second time over the same ~32 ms, doubling the timeline (about a second of A/V drift per second of audio).

strmtyp 3 is reserved: its BSI layout is not defined, so its substreamid bits cannot be trusted and it is treated as starting a fresh access unit — an unknown frame is never merged into an unrelated programme, and never discarded as an orphan either.

Legacy AC-3 (bsid < 11) has no substream structure — byte 2 there is the crc1 field, never strmtyp — so it always starts an access unit.

A frame set MAY carry a substream as several consecutive syncframes of fewer than six blocks each (Annex E allows numblkscod < 3). Those extra syncframes are substreamid 0 too, so each starts its own access unit here rather than merging into one frame set. That is deliberate: each carries its OWN numblkscod-derived duration (see frame_duration_ns), so the timeline total stays exact, and the substreams are still delivered in bitstream order.

frame_crc_ok

Per ETSI TS 102 366 (ATSC A/52) the frame carries a CRC-16/ANSI (poly 0x8005, init 0, non-reflected) over the bytes after the 2-byte syncword — i.e. crc16_ansi(&buf[2..]) == 0 covers frame_size - 2 bytes; the trailing crc word makes a clean frame's residue zero. A nonzero residue is a ~1-in-65536-certain sign of payload corruption, so the frame is dropped (silence gap) rather than shipped as a glitch.

acmod_channels bit layout

This is the AUTHORITATIVE channel count for the track header: the DVD IFO audio_attr_t.channels nibble is a well-known unreliable/stale field, so the muxer prefers this over the IFO-claimed count (the bitstream acmod is authoritative; the IFO audio nibble is not trusted). LFE adds one channel (e.g. acmod=7 + lfeon → 6 = 5.1).

Bit layout from the syncword (A/52 §5.3.2 BSI):

  byte 5: bsid(5) | bsmod(3)
  byte 6: acmod(3) | [cmixlev(2) if acmod has a centre and acmod!=1]
                   | [surmixlev(2) if acmod has surround]
                   | [dsurmod(2) if acmod==2] | lfeon(1) | ...

acmod therefore always occupies byte-6 bits 7-5; lfeon follows a variable number of optional 2-bit fields, so the decoder tracks the bit cursor.

ACMOD_CHANNELS table

Base channel count per AC-3 acmod (A/52 Table 5.8), BEFORE the LFE. Index is the 3-bit acmod value; add 1 when lfeon is set.

  0 = 1+1 (Ch1, Ch2)  -> 2     4 = 2/1 (L,R,S)        -> 3
  1 = 1/0 (C, mono)   -> 1     5 = 3/1 (L,C,R,S)      -> 4
  2 = 2/0 (L, R)      -> 2     6 = 2/2 (L,R,SL,SR)    -> 4
  3 = 3/0 (L,C,R)     -> 3     7 = 3/2 (L,C,R,SL,SR)  -> 5

Test helpers and coverage notes

finalize_ac3_crc

Relies on the CRC-16/ANSI residue property: appending crc16([2..n-2]) (big-endian) zeroes the register over [2..n].

the_working_buffer_is_reused_across_packets_not_reallocated

parse runs once per PES packet — of the order of 10^5 times on a feature's audio track — and used to build a fresh Vec every call. The copy itself cannot go without restructuring the borrow relationship, but the allocation can: the buffer is taken out of self, filled, and put back with its capacity intact. Asserting on capacity is deliberately white-box, because that is exactly what regresses if someone reverts to to_vec() — the behaviour would be identical and no other test would notice.

make_eac3_frame byte layout

ETSI TS 102 366 Annex E: byte 2 is strmtyp(2) | substreamid(3) | frmsiz[10:8], byte 3 is frmsiz[7:0], and the frame is (frmsiz + 1) * 2 bytes. byte 4 = fscod 0 (48 kHz), numblkscod 3 (6 blocks → 1536 samples → 32 ms), acmod 7 + lfeon (5.1); byte 5 = bsid 16 so the E-AC-3 paths are taken.

a_held_access_unit_is_not_rescanned_from_its_first_frame_every_packet

An access unit closes only at the next substreamid-0 independent substream, so one that keeps gaining dependent substreams stays OPEN across PES boundaries and its bytes stay in the carry-over. The carry-over must not be re-scanned — and re-CRCed — from the access unit's first byte on every packet: the buffer only stops growing at MAX_AC3_BUF (1 MiB), and a PES on a DVD is about 2 KiB, so re-deriving the held access unit costs work quadratic in the packets fed.

Measured directly, because a work bound has no frame-level shadow: frames_scanned counts the syncframes the scanner sizes and CRC-gates. Re-scanning from byte 0 examines 1 + 2 + ... + (N+1) frames.

Mutation: pass None for held in parse (or drop the if let Some(h) = held resume) — the count returns to the quadratic figure.

a_discontinuity_drops_the_held_access_unit_with_its_bytes

parse clears self.acc on a discontinuity because the buffered bytes are a truncated frame. The held access unit is described by OFFSETS into exactly those bytes, so it has to go with them. Without self.held = None, the next packet resumes a HeldAu whose start/end were computed against the pre-gap buffer but are applied to the unrelated post-gap bytes — splicing audio across the gap at best, and indexing past the end of the new, shorter buffer at worst.

The two existing discontinuity tests use plain AC-3 (bsid < 11), which never holds an access unit open, so neither of them reaches this reset.

a_held_access_unit_is_dropped_when_the_track_poisons_before_it_resumes

Mutation this catches: deleting (or inverting) the resume-path re-check if drop_reason.is_none() && self.tally.is_poisoned() at the top of scan_access_units. The verdict on a held access unit is frozen at the moment it was OPENED, and ac3_drop_reason reads the tally BEFORE the previous access unit is closed — so the very drop that crosses the poison threshold lands after the next unit's verdict was already taken as None. Without the re-check that unit is emitted as an ordinary frame after the track has been judged too damaged to mux: corrupt audio passed through as success, and worse, it is the ONE frame that escapes a whole-track fallback whose entire point is that nothing after the verdict ships.

Neither existing held-AU test reaches this: both keep a pristine tally.

The fixture drives the exact interleaving above. DropTally poisons once verified_dropped * 2 > kept + dropped past the 200-AU gate, so one PES carries 200 CRC-failing substreamid-0 syncframes followed by a clean one. Closing corrupt unit #200 (which happens only when the clean frame is reached) latches the poison — after that clean frame's own verdict was computed. Being an E-AC-3 unit that can still gain substreams, it is then HELD across the boundary with drop_reason: None, which is precisely the state the re-check exists for.