Skip to content
Merged
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ the public-API contract.

### Added

- The session log places missing TrueHD Atmos heights or LFE. With `objectAudioRendering` the bridge logs each bed channel's RMS and peak level, the window's timeline position and the active and elevated object counts, after the first 5 s, every 30 s, and when the stream seeks, ends or stops; the engine logs the master's audio `CODECS` and rendition `CHANNELS`; and the audio route line adds `AVAudioSession.renderingMode`, multichannel-content support, each port's channel labels with a height count and its spatial-audio flag, and is also written when the rendering mode or spatial capability changes. Route lines name ports by type and no longer carry `portName`, which for AirPods, Bluetooth and AirPlay is the user's own name for the device. A TrueHD Atmos track loaded with object rendering off says so. The readyToPlay track dump adds the item's `allowedAudioSpatializationFormats`. See [formats.md › TrueHD Atmos (object rendering)](docs/formats.md#truehd-atmos-object-rendering).
- `LoadOptions.objectAudioRendering` keeps the height channels and object positioning of TrueHD Atmos by rendering its objects into a speaker bed; it is a lossy conversion, not passthrough of the original stream. With `.apac(SpatialSpeakerLayout)` a TrueHD track FFmpeg marks as Atmos is decoded (beds, objects and their metadata), rendered into the chosen 5.1.2 to 9.1.6 speaker bed and delivered as Apple Positional Audio (`CODECS="apac.31.LL"`), which tvOS sends to an Atmos receiver as Dolby MAT. Lossy (320 kbps per bed channel) in place of the lossless 7.1 channel presentation, so it is opt-in; requires OS 26 and falls back to `audioBridgeMode` otherwise. See [formats.md › TrueHD Atmos (object rendering)](docs/formats.md#truehd-atmos-object-rendering).
- `aetherctl serve --atmos-bed <layout>` and `serve --audio-index <n>` serve a TrueHD Atmos track through that path.
- Live HTTPS-to-HTTP redirect tests cover provider refusal, anonymous redirects, static-header stripping and the downgrade credential filter.
Expand All @@ -36,7 +37,7 @@ the public-API contract.
- Sidecar decoding runs on a dispatch worker instead of a cooperative-pool thread, so concurrent authorized decodes cannot starve the async resolvers their requests wait on.
- A pause made through the engine while the premature-end recovery re-seeks (AE#287) is kept. The recovery called `play()` after its seek whatever the play intent said, so the title started again.
- An external ASS sidecar the host draws itself (`preserveASSMarkup`) on the `nativeRemoteHLS` bypass keeps its plain rendition selected behind a suppressing legible output instead of deselecting it. AE#616 measures `sourceTime` off the selected injected rendition, so the deselect left the clock a styled-ASS overlay draws from on item time, 1 to 8 s ahead of the picture after a seek on a transcode restarted at an earlier keyframe. The hidden rendition is selected only once its whole-program WebVTT has been extracted and playback is running, because AVPlayer fetches and buffers a selected rendition and AE#616 measures only presented lines. Turning subtitles off takes it down immediately.
- TrueHD Atmos rendered to APAC reports a decoder that produces nothing (`.audioBridgeProducedNoOutput` on VOD), as the channel bridges do since AE#641, instead of playing the picture over silence.
- TrueHD Atmos rendered to APAC reports a decoder that produces nothing (`.audioBridgeProducedNoOutput` on VOD), as the channel bridges do since AE#641, instead of playing the picture over silence. Its threshold is 1200 packets, a second of TrueHD, not the channel bridge's 64: after a start or resume mid-stream the object decoder waits for the next major sync, measured at up to 102 access units on Dolby's Unfold demo, and at 64 a resumed session was reported dead and went to `.error` while it played, ignoring every later seek.
- Playlist preflight through a refreshable `HTTPRequestAuthorization` keeps the 2 MB playlist cap the static-header preflight has (audit NAT-5), and relayed fetches use upstream's per-fetch pending cap (audit NET-107) in place of the fork's high-water wait.
- A 206 whose `Content-Range` total is zero or negative no longer crashes an HTTP source as it opens. `Int64` parses a signed total, so `bytes 0-0/-9223372036854775808` reached `total - 1` with `Int64.min` and trapped.
- Below 1x, or after a pause in the last quarter second, the audio-only host parks its clock when it reaches the last sample. It parked after the tail's length in wall time, which cut up to 0.125 s at 0.5x.
Expand Down
14 changes: 13 additions & 1 deletion Sources/AetherEngine/AetherEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3477,14 +3477,21 @@ public final class AetherEngine: ObservableObject {
/// `sessionRebuildResumesPlaying`. See `rebuildResumesPlaying`.
var transportIntentUnderReconstruction: Bool?
private(set) var audioSelectionTask: Task<Void, Never>?
private var pendingAudioSelection: Int?
/// A `selectAudioTrack` pick not yet started, and the one whose rebuild is running. A reload that
/// lands meanwhile restores these before `activeAudioTrackIndex`, which the switch's own
/// `stopInternal` has just cleared (see `liveSelection`).
var pendingAudioSelection: Int?
var audioSwitchInFlight: Int?
private var audioSelectionEpoch = UUID()

private func cancelPendingAudioSelection() {
audioSelectionEpoch = UUID()
audioSelectionTask?.cancel()
audioSelectionTask = nil
pendingAudioSelection = nil
// The cancelled switch is no longer anyone's intent: a reload that snapshots the selection
// before the cancelled task unwinds must not restore it.
audioSwitchInFlight = nil
}

/// AE#464 round 3: true while a re-anchor raised by `setAudioDelay` is running, so the presses
Expand Down Expand Up @@ -6988,8 +6995,13 @@ public final class AetherEngine: ObservableObject {
guard !Task.isCancelled, self.audioSelectionEpoch == epoch else { return }
if self.activeAudioTrackIndex == selected { continue }
let beforeReload = self.loadGeneration
self.audioSwitchInFlight = selected
Comment thread
coderabbitai[bot] marked this conversation as resolved.
let failure = await self.reloadWithAudioOverride(
url: url, audioStreamIndex: Int32(selected), expectedGeneration: beforeReload)
// A cancelled task returns late: the marker may now belong to a newer switch.
if self.audioSelectionEpoch == epoch, self.audioSwitchInFlight == selected {
self.audioSwitchInFlight = nil
}
// This rebuild owns one stopInternal generation. Background teardown or
// another SDK recovery cannot lend its successor to queued audio work.
guard !Task.isCancelled, self.audioSelectionEpoch == epoch, failure == nil,
Expand Down
155 changes: 145 additions & 10 deletions Sources/AetherEngine/Audio/AudioRouteDescription.swift
Original file line number Diff line number Diff line change
@@ -1,23 +1,35 @@
import Foundation
import AVFoundation
import CoreAudioTypes

/// The audio route as one log fragment, shared by every host that writes a route line, so the native and
/// the software path describe the same route in the same words and a diff between their logs is a diff
/// of routes, not of formats.
enum AudioRouteDescription {

/// `output=… preferred=… max=… ports=[name[type, ch=n], …] latency=…ms io=…ms`, or nil where there is
/// no `AVAudioSession`. The output latency is the field AE#395 needs: a buffered AirPlay route delays
/// sound by seconds where HDMI delays it by milliseconds, and a feed that is fine on one is late on
/// the other.
/// `output=… preferred=… max=… rendering=… multichannelContent=… ports=[type[ch=n, labels=…,
/// heights=n], …] latency=…ms io=…ms`, or nil where there is no `AVAudioSession`. The output latency is
/// the field AE#395 needs: a buffered AirPlay route delays sound by seconds where HDMI delays it by
/// milliseconds, and a feed that is fine on one is late on the other.
///
/// `rendering` and the port's channel labels are what a missing-heights report turns on: the
/// rendering mode is the system's own word for what it does with the audio (`dolbyAtmos` against
/// `surround` or `spatialAudio`), and the labels are the speakers the route reports. Read the two
/// together: a Dolby MAT carrier can report fewer channels than it carries (AE#520), so `heights=0`
/// next to `rendering=dolbyAtmos` proves nothing on its own, while `heights=0` next to `surround`
/// is a route with no height channels at all.
///
/// Ports are named by type, never by `portName`: for AirPods, Bluetooth and AirPlay that is
/// whatever the user called the device, and an HDMI sink's name can be a renamed CEC name. The
/// line reaches the host's handler and from there diagnostics reports, and no host-side scrub of
/// a free-text name inside it can be made reliable.
static func current() -> String? {
#if os(iOS) || os(tvOS)
let session = AVAudioSession.sharedInstance()
let ports = session.currentRoute.outputs.map { port in
"\(port.portName)[\(port.portType.rawValue), ch=\(port.channels?.count ?? -1)]"
}.joined(separator: ", ")
let ports = session.currentRoute.outputs.map(portDescription).joined(separator: ", ")
return "output=\(session.outputNumberOfChannels) preferred=\(session.preferredOutputNumberOfChannels) "
+ "max=\(session.maximumOutputNumberOfChannels) ports=[\(ports)] "
+ "max=\(session.maximumOutputNumberOfChannels) rendering=\(renderingModeName(session.renderingMode)) "
+ "multichannelContent=\(session.supportsMultichannelContent) ports=[\(ports)] "
+ "latency=\(String(format: "%.0f", session.outputLatency * 1000))ms "
+ "io=\(String(format: "%.1f", session.ioBufferDuration * 1000))ms"
#else
Expand All @@ -26,18 +38,141 @@ enum AudioRouteDescription {
}

#if os(iOS) || os(tvOS)
private static func portDescription(_ port: AVAudioSessionPortDescription) -> String {
var fields = ["ch=\(port.channels?.count ?? -1)"]
if let labels = port.channels?.map(\.channelLabel), !labels.isEmpty {
fields.append("labels=\(channelLabelSummary(labels))")
fields.append("heights=\(heightChannelCount(labels))")
}
if port.isSpatialAudioEnabled { fields.append("spatial") }
return "\(port.portType.rawValue)[\(fields.joined(separator: ", "))]"
}

static func renderingModeName(_ mode: AVAudioSession.RenderingMode) -> String {
switch mode {
case .notApplicable: return "notApplicable"
case .monoStereo: return "monoStereo"
case .surround: return "surround"
case .spatialAudio: return "spatialAudio"
case .dolbyAudio: return "dolbyAudio"
case .dolbyAtmos: return "dolbyAtmos"
@unknown default: return "raw\(mode.rawValue)"
}
}

/// AE#395: one line per route change, for the process rather than per engine (a multiview runs several).
/// A change mid-session leaves the session's start line describing a route that is gone, and a user
/// switching output to test a theory is exactly that case. Installed on first touch, never removed.
///
/// A rendering-mode, rendering-capability or spatial-playback change gets the same line: an output
/// can switch between multichannel PCM and Dolby Atmos, or change the layouts it supports, without
/// the route itself changing.
static let changeLogger: Void = {
let session = AVAudioSession.sharedInstance()
_ = NotificationCenter.default.addObserver(
forName: AVAudioSession.routeChangeNotification,
object: AVAudioSession.sharedInstance(), queue: nil
forName: AVAudioSession.routeChangeNotification, object: session, queue: nil
) { note in
let reason = (note.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt).map(String.init) ?? "?"
guard let route = current() else { return }
EngineLog.emit("[AetherEngine] audioRoute changed reason=\(reason) \(route)", category: .engine)
}
for (name, what) in [(AVAudioSession.renderingModeChangeNotification, "rendering mode"),
(AVAudioSession.renderingCapabilitiesChangeNotification, "rendering capabilities"),
(AVAudioSession.spatialPlaybackCapabilitiesChangedNotification, "spatial playback")] {
Comment thread
Quick104 marked this conversation as resolved.
_ = NotificationCenter.default.addObserver(forName: name, object: session, queue: nil) { _ in
guard let route = current() else { return }
EngineLog.emit("[AetherEngine] audioRoute \(what) changed \(route)", category: .engine)
}
}
}()
#endif

// MARK: - Channel labels

/// Height labels in CoreAudio's numbering: top centre, the three vertical-height (top front)
/// positions, the three top-back positions, the top-middle and top-rear rows Atmos layouts use,
/// and the top-surround pair.
private static let heightLabels: Set<AudioChannelLabel> = [
kAudioChannelLabel_TopCenterSurround,
kAudioChannelLabel_VerticalHeightLeft, kAudioChannelLabel_VerticalHeightCenter,
kAudioChannelLabel_VerticalHeightRight,
Comment on lines +97 to +98

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Count Core Audio's top-front labels as heights

On routes that expose a standard 5.1.4, 7.1.4, or 9.1.6 Core Audio layout, the front height channels are labeled kAudioChannelLabel_LeftTopFront and kAudioChannelLabel_RightTopFront, not the legacy VerticalHeight labels used here. Apple's USB audio layout table confirms those labels for the predefined Atmos layouts. Because they are absent from both heightLabels and labelNames, a 7.1.4 route can be logged as heights=2 with its front heights shown as numeric unknowns; SpatialSpeaker.channelLabel also calls the matching bed channels Vhl/Vhr, preventing a reliable route-to-bed comparison. Add the top-front labels (and center variants where applicable) and map the bed's top-front speakers to them.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are already counted. In CoreAudioBaseTypes.h (tvOS 27 SDK) kAudioChannelLabel_LeftTopFront = kAudioChannelLabel_VerticalHeightLeft, CenterTopFront = VerticalHeightCenter and RightTopFront = VerticalHeightRight: the same values, so heightLabels and labelNames cover them, and a 7.1.4 route reads heights=4 (AudioRouteDescriptionTests). The Vhl/Vhr names are the ones the header's own comment uses for kAudioChannelLayoutTag_Atmos_7_1_4 (L R C LFE Ls Rs Rls Rrs Vhl Vhr Ltr Rtr), and the bed and the route share AudioRouteDescription.labelName, so they print the same name for the same label.

kAudioChannelLabel_TopBackLeft, kAudioChannelLabel_TopBackCenter, kAudioChannelLabel_TopBackRight,
kAudioChannelLabel_LeftTopMiddle, kAudioChannelLabel_RightTopMiddle,
kAudioChannelLabel_LeftTopRear, kAudioChannelLabel_CenterTopRear, kAudioChannelLabel_RightTopRear,
kAudioChannelLabel_LeftTopSurround, kAudioChannelLabel_RightTopSurround,
]

/// The abbreviations CoreAudioBaseTypes.h uses in its layout comments.
private static let labelNames: [AudioChannelLabel: String] = [
kAudioChannelLabel_Left: "L", kAudioChannelLabel_Right: "R", kAudioChannelLabel_Center: "C",
kAudioChannelLabel_LFEScreen: "LFE", kAudioChannelLabel_LFE2: "LFE2",
kAudioChannelLabel_LeftSurround: "Ls", kAudioChannelLabel_RightSurround: "Rs",
kAudioChannelLabel_LeftCenter: "Lc", kAudioChannelLabel_RightCenter: "Rc",
kAudioChannelLabel_CenterSurround: "Cs",
kAudioChannelLabel_LeftSurroundDirect: "Lsd", kAudioChannelLabel_RightSurroundDirect: "Rsd",
kAudioChannelLabel_RearSurroundLeft: "Rls", kAudioChannelLabel_RearSurroundRight: "Rrs",
kAudioChannelLabel_LeftWide: "Lw", kAudioChannelLabel_RightWide: "Rw",
kAudioChannelLabel_TopCenterSurround: "Ts",
kAudioChannelLabel_VerticalHeightLeft: "Vhl", kAudioChannelLabel_VerticalHeightCenter: "Vhc",
kAudioChannelLabel_VerticalHeightRight: "Vhr",
kAudioChannelLabel_TopBackLeft: "Tbl", kAudioChannelLabel_TopBackCenter: "Tbc",
kAudioChannelLabel_TopBackRight: "Tbr",
kAudioChannelLabel_LeftTopMiddle: "Ltm", kAudioChannelLabel_RightTopMiddle: "Rtm",
kAudioChannelLabel_LeftTopRear: "Ltr", kAudioChannelLabel_CenterTopRear: "Ctr",
kAudioChannelLabel_RightTopRear: "Rtr",
kAudioChannelLabel_LeftSideSurround: "Lss", kAudioChannelLabel_RightSideSurround: "Rss",
kAudioChannelLabel_LeftBottom: "Lb", kAudioChannelLabel_RightBottom: "Rb",
kAudioChannelLabel_CenterBottom: "Cb",
kAudioChannelLabel_LeftTopSurround: "Lts", kAudioChannelLabel_RightTopSurround: "Rts",
kAudioChannelLabel_LFE3: "LFE3",
kAudioChannelLabel_LeftBackSurround: "Lbs", kAudioChannelLabel_RightBackSurround: "Rbs",
kAudioChannelLabel_LeftEdgeOfScreen: "Les", kAudioChannelLabel_RightEdgeOfScreen: "Res",
kAudioChannelLabel_Mono: "M",
kAudioChannelLabel_HeadphonesLeft: "HpL", kAudioChannelLabel_HeadphonesRight: "HpR",
kAudioChannelLabel_Unused: "-", kAudioChannelLabel_Unknown: "?",
]

static func heightChannelCount(_ labels: [AudioChannelLabel]) -> Int {
labels.filter(heightLabels.contains).count
}

/// Labels in route order, with runs folded so a 32-channel route stays one short field:
/// consecutive discrete channels as `D0-31`, repeats of one label as `?x32`.
/// `L R C LFE Ls Rs Rls Rrs Vhl Vhr Ltr Rtr` is a 7.1.4 route.
static func channelLabelSummary(_ labels: [AudioChannelLabel]) -> String {
var tokens: [String] = []
var index = 0
while index < labels.count {
let label = labels[index]
var end = index + 1
if let discrete = discreteIndex(label) {
while end < labels.count, discreteIndex(labels[end]) == discrete + UInt32(end - index) { end += 1 }
let run = end - index
tokens.append(run > 1 ? "D\(discrete)-\(discrete + UInt32(run - 1))" : "D\(discrete)")
} else {
while end < labels.count, labels[end] == label { end += 1 }
let run = end - index
let name = labelName(label)
tokens.append(run > 1 ? "\(name)x\(run)" : name)
}
index = end
}
return tokens.joined(separator: " ")
}

/// One label's short name: CoreAudio's abbreviation, `D<n>` for a discrete channel, `A<n>` for an
/// ambisonic component, `#<value>` for anything else.
static func labelName(_ label: AudioChannelLabel) -> String {
if let name = labelNames[label] { return name }
if let discrete = discreteIndex(label) { return "D\(discrete)" }
return hoaName(label) ?? "#\(label)"
}

private static func discreteIndex(_ label: AudioChannelLabel) -> UInt32? {
label >> 16 == 1 ? label & 0xFFFF : nil
}

private static func hoaName(_ label: AudioChannelLabel) -> String? {
label >> 16 == 2 ? "A\(label & 0xFFFF)" : nil
}
}
Loading
Loading