From 5e3d85fce2ec6a28f8d0469f45f756e550e470f3 Mon Sep 17 00:00:00 2001 From: Vincent Herbst Date: Sun, 18 Oct 2026 18:38:07 +0200 Subject: [PATCH 1/2] fix(audio): read TrueHD Atmos from the major sync when the probe never reached the track A TrueHD track that starts after the probe window (John Wick: Chapter 2, TrueHD from 22 s, 45.8 MB in) leaves find_stream_info without a decoded frame, so the stream keeps AV_PROFILE_UNKNOWN and objectAudioRendering played it through the channel bridge. When object rendering is wanted and the profile is unknown, the engine now peeks the track's packets where the first producer starts and reads the major sync the way mlpdec does (four substreams, top bit of substream_info). Measured with the tvOS probe budget (16 MiB / 10 s): from the start 2599 packets, 44.7 MB, 679 ms; on a resume 10 packets, 30 ms; APAC 7.1.4 in both, where the same build without the check served E-AC-3. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WJcXkGQrMiBPixVEFTSBJS --- .../Audio/Spatial/TrueHDMajorSync.swift | 32 +++++++++ .../Video/HLSVideoEngine+SpatialAudio.swift | 62 +++++++++++++++++ .../AetherEngine/Video/HLSVideoEngine.swift | 2 + .../TrueHDMajorSyncTests.swift | 66 +++++++++++++++++++ 4 files changed, 162 insertions(+) create mode 100644 Sources/AetherEngine/Audio/Spatial/TrueHDMajorSync.swift create mode 100644 Tests/AetherEngineTests/TrueHDMajorSyncTests.swift diff --git a/Sources/AetherEngine/Audio/Spatial/TrueHDMajorSync.swift b/Sources/AetherEngine/Audio/Spatial/TrueHDMajorSync.swift new file mode 100644 index 00000000..777f71db --- /dev/null +++ b/Sources/AetherEngine/Audio/Spatial/TrueHDMajorSync.swift @@ -0,0 +1,32 @@ +import Foundation + +/// Reads whether a TrueHD stream carries Atmos from the major sync in its packets, the same test +/// FFmpeg's mlpdec applies when it sets `AV_PROFILE_TRUEHD_ATMOS`: four substreams, and the top bit +/// of `substream_info` set (the 16-channel presentation). FFmpeg only learns it by decoding a frame +/// during `find_stream_info`, so a track that starts after the probe window keeps an unknown profile. +enum TrueHDMajorSync { + /// `format_sync` of a TrueHD (not MLP) major sync. + static let formatSync: [UInt8] = [0xF8, 0x72, 0x6F, 0xBA] + /// `signature` following `format_info`. + static let signature: [UInt8] = [0xB7, 0x52] + + /// Walks the access units in one demuxed packet. True or false from the first major sync found, + /// nil when the packet holds none (a major sync recurs at least every 128 access units). + static func isAtmos(_ packet: UnsafeRawBufferPointer) -> Bool? { + var offset = 0 + while offset + 4 <= packet.count { + // Access unit header: 4 bits check nibble, 12 bits length in 16-bit words, 16 bits timing. + let length = (Int(packet[offset] & 0x0F) << 8 | Int(packet[offset + 1])) * 2 + guard length >= 4 else { return nil } + if offset + 22 <= packet.count, + zip(formatSync.indices, formatSync).allSatisfy({ packet[offset + 4 + $0] == $1 }), + packet[offset + 12] == signature[0], packet[offset + 13] == signature[1] { + let substreams = packet[offset + 20] >> 4 + let substreamInfo = packet[offset + 21] + return substreams == 4 && substreamInfo >> 7 == 1 + } + offset += length + } + return nil + } +} diff --git a/Sources/AetherEngine/Video/HLSVideoEngine+SpatialAudio.swift b/Sources/AetherEngine/Video/HLSVideoEngine+SpatialAudio.swift index 9d5a5a08..f2cc7c5b 100644 --- a/Sources/AetherEngine/Video/HLSVideoEngine+SpatialAudio.swift +++ b/Sources/AetherEngine/Video/HLSVideoEngine+SpatialAudio.swift @@ -126,6 +126,68 @@ extension HLSVideoEngine { } #endif + /// Packets `resolveUnlabelledTrueHDAtmos` may look at. Sized for a track that starts well after the + /// probe window: 22 s of a 4K remux interleaved with a second audio track is about 3,000 packets. + static let trueHDVerdictPacketBudget = 8192 + /// Bytes it may hold. Peeked packets stay resident until the producer reads them, so this is the + /// worst-case extra memory of a session start; it is paid only by a TrueHD track whose profile the + /// probe could not read, and the measured case (TrueHD from 22 s, 45.8 MB in) fits. + static let trueHDVerdictByteBudget = 64 << 20 + + /// A TrueHD track the probe never decoded a frame of keeps `AV_PROFILE_UNKNOWN`, which reads as + /// "not Atmos" everywhere below. When object rendering is wanted, look at the track's own major + /// sync where the first producer starts, and mark the stream Atmos if it is. Peeks without + /// consuming (the producer reads the same packets) and leaves the demuxer at the producer start. + func resolveUnlabelledTrueHDAtmos( + audioStream: UnsafeMutablePointer, + audioDemuxer: Demuxer, + streamIndex: Int32 + ) { + let par = audioStream.pointee.codecpar.pointee + guard par.codec_id == AV_CODEC_ID_TRUEHD, par.profile == AV_PROFILE_UNKNOWN, + objectAudioRendering.layout != nil, ObjectAudioSource.trueHDAtmos.isBuiltIn, + !isLiveSession + else { return } + if audioDemuxer === demuxer { + seekToInitialProducerStart(audioDemuxer) + demuxerAtInitialProducerStart = true + } + let started = DispatchTime.now() + var verdict: Bool? + var heldBytes = 0 + var packets = 0 + try? audioDemuxer.peekPackets(maxPackets: Self.trueHDVerdictPacketBudget) { packet in + packets += 1 + heldBytes += Int(max(packet.pointee.size, 0)) + if heldBytes > Self.trueHDVerdictByteBudget { return true } + guard packet.pointee.stream_index == streamIndex, + let data = packet.pointee.data, packet.pointee.size > 0 else { return false } + verdict = TrueHDMajorSync.isAtmos(UnsafeRawBufferPointer(start: data, count: Int(packet.pointee.size))) + return verdict != nil + } + let ms = Double(DispatchTime.now().uptimeNanoseconds - started.uptimeNanoseconds) / 1e6 + let span = "\(packets) packets, \(heldBytes >> 10) KiB, \(String(format: "%.0f", ms)) ms" + switch verdict { + case true?: + audioStream.pointee.codecpar.pointee.profile = AV_PROFILE_TRUEHD_ATMOS + EngineLog.emit( + "[HLSVideoEngine] TrueHD profile was not probed; major sync says Atmos (\(span))", + category: .session + ) + case false?: + EngineLog.emit( + "[HLSVideoEngine] TrueHD profile was not probed; major sync says no Atmos (\(span))", + category: .session + ) + case nil: + EngineLog.emit( + "[HLSVideoEngine] TrueHD profile was not probed and no major sync within the budget (\(span)); " + + "treating it as without Atmos", + category: .session + ) + } + } + /// Build the producer on a `SpatialAudioBridge` when the host asked for object rendering and the /// source qualifies. Nil means "not this route": ineligible, unsupported OS, not DTS:X, or a /// bridge/header failure, each logged, and the caller carries on with the ordinary bridge diff --git a/Sources/AetherEngine/Video/HLSVideoEngine.swift b/Sources/AetherEngine/Video/HLSVideoEngine.swift index 4314ff27..09ba6a63 100644 --- a/Sources/AetherEngine/Video/HLSVideoEngine.swift +++ b/Sources/AetherEngine/Video/HLSVideoEngine.swift @@ -1905,6 +1905,8 @@ public final class HLSVideoEngine: @unchecked Sendable { category: .session ) } + resolveUnlabelledTrueHDAtmos( + audioStream: audioStream, audioDemuxer: audioDem, streamIndex: audioStreamIndex) let compat = AudioCodecCompat.from(codecID) // HE-AAC needs bridging only when there is no ASC (live ADTS/MPEG-TS): synthesized ASC // would declare LC at the SBR output rate, decoded as garbage by AudioToolbox (-11821). diff --git a/Tests/AetherEngineTests/TrueHDMajorSyncTests.swift b/Tests/AetherEngineTests/TrueHDMajorSyncTests.swift new file mode 100644 index 00000000..4821f0d0 --- /dev/null +++ b/Tests/AetherEngineTests/TrueHDMajorSyncTests.swift @@ -0,0 +1,66 @@ +import Testing +@testable import AetherEngine + +// A TrueHD track that starts after the probe window keeps AV_PROFILE_UNKNOWN, so the engine reads +// Atmos from the track's own major sync instead (John Wick: Chapter 2, TrueHD from 22 s). +@Suite("TrueHDMajorSync Atmos detection") +struct TrueHDMajorSyncTests { + + /// First 32 bytes of an access unit carrying a major sync, from a TrueHD Atmos remux: + /// four substreams, substream_info 0xFC. + static let atmosHead: [UInt8] = bytes("630fe8daf8726fba0017804fb752100000008a2a43fc00364aefe307e3011fc6") + + /// The same from FFmpeg's own TrueHD encoder (5.1, no Atmos): two substreams, substream_info 0x3C. + static let plainHead: [UInt8] = bytes("d063ffd8f8726fba0007800fb752000000008c7f203c0000000000000000e4d1") + + /// An access unit without a major sync, eight bytes long (length field 4 words). + static let syncless: [UInt8] = [0x20, 0x04, 0xE9, 0x28, 0x70, 0x23, 0x60, 0x7F] + + static func bytes(_ hex: String) -> [UInt8] { + var out: [UInt8] = [] + var index = hex.startIndex + while index < hex.endIndex { + let next = hex.index(index, offsetBy: 2) + out.append(UInt8(hex[index.. Bool? { + packet.withUnsafeBytes { TrueHDMajorSync.isAtmos($0) } + } + + @Test func atmosMajorSyncReadsAtmos() { + #expect(Self.verdict(Self.atmosHead) == true) + } + + @Test func plainMajorSyncReadsNoAtmos() { + #expect(Self.verdict(Self.plainHead) == false) + } + + @Test func majorSyncInALaterAccessUnitOfThePacketIsFound() { + #expect(Self.verdict(Self.syncless + Self.syncless + Self.atmosHead) == true) + } + + @Test func packetWithoutMajorSyncHasNoVerdict() { + #expect(Self.verdict(Self.syncless + Self.syncless) == nil) + #expect(Self.verdict([]) == nil) + } + + @Test func fourSubstreamsWithoutThePresentationBitIsNotAtmos() { + var head = Self.atmosHead + head[21] &= 0x7F + #expect(Self.verdict(head) == false) + } + + @Test func brokenSignatureIsNotAMajorSync() { + var head = Self.atmosHead + head[13] ^= 0xFF + #expect(Self.verdict(head) == nil) + } + + @Test func zeroLengthAccessUnitStopsTheWalk() { + #expect(Self.verdict([0x00, 0x00, 0x00, 0x00] + Self.atmosHead) == nil) + } +} From 4f70cfaa17f298cfffc8d5731664b9720c4b6f40 Mon Sep 17 00:00:00 2001 From: Vincent Herbst Date: Sun, 18 Oct 2026 18:40:44 +0200 Subject: [PATCH 2/2] chore(release): prep 7.40.1 - TrueHD Atmos read from the major sync when the probe misses the track Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WJcXkGQrMiBPixVEFTSBJS --- CHANGELOG.md | 12 ++++++++++++ Examples/README.md | 2 +- README.md | 8 ++++---- Sources/AetherEngine/AetherEngine+Version.swift | 2 +- 4 files changed, 18 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 12d83a9d..d8c71053 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,18 @@ the public-API contract. _Nothing yet._ +## [7.40.1] - 2026-10-18 + +### Fixed + +- **TrueHD Atmos that starts after the probe window is rendered again.** A track whose first packet + lies beyond `find_stream_info`'s budget (John Wick: Chapter 2 carries TrueHD from 22 s, 45.8 MB + into the file) kept an unknown profile, so `objectAudioRendering` played it through the channel + bridge. With rendering requested and the profile unknown, the engine now reads the track's major + sync where the first producer starts, the same test mlpdec applies (four substreams, top bit of + `substream_info`), and renders it when it is Atmos. Costs 679 ms and 44.7 MB of held packets from + the start of that file at the tvOS probe budget, 30 ms on a resume; nothing for any other track. + ## [7.40.0] - 2026-10-18 ### Changed diff --git a/Examples/README.md b/Examples/README.md index 36a1954d..091ccb5b 100644 --- a/Examples/README.md +++ b/Examples/README.md @@ -18,7 +18,7 @@ Three samples covering different audiences: ``` https://github.com/superuser404notfound/AetherEngine ``` - Dependency Rule: Up to Next Major Version, starting from `7.40.0`. Add the `AetherEngine` library product to your app target. + Dependency Rule: Up to Next Major Version, starting from `7.40.1`. Add the `AetherEngine` library product to your app target. 3. **Drop the file in.** Replace the Xcode template's default `App.swift` (or whatever the generated `@main` file is called) with the contents of [`MinimalPlayerApp.swift`](MinimalPlayer/MinimalPlayerApp.swift). The file is self-contained: it defines both the `@main App` struct and the `ContentView`. diff --git a/README.md b/README.md index 6385f644..52126a81 100644 --- a/README.md +++ b/README.md @@ -387,7 +387,7 @@ Subtitle cues land in raw source PTS; render the overlay against `player.sourceT Install via Swift Package Manager: ```swift -.package(url: "https://github.com/superuser404notfound/AetherEngine", from: "7.40.0") +.package(url: "https://github.com/superuser404notfound/AetherEngine", from: "7.40.1") ``` Three samples ship in `Examples/`: @@ -634,10 +634,10 @@ Browse all of this as a searchable site at **[aetherengine.superuser404.de](http AetherEngine uses [Semantic Versioning](https://semver.org). The public API surface, every `public` declaration in `Sources/AetherEngine/`, is the stability contract. **Major** removes / renames public symbols or breaks adopters; **Minor** adds public API or codec / format support; **Patch** fixes bugs with no public API change. `internal` types are not part of the contract. ```swift -.package(url: "https://github.com/superuser404notfound/AetherEngine", from: "7.40.0") +.package(url: "https://github.com/superuser404notfound/AetherEngine", from: "7.40.1") ``` -Pin to `.upToNextMinor(from: "7.40.0")` for stricter teams that prefer to opt into minor bumps explicitly. +Pin to `.upToNextMinor(from: "7.40.1")` for stricter teams that prefer to opt into minor bumps explicitly. ## Requirements @@ -672,7 +672,7 @@ These components may be subject to third-party patent licensing. Integrators who To enable them, list the traits on the package dependency: ```swift -.package(url: "https://github.com/superuser404notfound/AetherEngine", from: "7.40.0", +.package(url: "https://github.com/superuser404notfound/AetherEngine", from: "7.40.1", traits: ["TrueHDObjectAudio", "DTSXObjectAudio", "DualLayerDolbyVision", "DolbyVisionStillReshaping"]) ``` diff --git a/Sources/AetherEngine/AetherEngine+Version.swift b/Sources/AetherEngine/AetherEngine+Version.swift index 996db0c1..6c193d41 100644 --- a/Sources/AetherEngine/AetherEngine+Version.swift +++ b/Sources/AetherEngine/AetherEngine+Version.swift @@ -11,5 +11,5 @@ extension AetherEngine { /// /// Between releases, and in a consumer that pins an unreleased commit for a device test, this /// names the last PUBLISHED version the checkout descends from, not every patch in it. - public nonisolated static let version = "7.40.0" + public nonisolated static let version = "7.40.1" }