Skip to content
Open
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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,20 @@ the public-API contract.
MacBook Air M1 SDR panel a Dolby Vision P8.1 master with and without `SUPPLEMENTAL-CODECS`, a
High-tier 3832x1600 PQ master and an audio switch to AC-3 on each were all accepted. The item still
falls back, later titles are still offered the master, and the log now says so. tvOS is unchanged.
- **A software live session reports its edge rebuffer as `.rebuffering`.** When the source paused, the
live feeder parked the synchronizer to refill but kept that to itself: `playbackPhase` stayed
`.playing` over a standing picture until the reader's own stall timeout promoted the source to
`.stalled`, 20 s later. A host app watching the clock reloaded the session after 2 s, and each fresh
join landed the lead behind the same frozen burst, so the viewer saw the last four seconds again,
three times over, before the feed came back. `SoftwarePlaybackHost.isRebuffering` now follows the
pumps' hold (the live feeder's dry ring and the on-demand loop's exhausted audio lead) and the
engine folds it into `isBuffering`, as it does the audio host's wait.
- **A software live session catches up after a pause whose backlog arrives at once.** A source that
resumes by handing over everything it missed left the picture the length of the pause behind the
newest media, and nothing on that path ever caught up. The feeder now watches a resume with the
join's own rule and, once the backlog has landed, moves the picture up to the join lead behind the
newest media through the engine's live rejoin seek; a source that skipped what it missed is left
where it resumed.

## [7.47.0] - 2026-10-23

Expand Down
24 changes: 24 additions & 0 deletions Sources/AetherEngine/AetherEngine+Loading.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2207,6 +2207,30 @@ extension AetherEngine {
self?.clock.sourceTime = value
}
.store(in: &softwareCancellables)
// The pumps pause the synchronizer to refill when the ring runs dry at the live edge (#107) or
// the audio lead is exhausted on demand. Folded into `isBuffering` exactly as the audio host's
// wait is, so `playbackPhase` reads `.rebuffering` from the moment the clock stops. Before, the
// phase stayed `.playing` over a standing picture until the reader's own stall timeout promoted
// it to `.stalled`, 20 s later on a live source that merely paused, and a host app watching the
// clock reloaded the session in the meantime. Gated on the engine's own `.playing`: a paused
// engine refilling stays `.paused`.
host.$isRebuffering
.sink { [weak self] rebuffering in
guard let self else { return }
if case .error = self.state { return }
self.isBuffering = self.state == .playing && rebuffering
}
.store(in: &softwareCancellables)
// The feeder's catch-up after an edge rebuffer that resumed behind a backlog: the engine's own
// rejoin, not a scrub, so it is not bound by the scrubber's contract and lands like the AE#446
// outage swap does.
host.liveResumeCatchUp
.sink { [weak self] target in
self?.hop(for: generation) { [weak self] in
await self?.seek(to: target, origin: .liveRejoin)
}
}
.store(in: &softwareCancellables)
// AE#440: this path publishes no transport status to latch a roll from, and all of its
// transport flows through the engine's own play()/pause(), so `state` IS its motion signal.
// Crediting the roll here keeps `playbackPhase` on the software path exactly as it was.
Expand Down
97 changes: 93 additions & 4 deletions Sources/AetherEngine/Native/SoftwarePlaybackHost.swift
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,19 @@ final class SoftwarePlaybackHost {
/// publishes this as `clock.sourceTime` so the subtitle overlay drainer scans the
/// packet store on the right axis.
@Published private(set) var sourceClockSeconds: Double = 0
/// The pump has paused the synchronizer to refill: the live feeder at a dry ring (#107, a
/// source that paused or delivers below real time) or the on-demand loop with its audio lead
/// exhausted. The engine folds it into `isBuffering`, so `playbackPhase` reads `.rebuffering`
/// from the moment the clock stops, as it does on the AVPlayer-backed hosts; before, the phase
/// stayed `.playing` over a standing picture until the reader's own stall timeout promoted it
/// to `.stalled`, and a host app watching the clock had nothing to tell a paused source from a
/// dead session.
@Published private(set) var isRebuffering: Bool = false
/// A position on the session axis the live feeder wants the picture moved up to after an edge
/// rebuffer resumed behind a backlog the source handed over at once (the join lead behind the
/// newest media, decided by `SoftwareLiveJoinPolicy` as the join itself was). The engine seeks
/// there as its own rejoin; the host does not move the cursor itself.
let liveResumeCatchUp = PassthroughSubject<Double, Never>()
@Published private(set) var duration: Double = 0
@Published private(set) var rate: Float = 0
/// #376: carries the classification with the message, so the engine can publish both.
Expand Down Expand Up @@ -315,6 +328,15 @@ final class SoftwarePlaybackHost {
return (sessionStartPts.isFinite ? sessionStartPts : 0) + seconds
}

/// The live feeder's inverse of `sourceSeconds(forSession:)`: a source PTS on the session axis,
/// nil before the first packet has anchored the session.
func liveSessionSeconds(forSource pts: Double) -> Double? {
liveEdgeLock.lock()
defer { liveEdgeLock.unlock() }
guard isLive, sessionStartPts.isFinite, pts.isFinite else { return nil }
return max(0, pts - sessionStartPts)
}

private let demuxQueue = DispatchQueue(label: "engine.sw.demux", qos: .userInitiated)

/// #254: every demuxer reposition runs here, never on the main actor. See
Expand Down Expand Up @@ -2257,6 +2279,7 @@ final class SoftwarePlaybackHost {
stopRequested = true
pausedBeforeFirstFrame = false
isPlaying = false
isRebuffering = false
seekInFlight = false
// A teardown inside a seek's window would otherwise leave it open on this instance.
noteSeekSettled(seekGeneration)
Expand Down Expand Up @@ -2330,6 +2353,24 @@ final class SoftwarePlaybackHost {
let vTbSec = videoTimeBaseSeconds
let aTbSec = audioTimeBaseSeconds
let liveSession = isLive
// Pump-thread side of `isRebuffering`: hops to the main actor and writes only on a change.
let noteRebuffering: @Sendable (Bool) -> Void = { [weak self] rebuffering in
Task { @MainActor [weak self] in
guard let self, self.isRebuffering != rebuffering else { return }
self.isRebuffering = rebuffering
}
}
// The feeder's resume re-placement, handed to the engine on the session axis: the engine's
// own live seek carries it through the tickets, the subtitle anchor and the clock like any
// other. The feeder states the target on the source axis; the mapping is `sourceSeconds
// (forSession:)` inverted, under the same lock.
let noteLiveResumeCatchUp: @Sendable (Double) -> Void = { [weak self] targetPts in
Task { @MainActor [weak self] in
guard let self, !self.stopRequested,
let target = self.liveSessionSeconds(forSource: targetPts) else { return }
self.liveResumeCatchUp.send(target)
}
}
let noteEdge: @Sendable (Double) -> Void = { [weak self] ptsSec in
guard let self, ptsSec.isFinite else { return }
self.liveEdgeLock.lock()
Expand Down Expand Up @@ -2529,7 +2570,9 @@ final class SoftwarePlaybackHost {
markClockArmed: setClockArmed,
onEnd: onEnd,
audioTapSink: getAudioTapSink,
noteDecodeGeneration: noteDecodeGeneration
noteDecodeGeneration: noteDecodeGeneration,
noteRebuffering: noteRebuffering,
noteLiveResumeCatchUp: noteLiveResumeCatchUp
)
}
return
Expand Down Expand Up @@ -2577,7 +2620,8 @@ final class SoftwarePlaybackHost {
splitDisplaySetSubtitleStreamIndices: subSplitSetIndices,
subtitleTapSink: getSubtitleTapSink,
recordingTap: recordingTap,
playedMedia: playedMediaLedger
playedMedia: playedMediaLedger,
noteRebuffering: noteRebuffering
)
}
}
Expand Down Expand Up @@ -2807,7 +2851,9 @@ final class SoftwarePlaybackHost {
markClockArmed: @Sendable () -> Void,
onEnd: @Sendable () -> Void,
audioTapSink: @Sendable () -> ((@Sendable (CMSampleBuffer) -> Void)?),
noteDecodeGeneration: @Sendable () -> Void
noteDecodeGeneration: @Sendable () -> Void,
noteRebuffering: @Sendable (Bool) -> Void = { _ in },
noteLiveResumeCatchUp: @Sendable (Double) -> Void = { _ in }
) {
// The join's start is placed once, before the first packet is fed; a seek before then is
// the viewer's own choice of position and retires it.
Expand All @@ -2816,6 +2862,10 @@ final class SoftwarePlaybackHost {
// while that is pending: when the join was decided.
var liveJoinAlignSince: TimeInterval?
var liveJoinAlignLead: Double?
// After an edge rebuffer resumes, watches what the source hands over on its way back and
// moves the picture up behind it once (see the `.none` arm below). Nil when nothing is
// being watched.
var resumePlacement: SoftwareLiveJoinPolicy? = nil
// Audio look-ahead pump (#107 audio chopping): feed audio packets ahead of the
// combined cursor so the audio renderer holds AudioLookaheadPolicy.targetLeadSeconds
// of decoded audio regardless of video decode pace. Without it audio lead is capped
Expand Down Expand Up @@ -2905,6 +2955,8 @@ final class SoftwarePlaybackHost {
) {
case .pauseForRebuffer:
rebuffering = true
noteRebuffering(true)
resumePlacement = nil
let resumesAt = cushion.map { ", resumes at \(String(format: "%.1f", $0))s ahead" } ?? ""
EngineLog.emit(
"[SWHost] live source underrun: pausing clock to rebuffer "
Expand All @@ -2914,13 +2966,43 @@ final class SoftwarePlaybackHost {
aOut.pause()
case .resume:
rebuffering = false
noteRebuffering(false)
EngineLog.emit(
"[SWHost] rebuffered: resuming clock (lead=\(String(format: "%.2f", lead))s "
+ "ahead=\(String(format: "%.2f", ahead))s)",
category: .swPlayback
)
aOut.setRate(currentRate())
// A source that paused comes back one of two ways: it skips what it missed and
// continues near real time, or it hands the whole backlog over at once. The
// second leaves the picture the length of the pause behind the newest media,
// and nothing on this path ever catches up. So the join's own rule watches the
// resume the way it watched the connect burst, and moves the picture up once
// the backlog has landed: `.fromStart` is a source that skipped (nothing worth
// moving for), `.place` the lead behind what it handed over.
resumePlacement = SoftwareLiveJoinPolicy()
case .none:
if var placement = resumePlacement, newest.isFinite, clock.isFinite {
let holdback = liveHoldback()
switch placement.observe(firstPts: clock, newestPts: newest,
at: ProcessInfo.processInfo.systemUptime,
leadSeconds: holdback.joinLeadSeconds) {
case nil:
resumePlacement = placement
case .fromStart?:
resumePlacement = nil
case .place(let targetPts)?:
resumePlacement = nil
EngineLog.emit(
"[SWHost] live resume re-placed \(String(format: "%.1f", holdback.joinLeadSeconds))s "
+ "behind the newest media: the source handed over "
+ "\(String(format: "%.1f", newest - clock))s at once after the pause "
+ "(target pts=\(String(format: "%.2f", targetPts)))",
category: .swPlayback
)
noteLiveResumeCatchUp(targetPts)
}
}
// Decode-lag visibility for device logs: only after lead was once
// healthy (startup fill must not trip it), rate-limited to one per 5 s.
if lead >= 0.5 { hadLead = true }
Expand Down Expand Up @@ -2960,7 +3042,9 @@ final class SoftwarePlaybackHost {
feederGeneration = generation
preArmPacketsFed = 0
hadLead = false
if rebuffering { noteRebuffering(false) }
rebuffering = false
resumePlacement = nil
liveJoinPending = false
liveJoinAlignSince = nil
}
Expand Down Expand Up @@ -3245,7 +3329,8 @@ final class SoftwarePlaybackHost {
splitDisplaySetSubtitleStreamIndices: Set<Int32> = [],
subtitleTapSink: @Sendable () -> ((@Sendable (Int32, UnsafeMutablePointer<AVPacket>, AVRational, Bool) -> Void)?) = { nil },
recordingTap: @escaping @Sendable (UnsafeMutablePointer<AVPacket>) -> Void = { _ in },
playedMedia: PlayedMediaLedger? = nil
playedMedia: PlayedMediaLedger? = nil,
noteRebuffering: @Sendable (Bool) -> Void = { _ in }
) {
// Clock arming: one-shot latch (seekClock is not idempotent -- re-calling snaps clock back to initialClockTime). Shared with host so a seek before first audio isn't overridden by a late re-arm.

Expand Down Expand Up @@ -3363,6 +3448,7 @@ final class SoftwarePlaybackHost {
func releaseRebufferHold(_ why: String) {
guard rebuffering else { return }
rebuffering = false
noteRebuffering(false)
EngineLog.emit("[SWHost] releasing rebuffer hold: \(why)", category: .swPlayback)
audioOutput?.setRate(currentRate())
}
Expand Down Expand Up @@ -3466,6 +3552,7 @@ final class SoftwarePlaybackHost {
) {
case .pauseForRebuffer:
rebuffering = true
noteRebuffering(true)
EngineLog.emit(
"[SWHost] audio lead exhausted (\(String(format: "%.2f", lead))s); "
+ "pausing clock for rebuffer",
Expand All @@ -3474,6 +3561,7 @@ final class SoftwarePlaybackHost {
aOut.pause()
case .resume:
rebuffering = false
noteRebuffering(false)
EngineLog.emit(
"[SWHost] rebuffered to \(String(format: "%.2f", lead))s audio lead; "
+ "resuming clock",
Expand Down Expand Up @@ -3540,6 +3628,7 @@ final class SoftwarePlaybackHost {
lastEnqueuedAudioPtsSec = .nan
diagAudioPtsSec = .nan
diagAudioPtsGeneration = gen
if rebuffering { noteRebuffering(false) }
rebuffering = false
// The lead is zero again after a seek, so the latch has to earn itself back:
// keeping it set pauses the clock for a rebuffer on the first post-seek check.
Expand Down
39 changes: 39 additions & 0 deletions Tests/AetherEngineTests/SoftwareLiveJoinPlacementTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,4 +91,43 @@ struct SoftwareLiveJoinPlacementTests {
}
#expect(decision == .fromStart)
}

// MARK: - The resume after an edge rebuffer

/// The feeder watches a resume with the same rule, from the clock instead of the ring's first
/// packet: a source that paused and then handed its whole backlog over at once leaves the picture
/// the length of the pause behind the newest media, so it is moved up the lead behind it.
@Test("a backlog handed over at once after a pause moves the picture up behind it")
func backlogAfterAPauseIsCaughtUp() throws {
var policy = SoftwareLiveJoinPolicy()
let clock = 35_154.6
var decision: SoftwareLiveJoinPolicy.Decision?
var decidedAt: TimeInterval?
var now: TimeInterval = 0
while now <= 2, decision == nil {
// 18 s of backlog over the first 0.4 s, then real time.
let newest = now < 0.4 ? clock + 2 + 40 * now : clock + 18 + (now - 0.4)
decision = policy.observe(firstPts: clock, newestPts: newest, at: now)
if decision != nil { decidedAt = now }
now += 0.05
}
let at = try #require(decidedAt)
let newestThen = clock + 18 + (at - 0.4)
#expect(decision == .place(targetPts: newestThen - SoftwareLiveJoinPolicy.leadSeconds))
}

/// A source that skipped what it missed comes back a few seconds ahead of the clock: nothing is
/// worth moving for, and the picture stays where the resume left it.
@Test("a source that skipped its backlog leaves the picture where it resumed")
func skippedBacklogStaysPut() {
var policy = SoftwareLiveJoinPolicy()
let clock = 35_154.6
var decision: SoftwareLiveJoinPolicy.Decision?
var now: TimeInterval = 0
while now <= 2, decision == nil {
decision = policy.observe(firstPts: clock, newestPts: clock + 3 + now, at: now)
now += 0.05
}
#expect(decision == .fromStart)
}
}
Loading
Loading