Skip to content

TrueHD Atmos with heights on Apple TV: our object-to-APAC rendering, offered as a reference #731

Open

Description

@Quick104

What should the engine do?

Keep the heights and object positions of TrueHD Atmos on Apple TV and iPhone. Today the channel bridges decode the 7.1 presentation, so the objects are folded into the floor channels. We decode the Atmos objects instead, render them into a speaker bed, and deliver that bed as Apple Positional Audio (APAC). tvOS sends APAC to an Atmos receiver as Dolby MAT, and iOS plays it as Spatial Audio.

We built this in our fork for Silo. We'd like the engine to have it, maintained in one place, with Silo using it from upstream like any other host. We'd rather you build it your own way than review a large PR from us, so we're offering our implementation as a reference.

Motivating media or use case

UHD Blu-ray remuxes with TrueHD Atmos, which is most of them. On Apple TV, TrueHD can't be bitstreamed, so the bridge is the only route and the heights are lost. Our test title is Dolby's "Unfold" demo (M2TS, H.264, TrueHD Atmos with LFE + 15 objects).

How we implemented it

It's opt-in: LoadOptions.objectAudioRendering = .apac(SpatialSpeakerLayout), default .off. It only applies to TrueHD that FFmpeg marks as Atmos (profile 30), at 48 kHz, on OS 26+, where the APAC encoder API exists. Anything else, or any failure to start, falls back to audioBridgeMode with audio intact.

  • Decoder. A new binary dependency, SiloObjectAudio. It wraps the Rust truehd crate behind a C API and ships as an xcframework, the same way LibDovi does, so consumers don't need Rust. It decodes the beds, up to 15 objects and their OAMD metadata, with update timing per ETSI TS 103 420 5.3. It also reports where each decoded block sits in the input, so audio stays on the source timeline after a seek. PCM is verified bit-exact against truehdd.
  • Renderer (Audio/Spatial/, about 1,600 lines of Swift). A constant-power allocentric panner with object size, snap, zone masks and per-update gain ramps. Beds route discretely. Output layouts are 5.1.2, 5.1.4, 7.1.2, 7.1.4 and 9.1.6 in CoreAudio channel order.
  • Encoder. APAC through AVAudioConverter at 320 kbps per bed channel, with DRC off. With DRC on, the encoder holds 1.5 to 5+ s of audio before its first packet, which leaves the first segment after a seek silent. The 2048 priming frames stay in the stream, because AVFoundation presents an APAC packet 2048 frames before its timestamp. Trimming them put the audio 42.7 ms ahead of the video.
  • Muxing. movenc can't write APAC, so the track is muxed as an ALAC stand-in, and the init segment's sample entry is replaced with an apac entry carrying the encoder's dapa box. The master advertises apac.31.LL, because AVPlayer rejects a bare apac.
  • Route. SpatialAudioBridge is tried before the channel bridges. AudioBridge now sits behind an AudioTranscodingBridge protocol so both bridges share the producer lifecycle.
  • Diagnostics and tooling. The session log reports per-bed-channel levels, object and elevated-object counts, LFE level and gain, the master's audio CODECS and CHANNELS, and the audio route's rendering mode and channel labels. There's also aetherctl serve --atmos-bed <layout> --audio-index <n>.

Our PRs, with the full test plans: Silo-Server#6 (the feature), Silo-Server#8 (the priming/sync fix), Silo-Server#17 (diagnostics).

What's been tested

  • Apple TV 4K (2nd gen), tvOS 26.6, into a Sonos Arc over eARC: the Sonos app reports Dolby Atmos.
  • Apple TV 4K into a Sony HT-ST5000: a tester reports it sounds nearly perfect, and they can't tell it apart from the title's E-AC-3 Atmos track.
  • iPhone on iOS 27 with AirPods: plays with head tracking.
  • A/V sync measured on macOS against an ALAC reference, from the start and after seeks: the APAC path now lines up with the reference to within 0.1 ms.
  • Unit tests for the panner, renderer, sample entry rewrite, priming and bridge timestamps, plus an opt-in real-content test (AE_TRUEHD_ATMOS_SAMPLE).

It's a lossy conversion, not passthrough, which is why it's opt-in.

Licensing

SiloObjectAudio is Apache-2.0. The truehd and oamd crates it compiles in are Apache-2.0 as well (copyright Rainbaby and the truehdd contributors). Apache-2.0 can be combined with LGPL-3.0, so we don't see a conflict. The README's license section would need a paragraph like the LibDovi one, because apps have to ship the Apache license and the NOTICE text.

Our fork's engine code is under the engine's own LGPL terms, so you're free to copy, adapt or rewrite any of it. If you want to use SiloObjectAudio, we can re-license our wrapper code (the C API, framer, packaging) under whatever suits you: MIT, like LibDovi's packaging, or MIT OR Apache-2.0. That doesn't change the obligations for the truehd crate inside the binary.

What we're offering

Our implementation as a reference, if you'd like to build this into the engine yourself. We aren't planning to open a PR; the design, the code and how it lands are up to you.

  • The engine code, tests and docs in our fork. The PRs linked above explain each piece and the problems we hit along the way: DRC latency, the priming offset, the decoder's wait for a major sync after a seek, and the ALAC stand-in in the init segment.
  • SiloObjectAudio. Use it as it is, fork it, or have us transfer the repository to your account. GitHub redirects the old URL after a transfer, and you can rename the repository to match your other packages.

We're glad to answer questions and pass along what testers report. Once the engine has its own version, Silo would drop ours and track the engine's releases.

Is this something you'd want in the engine? If it isn't a fit, that's fine too, and we'll keep it in our fork.

Host app / integration context

Silo (tvOS / iOS client).


The code, tests and docs were written with Claude Opus 5.5 in Claude Code and reviewed by Claude and Codex agents. Hardware testing was done by people.

Activity

  1. superuser404notfound commented on Oct 10, 2026

    @superuser404notfound
    Owner

    Thank you, this is a generous offer and a very well documented one. Yes, I'd like this in the engine.

    Here is how I'd go about it:

    I'll post here as the work progresses and again when it ships in a release, so Silo can switch to upstream. I'll probably have questions along the way, most likely about the wait for a major sync after a seek and about the DRC latency.

  2. Quick104 commented on Oct 10, 2026

    @Quick104
    ContributorAuthor

    Thanks for the quick and thoughtful reply, and for taking this on. Your plan sounds good to us.

    The wrapper code (the C API, framer and packaging) is now MIT, and I've sent the transfer request to superuser404notfound. It should show up in your GitHub notifications or email for you to accept.

    Once it's with you, please treat it as your own project. Feel free to rename it, restructure it, rewrite parts of it, or take the engine side in a different direction if that fits better with how the bridge works now. Our fork was how we got it working for Silo, so there's no need to stay close to it.

    Thanks again!

  3. superuser404notfound commented on Oct 11, 2026

    @superuser404notfound
    Owner

    This shipped in AetherEngine 7.34.0 (ed8f8c7). Thank you again, both for the implementation and for handing over the decoder.

    What landed, and where it differs from your fork:

    • API as you had it: LoadOptions.objectAudioRendering = .apac(SpatialSpeakerLayout), default .off, TrueHD Atmos at 48 kHz on OS 26+, falling back to audioBridgeMode on anything else. It is VOD only for now; a live session keeps the channel bridge.
    • Decoder: SiloObjectAudio is now LibTrueHD, product and module TrueHD, C API unchanged. 2.0.0 is a rebuild from source of your tree (it matched your shipped binary function for function), and 2.1.0 adds x86_64 to the iOS simulator slice, which a generic iOS Simulator build of a host needs. The Silo-Server/SiloObjectAudio URL redirects, so your 1.0.x pin keeps resolving.
    • Peak limiter before the encoder. The APAC encoder carries samples over full scale through unchanged (a tone at amplitude 4.0 decodes back at 4.001), and on Unfold the rendered 7.1.4 bed goes over full scale: the limiter reduces 10458 of its 1.3 M frames, by up to 1.2 dB. It applies one gain to all channels, attacks instantly without a lookahead, and passes a bed below −0.18 dBFS bit for bit.
    • Timeline after a decoder self-reset. When truehd_atmos_decoder_pull or push reports a panic, the wrapper resets the handle, and the bridge re-anchors on the next packet instead of appending the rest of the run to the encoder early.
    • CHANNELS in the master counts the bed (12 for 7.1.4) rather than the 2-channel ALAC stand-in; in our codebase it read the muxer's codec parameters.
    • Renderer: a screen-anchored object keeps its room depth (only x and z are screen-relative), a 7.1 bed's side and rear surrounds fold onto a 5.1 layout's surround pair instead of leaking into the fronts, and screen, centre-back and top-centre bed channels sit at their nominal positions, so a presentation without OAMD still plays them.
    • Track choice: with the option on, the automatic audio pick prefers a TrueHD Atmos track over the other tracks of the same language and dub.

    Hardware: Apple TV 4K (3rd gen, tvOS 27.0) into a Sonos soundbar, which reports Dolby Atmos; seeking and end of file checked there. Those sessions ran on the media playlist alone, so the master's CODECS/CHANNELS path is covered by tests but not yet by a device. The one thing to expect is cost: a seek past the buffer waits longer than on the channel bridge, since the decode and encode run at roughly 9.5x realtime on Apple Silicon against about 50x for EAC3. Pipelining decode and encode is the obvious next step.

    Sodalite turns it on by default in its 1.1 release. Happy to hear how it behaves for Silo once you are on upstream.

  4. superuser404notfound commented on Oct 11, 2026

    @superuser404notfound
    Owner

    A follow-up in 7.34.1 (cc070bf): the TrueHD Atmos path is about twice as fast, which is what a seek past the buffer waits for.

    • Decode and encode overlap. The TrueHD object decode and Apple's APAC encoder each take about a core and ran in series on the producer thread. The encoder now runs on its own serial queue while the next chunk decodes. Segments carry byte-identical audio to 7.34.0.
    • The decoder itself is about 30% faster. The truehd crate copied 160-row sample buffers for blocks that are usually 40 samples. That fix is truehdd#42, shipped in LibTrueHD 2.2.0 from a fork until it is merged upstream, with bit-exact output.

    Dolby's Unfold demo now takes 1.40 s instead of 2.73 s end to end (M1, release build). On an Apple TV 4K into a Sonos soundbar, seeks past the buffer come back noticeably faster and the soundbar still reports Dolby Atmos. If your fork carries the same bridge, both changes should port as they are.

  5. kristofferR commented on Oct 11, 2026

    @kristofferR
    Contributor

    A follow-up in 7.34.1 (cc070bf): the TrueHD Atmos path is about twice as fast, which is what a seek past the buffer waits for.

    * **Decode and encode overlap.** The TrueHD object decode and Apple's APAC encoder each take about a core and ran in series on the producer thread. The encoder now runs on its own serial queue while the next chunk decodes. Segments carry byte-identical audio to 7.34.0.
    
    * **The decoder itself is about 30% faster.** The `truehd` crate copied 160-row sample buffers for blocks that are usually 40 samples. That fix is [truehdd#42](https://github.com/truehdd/truehdd/pull/42), shipped in [LibTrueHD 2.2.0](https://github.com/superuser404notfound/LibTrueHD/releases/tag/2.2.0) from a fork until it is merged upstream, with bit-exact output.
    

    Dolby's Unfold demo now takes 1.40 s instead of 2.73 s end to end (M1, release build). On an Apple TV 4K into a Sonos soundbar, seeks past the buffer come back noticeably faster and the soundbar still reports Dolby Atmos. If your fork carries the same bridge, both changes should port as they are.

    Check out this fork harletty/truehdd, it includes the same fix ("size a block's sample rows to the block") plus further speed work: no block copying, table-driven Huffman, CRC-8 eight bytes at a time, vectorised prediction filters, whole-block matrices.

  6. superuser404notfound commented on Oct 13, 2026

    @superuser404notfound
    Owner

    Thanks for the pointer. I measured harletty's branch (5c8b880) against the LibTrueHD 2.2.x pin: the decode takes about a third less time again (Unfold 0.98 s to 0.60 s, Dolby's 7.1.4 test file 3.04 s to 2.00 s for 120 s, M1), with bit-identical output and no change to the wrapper.

    It has not been proposed upstream yet, and it contains the same fix as truehdd#42, so I've asked harletty there to open it as a PR and will close #42 in its favour. Once it lands upstream, LibTrueHD moves back to an upstream pin.

    One thing to set expectations: since 7.34.1 the APAC encoder is the slower stage of the pipeline, so a seek past the buffer gets only a little faster from this. The bigger gain is CPU: the decoder thread does about a third less work, which leaves more room on the Apple TV for everything else running during playback.

  7. added a commit that references this issue on Oct 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions