mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
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.
221 lines
11 KiB
Markdown
221 lines
11 KiB
Markdown
# 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):
|
||
|
||
```text
|
||
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.
|
||
|
||
```text
|
||
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.
|