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" } 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) + } +}