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
13 changes: 12 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,18 @@ the public-API contract.

## [Unreleased]

_Nothing yet._
### Fixed

- **`LiveTelemetry`'s two bitrate fields measure the media played, not the bytes transferred** (#514).
Both were metered from the reader's transfer counter, which parts from playback on every route that
reads ahead: a 20 Mbps VOD stream read about 35 Mbps (prefetch and seek re-fetches counted as they
arrived), and a paused live session kept draining its origin into the DVR window while the divisor
stood still, so its average climbed for as long as the pause ran. The pumps now record the played
video and audio packets by presentation time, and the sampler charges what the playhead crossed:
`instantBitrateMbps` over about the last 10 s of playback, `averageBitrateMbps` over the session.
Both stand still through a pause on every route, live included, and a seek charges nothing for the
span it jumps. The transfer remains `networkThroughputMbps`. The remote-HLS bypass is unchanged (it
reports the variant's declared rates).

## [7.22.0] - 2026-09-28

Expand Down
6 changes: 6 additions & 0 deletions Sources/AetherEngine/AetherEngine+Diagnostics.swift
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,12 @@ extension AetherEngine {
native: nativeVideoSession?.demuxerBytesFetched)
}

/// AE#514: the played-media ledger of whichever path owns the pump, same precedence as the byte
/// counter above. nil before a pipeline exists and on the paths with no pump to feed one.
var playedMediaLedger: PlayedMediaLedger? {
softwareHost?.playedMediaLedger ?? nativeVideoSession?.playedMediaLedger
}

/// #306: the precedence itself, as a function, so the ordering is assertable without a live
/// session on either path. Software first: only one of the two exists per session, and a
/// software session's counter is the one that used to be dropped.
Expand Down
22 changes: 13 additions & 9 deletions Sources/AetherEngine/Diagnostics/LiveTelemetry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,20 @@ 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.
/// Rate of the media the playhead crossed over about the last 10 s of playback: the bytes of the
/// played video and audio packets presented in that span, over the media seconds it covered
/// (AE#514). Not the transfer, which is `networkThroughputMbps`: a read-ahead, a re-fetch after a
/// seek and a live source draining into its DVR window while paused all move bytes that nobody
/// played. Stands still through a pause, on live too. nil until the playhead has crossed a span the
/// session has bytes for. The remote-HLS bypass has no demuxer, 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). 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.
/// Lifetime mean of the same quotient: every played byte over every media second played this
/// session (AE#514). A seek charges nothing for the span it jumped, a pause and the tail after
/// end-of-media charge nothing at all, and the prefetch counts once it is played, not when it
/// arrives. On the remote-HLS bypass it is the variant's declared AVERAGE-BANDWIDTH, or BANDWIDTH
/// where the master omits it. nil until something was played: like `networkThroughputMbps`, a value
/// that cannot be measured yet is a gap, never a zero.
public let averageBitrateMbps: Double?
/// Live bitrate of the audio bridge's encoded output, or nil when no bridge is active (stream-copy /
/// AVPlayer-native path) or before the first delta. Measured from the bridge's cumulative output-byte
Expand Down
113 changes: 52 additions & 61 deletions Sources/AetherEngine/Diagnostics/LiveTelemetrySampler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -118,16 +118,16 @@ final class LiveTelemetrySampler {
private var lastDemuxerBytes: Int64 = 0
private var lastBridgeBytes: Int64 = 0
private var lastFramesEnqueued: Int = 0
private var sessionStartBytes: Int64 = 0

/// AE#514: wall-clock seconds this session spent in a phase that consumes media, accumulated one
/// tick at a time. The divisor of the lifetime average, in place of the wall clock since start.
/// Readable so a test can pin that the tick charges it, not only that the fold over it is right.
private(set) var activeSeconds: Double = 0
/// AE#514: both bitrate fields, metered over what the playhead crossed. Readable so a test can pin
/// that the tick feeds it, not only that the meter is right.
private(set) var playedMeter = PlayedBitrateMeter()

/// Timestamp of the previous tick, anchoring the delta charged above. nil until the first tick,
/// which therefore charges nothing: that is also the tick which seeds `sessionStartBytes`, so the
/// numerator and the divisor start counting at the same instant.
/// The ledger the meter was last advanced against. A different one is a different session, whose
/// playhead and bytes have nothing to do with the old meter's.
private var meterLedger: ObjectIdentifier?

/// Timestamp of the previous tick: the wall time the playhead had to cover its step in.
private var lastTickTime: Date?

/// [LagDiag] tick-over-tick state (#93 post-recovery lag diagnosis).
Expand Down Expand Up @@ -160,9 +160,9 @@ final class LiveTelemetrySampler {
lastDemuxerBytes = engine?.demuxerBytesFetched ?? 0
lastBridgeBytes = engine?.audioBridgeOutputBytesLifetime ?? 0
lastFramesEnqueued = 0
activeSeconds = 0
playedMeter = PlayedBitrateMeter()
meterLedger = nil
lastTickTime = nil
sessionStartBytes = 0
lagLastClock = nil
lagLastDroppedSum = 0
eomParkFrozenTicks = 0
Expand Down Expand Up @@ -231,78 +231,48 @@ final class LiveTelemetrySampler {
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
/// permanently wrong in one direction. `demuxerBytesFetched` stops advancing once the forward
/// buffer is full, the wall clock does not, so a 2.8 Mbps file left paused for three minutes
/// reported 0.4 Mbps and afterwards climbed back only asymptotically: the paused seconds never
/// left the divisor again. Same shape at the end of a source, where the sampler keeps ticking
/// until the host tears the session down.
///
/// The line is drawn at "is the session consuming media", not at "is the picture moving". A seek
/// and a rebuffer are where the bytes arrive hardest, and a stalled reader is a real part of what
/// this session averaged, so charging their seconds is what keeps the quotient equal to the rate
/// the session pulled at. Only a pause and the three phases with no live session behind them
/// stand outside it.
static func chargesActiveTime(_ phase: PlaybackPhase) -> Bool {
switch phase {
case .paused, .idle, .ended, .error:
return false
case .loading, .playing, .seeking, .rebuffering, .stalled:
return true
/// AE#514: where the playhead stands on the axis the played-media ledger is keyed on, the source
/// axis the pumps read packets on. Native folds AVPlayer's clock back with the producer's shift
/// (`source_pts - playlistShiftSeconds`) rather than reading `sourceTime`, which publishes the item
/// axis on a sequential origin (#368). nil where there is no ledger-fed pipeline to read it off.
nonisolated static func ledgerPlayhead(backend: PlaybackBackend, nativeClock: Double?, playlistShift: Double,
softwareSourceClock: Double?) -> Double? {
switch backend {
case .native:
guard let nativeClock, nativeClock.isFinite else { return nil }
return nativeClock + playlistShift
case .software:
return softwareSourceClock
case .aether, .none, .audio:
return nil
}
}

/// Lifetime mean of the bytes the session fetched over the seconds it spent consuming them (AE#514).
///
/// nil rather than zero until both halves are measurable, mirroring `observedTransferMbps`: a host
/// cannot tell a confident 0.00 Mbps from a session that has not fetched anything yet. The old
/// wall-clock form published exactly that on its first tick, where the lifetime delta is zero by
/// construction because the same tick seeds the baseline it then subtracts.
static func averageBitrateMbps(lifetimeBytes: Int64, activeSeconds: Double) -> Double? {
guard activeSeconds > 0, lifetimeBytes > 0 else { return nil }
return Double(lifetimeBytes) * 8.0 / activeSeconds / 1_000_000.0
}

private func tick() async {
guard let engine = engine else { return }

// AE#514: this tick's wall-clock second goes into the lifetime average's divisor only if the
// session was consuming media in it. See `chargesActiveTime` for where the line runs.
let tickTime = Date()
if let previous = lastTickTime, Self.chargesActiveTime(engine.playbackPhase) {
activeSeconds += tickTime.timeIntervalSince(previous)
}
let wallSeconds = lastTickTime.map { tickTime.timeIntervalSince($0) } ?? 0
lastTickTime = tickTime

let route = engine.videoRoute

// Instant + average bitrate from demuxer byte counters (loopback and SW; the bypass overrides below)
// What the reader pulled from the source. Transfer, not media rate: it feeds the network fields
// and [LagDiag], never the bitrate fields (AE#514, see `playedMeter`).
let demuxerBytes = engine.demuxerBytesFetched
let bytesThisTick = max(0, demuxerBytes - lastDemuxerBytes)
lastDemuxerBytes = demuxerBytes
if sessionStartBytes == 0 { sessionStartBytes = demuxerBytes }
byteWindow.push(bytesThisTick)

var instantBitrateMbps: Double?
if byteWindow.count >= 2 {
let totalBytes = byteWindow.sum
let seconds = Double(byteWindow.count)
instantBitrateMbps = Double(totalBytes) * 8.0 / seconds / 1_000_000.0
} else {
instantBitrateMbps = nil
}
let transferMbps: Double? = byteWindow.count >= 2
? Double(byteWindow.sum) * 8.0 / Double(byteWindow.count) / 1_000_000.0
: nil

let observedTransferMbps = Self.observedTransferMbps(
windowBytes: byteWindow.sum,
activeSeconds: byteWindow.activeCount,
samples: byteWindow.count)

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

// Live audio-bridge output bitrate from the bridge's cumulative encoded-byte counter. 0 on the
// stream-copy / AVPlayer-native / video-only paths (no bridge), which surfaces as nil.
let bridgeBytes = engine.audioBridgeOutputBytesLifetime
Expand Down Expand Up @@ -420,6 +390,27 @@ final class LiveTelemetrySampler {
accumulatedFrameDelaySeconds = nil
}

// AE#514: the bitrate fields are the media the playhead crossed, never the transfer.
var instantBitrateMbps: Double?
var averageBitrateMbps: Double?
if let ledger = engine.playedMediaLedger {
let identity = ObjectIdentifier(ledger)
if meterLedger != identity {
meterLedger = identity
playedMeter = PlayedBitrateMeter()
}
playedMeter.advance(
to: Self.ledgerPlayhead(
backend: engine.playbackBackend,
nativeClock: nativeReadings?.currentTimeSeconds,
playlistShift: engine.playlistShiftSeconds,
softwareSourceClock: engine.softwareHost?.sourceClockSeconds),
wallSeconds: wallSeconds,
ledger: ledger)
instantBitrateMbps = playedMeter.instantMbps
averageBitrateMbps = playedMeter.averageMbps
}

// Remote-HLS bypass: no demuxer, so both rates are what the playing variant declares.
if Self.bitrateCounter(for: route) == .declaredVariant {
let declared = Self.declaredBitrates(
Expand All @@ -434,7 +425,7 @@ final class LiveTelemetrySampler {
engine.extractorYieldState.setForwardBuffer(forwardBufferSeconds)

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