Skip to content

fix(hls): declare CHANNELS on the audio rendition, "16/JOC" for E-AC-3 Atmos - #727

Merged
superuser404notfound merged 1 commit into
superuser404notfound:mainfrom
kdorepos:pr/hls-audio-channels-attribute
Oct 8, 2026
Merged

superuser404notfound merged 1 commit into
superuser404notfound:mainfrom
kdorepos:pr/hls-audio-channels-attribute

Conversation

@kdorepos

@kdorepos kdorepos commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Summary

The loopback master's EXT-X-MEDIA:TYPE=AUDIO tag carried no CHANNELS
attribute, which Apple's HLS Authoring Specification requires and which is the
only playlist-level statement that a Dolby Digital Plus rendition carries
objects — so a stream-copied Atmos bitstream reached an Atmos-capable receiver
described as an untyped rendition and was rendered as the 5.1 bed.

Re #726 (kept open for the follow-up on untagged audio)

What changed

  • Network/HLSLocalServer.swift — HLSSegmentProvider gains
    masterAudioChannels, defaulted to nil in the protocol extension so every
    existing conformance (including the Issue458…Tests stub) compiles untouched.
    The master builder appends CHANNELS to the EXT-X-MEDIA attribute list when
    the provider answers, and omits the attribute entirely when it does not.
  • Video/VideoSegmentProvider.swift — two new stored properties
    (audioChannelCount, audioIsAtmosStreamCopy, both defaulted) and the answer:
    "16/JOC" for a stream-copied JOC track, the served channel count otherwise,
    nil when there is no rendition to attribute. The literal lives in
    VideoSegmentProvider.atmosChannelsAttribute so the docs can be pinned to it.
  • Video/HLSVideoEngine.swift — threads both through at provider construction,
    beside the existing servedAudioLanguage.
  • CHANGELOG.md under [Unreleased]; docs/formats.md (Dolby Atmos) and
    docs/architecture.md (HLSLocalServer) state the rule; a
    DocumentedConstantsTests pin holds the documented CHANNELS="16/JOC" to the
    constant. Nothing here is public, so docs/api.md is unaffected —
    git show <sha> | grep '^+.*public' is empty.

Two decisions worth a reviewer's attention. The JOC arm is gated on
audioDelivery == .streamCopy as well as on the probe flag, because
audioIsAtmosStreamCopy is latched from the source probe before the audio
cascade runs and so stays true on a JOC source whose stream-copy probe was
rejected and which fell back to the bridge; that session has no objects left, and
advertising 16/JOC over a bridged bed would be worse than saying nothing. And
the channel count is read from savedAudioConfig.codecpar, the configuration the
muxer actually got, so a bridged track reports the encoder's layout.

On the literal 16: the first parameter should strictly be
complexity_index_type_a from the EC3SpecificBox, which is not reachable at
the playlist layer. 16 is what Dolby's delivery kit and Apple's published Atmos
masters carry. (It is also what the dec3 PR measures on the same source, so the
value is corroborated rather than assumed — but this PR does not depend on that
one.)

CODECS is untouched and stays ec-3 (#34). The EXT-X-STREAM-INF line is
byte-identical before and after, and so is init.mp4: this does not touch the
muxer.

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.
    Playlist and init-segment observations on macOS 15 / Swift 6.4; the branch is
    rebased onto main at 7.32.3 (4f46e689) and the full suite re-run there.
  • Source media:
    1. "28 Years Later" — MKV / HEVC Dolby Vision Profile 8.1, 3840x1392 @ 23.976 /
      AC-3 5.1 core plus an E-AC-3 dependent substream, chanmap 0xA010, JOC /
      DV Profile 8.1.
    2. "Dune: Part Two" — MKV / HEVC / 6-channel E-AC-3 JOC (objects in the
      independent substream).
  • Result:
    • Master before: …,DEFAULT=YES,AUTOSELECT=YES. After:
      …,DEFAULT=YES,AUTOSELECT=YES,CHANNELS="16/JOC". The EXT-X-STREAM-INF
      line is byte-identical, and cmp reports no difference on init.mp4
      (1339 B in both arms).
    • Receiver-reported format, both titles: Multichannel PCM 5.1 before, Dolby
      Atmos after.
    • On this branch as rebased onto main at 7.32.3 (4f46e689): swift build,
      full swift test — 4184 Swift Testing tests in 581 suites passed, every
      XCTest suite 0 failures
      — and python3 Scripts/check-doc-links.py, all on
      macOS 15 / Swift 6.4. DocumentedConstantsTests and
      PublicAPIDocumentationTests pass.

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

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

🤖 Generated with Claude Code

…3 Atmos

The loopback master's EXT-X-MEDIA:TYPE=AUDIO tag carried no CHANNELS attribute
at all. Apple's HLS Authoring Specification makes it REQUIRED on an audio
rendition, and Dolby's DD+ Online Delivery Kit makes it the one playlist-level
statement that a rendition is object audio: the number of decodable objects, a
slash, then JOC. CODECS cannot carry it -- a JOC track is signaled `ec-3`, the
same string a non-JOC E-AC-3 5.1 track gets (superuser404notfound#34: `ec+3` is refused by tvOS
26.5) -- and the `dec3` box sits a layer below the playlist. So with no
CHANNELS the master said nothing about objects anywhere, AVFoundation settled
on the bed, and an Atmos bitstream that had been stream-copied intact reached
an Atmos-capable receiver as 5.1 PCM.

The engine already knows the answer. HLSVideoEngine latches
audioIsAtmosStreamCopy from the source probe (E-AC-3 profile 30 =
AV_PROFILE_EAC3_DDP_ATMOS) and logs "EAC3+JOC Atmos: stream-copy engaged";
nothing carried that fact up to the playlist builder.

What changed

- `Network/HLSLocalServer.swift`: `HLSSegmentProvider` gains
  `masterAudioChannels`, defaulted to nil in the protocol extension so every
  existing conformance (including the Issue458 test stub) compiles untouched.
  The master builder appends CHANNELS to the EXT-X-MEDIA attribute list when
  the provider answers, and omits the attribute when it does not.
- `Video/VideoSegmentProvider.swift`: two new stored properties
  (`audioChannelCount`, `audioIsAtmosStreamCopy`, both defaulted) and the
  answer. `VideoSegmentProvider.atmosChannelsAttribute` is the "16/JOC"
  literal, named so docs/formats.md can be pinned to it.
- `Video/HLSVideoEngine.swift`: threads both through at provider construction,
  beside the existing servedAudioLanguage.

Two decisions worth keeping. The JOC arm is gated on
`audioDelivery == .streamCopy` as well as on the probe flag, because
audioIsAtmosStreamCopy is latched from the source probe BEFORE the audio
cascade runs and so stays true on a JOC source whose stream-copy probe was
rejected and which fell back to the bridge; that session has no objects left,
and advertising 16/JOC over a bridged bed would be worse than saying nothing.
And the channel count is read from `savedAudioConfig.codecpar`, the
configuration the muxer actually got, so a bridged track reports the encoder's
layout rather than the source's.

On the literal 16: the first CHANNELS parameter should strictly be
`complexity_index_type_a` from the EC3SpecificBox, which is not reachable at
the playlist layer. 16 is the count Dolby's delivery kit and Apple's published
Atmos masters carry.

Nothing here is public, so docs/api.md is unaffected; docs/formats.md
(Dolby Atmos) and docs/architecture.md (HLSLocalServer) state the rule, and
DocumentedConstantsTests pins the documented "16/JOC" to the constant.

Measured with `aetherctl serve` against a real 8-channel E-AC-3 JOC source over
http (MKV, HEVC DV P8.1, 3840x1392 @ 23.976), macOS 15:

  before  #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aud",NAME="English",
          LANGUAGE="eng",DEFAULT=YES,AUTOSELECT=YES
  after   ...,DEFAULT=YES,AUTOSELECT=YES,CHANNELS="16/JOC"

The EXT-X-STREAM-INF line is byte-identical before and after, and so is
init.mp4 (1339 B both ways, `cmp` reports no difference): this does not touch
the muxer.

Test plan

- `swift build`, full `swift test` (3347 tests in 452 suites) 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, with an 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 (objects in the
  independent substream). Before: Multichannel PCM 5.1. After: Dolby Atmos.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@superuser404notfound
superuser404notfound force-pushed the pr/hls-audio-channels-attribute branch from ea61397 to 9227c1e Compare October 8, 2026 21:42
@superuser404notfound
superuser404notfound merged commit 230c406 into superuser404notfound:main Oct 8, 2026
superuser404notfound pushed a commit that referenced this pull request Oct 8, 2026
…n too

PR #727 declared CHANNELS on the audio rendition, but the rendition is
only written for audio with a resolvable language (AE#458), so an und or
untagged E-AC-3 JOC track, Dolby's own test signals among them, still
reached the master without a word about objects. A stream-copied JOC
track now gets a rendition without LANGUAGE (NAME="Dolby Atmos"), which
forces the master on an SDR source the way a language does. An untagged
track that is not object audio is unchanged, so no other source changes
route.

Measured on macOS with aetherctl play against Dolby's untagged JOC test
signal: before, no audible group; after, one option "Unknown", plays
through. The AE#458 readback line reports the session as served=untagged.

Adds master-line tests for CHANNELS, which #727 pinned only in the docs.

Re #726

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, merged in 230c406. The receiver A/B on two titles was exactly the evidence this needed, and the stream-copy gate on the JOC arm is the right call.

Two notes on what happened around the merge:

  • The branch conflicted with main only in the [Unreleased] block of CHANGELOG.md (eb6de4d landed after your rebase). I rebased it onto main and force-pushed it to your branch before merging; the code is unchanged. Full suite green on the rebased commit (4184 Swift Testing tests, XCTest 0 failures).
  • The body's closing keyword now reads "Re HLS master omits CHANNELS on the audio rendition, so E-AC-3 JOC plays as the 5.1 bed on tvOS #726", so the issue stays open for one gap found while verifying: the rendition, and so CHANNELS, only existed for audio with a resolvable language. Dolby's own test signals are tagged und, and on them the master still said nothing about objects. 77420a6 gives a stream-copied JOC track a rendition without LANGUAGE (NAME="Dolby Atmos", CHANNELS="16/JOC"), leaves every other untagged track as it was, and adds master-line tests for the attribute.

This ships in the next AetherEngine release.

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.

2 participants