Skip to content

fix(muxer): the init segment's dec3 keeps a dependent substream's channels and JOC - #729

Closed
kdorepos wants to merge 1 commit into
superuser404notfound:mainfrom
kdorepos:pr/dec3-dependent-substream
Closed

kdorepos wants to merge 1 commit into
superuser404notfound:mainfrom
kdorepos:pr/dec3-dependent-substream

Conversation

@kdorepos

@kdorepos kdorepos commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

Summary

FFmpeg's movenc.c: handle_eac3() describes a DD+ track whose objects live in a
dependent substream as a bare 5.1 bed — chan_loc = 0 and no ETSI TS 103 420
extension — so tvOS decodes the core and an Atmos-capable receiver reports
multichannel PCM. FFmpegBuild ships prebuilt xcframeworks with no C sources, so
the box is re-derived from the bitstream and spliced into the captured init
segment instead.

Closes #728

What changed

  • Audio/EAC3Bitstream.swift (new) — a pure MSB-first bit reader/writer and a
    syncframe header walk. It speaks both syntaxes behind the 0x0B77
    syncword, chosen by peeking bsid the way ff_ac3_parse_header does, because
    the source class this fixes is an AC-3 core frame (bsid 6, Annex D alternate
    syntax) followed by an E-AC-3 dependent frame. It recovers the per-substream
    fields, the dependent substream's 16-bit chanmap, and the addbsi bytes
    carrying flag_ec3_extension_type_a / complexity_index_type_a, and maps
    chanmap to chan_loc per TS 102 366 F.6.2.3 (chan_loc bit k is chanmap
    transmission bit k+5, across nine bits).
  • Audio/EC3SpecificBox.swift (new) — parse and re-encode a dec3 payload per
    TS 102 366 F.6, including the three-byte substream form when
    num_dep_sub == 0. Re-encoding an unchanged parse is byte-identical to what
    mov_write_eac3_tag emitted, which is what lets the rewrite decide "nothing to
    do" by comparison.
  • Video/MP4BoxTree.swift (new) — a small ISO BMFF walker: find a box by fourCC
    path, backtracking over same-typed siblings so naming ec-3 is what selects
    the audio trak, and replace its payload while reconciling the box's own size
    and every ancestor's. No hard-coded offsets. It refuses a 64-bit largesize box
    rather than mis-patching it.
  • Video/InitSegmentDec3Rewrite.swift (new) — the decision and one log line.
  • Video/MP4SegmentMuxer.swift — AudioConfig gains isStreamCopy and
    isAtmosStreamCopy, both defaulted so the four existing tests that build one by
    hand compile untouched; a reference-typed EAC3InitWitness keeps the first muxed
    audio packet, filled by writePacket before av_interleaved_write_frame takes
    ownership and blanks it, and by the AE#222 moov prime, and read by the
    FragmentSplitter init-capture closure. The witness is reference-typed for the
    same reason ByteCounter is: that closure is built during init and cannot
    capture self.
  • Video/HLSSegmentProducer.swift, Video/HLSVideoEngine.swift — thread
    bridge == nil and the existing profile == 30 verdict through. The engine's
    JOC verdict is only a fallback for flag_ec3_extension_type_a when the addbsi
    walk cannot reach a dependent substream; the bitstream is authoritative where it
    is readable.
  • CHANGELOG.md under [Unreleased]; docs/formats.md and
    docs/architecture.md in the same commit; DocumentedConstantsTests pins both
    payloads and the derived chan_loc the prose quotes. Nothing new is public,
    so docs/api.md is unaffected — verified by grep over the diff.

A documentation correction rides along. docs/formats.md claimed AVPlayer
"recognises JOC from the dec3 box (numDepSub=1, depChanLoc=0x0100)".
Neither half held: the box produced for this source carries chan_loc = 0x000,
so the stated signal was not in it, and JOC is recognised from the TS 103 420
extension rather than from num_dep_sub.

It is gated hard. Stream-copied E-AC-3 only (bridge == nil and
codec_id == AV_CODEC_ID_EAC3); both the existing box and the bitstream must
parse; the two must agree about the bed (fscod, acmod, lfeon — bsid is
excluded on purpose, since a dec3 carrying the AC-3 core's value is one of the
things worth correcting); and the re-encoded payload must actually differ. Every
other outcome forwards the captured segment byte for byte, with a log line naming
the reason whenever the track was a JOC candidate.
AETHER_DISABLE_DEC3_REWRITE disables it entirely, mirroring
AETHER_DISABLE_NAL_SANITIZER.

Why changing a box's length in an init segment is safe at all: an empty_moov
init has an empty sample table and no media data, so there are no stco / co64
chunk offsets and no sidx references to fix up. The walker is scoped to init
segments for exactly that reason.

Test plan

  • Device / OS: Apple TV 4K (3rd gen), tvOS 26.6 → LG G5 OLED → Sonos Arc
    over eARC, reading the incoming format string in the Sonos app, with a
    known-Atmos title in the Apple TV app immediately before as the route control.
    Bitstream and box measurements on macOS 15 / Swift 6.4.
  • Source media:
    1. "28 Years Later" — MKV / HEVC Dolby Vision Profile 8.1, 3840x1392 @ 23.976 /
      AC-3 5.1 core at 640 kbps plus an E-AC-3 dependent substream with
      chanmap = 0xA010, flag_ec3_extension_type_a = 1,
      complexity_index_type_a = 16 / DV Profile 8.1.
    2. "Dune: Part Two" — MKV / HEVC / 6-channel E-AC-3 JOC with the objects in the
      independent substream. This is the control: the case
      handle_eac3() already handles.
  • Result:
    • Source 1, dec3 payload 14 00 0C 0F 02 00 → 14 00 0C 0F 02 40 01 10;
      init.mp4 1339 → 1341 B, with dec3 / ec-3 / stsd / stbl / minf /
      mdia / trak / moov each grown by exactly 2 and ftyp and the video
      trak byte-identical (full walk of both files, checking every size field
      closes on its parent's end).
    • Source 2, init.mp4 byte-identical with and without the change (cmp
      reports no difference): the re-derived payload equals FFmpeg's, so the
      alreadyCorrect arm forwards the segment. That is also an independent check
      on the encoder — it reproduces a real muxer's num_dep_sub == 0 short form
      and its extension bytes exactly.
    • Receiver-reported format, both titles: Multichannel PCM 5.1 before, Dolby
      Atmos after.
    • On this branch as rebased onto main after fix(hls): declare CHANNELS on the audio rendition, "16/JOC" for E-AC-3 Atmos #727 (7.32.3 + 230c406c):
      swift build, full swift test — 4214 Swift Testing tests in 583 suites
      passed, every XCTest suite 0 failures
      , of which 24 are the new
      EAC3Dec3RewriteTests (the dec3 build from synthetic
      fields against the hand-computed bytes; parse and round-trip of both measured
      payloads; all nine chanmap → chan_loc bits individually plus the real
      0xA010; both real syncframe headers; the walker patching all eight nested
      lengths by +2 and by −2 on a shrinking replacement; the largesize refusal;
      and the end-to-end rewrite plus each of its five skip arms) — and
      python3 Scripts/check-doc-links.py. macOS 15 / Swift 6.4.
    • ffprobe reads both the patched and the unpatched init as eac3 / 5.1(side);
      FFmpeg's mov demuxer does not expand chan_loc, so that is the expected
      non-result rather than a regression.

Checklist

  • CHANGELOG.md updated
  • Commit messages follow Conventional Commits (feat(...), fix(...), chore(...))
  • The fix lives in the engine, not in a host-side workaround
  • Public API changes are intentional and documented — there are none; verified
    by grep over the diff

One thing I could not settle from a Mac, worth knowing before merging: the
chanmap → chan_loc bit order. chan_loc = 0x040 follows from reading ETSI's
tables as transmission-ordered (bit 0 = first bit on the wire). Under that
reading 0xA010 decodes to L + R + Lvh/Rvh, which is exactly what a 5.1.2
dependent substream should add to a 5.1 core, and the two bits that fall outside
chan_loc's range are precisely the two the core already carries; under the
opposite reading the same value decodes to reserved + LFE2 + Rs, which is
nonsense here. The parsed chanmap is in the log line so this stays checkable on
other sources.

Note for whoever merges: the CHANNELS PR touches the same docs/formats.md
paragraph and adds its own [Unreleased] bullet, so whichever lands second needs
a trivial rebase. They are otherwise independent — this branch cherry-picks onto
main on its own and builds without the other.

🤖 Generated with Claude Code

…nnels and JOC

FFmpeg's `movenc.c: handle_eac3()` describes an 8-channel E-AC-3 JOC source as a
bare 5.1 bed. For a track whose height channels and JOC metadata live in a
DEPENDENT substream it writes `chan_loc = 0` -- it derives the field from the
dependent substream's `chanmap` with a shift/mask that does not match ETSI
TS 102 366 F.6.2.3's bit order -- and it omits the ETSI TS 103 420 extension
entirely, because `complexity_index_type_a` is latched from the INDEPENDENT
substream header before the dependent-substream loop and that loop never copies
it back (cf. jellyfin/jellyfin-ffmpeg#584). tvOS cannot map a dependent substream
it has not been told the channels of, so it decodes the 5.1 core and an
Atmos-capable receiver reports multichannel PCM.

FFmpegBuild ships prebuilt xcframeworks with no C sources, so `handle_eac3()` is
not reachable from here. The init segment is captured as `Data` on its way out of
the muxer, which is the last point before any client sees it, so the box is
corrected there.

What changed

- `Audio/EAC3Bitstream.swift` (new): a pure MSB-first bit reader/writer and a
  syncframe header walk. It speaks BOTH syntaxes behind the 0x0B77 syncword,
  chosen by peeking `bsid` the way `ff_ac3_parse_header` does, because a
  Blu-ray-style DD+ track is an AC-3 core frame (`bsid 6`, Annex D alternate bit
  stream syntax) followed by an E-AC-3 dependent frame. It recovers `strmtyp`,
  `substreamid`, `fscod`, `bsid`, `bsmod`, `acmod`, `lfeon`, the dependent
  substream's 16-bit `chanmap`, and the `addbsi` bytes that carry
  `flag_ec3_extension_type_a` / `complexity_index_type_a`, plus the
  chanmap -> chan_loc mapping (chan_loc bit k is chanmap transmission bit k+5).
- `Audio/EC3SpecificBox.swift` (new): parse and re-encode a `dec3` payload per
  ETSI TS 102 366 F.6, including the 3-byte substream form when `num_dep_sub` is
  0 and the two TS 103 420 extension bytes. `data_rate` is carried over from the
  box FFmpeg wrote, which derives it from real packet sizes.
- `Video/MP4BoxTree.swift` (new): a small ISO BMFF walker that finds a box by
  fourCC path -- backtracking over same-typed siblings, so `ec-3` is what selects
  the audio `trak` -- and replaces its payload, reconciling the `size` field of
  the box and of every ancestor. No hard-coded offsets: `dec3` moves with the
  video track's `hvcC`, the DV record and the AE#458 `mdhd`.
- `Video/InitSegmentDec3Rewrite.swift` (new): the decision. It runs only for a
  stream-copied E-AC-3 track, only when both the existing box and the bitstream
  parse cleanly, only when the two agree about the bed (`fscod`/`acmod`/`lfeon`),
  and only when the re-encoded payload actually differs. Anything else forwards
  the segment byte for byte. One log line records both payloads, the parsed
  chanmap and the size delta. `AETHER_DISABLE_DEC3_REWRITE` turns it off.
- `Video/MP4SegmentMuxer.swift`: `AudioConfig` gains `isStreamCopy` and
  `isAtmosStreamCopy` (both defaulted, so every existing conformance and test is
  unchanged); a reference-typed `EAC3InitWitness` keeps the first muxed audio
  packet, filled by `writePacket` before `av_interleaved_write_frame` takes
  ownership and by the AE#222 moov prime, and read by the init-capture closure.
- `Video/HLSSegmentProducer.swift`, `Video/HLSVideoEngine.swift`: thread
  `bridge == nil` and the existing `profile == 30` JOC verdict through to the
  muxer. The JOC flag is only a fallback; the `addbsi` walk is authoritative.

Measured with `aetherctl serve` against a real 8-channel E-AC-3 JOC source
(MKV, HEVC DV P8.1, AC-3 5.1 core at 640 kbps plus an E-AC-3 dependent substream
with `chanmap = 0xA010`, `flag_ec3_extension_type_a = 1`,
`complexity_index_type_a = 16`):

  before  dec3 payload  14 00 0C 0F 02 00   (6 B, chan_loc 0x000, no extension)
  after   dec3 payload  14 00 0C 0F 02 40 01 10

init.mp4 1339 -> 1341 B, and `dec3`/`ec-3`/`stsd`/`stbl`/`minf`/`mdia`/`trak`/
`moov` each grew by exactly 2 while `ftyp` and the video track stayed
byte-identical. A 6-channel E-AC-3 JOC source whose JOC sits in the independent
substream (`20 00 20 0F 00 01 10`) is left byte-identical, with and without the
patch.

Documentation, in this commit. docs/formats.md's Dolby Atmos section claimed
AVPlayer recognises JOC from `numDepSub=1, depChanLoc=0x0100`. Neither half was
right: the box FFmpeg wrote for this source carries `chan_loc = 0x000`, so the
stated signal was not in it at all, and JOC is recognised from the TS 103 420
extension rather than from `num_dep_sub`. That sentence is corrected, and the
section now describes when the rewrite triggers and what it writes.
docs/architecture.md gains the four new files. Nothing here is public, so
docs/api.md is unaffected, and DocumentedConstantsTests pins the payloads and
the `chan_loc` the prose quotes to `EAC3Bitstream.chanLoc(fromChanmap:)` and
`EC3SpecificBox`.

Test plan

- `swift build`, full `swift test` (3371 tests in 453 suites, of which 24 are
  the new EAC3Dec3RewriteTests) and `python3 Scripts/check-doc-links.py`,
  macOS 15 / Swift 6.4.
- Device: Apple TV 4K (3rd gen), tvOS 26.6 -> LG G5 OLED -> Sonos Arc over
  eARC, reading the incoming format string in the Sonos app, with a known-Atmos
  title in the Apple TV app immediately before as the route control.
- Media 1: "28 Years Later", MKV, HEVC Dolby Vision Profile 8.1, AC-3 core plus
  an E-AC-3 dependent substream, chanmap 0xA010, JOC.
  Before: Multichannel PCM 5.1. After: Dolby Atmos.
- Media 2: "Dune: Part Two", MKV, 6-channel E-AC-3 JOC with the objects in the
  independent substream, the untouched control. init.mp4 is byte-identical with
  and without this change. Before: Multichannel PCM 5.1. After: Dolby Atmos.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
superuser404notfound pushed a commit that referenced this pull request Oct 9, 2026
…nels and JOC (#728)

A Blu-ray style DD+ Atmos track (AC-3 core plus an E-AC-3 dependent
frame with the height channels and the TS 103 420 objects) was muxed
with a dec3 box describing a bare 5.1 bed, so tvOS decoded the core
and an Atmos receiver reported multichannel PCM. FFmpegBuild 3.7.0
fixes both fields in the muxer (upstream f10fdd6310 for chan_loc, FFmpeg
PR 24963 for the JOC extension).

Measured with aetherctl serve on an MKV carrying fate-suite
eac3/the_great_wall_7.1.eac3: init.mp4 dec3 12 00 0C 0F 02 10 on 3.6.0,
12 00 0C 0F 02 02 01 0C on 3.7.0.

docs/formats.md: the Atmos paragraph no longer says AVPlayer recognises
JOC from numDepSub / depChanLoc, correction taken from PR #729 by
@kdorepos, whose diagnosis and measured headers this fix is built on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WJcXkGQrMiBPixVEFTSBJS
@superuser404notfound

Copy link
Copy Markdown
Owner

Thank you, this was an excellent report and a very careful PR. The diagnosis was right on both counts, and the two measured syncframe headers made the fix verifiable without the source file.

I am closing it in favour of fixing the muxer itself, and want to be transparent about why. The premise that the box cannot be corrected at the source holds from the engine's side, but FFmpegBuild builds those xcframeworks from FFmpeg sources and already carries a handful of local patches for exactly this kind of upstream bug. A dec3 rewrite in the engine would mean maintaining an AC-3/E-AC-3 header parser, an ISO BMFF walker and an init-segment splice in parallel to the muxer that produces the box, for as long as upstream does not ship the fix.

What landed instead:

  • chan_loc: already fixed upstream as f10fdd6310 (master only, not in any release branch), with the same bit mapping you derived; your 0xA010 gives 0x040 there too. Backported in FFmpegBuild.
  • complexity_index_type_a: not fixed upstream. The AC-3 parser does read addbsi from the dependent frame; handle_eac3() just never took the value from it, and re-read the independent header's 0 on every packet. Now taken from every substream of the access unit and kept at its maximum. Proposed upstream as FFmpeg PR 24963.
  • FFmpegBuild 3.7.0 ships both as patch_ffmpeg_eac3_dec3. Its new EAC3Dec3Tests mux your two headers through the shipped libavformat with the engine's movflags: 14 00 0C 0F 02 00 on 3.6.0, 14 00 0C 0F 02 40 01 10 on 3.7.0, exactly the payload you expected. An AC-3 core alone keeps its short box.
  • End to end with aetherctl serve on an MKV carrying FFmpeg's own eac3/the_great_wall_7.1.eac3, which turned out to have the same AC-3 core plus dependent JOC layout: the served init.mp4 carries 12 00 0C 0F 02 10 on 3.6.0 (chan_loc 0x010, no extension) and 12 00 0C 0F 02 02 01 0C on 3.7.0. FFmpeg's FATE reference for that sample had the missing extension baked in; the upstream PR updates it.
  • AetherEngine picks up 3.7.0 in db9e915, which also takes over your docs/formats.md correction of the Atmos paragraph (numDepSub / depChanLoc was wrong), credited to you in the CHANGELOG. It goes out with the next engine release.

#728 stays open until it can be retested on a release. Thanks again for the depth of this one, the before/after receiver check and the independent-substream control made it straightforward to confirm.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

dec3 loses chan_loc and the JOC extension for a DD+ source whose objects live in a dependent substream

2 participants