Repository navigation
Conversation
…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>
…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
|
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 What landed instead:
#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. |
Summary
FFmpeg's
movenc.c: handle_eac3()describes a DD+ track whose objects live in adependent substream as a bare 5.1 bed —
chan_loc = 0and no ETSI TS 103 420extension — 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 asyncframe header walk. It speaks both syntaxes behind the
0x0B77syncword, chosen by peeking
bsidthe wayff_ac3_parse_headerdoes, becausethe source class this fixes is an AC-3 core frame (
bsid 6, Annex D alternatesyntax) followed by an E-AC-3 dependent frame. It recovers the per-substream
fields, the dependent substream's 16-bit
chanmap, and theaddbsibytescarrying
flag_ec3_extension_type_a/complexity_index_type_a, and mapschanmaptochan_locper TS 102 366 F.6.2.3 (chan_locbit k ischanmaptransmission bit k+5, across nine bits).
Audio/EC3SpecificBox.swift(new) — parse and re-encode adec3payload perTS 102 366 F.6, including the three-byte substream form when
num_dep_sub == 0. Re-encoding an unchanged parse is byte-identical to whatmov_write_eac3_tagemitted, which is what lets the rewrite decide "nothing todo" by comparison.
Video/MP4BoxTree.swift(new) — a small ISO BMFF walker: find a box by fourCCpath, backtracking over same-typed siblings so naming
ec-3is what selectsthe audio
trak, and replace its payload while reconciling the box's ownsizeand every ancestor's. No hard-coded offsets. It refuses a 64-bit
largesizeboxrather than mis-patching it.
Video/InitSegmentDec3Rewrite.swift(new) — the decision and one log line.Video/MP4SegmentMuxer.swift—AudioConfiggainsisStreamCopyandisAtmosStreamCopy, both defaulted so the four existing tests that build one byhand compile untouched; a reference-typed
EAC3InitWitnesskeeps the first muxedaudio packet, filled by
writePacketbeforeav_interleaved_write_frametakesownership and blanks it, and by the AE#222 moov prime, and read by the
FragmentSplitterinit-capture closure. The witness is reference-typed for thesame reason
ByteCounteris: that closure is built duringinitand cannotcapture
self.Video/HLSSegmentProducer.swift,Video/HLSVideoEngine.swift— threadbridge == niland the existingprofile == 30verdict through. The engine'sJOC verdict is only a fallback for
flag_ec3_extension_type_awhen theaddbsiwalk cannot reach a dependent substream; the bitstream is authoritative where it
is readable.
CHANGELOG.mdunder[Unreleased];docs/formats.mdanddocs/architecture.mdin the same commit;DocumentedConstantsTestspins bothpayloads and the derived
chan_locthe prose quotes. Nothing new ispublic,so
docs/api.mdis unaffected — verified by grep over the diff.A documentation correction rides along.
docs/formats.mdclaimed AVPlayer"recognises JOC from the
dec3box (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 == nilandcodec_id == AV_CODEC_ID_EAC3); both the existing box and the bitstream mustparse; the two must agree about the bed (
fscod,acmod,lfeon—bsidisexcluded on purpose, since a
dec3carrying the AC-3 core's value is one of thethings 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_REWRITEdisables it entirely, mirroringAETHER_DISABLE_NAL_SANITIZER.Why changing a box's length in an init segment is safe at all: an
empty_moovinit has an empty sample table and no media data, so there are no
stco/co64chunk offsets and no
sidxreferences to fix up. The walker is scoped to initsegments for exactly that reason.
Test plan
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.
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.independent substream. This is the control: the case
handle_eac3()already handles.dec3payload14 00 0C 0F 02 00→14 00 0C 0F 02 40 01 10;init.mp41339 → 1341 B, withdec3/ec-3/stsd/stbl/minf/mdia/trak/mooveach grown by exactly 2 andftypand the videotrakbyte-identical (full walk of both files, checking everysizefieldcloses on its parent's end).
init.mp4byte-identical with and without the change (cmpreports no difference): the re-derived payload equals FFmpeg's, so the
alreadyCorrectarm forwards the segment. That is also an independent checkon the encoder — it reproduces a real muxer's
num_dep_sub == 0short formand its extension bytes exactly.
Atmos after.
mainafter fix(hls): declare CHANNELS on the audio rendition, "16/JOC" for E-AC-3 Atmos #727 (7.32.3 +230c406c):swift build, fullswift test— 4214 Swift Testing tests in 583 suitespassed, every XCTest suite 0 failures, of which 24 are the new
EAC3Dec3RewriteTests(thedec3build from syntheticfields against the hand-computed bytes; parse and round-trip of both measured
payloads; all nine
chanmap→chan_locbits individually plus the real0xA010; both real syncframe headers; the walker patching all eight nestedlengths by +2 and by −2 on a shrinking replacement; the
largesizerefusal;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.ffprobereads both the patched and the unpatched init aseac3 / 5.1(side);FFmpeg's
movdemuxer does not expandchan_loc, so that is the expectednon-result rather than a regression.
Checklist
CHANGELOG.mdupdatedfeat(...),fix(...),chore(...))by grep over the diff
One thing I could not settle from a Mac, worth knowing before merging: the
chanmap→chan_locbit order.chan_loc = 0x040follows from reading ETSI'stables as transmission-ordered (bit 0 = first bit on the wire). Under that
reading
0xA010decodes to L + R + Lvh/Rvh, which is exactly what a 5.1.2dependent 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 theopposite reading the same value decodes to reserved + LFE2 + Rs, which is
nonsense here. The parsed
chanmapis in the log line so this stays checkable onother sources.
Note for whoever merges: the
CHANNELSPR touches the samedocs/formats.mdparagraph and adds its own
[Unreleased]bullet, so whichever lands second needsa trivial rebase. They are otherwise independent — this branch cherry-picks onto
mainon its own and builds without the other.🤖 Generated with Claude Code