Files
plezy/shared/apple/AtmosProbe/AtmosProbePlugin.swift
T
edde746 6c14049e95 fix(tvos): make EAC3 playback conform to Dolby's guidance
Groundwork for #1300. Establishes the session, buffering and route
handling Dolby's application guide prescribes, and adds the diagnostic
arm needed to find out whether Apple's sample-buffer renderer can carry
Atmos objects at all.

Audio session, per the guide's sequence:

- Adopt the long-form playback profile in one atomic call at app launch
  and activate the session there. The SDK only accepts that policy with
  category Playback, a Default/MoviePlayback/SpokenAudio mode and no
  options, so it cannot be assembled from separate calls.
- Report the resolved rendering mode in the player, hidden unless the
  system resolves it. Apple only resolves it for CarPlay and AirPlay, so
  an unresolved value means unknown, never "not Dolby".

Diagnostics (Apple TV only, Settings > Video Playback > Atmos Output Test):

- Add a sample-buffer arm. It reads the asset with AVAssetReader at
  outputSettings nil and hands the untouched compressed buffers and the
  untouched format description straight to the renderer, with a variant
  that rebuilds the description the way playback builds it. Every
  existing mode went through AVPlayer, so nothing exercised the path
  playback actually uses; this is what tells us whether the renderer or
  our construction is at fault.
- Add an AirPlay route picker. AirPlay is the only route where the system
  resolves the rendering mode and the supported channel layouts, so it is
  what makes those observations reachable at all, and the AVPlayer arms
  now allow external playback so every arm can be compared on the same
  destination.
- Add a session-mode toggle for the one profile difference between the
  guide and previous playback behaviour.
- Report the session profile, supported layouts, both format
  descriptions, the magic cookie and the renderer status, and release the
  session on stop so a failed run cannot contaminate the next one.

Also bumps MPVKit to 1.0.14, which carries the matching audio output
work: the channel layout AVFoundation itself uses for Dolby content, a
renderer-failure observer so the fallback to PCM can actually run, the
prescribed feed ordering and preroll, flush recovery that re-supplies the
discarded audio instead of shifting later audio into its place, and
capability-driven fallback on route and capability changes.

This does not yet fix #1300. Whether the sample-buffer renderer can carry
JOC is still unknown; it removes every difference from the documented
setup that could explain the failure, and gives us the arm to answer it
on real hardware.
2026-07-26 20:42:58 +02:00

1066 lines
36 KiB
Swift

import AVFoundation
import Flutter
import Foundation
#if os(iOS) || os(tvOS)
import AVKit
import UIKit
#endif
/// Diagnostics harness for #1300: plays known assets through a bare AVPlayer
/// or through a bare AVSampleBufferAudioRenderer so a tester can read the
/// receiver's format display per test.
///
/// Modes:
/// - hlsAtmos: Apple's public fMP4 Atmos example stream (device+AVR+MAT baseline)
/// - hlsControl: Apple's public EC3 5.1 (non-JOC) example stream (control)
/// - rawEc3: raw .ec3 elementary stream via AVAssetResourceLoader with an
/// unbounded content length — a faithful rehearsal of the mpv
/// AVPlayer audio sink's feeding model
/// - rawEc3Finite: same loader but passing through the real content length,
/// isolating "loader trick" failures from "raw ES" failures
/// - asbarNative: the decisive arm. Reads the asset with AVAssetReader at
/// `outputSettings: nil` and hands the resulting *native*
/// compressed CMSampleBuffers and *native* CMFormatDescription
/// straight to AVSampleBufferAudioRenderer, following Apple's
/// flexible enhanced buffering sequence. No mpv, no FFmpeg
/// spdif muxer, no generated ASBD, no hand-built dec3. If this
/// reaches Atmos, the production sink has a construction bug;
/// if it does not, AVSampleBufferAudioRenderer cannot carry
/// JOC on this route and the architecture must change.
/// - asbarGenerated: same feed, but the format description is rebuilt the way
/// the mpv AO builds it (generated ASBD + dec3 magic cookie +
/// MPEG_5_1_C layout). A/B against asbarNative isolates
/// descriptor construction from everything else.
public class AtmosProbePlugin: NSObject, FlutterPlugin {
private static let hlsAtmosUrl =
"https://devstreaming-cdn.apple.com/videos/streaming/examples/adv_dv_atmos/main.m3u8"
private static let hlsControlUrl =
"https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_adv_example_hevc/master.m3u8"
private var player: AVPlayer?
private var loader: RawEc3Loader?
private var asbar: AsbarProbe?
#if os(iOS) || os(tvOS)
private var routePicker: AVRoutePickerView?
#endif
public static func register(with registrar: FlutterPluginRegistrar) {
let channel = FlutterMethodChannel(
name: "plezy/atmos_probe", binaryMessenger: registrar.messenger())
let instance = AtmosProbePlugin()
registrar.addMethodCallDelegate(instance, channel: channel)
}
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "start":
guard let args = call.arguments as? [String: Any],
let mode = args["mode"] as? String
else {
result(FlutterError(code: "bad_args", message: "mode required", details: nil))
return
}
start(
mode: mode, url: args["url"] as? String,
sessionMode: args["sessionMode"] as? String, result: result)
case "stop":
stopPlayback()
result(nil)
case "showRoutePicker":
result(setRoutePickerVisible(true))
case "hideRoutePicker":
result(setRoutePickerVisible(false))
case "getStatus":
result(status())
default:
result(FlutterMethodNotImplemented)
}
}
private func start(
mode: String, url: String?, sessionMode: String?,
result: @escaping FlutterResult
) {
stopPlayback()
let item: AVPlayerItem
switch mode {
case "hlsAtmos", "hlsControl":
let raw = mode == "hlsAtmos" ? Self.hlsAtmosUrl : Self.hlsControlUrl
guard let streamUrl = URL(string: raw) else {
result(FlutterError(code: "bad_url", message: raw, details: nil))
return
}
item = AVPlayerItem(url: streamUrl)
case "rawEc3", "rawEc3Finite":
guard let source = url.flatMap(URL.init(string:)) else {
result(FlutterError(code: "bad_url", message: "rawEc3 needs a source url", details: nil))
return
}
let loader = RawEc3Loader(source: source, finiteLength: mode == "rawEc3Finite")
self.loader = loader
item = AVPlayerItem(asset: loader.asset)
item.preferredForwardBufferDuration = 1.0
loader.begin()
case "asbarNative", "asbarGenerated":
guard let source = url.flatMap(URL.init(string:)) else {
result(
FlutterError(code: "bad_url", message: "\(mode) needs a source url", details: nil))
return
}
let probe = AsbarProbe(
source: source,
regenerateFormatDescription: mode == "asbarGenerated",
// Dolby's Figure 1 prescribes mode .default; .moviePlayback is what
// the shipping AO used. Selectable so the tester can A/B it.
sessionMode: sessionMode == "moviePlayback" ? .moviePlayback : .default)
asbar = probe
probe.start { error in
if let error {
result(FlutterError(code: "asbar_failed", message: error, details: nil))
} else {
result(nil)
}
}
return
default:
result(FlutterError(code: "bad_mode", message: mode, details: nil))
return
}
let player = AVPlayer(playerItem: item)
player.automaticallyWaitsToMinimizeStalling = false
// Must stay true: the route picker exists so these arms can be compared
// against the sample-buffer arms on an AirPlay destination, and AirPlay is
// the only route where the system resolves renderingMode and
// supportedOutputChannelLayouts. Pinning playback local would leave the
// AVPlayer controls on HDMI while the picker moved everything else.
player.allowsExternalPlayback = true
self.player = player
player.play()
result(nil)
}
/// Dolby's flow opens with an AVRoutePickerView so the tester can move
/// playback to an AirPlay destination. That matters here beyond conformance:
/// AVRoutePickerView.h states media from an AVSampleBufferAudioRenderer can
/// be routed to AirPlay on tvOS, and AirPlay is the only route where
/// `renderingMode` and `supportedOutputChannelLayouts` actually resolve — so
/// this is what makes the AirPlay arm of the test matrix reachable from the
/// device.
private func setRoutePickerVisible(_ visible: Bool) -> Bool {
#if os(iOS) || os(tvOS)
guard visible else {
routePicker?.removeFromSuperview()
routePicker = nil
return true
}
if routePicker != nil { return true }
guard let window = Self.keyWindow() else { return false }
let picker = AVRoutePickerView()
picker.translatesAutoresizingMaskIntoConstraints = false
window.addSubview(picker)
NSLayoutConstraint.activate([
picker.centerXAnchor.constraint(equalTo: window.centerXAnchor),
picker.bottomAnchor.constraint(equalTo: window.centerYAnchor, constant: -40),
picker.widthAnchor.constraint(equalToConstant: 120),
picker.heightAnchor.constraint(equalToConstant: 80),
])
routePicker = picker
window.setNeedsFocusUpdate()
window.updateFocusIfNeeded()
return true
#else
return false
#endif
}
#if os(iOS) || os(tvOS)
private static func keyWindow() -> UIWindow? {
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.flatMap(\.windows)
.first { $0.isKeyWindow }
?? UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.flatMap(\.windows)
.first
}
#endif
private func stopPlayback() {
player?.pause()
player?.replaceCurrentItem(with: nil)
player = nil
loader?.cancel()
loader = nil
asbar?.cancel()
asbar = nil
_ = setRoutePickerVisible(false)
// Run isolation: never let a failed compressed route leak into the next
// variant or into other apps.
try? AVAudioSession.sharedInstance().setActive(
false, options: .notifyOthersOnDeactivation)
}
private func status() -> [String: Any] {
var out: [String: Any] = [:]
let session = AVAudioSession.sharedInstance()
out["maxOutputChannels"] = session.maximumOutputNumberOfChannels
out["outputLatencyMs"] = Int(session.outputLatency * 1000)
out["route"] = session.currentRoute.outputs.map { port in
"\(port.portType.rawValue)/\(port.portName)/\(port.channels?.count ?? 0)ch"
}.joined(separator: ", ")
out["sessionCategory"] = session.category.rawValue
out["sessionMode"] = session.mode.rawValue
out["routeSharingPolicy"] = session.routeSharingPolicy.rawValue
out["categoryOptions"] = session.categoryOptions.rawValue
if #available(iOS 17.2, tvOS 17.2, *) {
out["renderingMode"] = String(describing: session.renderingMode)
out["renderingModeRawValue"] = session.renderingMode.rawValue
// Empty on HDMI by design (CarPlay/AirPlay only). Reported so a tester
// can tell "empty because HDMI" from "empty because inactive".
out["supportedOutputChannelLayouts"] = session.supportedOutputChannelLayouts.map {
String(format: "0x%08x/%uch", $0.layoutTag, $0.channelCount)
}.joined(separator: ", ")
}
if let asbar {
out.merge(asbar.statusSnapshot()) { _, new in new }
return out
}
guard let player = player else {
out["state"] = "idle"
return out
}
let item = player.currentItem
out["state"] =
switch player.timeControlStatus {
case .paused: "paused"
case .waitingToPlayAtSpecifiedRate:
"waiting(\(player.reasonForWaitingToPlay?.rawValue ?? "-"))"
case .playing: "playing"
@unknown default: "unknown"
}
out["itemStatus"] =
switch item?.status {
case .readyToPlay: "readyToPlay"
case .failed: "failed"
default: "unknown"
}
if let error = item?.error as NSError? {
out["error"] = "\(error.domain):\(error.code) \(error.localizedDescription)"
}
if let item = item {
out["currentTime"] = CMTimeGetSeconds(item.currentTime())
out["tracks"] = item.tracks.compactMap { track -> String? in
guard let assetTrack = track.assetTrack else { return nil }
let formats = (assetTrack.formatDescriptions as! [CMFormatDescription]).map { desc in
fourCC(CMFormatDescriptionGetMediaSubType(desc))
}.joined(separator: "+")
return "\(assetTrack.mediaType.rawValue):\(formats)"
}.joined(separator: ", ")
}
if let loader = loader {
let snapshot = loader.statusSnapshot()
out["fedBytes"] = snapshot.bytesReceived
out["loaderRequests"] = snapshot.requestLog
out["loaderRetainedBytes"] = snapshot.retainedBytes
out["loaderPendingRequests"] = snapshot.pendingRequestCount
out["loaderMaximumBytes"] = snapshot.maximumBufferedBytes
if let errorCode = snapshot.errorCode {
out["loaderError"] = errorCode
}
}
return out
}
private func fourCC(_ code: FourCharCode) -> String {
let bytes = [
UInt8((code >> 24) & 0xFF), UInt8((code >> 16) & 0xFF),
UInt8((code >> 8) & 0xFF), UInt8(code & 0xFF),
]
return String(bytes: bytes, encoding: .ascii) ?? String(code)
}
}
/// Streams an HTTP source into memory and serves it to AVPlayer through an
/// AVAssetResourceLoader on a custom scheme, mirroring the mpv sink's model.
final class RawEc3Loader: NSObject, AVAssetResourceLoaderDelegate, URLSessionDataDelegate {
static let defaultMaximumBufferedBytes = 64 * 1_024 * 1_024
private static let maximumPendingRequests = 256
struct StatusSnapshot {
let bytesReceived: Int
let requestLog: String
let retainedBytes: Int
let pendingRequestCount: Int
let maximumBufferedBytes: Int
let errorCode: String?
let isFinished: Bool
}
private enum State {
case active
case finished
case failed(code: String, error: Error)
case cancelled
var terminalError: Error? {
switch self {
case .failed(_, let error):
return error
case .cancelled:
return URLError(.cancelled)
case .active, .finished:
return nil
}
}
var errorCode: String? {
if case .failed(let code, _) = self { return code }
return nil
}
var isFinished: Bool {
if case .finished = self { return true }
return false
}
}
let asset: AVURLAsset
let maximumBufferedBytes: Int
private let source: URL
private let finiteLength: Bool
private let sessionConfiguration: URLSessionConfiguration
private let queue = DispatchQueue(label: "plezy.atmos.probe.loader")
private var terminalHandlerForTesting: (() -> Void)?
private let queueKey = DispatchSpecificKey<Void>()
private var session: URLSession?
private var buffer = Data()
private var contentLength: Int64 = -1
private var state: State = .active
private var pending: [AVAssetResourceLoadingRequest] = []
private var bytesReceived = 0
private var requestLog = ""
private var hasBegun = false
init(
source: URL,
finiteLength: Bool,
maximumBufferedBytes: Int = RawEc3Loader.defaultMaximumBufferedBytes,
sessionConfiguration: URLSessionConfiguration = .default,
terminalHandlerForTesting: (() -> Void)? = nil
) {
precondition(maximumBufferedBytes > 0)
self.source = source
self.finiteLength = finiteLength
self.maximumBufferedBytes = maximumBufferedBytes
self.sessionConfiguration = sessionConfiguration
self.terminalHandlerForTesting = terminalHandlerForTesting
self.asset = AVURLAsset(url: URL(string: "plezy-ec3-probe://stream/audio.ec3")!)
super.init()
queue.setSpecific(key: queueKey, value: ())
asset.resourceLoader.setDelegate(self, queue: queue)
}
func begin() {
queue.async { [weak self] in
guard let self, !self.hasBegun else { return }
guard case .active = self.state else { return }
self.hasBegun = true
let session = URLSession(
configuration: self.sessionConfiguration,
delegate: self,
delegateQueue: nil
)
self.session = session
session.dataTask(with: self.source).resume()
}
}
func cancel() {
queue.async { [weak self] in
self?.cancelOnQueue()
}
}
/// Called only from the method-channel/main path, never from the loader queue.
func statusSnapshot() -> StatusSnapshot {
syncOnQueue {
StatusSnapshot(
bytesReceived: bytesReceived,
requestLog: requestLog,
retainedBytes: buffer.count,
pendingRequestCount: pending.count,
maximumBufferedBytes: maximumBufferedBytes,
errorCode: state.errorCode,
isFinished: state.isFinished
)
}
}
private func syncOnQueue<T>(_ body: () -> T) -> T {
if DispatchQueue.getSpecific(key: queueKey) != nil {
return body()
}
return queue.sync(execute: body)
}
private func notifyTerminalForTesting() {
let handler = terminalHandlerForTesting
terminalHandlerForTesting = nil
handler?()
}
private func cancelOnQueue() {
guard case .active = state else {
if case .finished = state {
state = .cancelled
finishPending(with: URLError(.cancelled))
releaseRetainedBytes()
}
return
}
state = .cancelled
session?.invalidateAndCancel()
session = nil
finishPending(with: URLError(.cancelled))
releaseRetainedBytes()
notifyTerminalForTesting()
}
private func failOnQueue(code: String, error: Error) {
guard case .active = state else { return }
state = .failed(code: code, error: error)
session?.invalidateAndCancel()
session = nil
finishPending(with: error)
releaseRetainedBytes()
notifyTerminalForTesting()
}
private func finishPending(with error: Error) {
let requests = pending
pending.removeAll(keepingCapacity: false)
for request in requests where !request.isFinished {
request.finishLoading(with: error)
}
}
private func releaseRetainedBytes() {
buffer.removeAll(keepingCapacity: false)
}
// MARK: URLSessionDataDelegate (background queue -> hop to `queue`)
func urlSession(
_ session: URLSession,
dataTask: URLSessionDataTask,
didReceive response: URLResponse,
completionHandler: @escaping (URLSession.ResponseDisposition) -> Void
) {
queue.async { [weak self] in
guard let self, case .active = self.state else { return }
self.contentLength = response.expectedContentLength
if response.expectedContentLength > Int64(self.maximumBufferedBytes) {
self.failOnQueue(
code: "response_too_large",
error: URLError(.dataLengthExceedsMaximum)
)
} else {
self.serve()
}
}
completionHandler(.allow)
}
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
queue.async { [weak self] in
guard let self, case .active = self.state else { return }
guard data.count <= self.maximumBufferedBytes - self.buffer.count else {
self.failOnQueue(
code: "response_too_large",
error: URLError(.dataLengthExceedsMaximum)
)
return
}
self.buffer.append(data)
self.bytesReceived += data.count
self.serve()
}
}
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
queue.async { [weak self] in
guard let self, case .active = self.state else { return }
self.session = nil
if let error {
self.state = .failed(code: "network_error", error: error)
self.finishPending(with: error)
self.releaseRetainedBytes()
} else {
self.state = .finished
self.serve()
}
self.notifyTerminalForTesting()
session.finishTasksAndInvalidate()
}
}
// MARK: AVAssetResourceLoaderDelegate (on `queue`)
func resourceLoader(
_ resourceLoader: AVAssetResourceLoader,
shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest
) -> Bool {
if let terminalError = state.terminalError {
loadingRequest.finishLoading(with: terminalError)
return true
}
guard pending.count < Self.maximumPendingRequests else {
loadingRequest.finishLoading(with: URLError(.resourceUnavailable))
return true
}
if let dataRequest = loadingRequest.dataRequest {
requestLog += "[\(dataRequest.requestedOffset)+\(dataRequest.requestedLength)]"
if requestLog.count > 300 { requestLog = String(requestLog.suffix(300)) }
}
pending.append(loadingRequest)
serve()
return true
}
func resourceLoader(
_ resourceLoader: AVAssetResourceLoader,
didCancel loadingRequest: AVAssetResourceLoadingRequest
) {
pending.removeAll { $0 === loadingRequest }
}
private func serve() {
let isFinished: Bool
switch state {
case .active:
isFinished = false
case .finished:
isFinished = true
case .failed, .cancelled:
return
}
// The logical 1-TiB length remains the raw probe contract. Retained bytes
// are independently bounded by maximumBufferedBytes.
let knownLength: Int64? =
finiteLength
? (contentLength >= 0 ? contentLength : (isFinished ? Int64(buffer.count) : nil))
: Int64(1) << 40
var index = 0
while index < pending.count {
let request = pending[index]
if let info = request.contentInformationRequest {
guard let length = knownLength else {
index += 1
continue
}
info.contentType = "public.enhanced-ac3-audio"
info.contentLength = length
info.isByteRangeAccessSupported = true
if request.dataRequest == nil {
request.finishLoading()
pending.remove(at: index)
continue
}
}
guard let dataRequest = request.dataRequest else {
index += 1
continue
}
let requestedOffset = dataRequest.requestedOffset
let currentOffset = dataRequest.currentOffset
let requestedLength = Int64(dataRequest.requestedLength)
let (end, overflow) = requestedOffset.addingReportingOverflow(requestedLength)
guard requestedOffset >= 0, currentOffset >= requestedOffset, requestedLength >= 0, !overflow else {
request.finishLoading(with: URLError(.badServerResponse))
pending.remove(at: index)
continue
}
let bufferedCount = Int64(buffer.count)
if currentOffset < bufferedCount {
let chunkEnd = min(bufferedCount, end)
guard currentOffset <= chunkEnd,
let start = Int(exactly: currentOffset),
let finish = Int(exactly: chunkEnd)
else {
request.finishLoading(with: URLError(.badServerResponse))
pending.remove(at: index)
continue
}
dataRequest.respond(with: buffer.subdata(in: start..<finish))
}
if dataRequest.currentOffset >= end
|| (isFinished && dataRequest.currentOffset >= bufferedCount)
{
request.finishLoading()
pending.remove(at: index)
continue
}
index += 1
}
}
}
/// Feeds compressed E-AC-3 access units to a bare `AVSampleBufferAudioRenderer`
/// using Apple's flexible enhanced buffering sequence.
///
/// This is deliberately the shortest possible path from file bytes to the
/// system decoder: `AVAssetReader` at `outputSettings: nil` yields the native
/// compressed sample buffers and the native `CMFormatDescription` that
/// AVFoundation itself would use. Nothing else touches the data, so a failure
/// here is a property of the renderer, not of our packetization.
final class AsbarProbe: NSObject {
private static var statusContext = 0
private let source: URL
private let regenerateFormatDescription: Bool
private let sessionMode: AVAudioSession.Mode
private let queue = DispatchQueue(
label: "plezy.atmos.probe.asbar", qos: .userInitiated)
private let lock = NSLock()
private var renderer: AVSampleBufferAudioRenderer?
private var synchronizer: AVSampleBufferRenderSynchronizer?
private var reader: AVAssetReader?
private var output: AVAssetReaderTrackOutput?
private var temporaryFile: URL?
private var download: URLSessionDownloadTask?
private var statusObserved = false
private var cancelled = false
// Guarded by `lock`; read from the method-channel thread.
private var enqueuedSamples = 0
private var nativeDescription = "unknown"
private var usedDescription = "unknown"
private var magicCookieHex = "none"
private var layoutTagText = "none"
private var phase = "starting"
private var failure: String?
init(
source: URL, regenerateFormatDescription: Bool, sessionMode: AVAudioSession.Mode
) {
self.source = source
self.regenerateFormatDescription = regenerateFormatDescription
self.sessionMode = sessionMode
super.init()
}
/// `completion` reports only whether the run could be started.
func start(completion: @escaping (String?) -> Void) {
if source.isFileURL {
queue.async { [weak self] in
guard let self else { return }
let error = self.beginReading(from: self.source)
DispatchQueue.main.async { completion(error) }
}
return
}
// AVAssetReader needs a seekable local asset; stage the source first.
setPhase("downloading")
let task = URLSession.shared.downloadTask(with: source) { [weak self] url, _, error in
guard let self else { return }
guard let url else {
let message = error?.localizedDescription ?? "download failed"
self.fail(message)
DispatchQueue.main.async { completion(message) }
return
}
// The temporary file is removed as soon as this handler returns.
let staged = FileManager.default.temporaryDirectory
.appendingPathComponent("plezy-asbar-\(UUID().uuidString)")
.appendingPathExtension(self.source.pathExtension.isEmpty ? "eac3" : self.source.pathExtension)
do {
try FileManager.default.moveItem(at: url, to: staged)
} catch {
let message = "failed to stage asset: \(error.localizedDescription)"
self.fail(message)
DispatchQueue.main.async { completion(message) }
return
}
self.lock.lock()
self.temporaryFile = staged
let aborted = self.cancelled
self.lock.unlock()
if aborted {
try? FileManager.default.removeItem(at: staged)
return
}
self.queue.async {
let failureMessage = self.beginReading(from: staged)
DispatchQueue.main.async { completion(failureMessage) }
}
}
lock.lock()
download = task
lock.unlock()
task.resume()
}
func cancel() {
lock.lock()
cancelled = true
let task = download
let staged = temporaryFile
download = nil
temporaryFile = nil
lock.unlock()
task?.cancel()
queue.sync {
if statusObserved, let renderer {
renderer.removeObserver(
self, forKeyPath: "status", context: &AsbarProbe.statusContext)
statusObserved = false
}
renderer?.stopRequestingMediaData()
renderer?.flush()
synchronizer?.rate = 0
if let renderer, let synchronizer {
synchronizer.removeRenderer(renderer, at: .zero, completionHandler: nil)
}
reader?.cancelReading()
reader = nil
output = nil
renderer = nil
synchronizer = nil
}
if let staged { try? FileManager.default.removeItem(at: staged) }
setPhase("cancelled")
}
/// Runs on `queue`. Returns a message on failure.
private func beginReading(from url: URL) -> String? {
lock.lock()
let aborted = cancelled
lock.unlock()
if aborted { return nil }
let asset = AVURLAsset(url: url)
guard let track = asset.tracks(withMediaType: .audio).first else {
let message = "no audio track in \(url.lastPathComponent)"
fail(message)
return message
}
guard
let nativeFormat = (track.formatDescriptions as? [CMFormatDescription])?.first
else {
let message = "audio track has no format description"
fail(message)
return message
}
describe(nativeFormat)
let assetReader: AVAssetReader
do {
assetReader = try AVAssetReader(asset: asset)
} catch {
let message = "AVAssetReader init failed: \(error.localizedDescription)"
fail(message)
return message
}
// `nil` output settings is what keeps the samples compressed.
let trackOutput = AVAssetReaderTrackOutput(track: track, outputSettings: nil)
trackOutput.alwaysCopiesSampleData = false
guard assetReader.canAdd(trackOutput) else {
let message = "AVAssetReader rejected a compressed output"
fail(message)
return message
}
assetReader.add(trackOutput)
guard assetReader.startReading() else {
let message =
"AVAssetReader.startReading failed: \(assetReader.error?.localizedDescription ?? "unknown")"
fail(message)
return message
}
var substituteFormat: CMFormatDescription?
if regenerateFormatDescription {
let rebuilt = Self.regenerate(from: nativeFormat)
guard let format = rebuilt.format else {
let message = rebuilt.error ?? "format regeneration failed"
fail(message)
return message
}
substituteFormat = format
describeUsed(format)
} else {
describeUsed(nativeFormat)
}
if let message = configureSession() { return message }
let renderer = AVSampleBufferAudioRenderer()
let synchronizer = AVSampleBufferRenderSynchronizer()
synchronizer.addRenderer(renderer)
// Deliberately NOT disabling delaysRateChangeUntilHasSufficientMediaData:
// the reliable-start preroll is part of the sequence under test.
renderer.addObserver(
self, forKeyPath: "status", options: [.new], context: &AsbarProbe.statusContext)
self.renderer = renderer
self.synchronizer = synchronizer
self.reader = assetReader
self.output = trackOutput
statusObserved = true
setPhase("feeding")
// Apple's order: request media first, start the clock after.
renderer.requestMediaDataWhenReady(on: queue) { [weak self] in
self?.pump(substituteFormat: substituteFormat)
}
synchronizer.rate = 1
return nil
}
/// Runs on `queue`.
private func pump(substituteFormat: CMFormatDescription?) {
guard let renderer, let output, let reader else { return }
while renderer.isReadyForMoreMediaData {
guard reader.status == .reading, let sample = output.copyNextSampleBuffer() else {
renderer.stopRequestingMediaData()
setPhase(reader.status == .completed ? "finished" : "stopped(\(reader.status.rawValue))")
return
}
let enqueued: CMSampleBuffer
if let substituteFormat {
guard let rewrapped = Self.rewrap(sample, with: substituteFormat) else {
fail("failed to rewrap a sample with the generated description")
renderer.stopRequestingMediaData()
return
}
enqueued = rewrapped
} else {
enqueued = sample
}
renderer.enqueue(enqueued)
lock.lock()
enqueuedSamples += 1
lock.unlock()
}
}
private func configureSession() -> String? {
#if os(iOS) || os(tvOS)
let session = AVAudioSession.sharedInstance()
do {
try session.setCategory(
.playback, mode: sessionMode, policy: .longFormAudio, options: [])
} catch {
let message = "long-form session profile rejected: \(error.localizedDescription)"
fail(message)
return message
}
if #available(iOS 15.0, tvOS 15.0, *) {
try? session.setSupportsMultichannelContent(true)
}
do {
try session.setActive(true)
} catch {
let message = "session activation failed: \(error.localizedDescription)"
fail(message)
return message
}
#endif
return nil
}
override func observeValue(
forKeyPath keyPath: String?, of object: Any?,
change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?
) {
guard context == &AsbarProbe.statusContext else {
super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
return
}
guard let renderer = object as? AVSampleBufferAudioRenderer,
renderer.status == .failed
else { return }
let error = renderer.error as NSError?
fail(
"renderer failed: \(error?.domain ?? "-"):\(error?.code ?? 0) "
+ (error?.localizedDescription ?? "unknown"))
}
func statusSnapshot() -> [String: Any] {
lock.lock()
defer { lock.unlock() }
var out: [String: Any] = [
"state": phase,
"asbarEnqueuedSamples": enqueuedSamples,
"asbarNativeFormat": nativeDescription,
"asbarUsedFormat": usedDescription,
"asbarMagicCookie": magicCookieHex,
"asbarChannelLayout": layoutTagText,
]
if let failure { out["error"] = failure }
if let renderer {
out["asbarRendererStatus"] =
switch renderer.status {
case .unknown: "unknown"
case .rendering: "rendering"
case .failed: "failed"
@unknown default: "unrecognized"
}
}
if let synchronizer {
out["currentTime"] = CMTimeGetSeconds(synchronizer.currentTime())
}
return out
}
// MARK: - Format description helpers
private func describe(_ format: CMFormatDescription) {
let text = Self.summarize(format)
lock.lock()
nativeDescription = text.summary
magicCookieHex = text.cookie
layoutTagText = text.layout
lock.unlock()
}
private func describeUsed(_ format: CMFormatDescription) {
let text = Self.summarize(format)
lock.lock()
usedDescription = text.summary
lock.unlock()
}
private static func summarize(
_ format: CMFormatDescription
) -> (summary: String, cookie: String, layout: String) {
var summary = "subType=\(fourCCText(CMFormatDescriptionGetMediaSubType(format)))"
if let asbd = CMAudioFormatDescriptionGetStreamBasicDescription(format)?.pointee {
summary +=
String(
format: " rate=%.0f ch=%u framesPerPacket=%u", asbd.mSampleRate,
asbd.mChannelsPerFrame, asbd.mFramesPerPacket)
}
var cookieSize = 0
var cookie = "none"
if let bytes = CMAudioFormatDescriptionGetMagicCookie(format, sizeOut: &cookieSize),
cookieSize > 0
{
cookie = Data(bytes: bytes, count: cookieSize).map { String(format: "%02x", $0) }
.joined()
}
var layoutSize = 0
var layout = "none"
if let acl = CMAudioFormatDescriptionGetChannelLayout(format, sizeOut: &layoutSize) {
layout = String(format: "0x%08x", acl.pointee.mChannelLayoutTag)
}
return (summary, cookie, layout)
}
private static func fourCCText(_ code: FourCharCode) -> String {
let bytes = [
UInt8((code >> 24) & 0xFF), UInt8((code >> 16) & 0xFF),
UInt8((code >> 8) & 0xFF), UInt8(code & 0xFF),
]
return String(bytes: bytes, encoding: .ascii) ?? String(code)
}
/// Rebuild the description the way the mpv AO does: same ASBD and magic
/// cookie, but constructed by us rather than parsed out of the container,
/// with the Dolby-order 5.1 layout attached.
private static func regenerate(
from native: CMFormatDescription
) -> (format: CMFormatDescription?, error: String?) {
guard var asbd = CMAudioFormatDescriptionGetStreamBasicDescription(native)?.pointee else {
return (nil, "native description has no ASBD")
}
var cookieSize = 0
var cookieBytes: [UInt8] = []
if let bytes = CMAudioFormatDescriptionGetMagicCookie(native, sizeOut: &cookieSize),
cookieSize > 0
{
cookieBytes = Array(UnsafeRawBufferPointer(start: bytes, count: cookieSize))
}
var layout = AudioChannelLayout()
layout.mChannelLayoutTag =
asbd.mChannelsPerFrame == 8
? kAudioChannelLayoutTag_MPEG_7_1_C : kAudioChannelLayoutTag_MPEG_5_1_C
var rebuilt: CMFormatDescription?
let status = cookieBytes.withUnsafeBytes { cookie -> OSStatus in
CMAudioFormatDescriptionCreate(
allocator: kCFAllocatorDefault,
asbd: &asbd,
layoutSize: MemoryLayout<AudioChannelLayout>.size,
layout: &layout,
magicCookieSize: cookie.count,
magicCookie: cookie.baseAddress,
extensions: nil,
formatDescriptionOut: &rebuilt)
}
guard status == noErr, let rebuilt else {
return (nil, "CMAudioFormatDescriptionCreate failed (\(status))")
}
return (rebuilt, nil)
}
private static func rewrap(
_ sample: CMSampleBuffer, with format: CMFormatDescription
) -> CMSampleBuffer? {
guard let blockBuffer = CMSampleBufferGetDataBuffer(sample) else { return nil }
var timing = CMSampleTimingInfo()
guard CMSampleBufferGetSampleTimingInfo(sample, at: 0, timingInfoOut: &timing) == noErr
else { return nil }
var sizeOut = 0
let sizeStatus = CMSampleBufferGetSampleSizeArray(
sample, entryCount: 0, arrayToFill: nil, entriesNeededOut: &sizeOut)
var sizes = [Int](repeating: 0, count: max(sizeOut, 1))
if sizeStatus == noErr, sizeOut > 0 {
_ = CMSampleBufferGetSampleSizeArray(
sample, entryCount: sizeOut, arrayToFill: &sizes, entriesNeededOut: nil)
} else {
sizes[0] = CMBlockBufferGetDataLength(blockBuffer)
}
var rewrapped: CMSampleBuffer?
let status = CMSampleBufferCreateReady(
allocator: kCFAllocatorDefault,
dataBuffer: blockBuffer,
formatDescription: format,
sampleCount: CMSampleBufferGetNumSamples(sample),
sampleTimingEntryCount: 1,
sampleTimingArray: &timing,
sampleSizeEntryCount: sizes.count,
sampleSizeArray: &sizes,
sampleBufferOut: &rewrapped)
return status == noErr ? rewrapped : nil
}
// MARK: - State
private func setPhase(_ value: String) {
lock.lock()
phase = value
lock.unlock()
}
private func fail(_ message: String) {
lock.lock()
if failure == nil { failure = message }
phase = "failed"
lock.unlock()
}
}