diff --git a/CHANGELOG.md b/CHANGELOG.md index cadfbc99f..9ee130f2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Sources/AetherEngine/AetherEngine+Loading.swift b/Sources/AetherEngine/AetherEngine+Loading.swift index 2dd380526..37a55703a 100644 --- a/Sources/AetherEngine/AetherEngine+Loading.swift +++ b/Sources/AetherEngine/AetherEngine+Loading.swift @@ -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. diff --git a/Sources/AetherEngine/Native/SoftwarePlaybackHost.swift b/Sources/AetherEngine/Native/SoftwarePlaybackHost.swift index 54fe47192..519694034 100644 --- a/Sources/AetherEngine/Native/SoftwarePlaybackHost.swift +++ b/Sources/AetherEngine/Native/SoftwarePlaybackHost.swift @@ -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() @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. @@ -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 @@ -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) @@ -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() @@ -2529,7 +2570,9 @@ final class SoftwarePlaybackHost { markClockArmed: setClockArmed, onEnd: onEnd, audioTapSink: getAudioTapSink, - noteDecodeGeneration: noteDecodeGeneration + noteDecodeGeneration: noteDecodeGeneration, + noteRebuffering: noteRebuffering, + noteLiveResumeCatchUp: noteLiveResumeCatchUp ) } return @@ -2577,7 +2620,8 @@ final class SoftwarePlaybackHost { splitDisplaySetSubtitleStreamIndices: subSplitSetIndices, subtitleTapSink: getSubtitleTapSink, recordingTap: recordingTap, - playedMedia: playedMediaLedger + playedMedia: playedMediaLedger, + noteRebuffering: noteRebuffering ) } } @@ -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. @@ -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 @@ -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 " @@ -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 } @@ -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 } @@ -3245,7 +3329,8 @@ final class SoftwarePlaybackHost { splitDisplaySetSubtitleStreamIndices: Set = [], subtitleTapSink: @Sendable () -> ((@Sendable (Int32, UnsafeMutablePointer, AVRational, Bool) -> Void)?) = { nil }, recordingTap: @escaping @Sendable (UnsafeMutablePointer) -> 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. @@ -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()) } @@ -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", @@ -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", @@ -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. diff --git a/Tests/AetherEngineTests/SoftwareLiveJoinPlacementTests.swift b/Tests/AetherEngineTests/SoftwareLiveJoinPlacementTests.swift index c163e7c98..fa8d8cc1a 100644 --- a/Tests/AetherEngineTests/SoftwareLiveJoinPlacementTests.swift +++ b/Tests/AetherEngineTests/SoftwareLiveJoinPlacementTests.swift @@ -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) + } } diff --git a/docs/api.md b/docs/api.md index 2a2a4da77..8763d7282 100644 --- a/docs/api.md +++ b/docs/api.md @@ -794,7 +794,7 @@ suppressing `AVPlayerItemLegibleOutput` to keep the measurement running. | --- | --- | | `$state` | `.idle`, `.loading`, `.playing`, `.paused`, `.seeking`, `.ended`, `.error(String)`. | | `$errorInfo` | `PlaybackErrorInfo?`, the machine-readable half of `.error`: a `PlaybackErrorKind` token, plus the underlying `NSError` domain and code where one is involved. Non-nil exactly while `state` is `.error`, assigned before it. Classify on this, never on the message. | -| `$playbackPhase` | The derived one-source-of-truth status, and **the only one of these that tracks motion**: `state` is transport INTENT and turns `.playing` the moment the engine has called `play()`, which on a live join can be seconds before the rate rolls (AE#440). Until the transport has moved once in a load, the phase stays `.loading`, and it turns `.playing` on the roll itself. Adds `.rebuffering` and `.stalled(reconnecting:)`. Prefer it over stitching `state` + `isBuffering` + `isSeeking`, and over matching log text. Precedence, highest first: `error > ended > idle > loading > stalled > seeking > rebuffering > playing/paused` - a source that stopped delivering stays visible while a seek is in flight, because no seek can land over it and `isSeeking` / `seekEvents` still carry the seek (#410). `.stalled(reconnecting: true)` is a reader that is retrying, `false` a reader whose ladder is spent and whose recovery has passed to the producer's reopen; both mean the source is down. `.rebuffering` is published on the AVPlayer-backed paths: the native loopback session, direct remote HLS, and the bare-AVPlayer audio host (a starved progressive stream, once the item has played), where `.stalled` cannot occur because there is no reader. The FFmpeg-backed hosts (software video, FFmpeg audio) have no AVPlayer to wait, so a starving source reads as `.stalled` there instead. | +| `$playbackPhase` | The derived one-source-of-truth status, and **the only one of these that tracks motion**: `state` is transport INTENT and turns `.playing` the moment the engine has called `play()`, which on a live join can be seconds before the rate rolls (AE#440). Until the transport has moved once in a load, the phase stays `.loading`, and it turns `.playing` on the roll itself. Adds `.rebuffering` and `.stalled(reconnecting:)`. Prefer it over stitching `state` + `isBuffering` + `isSeeking`, and over matching log text. Precedence, highest first: `error > ended > idle > loading > stalled > seeking > rebuffering > playing/paused` - a source that stopped delivering stays visible while a seek is in flight, because no seek can land over it and `isSeeking` / `seekEvents` still carry the seek (#410). `.stalled(reconnecting: true)` is a reader that is retrying, `false` a reader whose ladder is spent and whose recovery has passed to the producer's reopen; both mean the source is down. `.rebuffering` is published on the AVPlayer-backed paths: the native loopback session, direct remote HLS, and the bare-AVPlayer audio host (a starved progressive stream, once the item has played), where `.stalled` cannot occur because there is no reader. The software video host publishes it too, from the moment one of its pumps pauses the synchronizer to refill (a dry ring at the live edge, an exhausted audio lead on demand); a source whose connection is retrying reads as `.stalled` on the FFmpeg-backed hosts (software video, FFmpeg audio) once the reader's ladder is running. | | `$isBuffering`, `$isSeeking`, `$seekTarget` | The raw axes `playbackPhase` folds. On the AVPlayer paths a wait AVPlayer spends evaluating its buffering rate (every resume from pause, a frame or two) is not a rebuffer; one that moves on to minimizing stalls is (Sodalite#168). | | `sourceAuthorizationRefused` | `AnyPublisher`: the origin answered 401 or 403 to the session's request headers mid-session; one value per header set, the status. Renew and call `updateRequestHeaders(_:)`; see [Rotating a credential without a rebuild](#rotating-a-credential-without-a-rebuild). | | `seekEvents` | `AnyPublisher`: `.began`, `.landed(renderedTime:)`, `.stalled`, `.superseded`, `.rejected(SeekEvent.Rejection)`, each with its `target`, an `id` that spans the seek, and a `SeekEvent.Origin` (`.programmatic`, `.nativeScrub`, `.deferred`; a deferred seek is one the session could not take yet, which is where the engine publishes an optimistic `currentTime` for a position nothing has reached). Use it where the falling edge of `$isSeeking` matters: a level cannot say whether a seek landed, gave up, or was superseded, and a `.stalled` seek can still land later under the same id. | diff --git a/docs/architecture.md b/docs/architecture.md index fab48e1bc..abcf2cc3e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -105,7 +105,7 @@ AV1+DV (Profile 10.0 / 10.1 / 10.4) routes through the native path on hardware-A **Background audio (iOS).** When the app backgrounds while playing, the engine keeps audio going rather than tearing the pipeline down. The decision is a pure, unit-tested policy, `backgroundAction(isAudioBackend:hasSoftwareHost:softwareHostHasAudio:keepVideoAlive:pipActive:state:)`, driven from the `UIApplication` lifecycle observers; `keepVideoAlive` comes from `shouldKeepVideoAlive(enabled:pipActive:state:)` and is gated to iOS (tvOS always tears down, wedge-safe: a frozen decode session crossing a multi-hour suspension wedged `mediaserverd`). On the native path "keep audio alive" is just declining to tear down: `AVPlayer` under the `.playback` session keeps decoding. The software path has no `AVPlayer`, and its combined demux loop normally paces the whole loop (audio and video) on the video renderer's `isReadyForMoreMediaData`; once `AVSampleBufferDisplayLayer` stops draining in the background that gate never reopens and audio would starve. So the host enters `backgroundAudioOnly`: the loop drops video packets and paces on the audio renderer (`AudioOutput.isReadyForMoreMediaData`) instead, keeping `AVSampleBufferAudioRenderer` fed and the synchronizer advancing. On foreground return the flag clears, the video decoder and renderer flush, and video resyncs at the next keyframe with audio uninterrupted. A software host that renders no audio of its own never enters this mode (AE#739): the loop would have nothing to pace on and would drop the rest of the file within moments, parking the clock at end of media in the background, so such a session is released like a paused one and reloaded on return. The VideoToolbox session is rebuilt on every foreground return, because an iOS screen lock invalidates it and in audio-only mode nothing decodes video to notice. A loss observed while video is wanted (`kVTInvalidSessionErr` from a decode) is reported by `HardwareVideoDecoder.onSessionLost`; the host rebuilds the session and repositions to the playhead so the picture returns on the frame the audio is at, and owes the recovery to the next foreground return when it happens in the background or VT refuses the rebuild. Scope is the combined VOD loop (and live-without-DVR, which shares it); the DVR feeder loop is unchanged. Exercise it headless with `aetherctl bgaudio` (see [cli.md](cli.md)). -**Live audio look-ahead + edge rebuffer (#107 audio chopping).** The DVR feeder loop shares the pacing coupling above: audio is interleaved behind the video renderer's back-pressure gate, so the audio renderer could never hold more than the video queue allows (<1 s measured). On devices where software 1080i decode + deinterlace runs near real time that margin is zero and every feeder stall is an audible dropout. The feeder therefore runs an audio look-ahead pump (`AudioLookaheadPolicy` + `AudioLookaheadState`): an independent ring cursor that decodes and enqueues audio packets ahead of the combined cursor until the renderer holds `targetLeadSeconds` (4 s) over the synchronizer clock, making audio delivery independent of video decode pace (a chronically slow video decode now degrades to late video frames under smooth audio, the right priority). DVR seeks reset the pump cursor alongside `setFeedCursor` (compare-and-set, seek wins). The same pump pass handles live-edge source underruns: when the source itself delivers below real time and the pump drains the ring at the edge, the free-running clock would otherwise outrun the stream permanently (every later sample lands in the clock's past, continuous chopping that never recovers). `AudioLookaheadPolicy.clockAction` pauses the clock at `underrunPauseLeadSeconds`, refills, and resumes at `rebufferResumeLeadSeconds`, mirroring AVPlayer's stall handling on the native path. A host seek does not ask the pump to do that on purpose: a software live landing nearer the reader's frontier than `rebufferResumeLeadSeconds` is held that far back (`AetherEngine.softwareLiveLanding`, Sodalite#104 round 3), because on a real-time source the lead arrives exactly as slowly as it is deep, so a landing at the frontier parks the clock for that long and resumes at the same distance behind it that the held-back landing starts from. A source that delivers whole segments (a live HLS ingest) gets a holdback sized from the segment length instead (`SoftwareLiveHoldback`, AE#813): the join lead, the landing, the live-edge tolerance and a rebuffer's resume cushion (`newest source PTS - clock`, on top of the audio lead, since the pump caps that lead at `targetLeadSeconds`) all follow it, and a continuous source keeps the values above. Diagnose with `aetherctl play --audio-stats` (see [cli.md](cli.md)). +**Live audio look-ahead + edge rebuffer (#107 audio chopping).** The DVR feeder loop shares the pacing coupling above: audio is interleaved behind the video renderer's back-pressure gate, so the audio renderer could never hold more than the video queue allows (<1 s measured). On devices where software 1080i decode + deinterlace runs near real time that margin is zero and every feeder stall is an audible dropout. The feeder therefore runs an audio look-ahead pump (`AudioLookaheadPolicy` + `AudioLookaheadState`): an independent ring cursor that decodes and enqueues audio packets ahead of the combined cursor until the renderer holds `targetLeadSeconds` (4 s) over the synchronizer clock, making audio delivery independent of video decode pace (a chronically slow video decode now degrades to late video frames under smooth audio, the right priority). DVR seeks reset the pump cursor alongside `setFeedCursor` (compare-and-set, seek wins). The same pump pass handles live-edge source underruns: when the source itself delivers below real time and the pump drains the ring at the edge, the free-running clock would otherwise outrun the stream permanently (every later sample lands in the clock's past, continuous chopping that never recovers). `AudioLookaheadPolicy.clockAction` pauses the clock at `underrunPauseLeadSeconds`, refills, and resumes at `rebufferResumeLeadSeconds`, mirroring AVPlayer's stall handling on the native path. A host seek does not ask the pump to do that on purpose: a software live landing nearer the reader's frontier than `rebufferResumeLeadSeconds` is held that far back (`AetherEngine.softwareLiveLanding`, Sodalite#104 round 3), because on a real-time source the lead arrives exactly as slowly as it is deep, so a landing at the frontier parks the clock for that long and resumes at the same distance behind it that the held-back landing starts from. A source that delivers whole segments (a live HLS ingest) gets a holdback sized from the segment length instead (`SoftwareLiveHoldback`, AE#813): the join lead, the landing, the live-edge tolerance and a rebuffer's resume cushion (`newest source PTS - clock`, on top of the audio lead, since the pump caps that lead at `targetLeadSeconds`) all follow it, and a continuous source keeps the values above. 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, which leaves the picture the length of the pause behind the newest media on a path that never catches up on its own. So the feeder watches a resume with the join's rule (`SoftwareLiveJoinPolicy`, from the clock instead of the ring's first packet): once the backlog has landed, a span worth skipping moves the picture up to the join lead behind the newest media through the engine's own live seek (`SeekOrigin.liveRejoin`), and a source that skipped leaves it where it resumed. Diagnose with `aetherctl play --audio-stats` (see [cli.md](cli.md)). **Paused-background grace window (iOS, #127).** A paused session used to tear down the moment the app backgrounded, so a 10-30 s app switch paid a full pipeline rebuild (demuxer reopen, segment-plan scan, AVPlayer item reload). The teardown is now deferred by `backgroundTeardownGraceSeconds` (default 15 s, 0 restores the immediate teardown) held under a `UIBackgroundTask` assertion; `didBecomeActive` cancels the window, so a quick switch resumes on the live pipeline. At expiry the action is re-evaluated (PiP can start and lock-screen play can resume mid-window) and the teardown runs while the app is still genuinely running, never across an idle suspension; the assertion's expiration handler is a synchronous backstop. The step decision is the pure `backgroundStep(action:state:supportsGraceWindow:graceSeconds:)`; a PLAYING teardown (background playback disabled) stays immediate because its audio would keep sounding through the window, and tvOS keeps the unconditional teardown. Two hardening pieces ship with it: host seeks that arrive while the (re)built AVPlayer item is pre-ready are deferred (`shouldDeferHostSeek`) and the latest replays at readiness (an early seek clamps to 0 against empty seekable ranges and replaces `load()`'s own startPosition seek), and hosts observe the published `isSessionReady` to gate corrective actions (restore watchdogs, position clamps) instead of inferring readiness from `currentTime` being pinned at 0. @@ -146,7 +146,7 @@ Precedence (highest first): `error > ended > idle > loading > stalled > seeking The reader axis outranks `isSeeking` (#410). A seek cannot land over a source that stopped delivering, so the level stays up for as long as the seek stays alive, and the seek holding it is not necessarily the host's: the producer's restart coalescer issues its own `nativeScrub` seeks while recovering, so the engine's recovery used to hide the outage it was recovering from (measured at 29 s and 41 s on a killed LAN origin). A seek stays observable through `isSeeking` and `seekEvents`; the reader axis is observable nowhere else. Over a delivering source nothing changes: the reader is `flowing` and a seek reads `.seeking` exactly as before. -- `.rebuffering` is a healthy-connection buffer underrun (AVPlayer waiting to play); `state` stays `.playing` across it. +- `.rebuffering` is a healthy-connection buffer underrun (AVPlayer waiting to play, or a software pump that paused its synchronizer to refill: the live feeder at a dry ring, the on-demand loop with its audio lead exhausted); `state` stays `.playing` across it. The software host publishes its pumps' hold as `isRebuffering`, folded into `isBuffering` the way the audio host's wait is, so the phase turns `.rebuffering` the moment the clock stops. Before it did not, and a live source that merely paused read as `.playing` over a standing picture for the whole of the reader's stall timeout, until the reader's reconnect promoted it to `.stalled`; a host app watching the clock had reloaded the session long before, and the fresh join behind the same frozen burst replayed what it had just shown. - `.stalled(reconnecting:)` is a source-connection problem (drop / 429 / 503 backoff) where the `AVIOReader` is retrying. It is promoted from log text to a typed signal: the reader pushes a `flowing` / `reconnecting` phase through `Demuxer.onNetworkPhaseChanged`, the owning host (`HLSVideoEngine` on the native path, the software / audio hosts directly) forwards it, and the engine hops it to the main actor. The flag is `true` while the reader is retrying and `false` once it has spent its ladder (#410): the reader is out of the read, the producer's reopen owns the recovery, and the source is still down. Before that the dying reader claimed `flowing` on its way out, purely so the next reader's dedupe gate could not strand the phase, and the whole reopen window read as a healthy source. Only bytes that crossed the network move the axis back to `flowing` (#410), the same line the reconnect ladders have drawn since #380: a serve out of the resident window, the retained head/tail spans (#281) or a resident detour block hands back read-ahead the origin paid for before it died, and reporting delivery off it erased the stall on the next read. A metered detour fetch (429 / 503 / 509 on the arm that carries a throttling origin, #69/#71) now reports `reconnecting` and its give-up reports `exhausted`; it charged the ladder but told the axis nothing. One deliberate exception remains: the faulted-runway refill (#307/#309) replaces a dead connection while megabytes are still resident and stays silent, because playback there is uninterrupted; the empty-window ladder reports it when playback genuinely starves. The native text subtitle readers (WebVTT rendition prefetch) are deliberately left unwired so their stalls never move `playbackPhase`; the overlay itself is fed from the main read via the subtitle packet tap and has no connection of its own.