5.21.0
Correction, 2026-07-25. The paragraph below overstates how unreliable the pre-decode flag is, and later measurement contradicts it. With the audio at the head of the container,
avformat_find_stream_infodoes decode an E-AC-3 frame on its own andcodecpar.profilecomes back as 30, on MP4 and on MKV and still at a 50 KB probe budget, soprobe(url:)already reportedTrackInfo.isAtmoscorrectly for that content. Two remuxes of the same film do not in fact disagree unless one of them moves the audio away from the head. What stands is the narrower claim: nothing in libavformat sets the profile, so the flag depends entirely onfind_stream_inforeaching and decoding an audio frame, and it stops doing that on a coarse interleave over a slow link. That case is real and reproducible, and it is what this API is for, but it is not the common case. See the 5.22.1 notes for the measurement, and for a bug this same investigation found in the API shipped here.
Authoritative Dolby Atmos (E-AC-3 JOC) detection, opt-in and bounded (#214)
TrackInfo.isAtmos has always been the engine's answer to "should this UI say Atmos", and until now it was closer to a guess than an answer. The check behind it reads codecpar.profile == 30 after avformat_find_stream_info, which sounds authoritative and is not: nothing in libavformat ever sets AV_PROFILE_EAC3_DDP_ATMOS. Even the MP4 dec3 box reader, the one place a container explicitly describes an E-AC-3 track, takes the data rate, the channel mode and the LFE flag and walks straight past the complexity index that carries the JOC signal. The profile lands on codecpar only when avformat_find_stream_info happens to decode an audio frame while resolving something else, which depends on what the container already declared. Two remuxes of the same film could disagree.
The reason is that FFmpeg's E-AC-3 decoder only exposes the JOC flag after decoding. It rides in the additional bitstream info of the dependent substream, and avcodec surfaces it as AVCodecContext.profile once a frame has been through it. So an authoritative answer needs a decode, and the lightweight probe(url:) path deliberately opens no decoders.
This release adds a second entry point rather than changing that one:
let probe = try AetherEngine.probeDetectingAtmos(url: url)
let atmos = probe.audioTracks.first { $0.isDefault }?.isAtmos ?? falseprobeDetectingAtmos(url:) and probeDetectingAtmos(source:) run the normal probe, then open a single E-AC-3 decoder on the target audio track and read the post-decode profile. No video decode, no HLS server, no playback session. probe(url:) and probe(source:) are untouched and remain the demux-only path you want on the playback-start route; this one is strictly more expensive and belongs on a details screen, not in front of the first frame.
The decode pass is bounded by AtmosDetectionOptions: 64 packets, 8 MiB and 2 seconds of wall clock by default, plus an explicit targetTrackID when a host is badging a track the user picked rather than the container default. Every non-target stream is set to AVDISCARD_ALL for the pass and the budget is charged only for packets actually handed to the decoder, so the caps bound the audio rather than the interleaved container. Without that, a UHD remux's multi-MB video packets exhaust the byte cap before a single audio packet arrives, and a genuinely Atmos track reports as not-Atmos. A separate fuse bounds demuxers that treat AVDISCARD_ALL as advisory.
The scan deliberately does not answer off the first decoded frame. libavcodec/ac3dec.c assigns the profile per frame and resets it to unknown whenever the JOC flag is absent from that frame, so reading the field after frame one makes the result depend on the first packet being representative of the track. The pass keeps decoding inside the same caps until the JOC profile appears or a cap fires, drains every frame a single send produced, and reports a cap or EOF reached after real decodes as the observation it is rather than as a give-up.
Failure is never loud. No audio track, a non-EAC3 codec, a decoder that will not open, a demux error, or any cap hit all degrade to "not confirmed" instead of throwing, and enrichment is additive: the confirmed track's isAtmos goes from false to true and nothing else moves. Scope is E-AC-3 JOC, which is what isAtmos has always meant. TrueHD carrying Atmos is out of scope and keeps whatever the base probe reported.
Contributed by @thatcube, including the AVDISCARD_ALL budget accounting, which only shows up on real UHD remuxes, and device verification on an Apple TV 4K (3rd generation) running tvOS 27 against an E-AC-3 JOC MKV over SMB. Follow-up hardening in the same release covers the multi-frame scan described above, two trapping integer conversions on public option inputs, and replacing the field-by-field SourceProbe rebuild with a mutated copy so a field added later cannot silently vanish from an enriched probe.
TrackInfo.isAtmos and SourceProbe.audioTracks are now public internal(set) var instead of public let. Both stay read-only outside the module, so this is source compatible for adopters.
Covered by: 1035 tests green across 168 suites, AtmosDetectionOptionsTests and AtmosDetectionProbeIntegrationTests new in this release, strict-concurrency clean, macOS plus tvOS and iOS Simulator builds green.