Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`:
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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"])
```

Expand Down
2 changes: 1 addition & 1 deletion Sources/AetherEngine/AetherEngine+Version.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
32 changes: 32 additions & 0 deletions Sources/AetherEngine/Audio/Spatial/TrueHDMajorSync.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
62 changes: 62 additions & 0 deletions Sources/AetherEngine/Video/HLSVideoEngine+SpatialAudio.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<AVStream>,
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
Expand Down
2 changes: 2 additions & 0 deletions Sources/AetherEngine/Video/HLSVideoEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
66 changes: 66 additions & 0 deletions Tests/AetherEngineTests/TrueHDMajorSyncTests.swift
Original file line number Diff line number Diff line change
@@ -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..<next], radix: 16)!)
index = next
}
return out
}

static func verdict(_ packet: [UInt8]) -> 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)
}
}
Loading