From 8ff23f87750e42240a8c9e4adfba1874ac776e76 Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:11:03 -0400 Subject: [PATCH 1/7] feat(diagnostics): log where TrueHD Atmos heights and LFE are lost A tester on Apple TV hears TrueHD Atmos objects folded into the floor speakers and the LFE missing on AirPods, and a diagnostics report could not say which stage lost them. The session log now covers each stage: - SpatialAudioBridge logs every bed channel's RMS and peak level with CoreAudio channel names, 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 (BedLevelMeter). - The engine logs the master's audio CODECS and rendition CHANNELS once per session, or that the session is media-direct. - 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 on rendering-mode and spatial capability changes. - The readyToPlay track dump adds allowedAudioSpatializationFormats. - A TrueHD Atmos track loaded with object rendering off says so, and a 96 kHz Atmos track is no longer called "not Atmos". Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../Audio/AudioRouteDescription.swift | 148 ++++++++++++++++-- .../Audio/Spatial/BedLevelMeter.swift | 132 ++++++++++++++++ .../Audio/Spatial/SpatialAudioBridge.swift | 21 ++- .../Audio/Spatial/SpatialSpeakerLayout.swift | 24 +++ .../Native/NativeAVPlayerHost.swift | 17 ++ .../AetherEngine/Network/HLSLocalServer.swift | 16 ++ .../Video/HLSVideoEngine+SpatialAudio.swift | 23 ++- .../AetherEngine/Video/HLSVideoEngine.swift | 6 + .../AudioRouteDescriptionTests.swift | 75 +++++++++ .../DocumentedConstantsTests.swift | 8 + .../Issue458AudioLanguageMetadataTests.swift | 14 ++ .../SpatialAudioBridgeTests.swift | 82 ++++++++++ docs/formats.md | 2 + 14 files changed, 551 insertions(+), 18 deletions(-) create mode 100644 Sources/AetherEngine/Audio/Spatial/BedLevelMeter.swift create mode 100644 Tests/AetherEngineTests/AudioRouteDescriptionTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 7df7ea50d..b296a7e75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. 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 ` and `serve --audio-index ` 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. diff --git a/Sources/AetherEngine/Audio/AudioRouteDescription.swift b/Sources/AetherEngine/Audio/AudioRouteDescription.swift index b1307a20c..afd1d93b1 100644 --- a/Sources/AetherEngine/Audio/AudioRouteDescription.swift +++ b/Sources/AetherEngine/Audio/AudioRouteDescription.swift @@ -1,23 +1,30 @@ 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=[name[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. 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 @@ -26,18 +33,139 @@ enum AudioRouteDescription { } #if os(iOS) || os(tvOS) + private static func portDescription(_ port: AVAudioSessionPortDescription) -> String { + var fields = ["\(port.portType.rawValue)", "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.portName)[\(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 or spatial-capability change gets the same line: an HDMI sink can switch between + /// multichannel PCM and Dolby Atmos 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.spatialPlaybackCapabilitiesChangedNotification, "spatial playback")] { + _ = 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 = [ + kAudioChannelLabel_TopCenterSurround, + kAudioChannelLabel_VerticalHeightLeft, kAudioChannelLabel_VerticalHeightCenter, + kAudioChannelLabel_VerticalHeightRight, + 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` for a discrete channel, `A` for an + /// ambisonic component, `#` 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 + } } diff --git a/Sources/AetherEngine/Audio/Spatial/BedLevelMeter.swift b/Sources/AetherEngine/Audio/Spatial/BedLevelMeter.swift new file mode 100644 index 000000000..a5e3e261b --- /dev/null +++ b/Sources/AetherEngine/Audio/Spatial/BedLevelMeter.swift @@ -0,0 +1,132 @@ +import Accelerate +import Foundation + +/// Per-channel levels of the rendered bed, for the session log. +/// +/// A report that the heights or the LFE are missing can only be placed if the log says what the +/// engine handed AVPlayer. Silent here means the engine never sent that channel, or the programme +/// had nothing there at the time, which the object counts tell apart; present here means it was lost +/// downstream, in the system's rendering or in the receiver. +/// +/// Measured on the planes the encoder is fed, gap-fill silence included, because that is what was +/// sent. The first window after a start or a seek closes after `firstWindowSeconds`, so a short test +/// session still logs one; later windows close every `windowSeconds`. Channels are named with +/// CoreAudio's labels, as on the route line. +struct BedLevelMeter { + + static let firstWindowSeconds = 5 + static let windowSeconds = 30 + + private static let heightSpeakers = Set(SpatialSpeaker.allCases.filter(\.isHeight)) + + let layout: SpatialSpeakerLayout + let sampleRate: Int + + private var sumSquares: [Double] + private var peaks: [Float] + private var frames = 0 + private var windowsClosed = 0 + /// Where the window's first frame sits on the output timeline, in seconds, when the caller knows. + private var windowStart: Double? + + /// Latest metadata per element, since an update restates only the elements that changed. + private var elementStates: [ObjectAudioElementState?] = [] + /// Object counts as of the latest update, which hold until the next one: a window with no update + /// at all still reports what is playing. + private var activeObjects = 0 + private var elevatedObjects = 0 + private var maxActiveObjects = 0 + private var maxElevatedObjects = 0 + + init(layout: SpatialSpeakerLayout, sampleRate: Int) { + self.layout = layout + self.sampleRate = sampleRate + sumSquares = Array(repeating: 0, count: layout.channelCount) + peaks = Array(repeating: 0, count: layout.channelCount) + } + + /// Count the objects the metadata has playing, and how many of them sit above ear level with + /// elevation allowed. A new object configuration forgets the old elements' states; its first + /// update restates every element. + mutating func observe(roles: [ObjectAudioRole], updates: [ObjectAudioMetadataUpdate], + configurationChanged: Bool = false) { + if configurationChanged || elementStates.count != roles.count { + elementStates = Array(repeating: nil, count: roles.count) + } + for update in updates { + for (index, state) in update.states.enumerated() where index < elementStates.count { + if let state { elementStates[index] = state } + } + activeObjects = 0 + elevatedObjects = 0 + for (index, role) in roles.enumerated() where role == .object { + guard let state = elementStates[index], state.gain > 0 else { continue } + activeObjects += 1 + if state.position.z > 0, !Self.heightSpeakers.isSubset(of: state.excluded) { elevatedObjects += 1 } + } + maxActiveObjects = max(maxActiveObjects, activeObjects) + maxElevatedObjects = max(maxElevatedObjects, elevatedObjects) + } + } + + /// Measure `frameCount` frames of the bed (one plane per layout channel) starting at `startSeconds` + /// on the output timeline. Returns the log line when this closes a window. + mutating func add(_ planes: [UnsafePointer], frameCount: Int, startSeconds: Double? = nil) -> String? { + guard frameCount > 0 else { return nil } + if frames == 0 { windowStart = startSeconds } + let n = vDSP_Length(frameCount) + for c in 0..= due ? closeWindow() : nil + } + + /// The open window, if it holds at least a second, for a seek, end of stream or teardown. The + /// next window is a first window again, and the objects start over with the next update. + mutating func restart() -> String? { + let line = frames >= sampleRate ? closeWindow() : nil + elementStates = [] + activeObjects = 0 + elevatedObjects = 0 + clearWindow() + windowsClosed = 0 + return line + } + + /// `bed levels 7.1.4 at 3725.4 s over 30.0 s, rms/peak dBFS: L -22.4/-3.1, …; objects: up to 12 + /// active, 5 elevated` + private mutating func closeWindow() -> String { + let channels = layout.speakers.enumerated().map { c, speaker in + let rms = (sumSquares[c] / Double(max(frames, 1))).squareRoot() + return "\(AudioRouteDescription.labelName(speaker.channelLabel)) " + + "\(Self.dBFS(rms))/\(Self.dBFS(Double(peaks[c])))" + } + let at = windowStart.map { String(format: " at %.1f s", $0) } ?? "" + let seconds = String(format: "%.1f", Double(frames) / Double(sampleRate)) + let line = "bed levels \(layout.rawValue)\(at) over \(seconds) s, rms/peak dBFS: " + + channels.joined(separator: ", ") + + "; objects: up to \(maxActiveObjects) active, \(maxElevatedObjects) elevated" + windowsClosed += 1 + clearWindow() + return line + } + + private mutating func clearWindow() { + for c in sumSquares.indices { sumSquares[c] = 0; peaks[c] = 0 } + frames = 0 + windowStart = nil + maxActiveObjects = activeObjects + maxElevatedObjects = elevatedObjects + } + + static func dBFS(_ amplitude: Double) -> String { + amplitude > 0 ? String(format: "%.1f", 20 * log10(amplitude)) : "-inf" + } +} diff --git a/Sources/AetherEngine/Audio/Spatial/SpatialAudioBridge.swift b/Sources/AetherEngine/Audio/Spatial/SpatialAudioBridge.swift index d5b344abd..9e1fc5201 100644 --- a/Sources/AetherEngine/Audio/Spatial/SpatialAudioBridge.swift +++ b/Sources/AetherEngine/Audio/Spatial/SpatialAudioBridge.swift @@ -46,6 +46,8 @@ final class SpatialAudioBridge: AudioTranscodingBridge, @unchecked Sendable { private let encoder: APACEncoder private var renderer: ObjectAudioRenderer? private var rendererGeneration = -1 + /// What the encoder is fed, per bed channel, for the session log (see `BedLevelMeter`). + private var levelMeter: BedLevelMeter /// Rendered bed awaiting the encoder. TrueHD decodes one 40-frame access unit per block, 1200 /// a second; feeding the converter that often costs more in call overhead than the encode, so @@ -90,6 +92,7 @@ final class SpatialAudioBridge: AudioTranscodingBridge, @unchecked Sendable { self.srcTimeBase = srcTimeBase self.layout = layout self.decoder = decoder + levelMeter = BedLevelMeter(layout: layout, sampleRate: Self.sampleRate) let rate = bitRate ?? layout.channelCount * Self.bitRatePerChannel encoder = try APACEncoder(layout: layout, sampleRate: Double(Self.sampleRate), bitRate: rate) soundSampleEntryOverride = APACSampleEntry.sampleEntry( @@ -193,6 +196,7 @@ final class SpatialAudioBridge: AudioTranscodingBridge, @unchecked Sendable { drainedAtEOF = true var results: [UnsafeMutablePointer] = [] try? encodePending(into: &results) + logLevels(levelMeter.restart()) if let packets = try? encoder.flush() { emit(packets, into: &results) } if !results.isEmpty { EngineLog.emit("[SpatialAudioBridge] EOF flush emitted \(results.count) tail packet(s)", category: .session) @@ -206,6 +210,7 @@ final class SpatialAudioBridge: AudioTranscodingBridge, @unchecked Sendable { decoder.reset() renderer?.reset() encoder.reset() + logLevels(levelMeter.restart()) pendingFrames = 0 originFrame = nil encoderStartFrame = nil @@ -225,6 +230,8 @@ final class SpatialAudioBridge: AudioTranscodingBridge, @unchecked Sendable { func close() { opLock.lock() defer { opLock.unlock() } + // A viewer who hears no heights stops playback: the window they stopped in is the one to log. + logLevels(levelMeter.restart()) cleanup() } @@ -241,7 +248,8 @@ final class SpatialAudioBridge: AudioTranscodingBridge, @unchecked Sendable { guard block.sampleRate == Self.sampleRate else { throw BridgeError.unsupportedSampleRate(block.sampleRate) } guard block.frameCount > 0 else { return } - if renderer == nil || block.configurationGeneration != rendererGeneration { + let rebuilt = renderer == nil || block.configurationGeneration != rendererGeneration + if rebuilt { if renderer != nil { EngineLog.emit( "[SpatialAudioBridge] object configuration changed (\(block.roles.count) elements), renderer rebuilt", @@ -253,6 +261,7 @@ final class SpatialAudioBridge: AudioTranscodingBridge, @unchecked Sendable { } else if block.isDiscontinuity { renderer?.markDiscontinuity() } + levelMeter.observe(roles: block.roles, updates: block.updates, configurationChanged: rebuilt) // Anchor the encoder on the first block after a reset, and keep the encoder's input // contiguous with the source afterwards: a gap the decoder skipped (corrupt access units) is // filled with silence so everything after it stays on its timestamp. @@ -286,9 +295,12 @@ final class SpatialAudioBridge: AudioTranscodingBridge, @unchecked Sendable { guard pendingFrames > 0 else { return } let frames = pendingFrames pendingFrames = 0 + let planes = outputPlanes.map { UnsafePointer($0) } + // Where this chunk plays, for the level line: the anchor is a source PTS in 48 kHz frames. + let chunkStart = encoderStartFrame.map { Double($0 + encoderFramesIn) / Double(Self.sampleRate) } let packets: [APACEncoder.Packet] do { - packets = try encoder.encode(planes: outputPlanes.map { UnsafePointer($0) }, frameCount: frames) + packets = try encoder.encode(planes: planes, frameCount: frames) } catch { // Packet timestamps count packets since the encoder started, so a failed call that // swallowed packets would leave every later one stamped early. Start the encoder over @@ -304,9 +316,14 @@ final class SpatialAudioBridge: AudioTranscodingBridge, @unchecked Sendable { throw error } encoderFramesIn += Int64(frames) + logLevels(levelMeter.add(planes, frameCount: frames, startSeconds: chunkStart)) emit(packets, into: &results) } + private func logLevels(_ line: String?) { + if let line { EngineLog.emit("[SpatialAudioBridge] \(line)", category: .session) } + } + /// Stamp encoder packets onto the output timeline and wrap them for the muxer. Packet k is /// stamped `encoderStartFrame + k·1024`, the encoder's priming packets included, because /// AVFoundation presents an APAC packet's audio `leadingFrames` (2048) before its timestamp: it diff --git a/Sources/AetherEngine/Audio/Spatial/SpatialSpeakerLayout.swift b/Sources/AetherEngine/Audio/Spatial/SpatialSpeakerLayout.swift index ff913530f..dc81c81d0 100644 --- a/Sources/AetherEngine/Audio/Spatial/SpatialSpeakerLayout.swift +++ b/Sources/AetherEngine/Audio/Spatial/SpatialSpeakerLayout.swift @@ -96,4 +96,28 @@ public enum SpatialSpeaker: String, Sendable, CaseIterable { var isLFE: Bool { self == .lfe } var isHeight: Bool { position.z > 0 } + + /// The CoreAudio label for this position, so the bed's log line names channels the way the + /// route line and CoreAudioBaseTypes.h do. 5.1-style surrounds and 7.1 sides share + /// `LeftSurround`/`RightSurround`, as CoreAudio's own Atmos tags do; no layout has both. + var channelLabel: AudioChannelLabel { + switch self { + case .left: return kAudioChannelLabel_Left + case .right: return kAudioChannelLabel_Right + case .center: return kAudioChannelLabel_Center + case .lfe: return kAudioChannelLabel_LFEScreen + case .surroundLeft, .sideLeft: return kAudioChannelLabel_LeftSurround + case .surroundRight, .sideRight: return kAudioChannelLabel_RightSurround + case .rearLeft: return kAudioChannelLabel_RearSurroundLeft + case .rearRight: return kAudioChannelLabel_RearSurroundRight + case .wideLeft: return kAudioChannelLabel_LeftWide + case .wideRight: return kAudioChannelLabel_RightWide + case .topFrontLeft: return kAudioChannelLabel_VerticalHeightLeft + case .topFrontRight: return kAudioChannelLabel_VerticalHeightRight + case .topMiddleLeft: return kAudioChannelLabel_LeftTopMiddle + case .topMiddleRight: return kAudioChannelLabel_RightTopMiddle + case .topRearLeft: return kAudioChannelLabel_LeftTopRear + case .topRearRight: return kAudioChannelLabel_RightTopRear + } + } } diff --git a/Sources/AetherEngine/Native/NativeAVPlayerHost.swift b/Sources/AetherEngine/Native/NativeAVPlayerHost.swift index 75cc958bc..5d6f1b71c 100644 --- a/Sources/AetherEngine/Native/NativeAVPlayerHost.swift +++ b/Sources/AetherEngine/Native/NativeAVPlayerHost.swift @@ -2282,6 +2282,13 @@ final class NativeAVPlayerHost { /// Dump item.tracks at readyToPlay (HLS: asset.tracks is empty; item.tracks has the resolved list after playlist+init.mp4 parse). Channel layout tag diagnoses multichannel-routing path. private static func dumpPlayerItemTracks(_ item: AVPlayerItem, sid: Int) async { + // Whether AVPlayer may spatialize (virtualize) multichannel audio on this item: one of the + // candidates when a bed's heights come out of the floor speakers. + EngineLog.emit( + "[NativeAVPlayerHost] #\(sid) item.allowedAudioSpatializationFormats=" + + "\(spatializationFormatsName(item.allowedAudioSpatializationFormats)) (readyToPlay)", + category: .engine + ) let tracks = item.tracks if tracks.isEmpty { EngineLog.emit("[NativeAVPlayerHost] #\(sid) item.tracks empty (readyToPlay)", category: .engine) @@ -2756,6 +2763,16 @@ final class NativeAVPlayerHost { EngineLog.emit("[NativeAVPlayerHost] #\(sid) audioRoute \(route) (\(phase))", category: .engine) } + nonisolated static func spatializationFormatsName(_ formats: AVAudioSpatializationFormats) -> String { + switch formats { + case []: return "none" + case .monoAndStereo: return "monoAndStereo" + case .multichannel: return "multichannel" + case .monoStereoAndMultichannel: return "monoStereoAndMultichannel" + default: return "raw\(formats.rawValue)" + } + } + /// Read sr/ch/bits/layoutTag from CMAudioFormatDescription. Layout tag diagnoses where downmix occurs: unknown/stereo tag = AVPlayer parse layer; correct 7.1 tag = route/soundbar layer. nonisolated private static func audioFormatDescription(_ fmt: CMFormatDescription) -> String { var parts: [String] = [] diff --git a/Sources/AetherEngine/Network/HLSLocalServer.swift b/Sources/AetherEngine/Network/HLSLocalServer.swift index cf55ae87a..1a39ac534 100644 --- a/Sources/AetherEngine/Network/HLSLocalServer.swift +++ b/Sources/AetherEngine/Network/HLSLocalServer.swift @@ -1635,6 +1635,22 @@ final class HLSLocalServer: @unchecked Sendable { return nil } + /// What a master tells AVPlayer about the audio, read back from the text itself so the log line cannot + /// drift from what is served: the variant's `CODECS`, whether an `EXT-X-MEDIA` audio rendition is + /// declared, and its `CHANNELS`, which is where HLS states a channel count and spatial or object + /// audio. `codecs="avc1.640029,apac.31.03" audioRendition=yes channels=none`. + static func masterAudioSummary(_ master: String) -> String { + func quoted(_ value: String?) -> String { value.map { "\"\($0)\"" } ?? "none" } + let lines = master.split(whereSeparator: \.isNewline).map(String.init) + let codecs = lines.first { $0.hasPrefix("#EXT-X-STREAM-INF:") } + .flatMap { HLSPlaylistParser.attribute("CODECS", in: $0) } + let rendition = lines.first { + $0.hasPrefix("#EXT-X-MEDIA:") && HLSPlaylistParser.attribute("TYPE", in: $0) == "AUDIO" + } + let channels = rendition.flatMap { HLSPlaylistParser.attribute("CHANNELS", in: $0) } + return "codecs=\(quoted(codecs)) audioRendition=\(rendition == nil ? "no" : "yes") channels=\(quoted(channels))" + } + /// Pure playlist builders callable without a live server instance. subResourceBaseURL emits absolute URIs for AVAssetResourceLoader; nil emits relative URIs for the HTTP workflow. static func buildMasterPlaylistText(provider: HLSSegmentProvider, subResourceBaseURL: URL? = nil, diff --git a/Sources/AetherEngine/Video/HLSVideoEngine+SpatialAudio.swift b/Sources/AetherEngine/Video/HLSVideoEngine+SpatialAudio.swift index 9c9d110c8..d8216d836 100644 --- a/Sources/AetherEngine/Video/HLSVideoEngine+SpatialAudio.swift +++ b/Sources/AetherEngine/Video/HLSVideoEngine+SpatialAudio.swift @@ -42,12 +42,23 @@ extension HLSVideoEngine { rendering: objectAudioRendering, codecID: par.codec_id, profile: par.profile, sampleRate: par.sample_rate) else { - if objectAudioRendering != .off, par.codec_id == AV_CODEC_ID_TRUEHD { - EngineLog.emit( - "[HLSVideoEngine] object rendering requested but TrueHD is not Atmos " - + "(profile=\(par.profile) rate=\(par.sample_rate)); keeping the channel bridge", - category: .session - ) + if par.codec_id == AV_CODEC_ID_TRUEHD { + if objectAudioRendering != .off { + EngineLog.emit( + "[HLSVideoEngine] object rendering requested but TrueHD is not 48 kHz Atmos " + + "(profile=\(par.profile) rate=\(par.sample_rate)); keeping the channel bridge", + category: .session + ) + } else if par.profile == AV_PROFILE_TRUEHD_ATMOS { + // The host's choice, not a failure, but the one a missing-heights report turns on: + // the channel bridge plays the 7.1 presentation, where the objects are mixed into + // the floor channels. + EngineLog.emit( + "[HLSVideoEngine] TrueHD Atmos with objectAudioRendering off; the channel bridge " + + "carries the 7.1 presentation, with the objects mixed into the floor channels", + category: .session + ) + } } return nil } diff --git a/Sources/AetherEngine/Video/HLSVideoEngine.swift b/Sources/AetherEngine/Video/HLSVideoEngine.swift index 84c126870..c00b0d179 100644 --- a/Sources/AetherEngine/Video/HLSVideoEngine.swift +++ b/Sources/AetherEngine/Video/HLSVideoEngine.swift @@ -2128,6 +2128,12 @@ public final class HLSVideoEngine: @unchecked Sendable { self.servedSourceIsHDR = videoRange != .sdr self.servedDolbyVisionConversion = convertP7ToProfile81 ? .profile7ToProfile81 : nil EngineLog.emit("[HLSVideoEngine] serving on \(url.absoluteString) (dvModeAvailable=\(dvModeAvailable) effectiveDvMode=\(effectiveDvMode) panelIsHDR=\(panelIsInHDRMode) displaySupportsHDR=\(displaySupportsHDR) matchContent=\(matchContentEnabled) sourceIsHDR=\(videoRange != .sdr || effectiveDvMode) useMaster=\(useMasterPlaylist) videoRange=\(videoRange) dvVariant=\(dvVariant) audioLang=\(servedAudioLanguage ?? "none"))") + // Everything the playlist says about the audio, and a media-direct session says nothing: AVPlayer + // then reads only the init segment. + let masterAudio = useMasterPlaylist + ? HLSLocalServer.masterAudioSummary(HLSLocalServer.buildMasterPlaylistText(provider: prov)) + : "not served (media playlist only)" + EngineLog.emit("[HLSVideoEngine] master audio: \(masterAudio)", category: .session) return url } diff --git a/Tests/AetherEngineTests/AudioRouteDescriptionTests.swift b/Tests/AetherEngineTests/AudioRouteDescriptionTests.swift new file mode 100644 index 000000000..1bd49c4c4 --- /dev/null +++ b/Tests/AetherEngineTests/AudioRouteDescriptionTests.swift @@ -0,0 +1,75 @@ +import Testing +import CoreAudioTypes +@testable import AetherEngine + +/// The route line names the speakers the output offers, so a report can say whether an HDMI route had +/// height channels to put a bed's heights on. +struct AudioRouteDescriptionTests { + + @Test("a 7.1.4 route is spelled with CoreAudio's abbreviations and counts four heights") + func atmosRoute() { + let labels: [AudioChannelLabel] = [ + kAudioChannelLabel_Left, kAudioChannelLabel_Right, kAudioChannelLabel_Center, + kAudioChannelLabel_LFEScreen, kAudioChannelLabel_LeftSurround, kAudioChannelLabel_RightSurround, + kAudioChannelLabel_RearSurroundLeft, kAudioChannelLabel_RearSurroundRight, + kAudioChannelLabel_VerticalHeightLeft, kAudioChannelLabel_VerticalHeightRight, + kAudioChannelLabel_LeftTopRear, kAudioChannelLabel_RightTopRear, + ] + #expect(AudioRouteDescription.channelLabelSummary(labels) == "L R C LFE Ls Rs Rls Rrs Vhl Vhr Ltr Rtr") + #expect(AudioRouteDescription.heightChannelCount(labels) == 4) + } + + @Test("the top-middle row of a 9.1.6 route counts as heights") + func topMiddle() { + let labels = [kAudioChannelLabel_LeftTopMiddle, kAudioChannelLabel_RightTopMiddle, kAudioChannelLabel_Left] + #expect(AudioRouteDescription.heightChannelCount(labels) == 2) + } + + @Test("the top-surround pair counts as heights and every label in the SDK's 55-66 range has a name") + func topSurroundAndLaterLabels() { + let labels = (55...66).map { AudioChannelLabel($0) } + #expect(AudioRouteDescription.heightChannelCount(labels) == 2) + #expect(!AudioRouteDescription.channelLabelSummary(labels).contains("#")) + } + + @Test("a bed speaker and the route name the same channel the same way") + func bedSpeakersUseRouteNames() { + let names = SpatialSpeakerLayout.l714.speakers.map { AudioRouteDescription.labelName($0.channelLabel) } + #expect(names.joined(separator: " ") == "L R C LFE Ls Rs Rls Rrs Vhl Vhr Ltr Rtr") + let ninePointOneSix = SpatialSpeakerLayout.l916.speakers.map { AudioRouteDescription.labelName($0.channelLabel) } + #expect(ninePointOneSix.joined(separator: " ") == "L R C LFE Ls Rs Rls Rrs Lw Rw Vhl Vhr Ltm Rtm Ltr Rtr") + } + + @Test("runs fold so a 32-channel route stays one short field, and no label is a height") + func runsFold() { + let discrete = (0..<32).map { kAudioChannelLabel_Discrete_0 | AudioChannelLabel($0) } + #expect(AudioRouteDescription.channelLabelSummary(discrete) == "D0-31") + #expect(AudioRouteDescription.heightChannelCount(discrete) == 0) + let unknown = [AudioChannelLabel](repeating: kAudioChannelLabel_Unknown, count: 8) + #expect(AudioRouteDescription.channelLabelSummary(unknown) == "?x8") + let mixed = [kAudioChannelLabel_Left, kAudioChannelLabel_Right, + kAudioChannelLabel_Discrete_0 | 2, kAudioChannelLabel_Discrete_0 | 3, + kAudioChannelLabel_Discrete_0 | 5, kAudioChannelLabel_HOA_ACN_0 | 1, 9999] + #expect(AudioRouteDescription.channelLabelSummary(mixed) == "L R D2-3 D5 A1 #9999") + } +} + +#if os(iOS) || os(tvOS) +/// The live line, read off whatever route the device or simulator has: the fields a missing-heights +/// report reads are always there, in order, whatever the values. +struct AudioRouteDescriptionLiveTests { + @Test("the route line carries the rendering mode, multichannel support and per-port labels") + func liveLine() throws { + let line = try #require(AudioRouteDescription.current()) + print("[AudioRouteDescriptionLiveTests] \(line)") + let rendering = try #require(line.range(of: " rendering=")) + let multichannel = try #require(line.range(of: " multichannelContent=")) + let ports = try #require(line.range(of: " ports=[")) + #expect(rendering.lowerBound < multichannel.lowerBound && multichannel.lowerBound < ports.lowerBound) + #expect(!line.contains("rendering=raw"), "every mode the SDK defines has a name") + // Hosts that strip user-chosen device names before a report leaves the device find each port + // by this shape, `name[type, ch=n…]`, so it is held here rather than left to chance. + #expect(line.range(of: #"ports=\[(\]|.*\[[A-Za-z0-9]+, ch=-?\d+)"#, options: .regularExpression) != nil) + } +} +#endif diff --git a/Tests/AetherEngineTests/DocumentedConstantsTests.swift b/Tests/AetherEngineTests/DocumentedConstantsTests.swift index f1f9b520e..0cdcd4464 100644 --- a/Tests/AetherEngineTests/DocumentedConstantsTests.swift +++ b/Tests/AetherEngineTests/DocumentedConstantsTests.swift @@ -239,6 +239,14 @@ final class DocumentedConstantsTests: XCTestCase { "docs/formats.md and cli.md quote apac.31.04 for 16 channels") } + /// formats.md "TrueHD Atmos (object rendering)" and the CHANGELOG quote the bed level windows. + func testBedLevelWindowsAreWhatTheDocsSay() throws { + let docs = try documentation() + XCTAssertEqual(BedLevelMeter.firstWindowSeconds, 5) + XCTAssertEqual(BedLevelMeter.windowSeconds, 30) + assertDocumented("after the first 5 s of audio (and again after every seek), then every 30 s", docs) + } + func testExternalSubtitleTrackIDBaseIsDocumented() throws { let docs = try documentation() XCTAssertEqual(AetherEngine.externalSubtitleTrackIDBase, 100_000) diff --git a/Tests/AetherEngineTests/Issue458AudioLanguageMetadataTests.swift b/Tests/AetherEngineTests/Issue458AudioLanguageMetadataTests.swift index 0b62008b0..6ddedfc32 100644 --- a/Tests/AetherEngineTests/Issue458AudioLanguageMetadataTests.swift +++ b/Tests/AetherEngineTests/Issue458AudioLanguageMetadataTests.swift @@ -207,6 +207,20 @@ struct Issue458AudioRenditionPlaylistTests { #expect(!master.contains("AUDIO=\"aud\"")) } + /// The session log's `master audio:` line, read back from the served text so it cannot drift from it. + @Test("the master audio summary reads codecs, the rendition and its CHANNELS off the master text") + func masterAudioSummary() { + let labelled = HLSLocalServer.buildMasterPlaylistText( + provider: AudioRenditionMockProvider(audio: (language: "eng", name: "English"))) + #expect(HLSLocalServer.masterAudioSummary(labelled) + == "codecs=\"hvc1.1.6.L120.90,mp4a.40.2\" audioRendition=yes channels=none") + let unlabelled = HLSLocalServer.buildMasterPlaylistText(provider: AudioRenditionMockProvider(audio: nil)) + #expect(HLSLocalServer.masterAudioSummary(unlabelled) + == "codecs=\"hvc1.1.6.L120.90,mp4a.40.2\" audioRendition=no channels=none") + let declared = labelled.replacingOccurrences(of: "AUTOSELECT=YES", with: "AUTOSELECT=YES,CHANNELS=\"12/-/BED-12\"") + #expect(HLSLocalServer.masterAudioSummary(declared).hasSuffix("channels=\"12/-/BED-12\"")) + } + /// A group referenced by a variant has to exist, or AVPlayer fails the master outright. @Test("the variant's AUDIO attribute and the group it names appear together or not at all") func groupReferenceIsConsistent() { diff --git a/Tests/AetherEngineTests/SpatialAudioBridgeTests.swift b/Tests/AetherEngineTests/SpatialAudioBridgeTests.swift index b553ab377..f6356c26b 100644 --- a/Tests/AetherEngineTests/SpatialAudioBridgeTests.swift +++ b/Tests/AetherEngineTests/SpatialAudioBridgeTests.swift @@ -400,3 +400,85 @@ struct SpatialAudioBridgeTests { return nil } } + +/// The bed level line is the engine's half of a missing-heights report: it says which channels of the +/// rendered bed carried sound in what AVPlayer was handed, so a silent height here and a silent height +/// at the receiver are told apart. +@Suite("TrueHD Atmos bed level meter") +struct BedLevelMeterTests { + + /// `chunks` encode chunks (2048 frames, as the bridge feeds it) of a half-scale sine on one channel + /// of a 7.1.4 bed, the rest silent. At 48 kHz the 5 s first window closes on chunk 118 and a 30 s + /// window on chunk 704. + private func feed(_ meter: inout BedLevelMeter, chunks: Int, channel: Int = 0, + startSeconds: Double? = nil) -> [String] { + let layout = SpatialSpeakerLayout.l714 + let chunk = 2048 + let planes = (0...allocate(capacity: chunk) } + defer { planes.forEach { $0.deallocate() } } + for c in planes.indices { planes[c].update(repeating: 0, count: chunk) } + for i in 0.. Date: Thu, 8 Oct 2026 20:22:10 -0400 Subject: [PATCH 2/7] fix(diagnostics): name audio route ports by type and qualify the bed-level reading From the cross-provider review: - The route line carried each port's portName, which for AirPods, Bluetooth and AirPlay is the user's own name for the device and for an HDMI sink can be a renamed CEC name. The line now reaches host diagnostics, and a host cannot reliably scrub a free-text name from it (a name can imitate the line's own fields), so ports are named by type. - The docs read a silent height next to elevated objects as an engine loss. The counts come from metadata and an elevated object can be silent or snap to a floor speaker, so that reading is now a lead. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- .../AetherEngine/Audio/AudioRouteDescription.swift | 11 ++++++++--- .../AetherEngine/Audio/Spatial/BedLevelMeter.swift | 7 ++++--- .../AudioRouteDescriptionTests.swift | 5 ++--- docs/formats.md | 2 +- 5 files changed, 16 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b296a7e75..048afe74f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +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. 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). +- 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 ` and `serve --audio-index ` 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. diff --git a/Sources/AetherEngine/Audio/AudioRouteDescription.swift b/Sources/AetherEngine/Audio/AudioRouteDescription.swift index afd1d93b1..52bc124e6 100644 --- a/Sources/AetherEngine/Audio/AudioRouteDescription.swift +++ b/Sources/AetherEngine/Audio/AudioRouteDescription.swift @@ -7,7 +7,7 @@ import CoreAudioTypes /// of routes, not of formats. enum AudioRouteDescription { - /// `output=… preferred=… max=… rendering=… multichannelContent=… ports=[name[type, ch=n, labels=…, + /// `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. @@ -18,6 +18,11 @@ enum AudioRouteDescription { /// 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() @@ -34,13 +39,13 @@ enum AudioRouteDescription { #if os(iOS) || os(tvOS) private static func portDescription(_ port: AVAudioSessionPortDescription) -> String { - var fields = ["\(port.portType.rawValue)", "ch=\(port.channels?.count ?? -1)"] + 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.portName)[\(fields.joined(separator: ", "))]" + return "\(port.portType.rawValue)[\(fields.joined(separator: ", "))]" } static func renderingModeName(_ mode: AVAudioSession.RenderingMode) -> String { diff --git a/Sources/AetherEngine/Audio/Spatial/BedLevelMeter.swift b/Sources/AetherEngine/Audio/Spatial/BedLevelMeter.swift index a5e3e261b..6ea5a7244 100644 --- a/Sources/AetherEngine/Audio/Spatial/BedLevelMeter.swift +++ b/Sources/AetherEngine/Audio/Spatial/BedLevelMeter.swift @@ -4,9 +4,10 @@ import Foundation /// Per-channel levels of the rendered bed, for the session log. /// /// A report that the heights or the LFE are missing can only be placed if the log says what the -/// engine handed AVPlayer. Silent here means the engine never sent that channel, or the programme -/// had nothing there at the time, which the object counts tell apart; present here means it was lost -/// downstream, in the system's rendering or in the receiver. +/// engine handed AVPlayer. Level here means the encoder was given that channel's sound, so a height +/// heard from the floor speakers lost it after this point. Silence here is a lead, not a verdict: the +/// object counts come from the metadata (gain and position), and an elevated object can still be +/// silent or snap to a floor speaker. /// /// Measured on the planes the encoder is fed, gap-fill silence included, because that is what was /// sent. The first window after a start or a seek closes after `firstWindowSeconds`, so a short test diff --git a/Tests/AetherEngineTests/AudioRouteDescriptionTests.swift b/Tests/AetherEngineTests/AudioRouteDescriptionTests.swift index 1bd49c4c4..6bf1941f7 100644 --- a/Tests/AetherEngineTests/AudioRouteDescriptionTests.swift +++ b/Tests/AetherEngineTests/AudioRouteDescriptionTests.swift @@ -67,9 +67,8 @@ struct AudioRouteDescriptionLiveTests { let ports = try #require(line.range(of: " ports=[")) #expect(rendering.lowerBound < multichannel.lowerBound && multichannel.lowerBound < ports.lowerBound) #expect(!line.contains("rendering=raw"), "every mode the SDK defines has a name") - // Hosts that strip user-chosen device names before a report leaves the device find each port - // by this shape, `name[type, ch=n…]`, so it is held here rather than left to chance. - #expect(line.range(of: #"ports=\[(\]|.*\[[A-Za-z0-9]+, ch=-?\d+)"#, options: .regularExpression) != nil) + // Each port is its type and its channel fields, never the device's name. + #expect(line.range(of: #"ports=\[(\]|[A-Za-z0-9]+\[ch=-?\d+)"#, options: .regularExpression) != nil) } } #endif diff --git a/docs/formats.md b/docs/formats.md index e0620b29f..1b35e9c03 100644 --- a/docs/formats.md +++ b/docs/formats.md @@ -315,7 +315,7 @@ Off by default. With `LoadOptions.objectAudioRendering = .apac(layout)`, a TrueH libavformat cannot write APAC (its `apac` codec id is an unrelated legacy codec), so the muxer is given an ALAC stand-in and the init segment's sound sample entry is replaced with an `apac` entry carrying the encoder's magic cookie, which is the complete `dapa` box (`APACSampleEntry`). The master playlist advertises `apac.31.LL`: profile 31 (multichannel) and the channel-count level, `02` for 8 channels (5.1.2), `03` for 9 to 12 channels and `04` for 13 to 24; AVPlayer rejects a bare `apac`. Every APAC packet the encoder produces is independently decodable, so every segment starts on an Audio Sync Packet. The encoder runs with dynamic range control off: its DRC analysis otherwise holds 1.56 s (`.capture`) or more than 5 s (`.movie`) of audio before the first packet, which would leave the first segment after a seek without sound. The encoder's 2048 frames of priming stay in the stream: AVFoundation presents an APAC packet's audio 2048 frames before its timestamp, so the priming packets take the source position's timestamp and the first content frame plays on it. -What the session log shows, for a report that the heights or the LFE are missing. `[SpatialAudioBridge] bed levels` gives every bed channel's RMS and peak level in what the encoder was fed, named with CoreAudio's channel labels, after the first 5 s of audio (and again after every seek), then every 30 s, plus a partial window at a seek, at the end of the stream and when playback stops. Each line carries the window's position on the timeline and the most objects the metadata had playing during it, with how many of those sat above ear level with elevation allowed: a height channel with level here was lost downstream, in the system's rendering or the receiver, and a silent one next to elevated objects was lost in the engine. `[HLSVideoEngine] master audio:` is the master's `CODECS` and its audio rendition's `CHANNELS` (`channels=none` when the rendition declares none, which is every master this engine writes today), or `not served` when the session routes media-direct and AVPlayer reads only the init segment. The `audioRoute` line, written at ready-to-play, once playback has settled, and on every route, rendering-mode or spatial-capability change, is the system's side: `rendering` is `AVAudioSession.renderingMode` (`dolbyAtmos` against `surround` or `spatialAudio`), and each port lists the channel labels it reports with a `heights=` count. Read the two together, because a Dolby MAT carrier can report fewer channels than it carries. A TrueHD Atmos track loaded with `objectAudioRendering` off logs that too, since the channel bridge then carries the 7.1 presentation with the objects mixed into the floor channels. +What the session log shows, for a report that the heights or the LFE are missing. `[SpatialAudioBridge] bed levels` gives every bed channel's RMS and peak level in what the encoder was fed, named with CoreAudio's channel labels, after the first 5 s of audio (and again after every seek), then every 30 s, plus a partial window at a seek, at the end of the stream and when playback stops. Each line carries the window's position on the timeline and the most objects the metadata had playing during it, with how many of those sat above ear level with elevation allowed. Level on a height channel means the encoder was given height sound, so heights heard from the floor speakers were lost after that point, in the system's rendering or the receiver. A silent height next to elevated objects is a lead rather than a verdict, because the counts come from the metadata and an elevated object can be silent or snap to a floor speaker. `[HLSVideoEngine] master audio:` is the master's `CODECS` and its audio rendition's `CHANNELS` (`channels=none` when the rendition declares none, which is every master this engine writes today), or `not served` when the session routes media-direct and AVPlayer reads only the init segment. The `audioRoute` line, written at ready-to-play, once playback has settled, and on every route, rendering-mode or spatial-capability change, is the system's side: `rendering` is `AVAudioSession.renderingMode` (`dolbyAtmos` against `surround` or `spatialAudio`), and each port, named by type rather than by the user's name for the device, lists the channel labels it reports with a `heights=` count. Read the two together, because a Dolby MAT carrier can report fewer channels than it carries. A TrueHD Atmos track loaded with `objectAudioRendering` off logs that too, since the channel bridge then carries the 7.1 presentation with the objects mixed into the floor channels. ## Subtitles From 703f790cefadea594928ddd7a97d882dcee9d5ad Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:42:56 -0400 Subject: [PATCH 3/7] fix(audio): stop reporting a TrueHD resume as a dead APAC decoder; log the LFE element A diagnostics report from an Apple TV playing TrueHD Atmos resumed at 60 s showed the APAC bridge's silence check firing after 64 packets, the session going to .error while it kept playing, and every seek after it ignored. A TrueHD packet is one 40-frame access unit, and after a mid-stream start the object decoder waits for the next major sync: measured on Dolby's Unfold demo at up to 102 access units, so starts at 10, 13.7 and 20 s all reported a dead decoder. The channel bridge does not, because FFmpeg's decoder resyncs on its own. The APAC bridge now allows 1200 packets, a second of TrueHD. The same report showed the bed's LFE at -inf for minutes. The bed-level line now ends with the LFE element's input level and metadata gain, and the decoder's element layout is logged once per configuration, so a silent LFE reads as a silent source, a zero gain or a missing element. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- .../Audio/Spatial/BedLevelMeter.swift | 46 ++++++++++- .../Audio/Spatial/SpatialAudioBridge.swift | 27 +++++-- .../DocumentedConstantsTests.swift | 8 ++ .../SpatialAudioBridgeTests.swift | 78 ++++++++++++++++--- docs/formats.md | 2 +- 6 files changed, 140 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 048afe74f..fe0268bdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,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. diff --git a/Sources/AetherEngine/Audio/Spatial/BedLevelMeter.swift b/Sources/AetherEngine/Audio/Spatial/BedLevelMeter.swift index 6ea5a7244..7c5d315ad 100644 --- a/Sources/AetherEngine/Audio/Spatial/BedLevelMeter.swift +++ b/Sources/AetherEngine/Audio/Spatial/BedLevelMeter.swift @@ -39,6 +39,12 @@ struct BedLevelMeter { private var maxActiveObjects = 0 private var maxElevatedObjects = 0 + /// The decoder's LFE input, before the renderer applies its metadata gain. With the bed's LFE level + /// it splits a silent LFE into a silent source, a zero gain, or no LFE element at all. + private var lfeIndex: Int? + private var lfeInputSquares: Double = 0 + private var lfeInputFrames = 0 + init(layout: SpatialSpeakerLayout, sampleRate: Int) { self.layout = layout self.sampleRate = sampleRate @@ -53,6 +59,7 @@ struct BedLevelMeter { configurationChanged: Bool = false) { if configurationChanged || elementStates.count != roles.count { elementStates = Array(repeating: nil, count: roles.count) + lfeIndex = roles.firstIndex(of: .lfe) } for update in updates { for (index, state) in update.states.enumerated() where index < elementStates.count { @@ -70,6 +77,15 @@ struct BedLevelMeter { } } + /// Measure the LFE element's input for one decoded block (one plane per role). + mutating func measureInput(roles: [ObjectAudioRole], planes: [UnsafePointer], frameCount: Int) { + guard frameCount > 0, let index = roles.firstIndex(of: .lfe), index < planes.count else { return } + var squares: Float = 0 + vDSP_svesq(planes[index], 1, &squares, vDSP_Length(frameCount)) + lfeInputSquares += Double(squares) + lfeInputFrames += frameCount + } + /// Measure `frameCount` frames of the bed (one plane per layout channel) starting at `startSeconds` /// on the output timeline. Returns the log line when this closes a window. mutating func add(_ planes: [UnsafePointer], frameCount: Int, startSeconds: Double? = nil) -> String? { @@ -94,6 +110,7 @@ struct BedLevelMeter { mutating func restart() -> String? { let line = frames >= sampleRate ? closeWindow() : nil elementStates = [] + lfeIndex = nil activeObjects = 0 elevatedObjects = 0 clearWindow() @@ -102,7 +119,7 @@ struct BedLevelMeter { } /// `bed levels 7.1.4 at 3725.4 s over 30.0 s, rms/peak dBFS: L -22.4/-3.1, …; objects: up to 12 - /// active, 5 elevated` + /// active, 5 elevated; LFE input -18.2 dBFS, metadata gain 0.0 dB` private mutating func closeWindow() -> String { let channels = layout.speakers.enumerated().map { c, speaker in let rms = (sumSquares[c] / Double(max(frames, 1))).squareRoot() @@ -114,13 +131,40 @@ struct BedLevelMeter { let line = "bed levels \(layout.rawValue)\(at) over \(seconds) s, rms/peak dBFS: " + channels.joined(separator: ", ") + "; objects: up to \(maxActiveObjects) active, \(maxElevatedObjects) elevated" + + "; " + lfeSummary() windowsClosed += 1 clearWindow() return line } + private func lfeSummary() -> String { + guard let lfeIndex else { return "no LFE element" } + let input = Self.dBFS((lfeInputSquares / Double(max(lfeInputFrames, 1))).squareRoot()) + let gain = elementStates.indices.contains(lfeIndex) ? elementStates[lfeIndex]?.gain : nil + let gainText = gain.map { "metadata gain " + ($0 > 0 ? String(format: "%.1f dB", 20 * log10($0)) : "-inf dB") } + ?? "no metadata yet" + return "LFE input \(input) dBFS, \(gainText)" + } + + /// The decoder's elements, for the configuration line: `LFE + 15 objects`, or the bed channels it + /// names first. + static func configurationSummary(_ roles: [ObjectAudioRole]) -> String { + var parts: [String] = [] + let beds = roles.compactMap { role -> String? in + if case .bed(let speaker) = role { return AudioRouteDescription.labelName(speaker.channelLabel) } + return nil + } + if !beds.isEmpty { parts.append("beds " + beds.joined(separator: " ")) } + let lfe = roles.filter { $0 == .lfe }.count + parts.append(lfe == 0 ? "no LFE" : (lfe == 1 ? "LFE" : "\(lfe) LFE")) + parts.append("\(roles.filter { $0 == .object }.count) objects") + return parts.joined(separator: " + ") + } + private mutating func clearWindow() { for c in sumSquares.indices { sumSquares[c] = 0; peaks[c] = 0 } + lfeInputSquares = 0 + lfeInputFrames = 0 frames = 0 windowStart = nil maxActiveObjects = activeObjects diff --git a/Sources/AetherEngine/Audio/Spatial/SpatialAudioBridge.swift b/Sources/AetherEngine/Audio/Spatial/SpatialAudioBridge.swift index 9e1fc5201..f31ccd758 100644 --- a/Sources/AetherEngine/Audio/Spatial/SpatialAudioBridge.swift +++ b/Sources/AetherEngine/Audio/Spatial/SpatialAudioBridge.swift @@ -75,7 +75,7 @@ final class SpatialAudioBridge: AudioTranscodingBridge, @unchecked Sendable { private(set) var outputBytesLifetime: Int64 = 0 /// AE#641: called once, on the pump thread, when the object decoder has been fed - /// `AudioBridge.silentFeedPacketThreshold` packets in a row without returning a block. The APAC + /// `silentFeedPacketThreshold` packets in a row without returning a block. The APAC /// sample entry is written up front, like FLAC's, so nothing downstream fails on its own and the /// session would otherwise play the picture over silence. Set before the producer starts feeding. var onDecoderProducedNothing: (@Sendable (AudioBridge.FeedStats) -> Void)? @@ -83,6 +83,15 @@ final class SpatialAudioBridge: AudioTranscodingBridge, @unchecked Sendable { private static let avNoPTS: Int64 = -0x7FFFFFFFFFFFFFFF - 1 + /// Packets the object decoder may take without a block before it counts as producing nothing. + /// Not the channel bridge's 64: a TrueHD packet is one 40-frame access unit, and after a start or + /// resume mid-stream the decoder waits for the next major sync before its first block. Measured + /// on Dolby's Unfold demo, that wait was up to 102 access units; at 64 every resume past the + /// opening seconds was reported as a dead decoder and the session went to `.error` while it + /// played, ignoring every seek after it. 1200 is a second of TrueHD, still a fraction of a second + /// of the producer's time. + static let silentFeedPacketThreshold = 1200 + init( srcTimeBase: AVRational, layout: SpatialSpeakerLayout, @@ -181,7 +190,7 @@ final class SpatialAudioBridge: AudioTranscodingBridge, @unchecked Sendable { /// Caller holds `opLock`. private func noteDecodedNothingIfNeeded() { guard !decodedNothingReported, stats.decodedNothing, - stats.packetsFedSinceLastEnqueue >= AudioBridge.silentFeedPacketThreshold else { return } + stats.packetsFedSinceLastEnqueue >= Self.silentFeedPacketThreshold else { return } decodedNothingReported = true EngineLog.emit( "[SpatialAudioBridge] ERROR: AE#641 the TrueHD object decoder has produced nothing: " @@ -250,18 +259,20 @@ final class SpatialAudioBridge: AudioTranscodingBridge, @unchecked Sendable { let rebuilt = renderer == nil || block.configurationGeneration != rendererGeneration if rebuilt { - if renderer != nil { - EngineLog.emit( - "[SpatialAudioBridge] object configuration changed (\(block.roles.count) elements), renderer rebuilt", - category: .session - ) - } + // Whether the presentation has an LFE element at all is the first question a missing-LFE + // report asks. + EngineLog.emit( + "[SpatialAudioBridge] object configuration\(renderer == nil ? "" : " changed, renderer rebuilt"): " + + BedLevelMeter.configurationSummary(block.roles), + category: .session + ) renderer = ObjectAudioRenderer(layout: layout, roles: block.roles) rendererGeneration = block.configurationGeneration } else if block.isDiscontinuity { renderer?.markDiscontinuity() } levelMeter.observe(roles: block.roles, updates: block.updates, configurationChanged: rebuilt) + levelMeter.measureInput(roles: block.roles, planes: block.planes, frameCount: block.frameCount) // Anchor the encoder on the first block after a reset, and keep the encoder's input // contiguous with the source afterwards: a gap the decoder skipped (corrupt access units) is // filled with silence so everything after it stays on its timestamp. diff --git a/Tests/AetherEngineTests/DocumentedConstantsTests.swift b/Tests/AetherEngineTests/DocumentedConstantsTests.swift index 0cdcd4464..abe3fc582 100644 --- a/Tests/AetherEngineTests/DocumentedConstantsTests.swift +++ b/Tests/AetherEngineTests/DocumentedConstantsTests.swift @@ -247,6 +247,14 @@ final class DocumentedConstantsTests: XCTestCase { assertDocumented("after the first 5 s of audio (and again after every seek), then every 30 s", docs) } + /// formats.md "TrueHD Atmos (object rendering)" and the CHANGELOG quote the APAC silence threshold. + func testAPACSilenceThresholdIsWhatTheDocsSay() throws { + guard #available(macOS 26.0, iOS 26.0, tvOS 26.0, visionOS 26.0, *) else { return } + let docs = try documentation() + XCTAssertEqual(SpatialAudioBridge.silentFeedPacketThreshold, 1200) + assertDocumented("returns nothing for 1200 packets in a row, a second of TrueHD", docs) + } + func testExternalSubtitleTrackIDBaseIsDocumented() throws { let docs = try documentation() XCTAssertEqual(AetherEngine.externalSubtitleTrackIDBase, 100_000) diff --git a/Tests/AetherEngineTests/SpatialAudioBridgeTests.swift b/Tests/AetherEngineTests/SpatialAudioBridgeTests.swift index f6356c26b..c4db6a6cc 100644 --- a/Tests/AetherEngineTests/SpatialAudioBridgeTests.swift +++ b/Tests/AetherEngineTests/SpatialAudioBridgeTests.swift @@ -181,24 +181,26 @@ struct SpatialAudioBridgeTests { /// The APAC sample entry is written before any audio, so a decoder that never finds a major sync /// plays the picture over silence unless the bridge says so itself (AE#641). + final class Reports: @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + func add() { lock.lock(); count += 1; lock.unlock() } + var value: Int { lock.lock(); defer { lock.unlock() }; return count } + } + @Test("a decoder that returns or accepts nothing is reported once, at the threshold, and a healthy one never") func decodedNothingIsReportedOnce() throws { guard #available(macOS 26.0, iOS 26.0, tvOS 26.0, visionOS 26.0, *) else { return } - final class Reports: @unchecked Sendable { - private let lock = NSLock() - private var count = 0 - func add() { lock.lock(); count += 1; lock.unlock() } - var value: Int { lock.lock(); defer { lock.unlock() }; return count } - } let silent = try SpatialAudioBridge( srcTimeBase: AVRational(num: 1, den: 1000), layout: .l714, decoder: SyntheticDecoder(skipAfterReset: .max)) defer { silent.close() } let reports = Reports() silent.onDecoderProducedNothing = { _ in reports.add() } - _ = try feed(silent, startMs: 0, count: AudioBridge.silentFeedPacketThreshold - 1) + let threshold = SpatialAudioBridge.silentFeedPacketThreshold + _ = try feed(silent, startMs: 0, count: threshold - 1) #expect(reports.value == 0) - _ = try feed(silent, startMs: 1260, count: 40) + _ = try feed(silent, startMs: Int64(threshold - 1) * 20, count: 40) #expect(reports.value == 1) let rejecting = try SpatialAudioBridge( @@ -206,7 +208,7 @@ struct SpatialAudioBridgeTests { defer { rejecting.close() } let rejected = Reports() rejecting.onDecoderProducedNothing = { _ in rejected.add() } - _ = try feed(rejecting, startMs: 0, count: AudioBridge.silentFeedPacketThreshold + 10) + _ = try feed(rejecting, startMs: 0, count: SpatialAudioBridge.silentFeedPacketThreshold + 10) #expect(rejected.value == 1) let healthy = try SpatialAudioBridge( @@ -219,6 +221,35 @@ struct SpatialAudioBridgeTests { #expect(quiet.value == 0) } + /// The resume that put a tvOS session into `.error`: TrueHD in Matroska or M2TS is one 40-frame + /// access unit per packet, and after a mid-stream start the decoder skips to the next major sync, + /// 102 access units on Dolby's Unfold demo. That wait is not a dead decoder. + @Test("a mid-stream start that waits 102 access units for a major sync is not reported as silence") + func majorSyncWaitIsNotSilence() throws { + guard #available(macOS 26.0, iOS 26.0, tvOS 26.0, visionOS 26.0, *) else { return } + let bridge = try SpatialAudioBridge( + srcTimeBase: AVRational(num: 1, den: 48_000), layout: .l714, + decoder: SyntheticDecoder(skipAfterReset: 102)) + defer { bridge.close() } + let reports = Reports() + bridge.onDecoderProducedNothing = { _ in reports.add() } + var emitted = 0 + for i in 0..<300 { + guard let pkt = av_packet_alloc() else { continue } + defer { var p: UnsafeMutablePointer? = pkt; av_packet_free(&p) } + _ = av_new_packet(pkt, 1) + pkt.pointee.pts = Int64(i) * 40 + pkt.pointee.dts = pkt.pointee.pts + for fp in try bridge.feed(packet: pkt) { + emitted += 1 + var p: UnsafeMutablePointer? = fp + trackedPacketFree(&p) + } + } + #expect(reports.value == 0) + #expect(emitted > 0, "the decoder found its sync and the bridge encoded") + } + @Test("a producer restart re-anchors on the new position") func restartReanchors() throws { guard #available(macOS 26.0, iOS 26.0, tvOS 26.0, visionOS 26.0, *) else { return } @@ -472,13 +503,36 @@ struct BedLevelMeterTests { ]) meter.observe(roles: roles, updates: [first, second]) let line = try #require(feed(&meter, chunks: 118).first) - #expect(line.hasSuffix("; objects: up to 3 active, 1 elevated")) + #expect(line.contains("; objects: up to 3 active, 1 elevated;")) // Metadata that holds still sends no update; the next window still reports what plays. let held = try #require(feed(&meter, chunks: 704).first) - #expect(held.hasSuffix("; objects: up to 3 active, 1 elevated")) + #expect(held.contains("; objects: up to 3 active, 1 elevated;")) // A seek forgets the objects until the decoder restates them. _ = meter.restart() let afterSeek = try #require(feed(&meter, chunks: 118).first) - #expect(afterSeek.hasSuffix("; objects: up to 0 active, 0 elevated")) + #expect(afterSeek.contains("; objects: up to 0 active, 0 elevated;")) + } + + @Test("the line ends with the LFE element's input level and metadata gain, or says there is none") + func lfeInputAndGain() throws { + var meter = BedLevelMeter(layout: .l714, sampleRate: 48_000) + let roles: [ObjectAudioRole] = [.lfe, .object] + meter.observe(roles: roles, updates: [ObjectAudioMetadataUpdate( + frameOffset: 0, rampFrames: 0, states: [.init(gain: 0.5), .init(position: SIMD3(0.5, 0.5, 0))])]) + let frames = 48_000 + let lfe = UnsafeMutablePointer.allocate(capacity: frames) + let object = UnsafeMutablePointer.allocate(capacity: frames) + defer { lfe.deallocate(); object.deallocate() } + for i in 0.. Date: Thu, 8 Oct 2026 20:54:26 -0400 Subject: [PATCH 4/7] fix(audio): a session rebuild during an audio switch keeps the switched track On an Apple TV, a TrueHD pick on a file whose auto pick is E-AC-3 Atmos came back on E-AC-3. The host applied the saved TrueHD choice with selectAudioTrack, and while that rebuild was running (the APAC encoder makes it ~130 ms slower than a stream copy) the host rebuilt the session with reloadAtCurrentPosition. That reload read activeAudioTrackIndex, which the switch's own stopInternal had just cleared, loaded the auto pick and superseded the switch. The selection a reload restores now takes the newest audio intent first: a pick not yet started, then the switch in flight, then the active track, then a parked teardown snapshot. Co-Authored-By: Claude Opus 5.5 --- Sources/AetherEngine/AetherEngine.swift | 8 +++++++- .../BackgroundTeardownSelection.swift | 8 +++++++- ...ue357BackgroundTeardownSelectionTests.swift | 18 ++++++++++++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/Sources/AetherEngine/AetherEngine.swift b/Sources/AetherEngine/AetherEngine.swift index 2ed1bb446..f783884bf 100644 --- a/Sources/AetherEngine/AetherEngine.swift +++ b/Sources/AetherEngine/AetherEngine.swift @@ -3477,7 +3477,11 @@ public final class AetherEngine: ObservableObject { /// `sessionRebuildResumesPlaying`. See `rebuildResumesPlaying`. var transportIntentUnderReconstruction: Bool? private(set) var audioSelectionTask: Task? - 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() { @@ -6988,8 +6992,10 @@ 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 let failure = await self.reloadWithAudioOverride( url: url, audioStreamIndex: Int32(selected), expectedGeneration: beforeReload) + if 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, diff --git a/Sources/AetherEngine/BackgroundTeardownSelection.swift b/Sources/AetherEngine/BackgroundTeardownSelection.swift index 314e54ffb..93fdf6306 100644 --- a/Sources/AetherEngine/BackgroundTeardownSelection.swift +++ b/Sources/AetherEngine/BackgroundTeardownSelection.swift @@ -39,7 +39,13 @@ extension AetherEngine { BackgroundTeardownSelection( subtitles: Self.mergedSubtitleCarryover( live: captureSubtitleSessionCarryover(), snapshot: parked?.subtitles), - audioTrackIndex: activeAudioTrackIndex ?? parked?.audioTrackIndex, + // Newest audio intent first. A host that picks a track and then rebuilds the session + // (a transport restore, a foreground return) before the switch has landed would + // otherwise read the active track the switch's own teardown had just cleared, and come + // back on the auto pick, superseding the switch: a TrueHD pick on a file with an E-AC-3 + // Atmos track returned to the E-AC-3 track on an Apple TV. + audioTrackIndex: pendingAudioSelection ?? audioSwitchInFlight + ?? activeAudioTrackIndex ?? parked?.audioTrackIndex, discTitleID: activeDiscTitleID ?? parked?.discTitleID ) } diff --git a/Tests/AetherEngineTests/Issue357BackgroundTeardownSelectionTests.swift b/Tests/AetherEngineTests/Issue357BackgroundTeardownSelectionTests.swift index 535a754c5..c768aaccf 100644 --- a/Tests/AetherEngineTests/Issue357BackgroundTeardownSelectionTests.swift +++ b/Tests/AetherEngineTests/Issue357BackgroundTeardownSelectionTests.swift @@ -117,6 +117,24 @@ struct Issue357BackgroundTeardownSelectionTests { #expect(selection.discTitleID == 7) } + /// A host picked TrueHD on a file whose auto pick is E-AC-3 Atmos, then rebuilt the session while + /// the switch was running: the rebuild read the active track the switch's teardown had cleared + /// and came back on E-AC-3, superseding the switch. + @Test("a reload taken while an audio switch is pending or running restores the switch's track") + func reloadKeepsAudioSwitch() throws { + let engine = try AetherEngine() + engine.activeAudioTrackIndex = 3 + engine.pendingAudioSelection = 1 + #expect(engine.consumeReloadSelection().audioTrackIndex == 1) + engine.pendingAudioSelection = nil + engine.activeAudioTrackIndex = nil + engine.audioSwitchInFlight = 1 + #expect(engine.consumeReloadSelection().audioTrackIndex == 1) + engine.audioSwitchInFlight = nil + engine.activeAudioTrackIndex = 3 + #expect(engine.consumeReloadSelection().audioTrackIndex == 3) + } + @Test("a pick made after the teardown is newer intent and wins over the snapshot") func newerIntentWins() throws { let engine = try AetherEngine() From 18729d1aab1156c9eb837a22711b4c099ce5d97a Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Thu, 8 Oct 2026 21:17:47 -0400 Subject: [PATCH 5/7] fix(diagnostics): also log the route when its rendering capabilities change From review: AVAudioSession posts renderingCapabilitiesChangeNotification when the layouts an output supports change, which can happen without a route or rendering-mode change. Co-Authored-By: Claude Opus 5.5 --- Sources/AetherEngine/Audio/AudioRouteDescription.swift | 6 ++++-- docs/formats.md | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Sources/AetherEngine/Audio/AudioRouteDescription.swift b/Sources/AetherEngine/Audio/AudioRouteDescription.swift index 52bc124e6..b5f77d21d 100644 --- a/Sources/AetherEngine/Audio/AudioRouteDescription.swift +++ b/Sources/AetherEngine/Audio/AudioRouteDescription.swift @@ -64,8 +64,9 @@ enum AudioRouteDescription { /// 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 or spatial-capability change gets the same line: an HDMI sink can switch between - /// multichannel PCM and Dolby Atmos without the route itself changing. + /// 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( @@ -76,6 +77,7 @@ enum AudioRouteDescription { 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")] { _ = NotificationCenter.default.addObserver(forName: name, object: session, queue: nil) { _ in guard let route = current() else { return } diff --git a/docs/formats.md b/docs/formats.md index d8896e54d..3e21b4489 100644 --- a/docs/formats.md +++ b/docs/formats.md @@ -315,7 +315,7 @@ Off by default. With `LoadOptions.objectAudioRendering = .apac(layout)`, a TrueH libavformat cannot write APAC (its `apac` codec id is an unrelated legacy codec), so the muxer is given an ALAC stand-in and the init segment's sound sample entry is replaced with an `apac` entry carrying the encoder's magic cookie, which is the complete `dapa` box (`APACSampleEntry`). The master playlist advertises `apac.31.LL`: profile 31 (multichannel) and the channel-count level, `02` for 8 channels (5.1.2), `03` for 9 to 12 channels and `04` for 13 to 24; AVPlayer rejects a bare `apac`. Every APAC packet the encoder produces is independently decodable, so every segment starts on an Audio Sync Packet. The encoder runs with dynamic range control off: its DRC analysis otherwise holds 1.56 s (`.capture`) or more than 5 s (`.movie`) of audio before the first packet, which would leave the first segment after a seek without sound. The encoder's 2048 frames of priming stay in the stream: AVFoundation presents an APAC packet's audio 2048 frames before its timestamp, so the priming packets take the source position's timestamp and the first content frame plays on it. -What the session log shows, for a report that the heights or the LFE are missing. `[SpatialAudioBridge] bed levels` gives every bed channel's RMS and peak level in what the encoder was fed, named with CoreAudio's channel labels, after the first 5 s of audio (and again after every seek), then every 30 s, plus a partial window at a seek, at the end of the stream and when playback stops. Each line carries the window's position on the timeline and the most objects the metadata had playing during it, with how many of those sat above ear level with elevation allowed. Level on a height channel means the encoder was given height sound, so heights heard from the floor speakers were lost after that point, in the system's rendering or the receiver. A silent height next to elevated objects is a lead rather than a verdict, because the counts come from the metadata and an elevated object can be silent or snap to a floor speaker. The line ends with the LFE element's input level and its metadata gain, which split a silent LFE channel into a silent source, a zero gain or no LFE element; `[SpatialAudioBridge] object configuration` names the decoder's elements once per configuration (`LFE + 15 objects` on Dolby's Unfold demo). An object decoder that returns nothing for 1200 packets in a row, a second of TrueHD, is reported as `.audioBridgeProducedNoOutput`; the threshold is that long because after a start or resume mid-stream the decoder waits for the next major sync, up to 102 access units on Unfold. `[HLSVideoEngine] master audio:` is the master's `CODECS` and its audio rendition's `CHANNELS` (`channels=none` when the rendition declares none, which is every master this engine writes today), or `not served` when the session routes media-direct and AVPlayer reads only the init segment. The `audioRoute` line, written at ready-to-play, once playback has settled, and on every route, rendering-mode or spatial-capability change, is the system's side: `rendering` is `AVAudioSession.renderingMode` (`dolbyAtmos` against `surround` or `spatialAudio`), and each port, named by type rather than by the user's name for the device, lists the channel labels it reports with a `heights=` count. Read the two together, because a Dolby MAT carrier can report fewer channels than it carries. A TrueHD Atmos track loaded with `objectAudioRendering` off logs that too, since the channel bridge then carries the 7.1 presentation with the objects mixed into the floor channels. +What the session log shows, for a report that the heights or the LFE are missing. `[SpatialAudioBridge] bed levels` gives every bed channel's RMS and peak level in what the encoder was fed, named with CoreAudio's channel labels, after the first 5 s of audio (and again after every seek), then every 30 s, plus a partial window at a seek, at the end of the stream and when playback stops. Each line carries the window's position on the timeline and the most objects the metadata had playing during it, with how many of those sat above ear level with elevation allowed. Level on a height channel means the encoder was given height sound, so heights heard from the floor speakers were lost after that point, in the system's rendering or the receiver. A silent height next to elevated objects is a lead rather than a verdict, because the counts come from the metadata and an elevated object can be silent or snap to a floor speaker. The line ends with the LFE element's input level and its metadata gain, which split a silent LFE channel into a silent source, a zero gain or no LFE element; `[SpatialAudioBridge] object configuration` names the decoder's elements once per configuration (`LFE + 15 objects` on Dolby's Unfold demo). An object decoder that returns nothing for 1200 packets in a row, a second of TrueHD, is reported as `.audioBridgeProducedNoOutput`; the threshold is that long because after a start or resume mid-stream the decoder waits for the next major sync, up to 102 access units on Unfold. `[HLSVideoEngine] master audio:` is the master's `CODECS` and its audio rendition's `CHANNELS` (`channels=none` when the rendition declares none, which is every master this engine writes today), or `not served` when the session routes media-direct and AVPlayer reads only the init segment. The `audioRoute` line, written at ready-to-play, once playback has settled, and on every route, rendering-mode, rendering-capability or spatial-capability change, is the system's side: `rendering` is `AVAudioSession.renderingMode` (`dolbyAtmos` against `surround` or `spatialAudio`), and each port, named by type rather than by the user's name for the device, lists the channel labels it reports with a `heights=` count. Read the two together, because a Dolby MAT carrier can report fewer channels than it carries. A TrueHD Atmos track loaded with `objectAudioRendering` off logs that too, since the channel bridge then carries the 7.1 presentation with the objects mixed into the floor channels. ## Subtitles From ba895a5eaf9d39408304c9ff2ff22bffbf2c59e3 Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Thu, 8 Oct 2026 21:30:45 -0400 Subject: [PATCH 6/7] fix(audio): forget a cancelled audio switch From review: stop() and load() cancel a pending audio switch and clear pendingAudioSelection, but left audioSwitchInFlight set, so a reload that snapshotted the selection before the cancelled task unwound could restore the cancelled session's track. Co-Authored-By: Claude Opus 5.5 --- Sources/AetherEngine/AetherEngine.swift | 3 +++ .../Issue357BackgroundTeardownSelectionTests.swift | 12 ++++++++++++ 2 files changed, 15 insertions(+) diff --git a/Sources/AetherEngine/AetherEngine.swift b/Sources/AetherEngine/AetherEngine.swift index f783884bf..41af71e13 100644 --- a/Sources/AetherEngine/AetherEngine.swift +++ b/Sources/AetherEngine/AetherEngine.swift @@ -3489,6 +3489,9 @@ public final class AetherEngine: ObservableObject { 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 diff --git a/Tests/AetherEngineTests/Issue357BackgroundTeardownSelectionTests.swift b/Tests/AetherEngineTests/Issue357BackgroundTeardownSelectionTests.swift index c768aaccf..d84687d0a 100644 --- a/Tests/AetherEngineTests/Issue357BackgroundTeardownSelectionTests.swift +++ b/Tests/AetherEngineTests/Issue357BackgroundTeardownSelectionTests.swift @@ -135,6 +135,18 @@ struct Issue357BackgroundTeardownSelectionTests { #expect(engine.consumeReloadSelection().audioTrackIndex == 3) } + /// A `stop()` or `load()` cancels the switch; its track is then nobody's intent. + @Test("a cancelled audio switch is not restored by a later reload") + func cancelledSwitchIsForgotten() throws { + let engine = try AetherEngine() + engine.pendingAudioSelection = 2 + engine.audioSwitchInFlight = 1 + engine.stop() + #expect(engine.pendingAudioSelection == nil) + #expect(engine.audioSwitchInFlight == nil) + #expect(engine.consumeReloadSelection().audioTrackIndex == nil) + } + @Test("a pick made after the teardown is newer intent and wins over the snapshot") func newerIntentWins() throws { let engine = try AetherEngine() From d6cb0900afb6c3c5e807ae9d3193f3a980a4fc22 Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Thu, 8 Oct 2026 21:34:53 -0400 Subject: [PATCH 7/7] fix(diagnostics): meter every LFE element and keep switch cleanup to its own task From review: - The bed-level line measured only the first LFE element; TrueHD can carry LFE2 beside it and the renderer sums both. It now sums every LFE element's input and names each one's metadata gain. - A configuration change cleared element states but kept the held object counts, so a bed-only configuration kept reporting the previous one's objects in every later window. - A cancelled audio switch returning late could clear the in-flight marker of a newer switch to the same stream; the cleanup now checks the task's own selection epoch. Co-Authored-By: Claude Opus 5.5 --- Sources/AetherEngine/AetherEngine.swift | 5 ++- .../Audio/Spatial/BedLevelMeter.swift | 34 ++++++++++++------- .../SpatialAudioBridgeTests.swift | 25 ++++++++++++++ 3 files changed, 50 insertions(+), 14 deletions(-) diff --git a/Sources/AetherEngine/AetherEngine.swift b/Sources/AetherEngine/AetherEngine.swift index 41af71e13..42b11b2e1 100644 --- a/Sources/AetherEngine/AetherEngine.swift +++ b/Sources/AetherEngine/AetherEngine.swift @@ -6998,7 +6998,10 @@ public final class AetherEngine: ObservableObject { self.audioSwitchInFlight = selected let failure = await self.reloadWithAudioOverride( url: url, audioStreamIndex: Int32(selected), expectedGeneration: beforeReload) - if self.audioSwitchInFlight == selected { self.audioSwitchInFlight = nil } + // 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, diff --git a/Sources/AetherEngine/Audio/Spatial/BedLevelMeter.swift b/Sources/AetherEngine/Audio/Spatial/BedLevelMeter.swift index 7c5d315ad..d85d893af 100644 --- a/Sources/AetherEngine/Audio/Spatial/BedLevelMeter.swift +++ b/Sources/AetherEngine/Audio/Spatial/BedLevelMeter.swift @@ -41,7 +41,8 @@ struct BedLevelMeter { /// The decoder's LFE input, before the renderer applies its metadata gain. With the bed's LFE level /// it splits a silent LFE into a silent source, a zero gain, or no LFE element at all. - private var lfeIndex: Int? + /// Every LFE element: TrueHD can carry LFE2 beside LFE, and the renderer sums both. + private var lfeIndices: [Int] = [] private var lfeInputSquares: Double = 0 private var lfeInputFrames = 0 @@ -59,7 +60,10 @@ struct BedLevelMeter { configurationChanged: Bool = false) { if configurationChanged || elementStates.count != roles.count { elementStates = Array(repeating: nil, count: roles.count) - lfeIndex = roles.firstIndex(of: .lfe) + lfeIndices = roles.indices.filter { roles[$0] == .lfe } + // A configuration with no objects sends no object metadata; the old counts must not hold. + activeObjects = 0 + elevatedObjects = 0 } for update in updates { for (index, state) in update.states.enumerated() where index < elementStates.count { @@ -77,12 +81,15 @@ struct BedLevelMeter { } } - /// Measure the LFE element's input for one decoded block (one plane per role). + /// Measure the LFE elements' combined input for one decoded block (one plane per role). mutating func measureInput(roles: [ObjectAudioRole], planes: [UnsafePointer], frameCount: Int) { - guard frameCount > 0, let index = roles.firstIndex(of: .lfe), index < planes.count else { return } - var squares: Float = 0 - vDSP_svesq(planes[index], 1, &squares, vDSP_Length(frameCount)) - lfeInputSquares += Double(squares) + let indices = roles.indices.filter { roles[$0] == .lfe && $0 < planes.count } + guard frameCount > 0, !indices.isEmpty else { return } + for index in indices { + var squares: Float = 0 + vDSP_svesq(planes[index], 1, &squares, vDSP_Length(frameCount)) + lfeInputSquares += Double(squares) + } lfeInputFrames += frameCount } @@ -110,7 +117,7 @@ struct BedLevelMeter { mutating func restart() -> String? { let line = frames >= sampleRate ? closeWindow() : nil elementStates = [] - lfeIndex = nil + lfeIndices = [] activeObjects = 0 elevatedObjects = 0 clearWindow() @@ -138,12 +145,13 @@ struct BedLevelMeter { } private func lfeSummary() -> String { - guard let lfeIndex else { return "no LFE element" } + guard !lfeIndices.isEmpty else { return "no LFE element" } let input = Self.dBFS((lfeInputSquares / Double(max(lfeInputFrames, 1))).squareRoot()) - let gain = elementStates.indices.contains(lfeIndex) ? elementStates[lfeIndex]?.gain : nil - let gainText = gain.map { "metadata gain " + ($0 > 0 ? String(format: "%.1f dB", 20 * log10($0)) : "-inf dB") } - ?? "no metadata yet" - return "LFE input \(input) dBFS, \(gainText)" + let gains = lfeIndices.compactMap { elementStates.indices.contains($0) ? elementStates[$0]?.gain : nil } + guard !gains.isEmpty else { return "LFE input \(input) dBFS, no metadata yet" } + let gainText = gains.map { $0 > 0 ? String(format: "%.1f dB", 20 * log10($0)) : "-inf dB" } + .joined(separator: "/") + return "LFE input \(input) dBFS, metadata gain \(gainText)" } /// The decoder's elements, for the configuration line: `LFE + 15 objects`, or the bed channels it diff --git a/Tests/AetherEngineTests/SpatialAudioBridgeTests.swift b/Tests/AetherEngineTests/SpatialAudioBridgeTests.swift index c4db6a6cc..f03f6fc53 100644 --- a/Tests/AetherEngineTests/SpatialAudioBridgeTests.swift +++ b/Tests/AetherEngineTests/SpatialAudioBridgeTests.swift @@ -513,6 +513,21 @@ struct BedLevelMeterTests { #expect(afterSeek.contains("; objects: up to 0 active, 0 elevated;")) } + @Test("a configuration without objects does not keep reporting the previous configuration's objects") + func countsResetOnConfigurationChange() throws { + var meter = BedLevelMeter(layout: .l714, sampleRate: 48_000) + meter.observe(roles: [.object], updates: [ObjectAudioMetadataUpdate( + frameOffset: 0, rampFrames: 0, states: [.init(position: SIMD3(0.5, 0.5, 1))])]) + let first = try #require(feed(&meter, chunks: 118).first) + #expect(first.contains("; objects: up to 1 active, 1 elevated;")) + meter.observe(roles: [.bed(.left)], updates: [], configurationChanged: true) + // The window open at the change saw the object play; the one after it must not. + let during = try #require(feed(&meter, chunks: 704).first) + #expect(during.contains("; objects: up to 1 active, 1 elevated;")) + let after = try #require(feed(&meter, chunks: 704).first) + #expect(after.contains("; objects: up to 0 active, 0 elevated;")) + } + @Test("the line ends with the LFE element's input level and metadata gain, or says there is none") func lfeInputAndGain() throws { var meter = BedLevelMeter(layout: .l714, sampleRate: 48_000) @@ -528,6 +543,16 @@ struct BedLevelMeterTests { let line = try #require(feed(&meter, chunks: 118).first) #expect(line.hasSuffix("; LFE input -9.0 dBFS, metadata gain -6.0 dB")) + // TrueHD can carry LFE2 beside LFE; the renderer sums both, so the meter does too, and names + // each element's gain. + var twoLFE = BedLevelMeter(layout: .l714, sampleRate: 48_000) + let twoRoles: [ObjectAudioRole] = [.lfe, .lfe] + twoLFE.observe(roles: twoRoles, updates: [ObjectAudioMetadataUpdate( + frameOffset: 0, rampFrames: 0, states: [.init(), .init(gain: 0.5)])]) + twoLFE.measureInput(roles: twoRoles, planes: [UnsafePointer(object), UnsafePointer(lfe)], frameCount: frames) + let both = try #require(feed(&twoLFE, chunks: 118).first) + #expect(both.hasSuffix("; LFE input -9.0 dBFS, metadata gain 0.0 dB/-6.0 dB")) + var noLFE = BedLevelMeter(layout: .l714, sampleRate: 48_000) noLFE.observe(roles: [.object], updates: []) let without = try #require(feed(&noLFE, chunks: 118).first)