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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 29 additions & 2 deletions Sources/AetherEngine/AetherEngine+Loading.swift
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,29 @@ extension AetherEngine {
self.applyRemoteHLSDisplayCriteria(format: fmt, options: options)
}
.store(in: &nativeCancellables)
// What was DELIVERED, which on a capped transcode is not what the host's library holds: without it
// a stats panel fell back to the original file's 3840x2160 for a 1280x720 stream. Its own sink,
// because a later read can refine the description without changing the dynamic range.
// dropFirst on both: a reused host replays the outgoing item's reading on subscribe, before load resets it.
host.$detectedVideoDescription
.dropFirst()
.compactMap { $0 }
.sink { [weak self] video in
guard let self else { return }
self.publishRemoteHLSVideoDescription(video)
}
.store(in: &nativeCancellables)
// No probe lists this route's audio, so AVPlayer's own tracks are the list. Informational: AVPlayer
// owns the audio selection here, and `selectAudioTrack` refuses the route rather than reload it.
host.$detectedAudioTracks
.dropFirst()
.sink { [weak self] readings in
guard let self else { return }
let (tracks, active) = RemoteHLSStreamDescription.audioTracks(readings)
self.audioTracks = tracks
self.activeAudioTrackIndex = active
}
.store(in: &nativeCancellables)
// #168 follow-up: an advertised video rendition that never builds an item track means HEVC carried
// in MPEG-TS segments, which AVFoundation's HLS demuxer does not support (audio-only, black). The
// loopback ingest remuxes TS to fMP4 and plays the same stream, so reroute there transparently.
Expand Down Expand Up @@ -546,7 +569,9 @@ extension AetherEngine {
// #334: the ceiling on silence this path never had. AVPlayer's "gave up" covers an
// origin that stops answering; it does not cover one that answers everything while
// AVFoundation builds no track, where nothing terminal is ever published.
readinessDeadline: RemoteHLSReadinessDeadline.defaultBudgetSeconds))
readinessDeadline: RemoteHLSReadinessDeadline.defaultBudgetSeconds,
// No probe lists this route's audio, so the item's own tracks are the list.
readsBackAudioTracks: true))

attachRemoteHLSCueClock(host: host, expectedGeneration: bypassGeneration)

Expand All @@ -562,7 +587,9 @@ extension AetherEngine {
host.play()
}
startMemoryProbe()
// No startLiveTelemetrySampler: all sampler counters read the loopback pipeline (demuxer / producer / cache / server), none of which exists on this bypass.
// The sampler reads AVPlayer's access log on this route: both bitrates, network rate and transfer,
// dropped frames and forward buffer. The loopback counters (producer, muxer, server) read zero.
startLiveTelemetrySampler()
}

/// AE#616: on this bypass `sourceTime` would otherwise be item time, which an origin that restarts
Expand Down
35 changes: 32 additions & 3 deletions Sources/AetherEngine/AetherEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -597,9 +597,13 @@ public final class AetherEngine: ObservableObject {
@Published public internal(set) var sourceVideoCodecName: String? = nil

/// AE#658: the source video stream's pixel format, bit depth, colour description and profile as the
/// container and the probe's decoder declared them, nil before load, on sources without video, and on
/// the probe-free native HLS bypass. Companion to `decodedVideoFormat`, which is what the engine's own
/// decoder actually produced.
/// container and the probe's decoder declared them, nil before load and on sources without video.
/// Companion to `decodedVideoFormat`, which is what the engine's own decoder actually produced.
///
/// On the probe-free native HLS bypass it is read back from AVPlayer's item video track once that
/// resolves: colour from the format description's extensions, profile, bit depth and pixel format from
/// its avcC / hvcC record where it carries one (nil otherwise, AV1 and VP9 included). That is the
/// DELIVERED stream, which under a server-side transcode is not the file the host's library holds.
@Published public internal(set) var sourceVideoStreamFormat: VideoStreamFormat? = nil

/// AE#658: the format the engine's software decoder produced and the display buffer it went into,
Expand Down Expand Up @@ -2162,8 +2166,20 @@ public final class AetherEngine: ObservableObject {

/// Source video dimensions from the probe. Used as a bitmap-subtitle canvas fallback before the first PCS
/// is parsed. 0 before load or when source has no video (AetherEngine#28). Also available in SourceProbe.
/// On the probe-free native HLS bypass they are the delivered stream's, read back from AVPlayer's item
/// track once it resolves, so a capped transcode reports the resolution it is actually playing.
@Published public private(set) var sourceVideoWidth: Int32 = 0
@Published public private(set) var sourceVideoHeight: Int32 = 0

/// The remote-HLS bypass's stand-in for the probe: what AVPlayer parsed of the delivered video.
func publishRemoteHLSVideoDescription(_ video: RemoteHLSStreamDescription.Video) {
if video.width > 0, video.height > 0 {
sourceVideoWidth = video.width
sourceVideoHeight = video.height
}
if let codec = video.codecName { sourceVideoCodecName = codec }
sourceVideoStreamFormat = video.format
}
/// Display-width multiplier for non-square source pixels: `sourceVideoWidth * this` is the width
/// the picture presents at. 1 before load, on square-pixel sources, and whenever the declared
/// ratio is one the engine refuses to believe (#290), so it is never a number the picture
Expand Down Expand Up @@ -6653,7 +6669,20 @@ public final class AetherEngine: ObservableObject {
/// expects ~0.5-1 s black frame (AVPlayer.replaceCurrentItem tears the surface). Display-criteria handshake
/// is suppressed (video unchanged). `index` is the container stream index (TrackInfo.id). No-op if
/// out-of-range, pointing at a non-audio stream, or already active.
///
/// Not available on `VideoRoute.remoteBypass`: there `audioTracks` lists what AVPlayer built and is
/// informational, the selection belongs to AVFoundation, and a pick is logged and ignored rather than
/// turned into a reload the bypass has no audio override for. A host that wants another language on
/// that route loads a URL that carries it.
public func selectAudioTrack(index: Int) {
if videoRoute == .remoteBypass {
EngineLog.emit(
"[AetherEngine] selectAudioTrack(\(index)) ignored: AVPlayer owns the audio selection on "
+ "the remote-HLS bypass; audioTracks is informational there",
category: .engine
)
return
}
// Forward-only custom sources (incl. live HLS-ingest) can't rewind; rebuilding would re-consume a
// drained FIFO and stall silently. Logged so a picker that does nothing is explainable.
if isCustomSource && !customSourceIsSeekable {
Expand Down
6 changes: 5 additions & 1 deletion Sources/AetherEngine/Diagnostics/LiveTelemetry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,13 @@ import Foundation
/// interpret a sub-second decoded queue as the size of the compressed packet cache.
public struct LiveTelemetry: Equatable, Sendable {
// Enthusiast section
/// Mean rate over the last 10 s of the bytes the session pulled from its source, from the demuxer's
/// count on the loopback and software paths. nil until the window spans two ticks. The remote-HLS
/// bypass has no demuxer, and what AVPlayer transferred there is buffer fill at link speed rather than
/// the stream's rate, so on that route this is the playing variant's declared BANDWIDTH.
public let instantBitrateMbps: Double?
/// Lifetime mean rate of the session, over the seconds it spent consuming media rather than over
/// wall-clock seconds since it started (AE#514). A pause therefore leaves this value standing
/// wall-clock seconds since it started (AE#514). Metered from the same counter as `instantBitrateMbps`; on the remote-HLS bypass it is the variant's declared AVERAGE-BANDWIDTH, or BANDWIDTH where the master omits it. A pause therefore leaves this value standing
/// still instead of dragging it toward zero for as long as the pause lasts, and so does the tail
/// after end-of-media. nil until the session has both accrued active time and fetched something:
/// like `networkThroughputMbps`, a value that cannot be measured yet is a gap, never a zero.
Expand Down
61 changes: 56 additions & 5 deletions Sources/AetherEngine/Diagnostics/LiveTelemetrySampler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ struct NativeAVFReadings: Sendable {
var contiguousForwardBufferSeconds: Double? = nil
/// Sum over all access-log events, for the [LagDiag] tick-over-tick drop delta.
var droppedFramesLifetimeSum: Int = 0
/// The current variant's declared BANDWIDTH and AVERAGE-BANDWIDTH, in bits per second, from the
/// latest access-log entry. The log reports an undeclared value as negative.
var indicatedBitrate: Double = -1
var indicatedAverageBitrate: Double = -1
var currentTimeSeconds: Double = .nan
var timeControlStatus: AVPlayer.TimeControlStatus = .paused
var rate: Float = 0
Expand Down Expand Up @@ -196,6 +200,37 @@ final class LiveTelemetrySampler {
return Double(windowBytes) * 8.0 / Double(activeSeconds) / 1_000_000.0
}

/// Which cumulative counter the two bitrate fields are metered from.
enum BitrateCounter: Equatable {
/// Bytes the engine's demuxer pulled from the source (loopback and software).
case demuxer
/// The bitrate the playing variant declares, from AVPlayer's access log. The remote-HLS bypass has no
/// demuxer, and what AVPlayer transferred is not the stream's rate: a buffer filling after a start or
/// a seek pulls at link speed (22.6 Mbps measured for a 3.7 Mbps transcode). The transfer stays in the
/// network fields.
case declaredVariant
}

nonisolated static func bitrateCounter(for route: VideoRoute) -> BitrateCounter {
route == .remoteBypass ? .declaredVariant : .demuxer
}

/// Both bitrate fields from the access log's declarations, in Mbps: instant is BANDWIDTH, average is
/// AVERAGE-BANDWIDTH, falling back to BANDWIDTH where the master omits it. Undeclared reads nil.
nonisolated static func declaredBitrates(indicated: Double, indicatedAverage: Double)
-> (instant: Double?, average: Double?) {
func mbps(_ bps: Double) -> Double? { bps.isFinite && bps > 0 ? bps / 1_000_000.0 : nil }
let instant = mbps(indicated)
return (instant, mbps(indicatedAverage) ?? instant)
}

/// Whether the native branch has the loopback pipeline behind it. On the bypass the producer's A/V gap
/// does not exist (its getter reads 0, which would publish as a measured zero), and the #169 tail-park
/// synthesis stays with the route whose EXTINF overshoot it was built for.
nonisolated static func readsLoopbackPipeline(_ route: VideoRoute) -> Bool {
route != .remoteBypass
}

/// AE#514: whether a tick's second belongs in the lifetime average's divisor.
///
/// That average used to divide by wall-clock time since the session started, which made a pause
Expand Down Expand Up @@ -241,14 +276,16 @@ final class LiveTelemetrySampler {
}
lastTickTime = tickTime

// Instant + average bitrate from demuxer byte counters (both native and SW paths)
let route = engine.videoRoute

// Instant + average bitrate from demuxer byte counters (loopback and SW; the bypass overrides below)
let demuxerBytes = engine.demuxerBytesFetched
let bytesThisTick = max(0, demuxerBytes - lastDemuxerBytes)
lastDemuxerBytes = demuxerBytes
if sessionStartBytes == 0 { sessionStartBytes = demuxerBytes }
byteWindow.push(bytesThisTick)

let instantBitrateMbps: Double?
var instantBitrateMbps: Double?
if byteWindow.count >= 2 {
let totalBytes = byteWindow.sum
let seconds = Double(byteWindow.count)
Expand All @@ -262,7 +299,7 @@ final class LiveTelemetrySampler {
activeSeconds: byteWindow.activeCount,
samples: byteWindow.count)

let averageBitrateMbps = Self.averageBitrateMbps(
var averageBitrateMbps = Self.averageBitrateMbps(
lifetimeBytes: max(0, demuxerBytes - sessionStartBytes),
activeSeconds: activeSeconds)

Expand Down Expand Up @@ -298,7 +335,8 @@ final class LiveTelemetrySampler {
observedFps = nil
displayCushionSeconds = nil
accumulatedFrameDelaySeconds = nil
avSyncGapMs = engine.lastAVGapMs // HLSSegmentProducer audio-gate-open vs video-gate-open (native path only)
// HLSSegmentProducer audio-gate-open vs video-gate-open, loopback only.
avSyncGapMs = Self.readsLoopbackPipeline(route) ? engine.lastAVGapMs : nil
if let player = engine.currentAVPlayer, let item = player.currentItem {
var readings = await readNativeOffMain(player: player, item: item)
// stop() may have cancelled this tick, or a reload seam may have swapped the
Expand Down Expand Up @@ -382,13 +420,24 @@ final class LiveTelemetrySampler {
accumulatedFrameDelaySeconds = nil
}

// Remote-HLS bypass: no demuxer, so both rates are what the playing variant declares.
if Self.bitrateCounter(for: route) == .declaredVariant {
let declared = Self.declaredBitrates(
indicated: nativeReadings?.indicatedBitrate ?? -1,
indicatedAverage: nativeReadings?.indicatedAverageBitrate ?? -1)
instantBitrateMbps = declared.instant
averageBitrateMbps = declared.average
}

// Feed the extractor yield gate (#93 startup): nil on non-native paths keeps the
// gate conservative there, but those paths have no active session to gate anyway.
engine.extractorYieldState.setForwardBuffer(forwardBufferSeconds)

if let readings = nativeReadings {
emitLagDiag(engine: engine, readings: readings, netMbps: instantBitrateMbps)
evaluateEndOfMediaPark(engine: engine, readings: readings)
if Self.readsLoopbackPipeline(route) {
evaluateEndOfMediaPark(engine: engine, readings: readings)
}
}

let softwareCache = engine.softwarePacketCacheSnapshot
Expand Down Expand Up @@ -554,6 +603,8 @@ final class LiveTelemetrySampler {
let observed = event.observedBitrate
readings.networkThroughputMbps = observed.isFinite && observed > 0
? observed / 1_000_000.0 : nil
readings.indicatedBitrate = event.indicatedBitrate
readings.indicatedAverageBitrate = event.indicatedAverageBitrate
}
// AE#443: the counters are not. They are totals PER ENTRY, and AVFoundation opens a new entry
// whenever the playback session changes under it, so reading `.last` publishes a number that
Expand Down
Loading
Loading