diff --git a/CHANGELOG.md b/CHANGELOG.md index cadfbc99f..bee829695 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,11 @@ the public-API contract. ### Added +- **`installAudioTap(lossless: true)` preserves queued PCM for continuous analysis (AE#864).** + The opt-in stream does not evict queued buffers or block the producer. Its consumer must keep + draining it or cancel/remove the tap when it stops consuming. The default newest-64 policy + remains unchanged, and source discontinuities remain explicit. + - **Remote MPEG-DASH on-demand manifests play (AE#859, thanks @qoli).** A static, single-Period MPD whose representations are single files addressed by `SegmentBase` failed at open with `Invalid data found when processing input`: the FFmpeg build has no DASH @@ -112,6 +117,11 @@ the public-API contract. ### Fixed +- **Long audio-tap segments no longer look like backward seeks (AE#864).** Loopback and remote-HLS + VOD readers track actual playhead movement. A complete segment running beyond the preferred + lead waits for playback instead of repeatedly decoding the same segments; actual backward + jumps and forward seeks still re-anchor with a discontinuity. + - **Source failures keep their cause and request ownership.** Initial live probes preserve HTTP, URL and TLS failures without changing the live-start `liveSourceUnavailable` kind (HTTP and certificate classifications stay specific). A transport failure at open (a reset or a timeout from diff --git a/Sources/AetherEngine/AetherEngine+AudioTap.swift b/Sources/AetherEngine/AetherEngine+AudioTap.swift index b337ec374..3384d5042 100644 --- a/Sources/AetherEngine/AetherEngine+AudioTap.swift +++ b/Sources/AetherEngine/AetherEngine+AudioTap.swift @@ -6,7 +6,9 @@ extension AetherEngine { /// Install the tap on the currently loaded item and return its buffer stream. /// Mono Float32 48 kHz (`AetherEngine.audioTapFormat`), source-PTS timestamps, lossy under - /// pressure, never stalls playback. One tap per engine: installing again finishes the + /// pressure by default, never stalls playback. `lossless` prevents queue eviction for + /// consumers that pace input to the media clock. They must drain or cancel their stream; + /// source discontinuities remain explicit. One tap per engine: installing again finishes the /// previous stream. `load()` and `stop()` finish it too (opt-in is per load). With no /// active playback session (or a video-only source) the returned stream finishes /// immediately. @@ -24,7 +26,7 @@ extension AetherEngine { /// rejects outright (`SFSpeechErrorDomain Code=17`, the reason the filter exists). A stream /// that ends is a boundary a host can act on; a stitched one is not. @MainActor - public func installAudioTap() -> AsyncStream { + public func installAudioTap(lossless: Bool = false) -> AsyncStream { removeAudioTap() let controller = AudioTapController() audioTapController = controller @@ -37,25 +39,25 @@ extension AetherEngine { hasLoadedURL: loadedURL != nil) switch kind { case .loopback: - stream = controller.makeStream { onStop in + stream = controller.makeStream(lossless: lossless) { onStop in guard let reader = makeNativeTapReader(controller: controller) else { return } reader.start() onStop { reader.stop() } } case .remoteHLS: - stream = controller.makeStream { onStop in + stream = controller.makeStream(lossless: lossless) { onStop in guard let reader = makeRemoteHLSTapReader(controller: controller) else { return } reader.start() onStop { reader.stop() } } case .software: - stream = controller.makeStream { onStop in + stream = controller.makeStream(lossless: lossless) { onStop in guard softwareHost != nil else { return } installSoftwareTapSink(controller: controller) onStop { [weak self] in self?.softwareHost?.audioTapSink = nil } } case .none: - stream = controller.makeStream { _ in } + stream = controller.makeStream(lossless: lossless) { _ in } } // No session / no audio track / backend .none: nothing will ever yield, finish now. if !controller.hasDeliverySource { controller.teardown() } diff --git a/Sources/AetherEngine/Audio/Tap/AudioTapController.swift b/Sources/AetherEngine/Audio/Tap/AudioTapController.swift index 7d546faae..939c54894 100644 --- a/Sources/AetherEngine/Audio/Tap/AudioTapController.swift +++ b/Sources/AetherEngine/Audio/Tap/AudioTapController.swift @@ -17,9 +17,9 @@ final class AudioTapController { /// Build the stream and let the caller start its delivery source. `startReader` runs /// synchronously; it registers a stop closure via its callback. - func makeStream(startReader: StartReader) -> AsyncStream { + func makeStream(lossless: Bool = false, startReader: StartReader) -> AsyncStream { let (stream, cont) = AsyncStream.makeStream(of: AudioTapBuffer.self, - bufferingPolicy: .bufferingNewest(64)) + bufferingPolicy: lossless ? .unbounded : .bufferingNewest(64)) continuation = cont filter = AudioTapMonotonicFilter(downstream: { buf in _ = cont.yield(buf) }) startReader { [weak self] stop in self?.stopReader = stop } diff --git a/Sources/AetherEngine/Audio/Tap/AudioTapHLSReader.swift b/Sources/AetherEngine/Audio/Tap/AudioTapHLSReader.swift index 91647acf5..15ccca0fe 100644 --- a/Sources/AetherEngine/Audio/Tap/AudioTapHLSReader.swift +++ b/Sources/AetherEngine/Audio/Tap/AudioTapHLSReader.swift @@ -32,6 +32,7 @@ final class AudioTapHLSReader: @unchecked Sendable { private var tracker = HLSPlaylistTracker() private var nextIndex: Int? private var lastDecodedEndPTS: Double? + private var previousPlayhead: Double? private var pendingDiscontinuity = true init(deps: Dependencies) { self.deps = deps } @@ -76,7 +77,9 @@ final class AudioTapHLSReader: @unchecked Sendable { func stepVOD() async -> StepResult { guard let media = playlist else { return .finished } let playhead = deps.playhead() ?? 0 + defer { previousPlayhead = playhead } switch AudioTapPacing.decide(lastDecodedEndPTS: lastDecodedEndPTS, playhead: playhead, + previousPlayhead: previousPlayhead, leadSeconds: AudioTapDefaults.leadSeconds, toleranceSeconds: AudioTapDefaults.toleranceSeconds) { case .sleep: diff --git a/Sources/AetherEngine/Audio/Tap/AudioTapTypes.swift b/Sources/AetherEngine/Audio/Tap/AudioTapTypes.swift index 0894f57fc..9b30543b7 100644 --- a/Sources/AetherEngine/Audio/Tap/AudioTapTypes.swift +++ b/Sources/AetherEngine/Audio/Tap/AudioTapTypes.swift @@ -42,11 +42,13 @@ enum AudioTapPacing { case reanchor // playhead moved away; jump to the playhead's segment } - static func decide(lastDecodedEndPTS: Double?, playhead: Double, + static func decide(lastDecodedEndPTS: Double?, playhead: Double, previousPlayhead: Double?, leadSeconds: Double, toleranceSeconds: Double) -> Decision { guard let last = lastDecodedEndPTS else { return .decodeNext } + if let previousPlayhead, playhead < previousPlayhead - toleranceSeconds { return .reanchor } if last < playhead - toleranceSeconds { return .reanchor } - if last - playhead > leadSeconds + toleranceSeconds { return .reanchor } + // A complete segment can overshoot the preferred lead. Only movement + // of the playhead proves a backward seek; decoded lead alone does not. if last - playhead >= leadSeconds { return .sleep } return .decodeNext } diff --git a/Sources/AetherEngine/Audio/Tap/LoopbackAudioReader.swift b/Sources/AetherEngine/Audio/Tap/LoopbackAudioReader.swift index 9b8ee191c..0a814eb84 100644 --- a/Sources/AetherEngine/Audio/Tap/LoopbackAudioReader.swift +++ b/Sources/AetherEngine/Audio/Tap/LoopbackAudioReader.swift @@ -41,6 +41,7 @@ final class LoopbackAudioReader: @unchecked Sendable { // Worker-thread state (only touched from the loop / runOnce). private var nextIndex: Int? private var lastDecodedEndPTS: Double? + private var previousPlayhead: Double? private var pendingDiscontinuity = true private var missStreak = 0 @@ -79,8 +80,10 @@ final class LoopbackAudioReader: @unchecked Sendable { /// One loop iteration. Internal (not private) so tests drive it directly. func runOnce() -> StepResult { let playhead = deps.playhead() ?? 0 + defer { previousPlayhead = playhead } switch AudioTapPacing.decide(lastDecodedEndPTS: lastDecodedEndPTS, playhead: playhead, + previousPlayhead: previousPlayhead, leadSeconds: AudioTapDefaults.leadSeconds, toleranceSeconds: AudioTapDefaults.toleranceSeconds) { case .sleep: diff --git a/Tests/AetherEngineTests/AudioTapControllerTests.swift b/Tests/AetherEngineTests/AudioTapControllerTests.swift index 232b00930..1a22cd1df 100644 --- a/Tests/AetherEngineTests/AudioTapControllerTests.swift +++ b/Tests/AetherEngineTests/AudioTapControllerTests.swift @@ -8,10 +8,10 @@ import AVFAudio @MainActor final class AudioTapControllerTests: XCTestCase { - private func makeBuffer() -> AudioTapBuffer { + private func makeBuffer(sourceTime: Double = 1.0) -> AudioTapBuffer { let b = AVAudioPCMBuffer(pcmFormat: AetherEngine.audioTapFormat, frameCapacity: 480)! b.frameLength = 480 - return AudioTapBuffer(buffer: b, sourceTime: 1.0, discontinuity: false) + return AudioTapBuffer(buffer: b, sourceTime: sourceTime, discontinuity: false) } func testYieldReachesConsumerAndTeardownFinishes() async { @@ -44,4 +44,31 @@ final class AudioTapControllerTests: XCTestCase { _ = controller.makeStream(startReader: { _ in }) XCTAssertFalse(controller.hasDeliverySource) } + + func testLosslessDeliveryKeepsWholeBurstBeforeConsumerRuns() async { + let burstCount = 512 + let controller = AudioTapController() + let stream = controller.makeStream(lossless: true, startReader: { onStop in onStop {} }) + let yield = controller.makeYield()! + for index in 0..(1) + let fetched = Box<[Int]>([]) + let emitted = Box<[AudioTapBuffer]>([]) + let playlist = HLSMediaPlaylist(targetDuration: 15, mediaSequence: 0, + segments: (0..<4).map { HLSMediaSegment(uri: "s\($0).ts", duration: 15, discontinuityBefore: false) }, + hasEndList: true, isEncrypted: false, hasUnsupportedEncryption: false, hasMap: false) + let deps = AudioTapHLSReader.Dependencies( + playhead: { playhead.value }, + mediaURL: URL(string: "https://h/media.m3u8")!, + fetchPlaylist: { _ in playlist }, + fetchSegment: { uri, _ in + fetched.value.append(Int(uri.dropFirst().prefix(1))!) + return Data([1]) + }, + decodeSegment: { _ in + let buffer = AVAudioPCMBuffer(pcmFormat: AetherEngine.audioTapFormat, frameCapacity: 720_000)! + buffer.frameLength = 720_000 + return [AudioTapChunk(buffer: buffer, ptsSeconds: 0)] + }, + emit: { emitted.value.append($0) }) + let reader = AudioTapHLSReader(deps: deps) + reader.primeForTest(playlist: playlist) + let first = await reader.stepVOD() + XCTAssertEqual(first, .decoded) + for _ in 0..<10 { + let step = await reader.stepVOD() + XCTAssertEqual(step, .slept) + } + XCTAssertEqual(fetched.value, [0]) + playhead.value = 6 + let next = await reader.stepVOD() + XCTAssertEqual(next, .decoded) + let wait = await reader.stepVOD() + XCTAssertEqual(wait, .slept) + XCTAssertEqual(fetched.value, [0, 1]) + XCTAssertFalse(emitted.value[1].discontinuity) + playhead.value = 1 + let reanchor = await reader.stepVOD() + XCTAssertEqual(reanchor, .reanchored) + let afterSeek = await reader.stepVOD() + XCTAssertEqual(afterSeek, .decoded) + XCTAssertEqual(fetched.value, [0, 1, 0]) + XCTAssertTrue(emitted.value.last!.discontinuity) + XCTAssertEqual(emitted.value.last!.sourceTime, 0, accuracy: 0.001) + } } diff --git a/Tests/AetherEngineTests/AudioTapPacingTests.swift b/Tests/AetherEngineTests/AudioTapPacingTests.swift index 6f2cefad9..716a81670 100644 --- a/Tests/AetherEngineTests/AudioTapPacingTests.swift +++ b/Tests/AetherEngineTests/AudioTapPacingTests.swift @@ -6,8 +6,9 @@ import XCTest /// to decode the next segment, idle, or re-anchor after the playhead moved away. final class AudioTapPacingTests: XCTestCase { - private func decide(_ last: Double?, _ playhead: Double) -> AudioTapPacing.Decision { + private func decide(_ last: Double?, _ playhead: Double, previous: Double? = nil) -> AudioTapPacing.Decision { AudioTapPacing.decide(lastDecodedEndPTS: last, playhead: playhead, + previousPlayhead: previous, leadSeconds: 10, toleranceSeconds: 2) } @@ -39,7 +40,15 @@ final class AudioTapPacingTests: XCTestCase { func testPlayheadJumpedBackReanchors() { // Backward seek: decoded position is far ahead of the new playhead. - XCTAssertEqual(decide(300.0, 100.0), .reanchor) - XCTAssertEqual(decide(112.5, 100.0), .reanchor) // > lead + tolerance + XCTAssertEqual(decide(300.0, 100.0, previous: 290.0), .reanchor) + XCTAssertEqual(decide(112.5, 100.0, previous: 105.0), .reanchor) + XCTAssertEqual(decide(106.0, 100.0, previous: 105.0), .reanchor) + } + + func testWholeSegmentOvershootWaitsForPlayback() { + XCTAssertEqual(decide(120.0, 95.1, previous: 95.1), .sleep) + XCTAssertEqual(decide(120.0, 95.1), .sleep) + XCTAssertEqual(decide(120.0, 95.0, previous: 95.1), .sleep) + XCTAssertEqual(decide(120.0, 110.1, previous: 95.1), .decodeNext) } } diff --git a/Tests/AetherEngineTests/LoopbackAudioReaderTests.swift b/Tests/AetherEngineTests/LoopbackAudioReaderTests.swift index 2cc0dce3f..21c523acb 100644 --- a/Tests/AetherEngineTests/LoopbackAudioReaderTests.swift +++ b/Tests/AetherEngineTests/LoopbackAudioReaderTests.swift @@ -3,7 +3,7 @@ import AVFAudio @testable import AetherEngine /// #95: loop logic of the loopback reader, driven step-by-step with stubbed dependencies. -/// No media involved; decode is stubbed to return one 1 s chunk per decode call. +/// No media involved; decode returns the requested segment's duration and timestamp. final class LoopbackAudioReaderTests: XCTestCase { /// Mutable stub world shared with the closures. Single-threaded in these tests. @@ -23,15 +23,16 @@ final class LoopbackAudioReaderTests: XCTestCase { shiftSeconds: { w.shift }, anchorIndex: { Int($0 / w.segmentSeconds) }, initData: { _ in Data([0]) }, - segmentData: { w.segments.contains($0) ? Data([1]) : nil }, + segmentData: { w.segments.contains($0) ? Data(String($0).utf8) : nil }, highestStoredIndex: { w.highestStored }, - decodeSegment: { _, _ in - w.decoded.append(w.decoded.count) - let idx = Double(w.decoded.count - 1) + decodeSegment: { _, data in + let idx = Int(String(decoding: data, as: UTF8.self))! + w.decoded.append(idx) + let frames = AVAudioFrameCount(w.segmentSeconds * 48_000) let buf = AVAudioPCMBuffer(pcmFormat: AetherEngine.audioTapFormat, - frameCapacity: 48_000)! - buf.frameLength = 48_000 - return [AudioTapChunk(buffer: buf, ptsSeconds: idx * w.segmentSeconds)] + frameCapacity: frames)! + buf.frameLength = frames + return [AudioTapChunk(buffer: buf, ptsSeconds: Double(idx) * w.segmentSeconds)] }, emit: { w.emitted.append($0) } ) @@ -90,4 +91,39 @@ final class LoopbackAudioReaderTests: XCTestCase { XCTAssertEqual(reader.runOnce(), .decoded) XCTAssertTrue(w.emitted.last!.discontinuity) } + + func testLongSegmentsDoNotDecodeAgainWhilePlayheadIsUnchanged() { + let w = World() + w.segmentSeconds = 15 + w.playhead = 95.1 + w.segments = Set(0...30); w.highestStored = 30 + let reader = makeReader(w) + XCTAssertEqual(reader.runOnce(), .decoded) // 90..105 + XCTAssertEqual(reader.runOnce(), .decoded) // 105..120 overshoots the 10 s lead + for _ in 0..<10 { XCTAssertEqual(reader.runOnce(), .slept) } + XCTAssertEqual(w.decoded, [6, 7]) + XCTAssertEqual(w.emitted.count, 2) + XCTAssertFalse(w.emitted[1].discontinuity) + w.playhead = 110.1 + XCTAssertEqual(reader.runOnce(), .decoded) + XCTAssertEqual(reader.runOnce(), .slept) + XCTAssertEqual(w.decoded, [6, 7, 8]) + XCTAssertFalse(w.emitted[2].discontinuity) + } + + func testLongSegmentsStillReanchorOnBackwardSeek() { + let w = World() + w.segmentSeconds = 15 + w.playhead = 95.1 + w.segments = Set(0...30); w.highestStored = 30 + let reader = makeReader(w) + XCTAssertEqual(reader.runOnce(), .decoded) + XCTAssertEqual(reader.runOnce(), .decoded) + w.playhead = 50.1 + XCTAssertEqual(reader.runOnce(), .reanchored) + XCTAssertEqual(reader.runOnce(), .decoded) + XCTAssertEqual(w.decoded, [6, 7, 3]) + XCTAssertEqual(w.emitted.last!.sourceTime, 45, accuracy: 0.001) + XCTAssertTrue(w.emitted.last!.discontinuity) + } } diff --git a/docs/api.md b/docs/api.md index 2a2a4da77..1a0dd3151 100644 --- a/docs/api.md +++ b/docs/api.md @@ -221,6 +221,8 @@ A live join takes the same rung when the source delivers video for the whole key `installAudioTap()` returns an `AsyncStream` bound to the session it was installed against. It finishes on `load()`, on `stop()`, and on a **session-preserving reload**: an audio-track switch, a subtitle-track switch, a disc-title switch, `reloadAtCurrentPosition()`. Re-install on stream end to follow the new session (#356). This is deliberate rather than a gap: each install gets a fresh monotonic filter, so the new session's timeline starts clean instead of stitched across a reload that resumes slightly behind the old position. +`installAudioTap(lossless: Bool = false)` keeps the existing lossy newest-64 delivery by default. A continuous-analysis consumer can opt into `lossless: true` to retain queued PCM without eviction; the producer still never waits for the consumer. The queue is unbounded in this mode, so the host must drain it or cancel/remove the tap whenever it stops consuming, including a pause in a clock-paced analysis consumer. This policy does not repair gaps already present in the source or change the discontinuity flag or session lifetime. + Read `audioTapHasDeliverySource` synchronously after installing: false means the stream will finish without yielding (no session, a video-only source, a backend with no tap path), which is the moment to fail loudly rather than await an empty stream. ### Correcting a `LoadOption` without restarting the item @@ -845,7 +847,7 @@ suppressing `AVPlayerItemLegibleOutput` to keep the measurement running. | `$audioTracks` | `[TrackInfo]`. Republished when `LoadOptions.confirmAtmos` confirms a track, and when the DTS:X decoder confirms the playing track as DTS:X (`isDTSX`). On `.remoteBypass` it lists the audio tracks AVPlayer built for the item (normally just the one playing), read from their format descriptions: `codec` in libavcodec's spelling (`eac3`, `ac3`, `aac`), `channels`, `sampleRate`, `profile` (AAC's object type; "Dolby Digital Plus + Dolby Atmos" with `isAtmos` when the E-AC-3 dec3 record announces JOC) and `language` from the track or the selected audible option. The ids are synthetic (from 400000 up) because there is no stream index on that route; compare them only with `activeAudioTrackIndex`. | | `$activeAudioTrackIndex` | The selected track's id. On `.remoteBypass`, the enabled item track's. | | `selectAudioTrack(index:)` | Session-preserving reload. On the native path the old item stays mounted while the source reopens, and its last picture is held on the bound `AetherStillView` (or `AetherPlayerView`) across the item swap until the new item's first frame, so the switch shows a still rather than black (not gapless audio; nothing is held in PiP or on external playback, and a media-services reset still discards the item; see `AetherStillView` for the software path and Dolby Vision); elsewhere expect roughly 0.5 to 1 s of black. Rapid picks coalesce into one rebuild at a time, a pick back to the active track costs nothing, and `stop()` or a new `load` drops what is queued. It comes back in the session's transport like `reloadAtCurrentPosition()`: a paused session stays paused, and a `play()` or `pause()` that arrives while the rebuild awaits I/O decides the final state. `index` is `TrackInfo.id`. A no-op when out of range, already active, or on a forward-only custom source (live ingest included), which cannot rebuild its pipeline: there a track change is a fresh `load` naming the stream. Every refusal is logged, so a picker that does nothing is explainable. Also a no-op on `.remoteBypass`, where AVPlayer owns the audio selection and the list above is informational; a different language there is a different URL. | -| `installAudioTap()`, `removeAudioTap()`, `audioTapHasDeliverySource`, `AetherEngine.audioTapFormat` | Opt-in decoded PCM, mono Float32 48 kHz with source-PTS stamps, off the render path. See the contract above. | +| `installAudioTap()`, `installAudioTap(lossless:)`, `removeAudioTap()`, `audioTapHasDeliverySource`, `AetherEngine.audioTapFormat` | Opt-in decoded PCM, mono Float32 48 kHz with source-PTS stamps, off the render path. `lossless` defaults to false; true retains queued PCM without eviction. See the contract above. | ## Subtitles diff --git a/docs/architecture.md b/docs/architecture.md index fab48e1bc..7c86dd06a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -131,13 +131,15 @@ The native VIDEO host can own the same kind of session, but only on request (`ow `installAudioTap()` returns an `AsyncStream` of decoded playback audio: mono Float32 48 kHz (`AetherEngine.audioTapFormat`), stamped with source-PTS seconds (`sourceTime`, same axis as `engine.sourceTime`), with a `discontinuity` flag on any gap (seek, eviction, track switch, drops under pressure). Intended for host-side speech features (live transcription via SpeechAnalyzer) and audio recognition (ShazamKit signatures). -Native path: a loopback reader (`LoopbackAudioReader`) pulls the engine's own muxed fMP4 segments from the segment cache near the playhead and decodes their audio track out-of-band (libavcodec + libswresample, fresh per-segment demux context). Because it reads the mux, it follows the active audio track for free (the mux contains exactly the selected track, post-bridge), adds zero network load, and cannot stall playback by construction. Remote-HLS path (direct AVPlayer ingest, no loopback, VOD and live): `AudioTapHLSVariantResolver` picks the active audio rendition / muxed variant, `AudioTapHLSFetcher` fetches and decrypts (AES-128 clear-key) self-contained TS / fMP4 segments (sending `LoadOptions.httpHeaders`, same as the player's asset, #119), and a playhead-follow reader (`AudioTapHLSReader`) decodes them near the playhead; `AudioTapReaderSelection` makes the per-session reader choice, and `audioTapHasDeliverySource` tells the host whether the current session can deliver at all (fail-loud). Software path: the existing `AudioDecoder` PCM output is mirrored through an `AVAudioConverter` sink on `SoftwarePlaybackHost`. That decoder emits the source channel layout up to 7.1 without downmixing, so the sink takes its input format from the sample buffer's own channel layout (the channel-count-only `AVAudioFormat` initializer returns nil for every count above 2) and verifies once per format that the converter really mixes it down: some layouts (measured: 4ch Quadraphonic and any discrete layout) convert to digital silence without reporting an error, and those are folded to mono inside the tap instead (#400). One tap per engine; re-install replaces the previous stream, and `load()` / `stop()` finish it (opt-in is per load; with no session or a video-only source the stream finishes immediately). A session-preserving reload finishes it too (audio / subtitle / disc-title switch, `reloadAtCurrentPosition`): every delivery path is bound to the session it was installed against, and each install gets a fresh `AudioTapMonotonicFilter`, so a host re-installs on stream end and starts the new session on a clean timeline rather than one stitched across a reload that resumes slightly behind the old position (#356). Delivery is lossy under pressure (`bufferingNewest(64)`); live sources are best-effort. +Native path: a loopback reader (`LoopbackAudioReader`) pulls the engine's own muxed fMP4 segments from the segment cache near the playhead and decodes their audio track out-of-band (libavcodec + libswresample, fresh per-segment demux context). Because it reads the mux, it follows the active audio track for free (the mux contains exactly the selected track, post-bridge), adds zero network load, and cannot stall playback by construction. Remote-HLS path (direct AVPlayer ingest, no loopback, VOD and live): `AudioTapHLSVariantResolver` picks the active audio rendition / muxed variant, `AudioTapHLSFetcher` fetches and decrypts (AES-128 clear-key) self-contained TS / fMP4 segments (sending `LoadOptions.httpHeaders`, same as the player's asset, #119), and a playhead-follow reader (`AudioTapHLSReader`) decodes them near the playhead; `AudioTapReaderSelection` makes the per-session reader choice, and `audioTapHasDeliverySource` tells the host whether the current session can deliver at all (fail-loud). Software path: the existing `AudioDecoder` PCM output is mirrored through an `AVAudioConverter` sink on `SoftwarePlaybackHost`. That decoder emits the source channel layout up to 7.1 without downmixing, so the sink takes its input format from the sample buffer's own channel layout (the channel-count-only `AVAudioFormat` initializer returns nil for every count above 2) and verifies once per format that the converter really mixes it down: some layouts (measured: 4ch Quadraphonic and any discrete layout) convert to digital silence without reporting an error, and those are folded to mono inside the tap instead (#400). One tap per engine; re-install replaces the previous stream, and `load()` / `stop()` finish it (opt-in is per load; with no session or a video-only source the stream finishes immediately). A session-preserving reload finishes it too (audio / subtitle / disc-title switch, `reloadAtCurrentPosition`): every delivery path is bound to the session it was installed against, and each install gets a fresh `AudioTapMonotonicFilter`, so a host re-installs on stream end and starts the new session on a clean timeline rather than one stitched across a reload that resumes slightly behind the old position (#356). Delivery is lossy under pressure by default (`bufferingNewest(64)`). `installAudioTap(lossless: true)` selects an unbounded queue for a consumer that must keep draining or cancel/remove the tap when it stops consuming; this prevents queue eviction without blocking the producer. Source discontinuities remain explicit, and live sources remain best-effort. ``` native ──► SegmentCache (init + seg N) ──► mov demux ──► libavcodec ──► swr (mono 48k) ──► AsyncStream software ──► AudioDecoder CMSampleBuffers ──► AVAudioConverter (mono 48k) ──► AsyncStream ``` +Loopback and remote-HLS VOD readers remember the previous playhead to distinguish a real backward jump from a complete segment that overshoots the preferred decode lead (AE#864). Overshoot waits for playback; backward movement beyond the existing tolerance and a playhead ahead of decoded audio re-anchor with a discontinuity. The pacing decision does not infer a backward seek from decoded lead alone. + ### Playback status (`playbackPhase`) `AetherEngine.playbackPhase` is the single observable for what playback is doing right now, across all three pipelines. It is *derived*, not a parallel state machine: a pure fold of `state`, `isBuffering`, `isSeeking`, and a typed source-reconnect axis, recomputed on every input change so it can never desync from them. Each input's `didSet` triggers an idempotent recompute that re-emits only on an actual change.