app · 94669ee

Dev index Recent changes / versions Changelog About TODO

新增 A/V 漂移診斷並釐清編碼輸入佇列語意

94669ee · 2026-11-12 08:41 · app
Docs/ChangeHistory.mdDocs/av-pipeline.mdDocs/development/AV_LAG.mdSharedCapture/StreamDiagnostics.swiftliveAPP/Capture/AVLagDiagnostics.swiftliveAPP/Capture/AVLagReportView.swiftliveAPP/StreamDiagnosticsView.swiftliveAPPTests/AVLagDiagnosticsTests.swift
commit 94669ee60f9204b6b1e9f101288a81d7673859e8Author: Nuclear0709 <agp05432@gmail.com>Date:   Thu Nov 12 08:41:19 2026 +0800     新增 A/V 漂移診斷並釐清編碼輸入佇列語意 diff --git a/Docs/ChangeHistory.md b/Docs/ChangeHistory.mdindex 00a1646..ffc4535 100644--- a/Docs/ChangeHistory.md+++ b/Docs/ChangeHistory.md@@ -2,6 +2,8 @@  ## 2026.11.10 音訊主時鐘與 RTMP 診斷可讀性 +- A/V Lag 新增 `sourcePTSDeltaDrift`/`sourcePTSDeltaPerMinute`/`avClockDrift`/`avSyncState`,以第一筆有效來源差為基準區分 stable/drifting/jumped;報告頁與 JSON 都能看到漂移趨勢,不直接用 `publish throughput avOffset` 判斷口型同步。+- 編碼輸入緩衝 UI 改稱「佇列保留」,新增 `received`/`consumed` 累計交接數;文件明確說明直接 handoff 時 `bytes=0`、`peakBytes=0` 是正常,不代表影格沒有進入編碼,也不代表沒有配置容量。 - TTS 最小朗讀字數預設改為 5,並把 UI 的 `@AppStorage` 與 `TTSService` 的 fallback 統一由 `TTSDefaults` 提供,避免兩邊預設值不同。最小/最大字數、佇列上限與溢出策略現在變更時立即 `updateDefault()`,不再只依賴離開設定頁或按「套用設定」才生效;`[TTS]` 同步日誌會輸出目前生效的 `minLen`/`maxLen`/`maxQueue`。 - 贊助訊息 `text` 內的圖片 URL 不再只依副檔名判斷:`extractAllImageURLs` 可接受呼叫端提供的 `knownImageURLs`(例如 `iconURL`),因此 `/media/<hash>` 這類沒有 `.png` 的網址也能被解析成行內 emoji,不再把原始 URL 留在文案中被切成多頁。插圖位置改以 UTF-16 index 計算,修正 emoji 在前時與 CoreText/NSString 位置不一致;一般非圖片連結仍保留原文。 - 音訊 PTS 缺口改為大小分流:小於 250 ms 才補零;超過時重建來源時間軸(`rebase`),不再用數秒靜音塞滿環形緩衝並擠掉歷史音訊。`AudioRingBuffer` 產生最多 8 筆 `GapEvent`,含前後 PTS、gap samples、mode、緩衝前後量與 dropped samples;SCK/ReplayKit 會寫出 `[CaptureAudioGap]` event-level log,週期摘要也新增 `zeroFill`/`rebase` 計數。diff --git a/Docs/av-pipeline.md b/Docs/av-pipeline.mdindex 5b39072..ca50a68 100644--- a/Docs/av-pipeline.md+++ b/Docs/av-pipeline.md@@ -736,6 +736,8 @@ App 目前分開設定:  設定在 `screenCaptureKitVideoBufferMB`/`screenCaptureKitVideoBufferDurationMS`、`replayKitVideoBufferMB`/`replayKitVideoBufferDurationMS`。診斷會顯示 `byteLimit`、`bytes`、`queued`、`peakBytes`、`oldestAge`、capacity/expired/oversized drops、input/output idle;若 `capacityDrops` 與 `queued ≈ limit` 增加才考慮放寬,若 `expiredDrops` 增加則應先查 encoder 或 worker 效率。 +`bytes` 與 `queued` 是**取樣當下佇列仍保留的量**,不是累計送入量。`AdaptiveMediaQueue.offer()` 若發現 consumer 正在等待,會直接交接影格,不會 append 到 entries,所以 `queued=0`、`bytes=0`、甚至 `peakBytes=0` 可以是正常的即時 handoff;此時 `received`/`consumed` 仍會增加。只有在 consumer 落後時,`bytes`、`oldestAge` 與 drops 才會反映真正的 backlog。+ ### 音訊 PTS 缺口(2026-11-11)  來源 PTS 缺口不再一律補零:diff --git a/Docs/development/AV_LAG.md b/Docs/development/AV_LAG.mdindex fbbe703..64c4af3 100644--- a/Docs/development/AV_LAG.md+++ b/Docs/development/AV_LAG.md@@ -27,6 +27,10 @@ videoContentAge = arrival - videoPTS | `videoSourceAge` | 加工完成時間 - 影片來源 PTS | | `audioContentAge` | 系統音訊回呼到達時間 - 音訊來源 PTS | | `sourcePTSDelta` | `videoPTS - audioPTS`,只作交叉比對 |+| `sourcePTSDeltaDrift` | 相對本 session 第一筆有效 `sourcePTSDelta` 的累積變化;負值代表音訊 PTS 持續往後 |+| `sourcePTSDeltaPerMinute` | 上述漂移換算成每分鐘變化 |+| `avClockDrift` | `audioContentAge - videoContentAge`,觀察來源時鐘相對 host clock 的漂移 |+| `avSyncState` | `stable`/`drifting`/`jumped`;只作診斷,不直接修改時間戳 | | `contentAgeJump` | 與上一次內容年齡相差 ≥1 秒時記錄;沒有階梯事件時顯示 none,不是尚未實作 | | `videoQueue` | `CaptureMailbox` 的 queued/dropped/bytes 摘要 | @@ -62,5 +66,6 @@ videoContentAge = arrival - videoPTS  1. `videoContentAge` 與 `videoQueueOldestAge` 同時階梯上升 → 影片管線 backlog,優先查 queue/PiP/主執行緒 stall。 2. `videoContentAge` 穩定但 `publishPTS - sourcePTS` 變大 → timestamp 重基準。-3. `videoSourcePTS - audioSourcePTS` 長期漂移 → 來源時鐘或 audio preferredDelta。-4. 只有 PiP 計時器落後、遊戲畫面正常 → 檢查 overlay timer 本身,不要用 timer retime 整段 video。+3. `sourcePTSDeltaDrift`/`sourcePTSDeltaPerMinute` 長期單向變化 → 來源音訊時鐘或 audio preferredDelta;先看 `avClockDrift` 是否同時變大。+4. `avSyncState=jumped` 表示 10 秒內來源 A/V 差突然超過 100 ms;只當事件,不要直接推論整段漂移。+5. 只有 PiP 計時器落後、遊戲畫面正常 → 檢查 overlay timer 本身,不要用 timer retime 整段 video。diff --git a/SharedCapture/StreamDiagnostics.swift b/SharedCapture/StreamDiagnostics.swiftindex c0c1b9b..a14eda9 100644--- a/SharedCapture/StreamDiagnostics.swift+++ b/SharedCapture/StreamDiagnostics.swift@@ -39,6 +39,10 @@ struct StreamDiagnosticsSnapshot: Codable, Sendable {     struct EncoderInputBufferDiagnostics: Codable, Sendable {         let availability: String         let generation: UInt64?+        /// offer() 嘗試送入的累計影格數;包含拒收,不等於已編碼數。+        let received: Int?+        /// 已交付消費端的累計影格數。+        let consumed: Int?         let queued: Int?         let bytes: Int?         let byteLimit: Int?@@ -58,6 +62,8 @@ struct StreamDiagnosticsSnapshot: Codable, Sendable {             return Self(                 availability: stage.availability.rawValue,                 generation: stage.generation,+                received: queue?.received,+                consumed: queue?.consumed,                 queued: queue?.queued,                 bytes: queue?.bytes,                 byteLimit: queue?.byteLimit,diff --git a/liveAPP/Capture/AVLagDiagnostics.swift b/liveAPP/Capture/AVLagDiagnostics.swiftindex 5dd1691..34d3c25 100644--- a/liveAPP/Capture/AVLagDiagnostics.swift+++ b/liveAPP/Capture/AVLagDiagnostics.swift@@ -14,6 +14,14 @@ struct AVLagObservation: Codable, Equatable, Sendable {     var videoProcessAge: Double?     var videoSourceAge: Double?     var sourcePTSDelta: Double?+    /// 相對本 session 第一筆有效 `sourcePTSDelta` 的累積變化;負值代表音訊 PTS 相對影片持續往後。+    var sourcePTSDeltaDrift: Double?+    /// 上述漂移換算成每分鐘變化;取樣時間太短時為 nil。+    var sourcePTSDeltaPerMinute: Double?+    /// 音訊 host-minus-PTS 減影片 host-minus-PTS;用來觀察來源時鐘漂移。+    var avClockDrift: Double?+    /// stable / drifting / jumped;只是診斷,不直接修正時間戳。+    var avSyncState: String?     var videoQueue: String?     var contentAgeJump: Double?     var videoFrames = 0@@ -29,6 +37,8 @@ struct AVLagObservation: Codable, Equatable, Sendable {             + "videoQueueAge=\(number(videoQueueAge)) videoQueueOldestAge=\(number(videoQueueOldestAge)) "             + "videoProcessAge=\(number(videoProcessAge)) "             + "videoSourceAge=\(number(videoSourceAge)) sourcePTSDelta=\(number(sourcePTSDelta)) "+            + "deltaDrift=\(number(sourcePTSDeltaDrift)) deltaPerMin=\(number(sourcePTSDeltaPerMinute)) "+            + "clockDrift=\(number(avClockDrift)) syncState=\(avSyncState ?? "unknown") "             + "jump=\(number(contentAgeJump)) frames=\(videoFrames)/\(audioFrames) "             + "queue=\(videoQueue ?? "unknown")"     }@@ -51,6 +61,10 @@ final class AVLagDiagnostics: @unchecked Sendable {     private var videoFrames = 0     private var audioFrames = 0     private var lastVideoAt: Double?+    private var firstSourceDelta: Double?+    private var firstSourceDeltaAt: Double?+    private var lastSourceDelta: Double?+    private var lastSourceDeltaAt: Double?      /// 影片從 SCStream 交給 worker 時呼叫;contentAge 是回呼到達時間減來源 PTS。     func recordVideo(arrival: Double, pts: Double, at now: Double,@@ -102,6 +116,44 @@ final class AVLagDiagnostics: @unchecked Sendable {         } else {             sourceDelta = nil         }++        if let sourceDelta {+            if firstSourceDelta == nil {+                firstSourceDelta = sourceDelta+                firstSourceDeltaAt = now+            }+        }+        let sourceDeltaDrift = sourceDelta.flatMap { current in+            firstSourceDelta.map { current - $0 }+        }+        let sourceDeltaPerMinute: Double?+        if let sourceDeltaDrift, let firstSourceDeltaAt, now > firstSourceDeltaAt {+            sourceDeltaPerMinute = sourceDeltaDrift / ((now - firstSourceDeltaAt) / 60)+        } else {+            sourceDeltaPerMinute = nil+        }+        let clockDrift: Double?+        if let audioContentAge, let videoContentAge {+            clockDrift = audioContentAge - videoContentAge+        } else {+            clockDrift = nil+        }+        var syncState: String?+        if let sourceDelta {+            // 100 ms 以上的變化只在短時間內視為跳變;累積 60 秒的變化+            // 應標成 drifting,不要讓一次正常取樣窗口掩蓋漂移趨勢。+            let changedQuickly = lastSourceDeltaAt.map { now - $0 < 10 } ?? false+            let jumped = changedQuickly+                && (lastSourceDelta.map { abs(sourceDelta - $0) >= 0.100 } ?? false)+            let drifting = abs(sourceDeltaDrift ?? 0) >= 0.080+                || abs(clockDrift ?? 0) >= 0.080+            syncState = jumped ? "jumped" : (drifting ? "drifting" : "stable")+        }+        if sourceDelta != nil {+            lastSourceDelta = sourceDelta+            lastSourceDeltaAt = now+        }+         let observation = AVLagObservation(             session: session,             sampledAt: sampledAt,@@ -115,6 +167,10 @@ final class AVLagDiagnostics: @unchecked Sendable {             videoProcessAge: videoProcessAge,             videoSourceAge: videoSourceAge,             sourcePTSDelta: sourceDelta,+            sourcePTSDeltaDrift: sourceDeltaDrift,+            sourcePTSDeltaPerMinute: sourceDeltaPerMinute,+            avClockDrift: clockDrift,+            avSyncState: syncState,             videoQueue: videoQueue,             contentAgeJump: pendingJump,             videoFrames: videoFrames,diff --git a/liveAPP/Capture/AVLagReportView.swift b/liveAPP/Capture/AVLagReportView.swiftindex 5aa42f3..74c2ed9 100644--- a/liveAPP/Capture/AVLagReportView.swift+++ b/liveAPP/Capture/AVLagReportView.swift@@ -24,6 +24,10 @@ struct AVLagReportView: View {                     row("video process age", seconds(latest.videoProcessAge))                     row("video source age", seconds(latest.videoSourceAge))                     row("video PTS - audio PTS", seconds(latest.sourcePTSDelta))+                    row("A/V source drift", seconds(latest.sourcePTSDeltaDrift))+                    row("A/V drift per minute", seconds(latest.sourcePTSDeltaPerMinute))+                    row("audio - video host offset", seconds(latest.avClockDrift))+                    row("A/V sync state", latest.avSyncState ?? "unknown")                     row("content age jump", latest.contentAgeJump.map { seconds($0) }                         ?? AppLanguage.localized("avLag.noJump"))                     row("frames", "\(latest.videoFrames) video / \(latest.audioFrames) audio")diff --git a/liveAPP/StreamDiagnosticsView.swift b/liveAPP/StreamDiagnosticsView.swiftindex a4c9c23..812d2f4 100644--- a/liveAPP/StreamDiagnosticsView.swift+++ b/liveAPP/StreamDiagnosticsView.swift@@ -234,15 +234,19 @@ struct StreamDiagnosticsSections: View {         group("編碼輸入緩衝") {             if let input = value.encoderInput {                 detail("佇列狀態", input.availability)-                detail("容量", [+                detail("佇列上限", [                     megabytes(input.byteLimit).map { "上限 \($0)" },                     input.manualFrameLimit.map { "手動上限 \($0) frames" } ?? "自動(無手動 frame cap)",                     input.maxAge.map { "最長 \(milliseconds($0))" }                 ].compactMap { $0 }.joined(separator: " · "))-                detail("目前使用", [-                    megabytes(input.bytes).map { "已用 \($0)" },+                detail("佇列保留", [+                    megabytes(input.bytes).map { "保留 \($0)" },                     input.queued.map { "\($0) frames" },-                    megabytes(input.peakBytes).map { "峰值 \($0)" }+                    megabytes(input.peakBytes).map { "佇列峰值 \($0)" }+                ].compactMap { $0 }.joined(separator: " · "))+                detail("累計交接", [+                    input.received.map { "已接收 \($0)" },+                    input.consumed.map { "已取出 \($0)" }                 ].compactMap { $0 }.joined(separator: " · "))                 detail("最舊影格", input.oldestAge.map { milliseconds($0) })                 detail("丟棄", [@@ -254,7 +258,7 @@ struct StreamDiagnosticsSections: View {                     input.inputIdle.map { "input \(milliseconds($0))" },                     input.outputIdle.map { "output \(milliseconds($0))" }                 ].compactMap { $0 }.joined(separator: " · "))-                Text("這是編碼前的原始影格 queue,不是 VT pending,也不是 RTMP 已編碼待送 bytes。")+                Text("這是編碼前的原始影格 queue,不是 VT pending,也不是 RTMP 已編碼待送 bytes。保留 0.0 MB/0 frames 可能代表 worker 正在等待、影格已直接交接;不代表沒有影格進入編碼。佇列峰值只計算實際堆積,不包含直接交接。")                     .font(.caption).foregroundStyle(.secondary)             } else {                 Text("此快照沒有編碼輸入緩衝資料。")diff --git a/liveAPPTests/AVLagDiagnosticsTests.swift b/liveAPPTests/AVLagDiagnosticsTests.swiftindex c89c906..991db6e 100644--- a/liveAPPTests/AVLagDiagnosticsTests.swift+++ b/liveAPPTests/AVLagDiagnosticsTests.swift@@ -36,6 +36,23 @@ struct AVLagDiagnosticsTests {         #expect(diagnostics.snapshot(session: "s", now: 21)?.contentAgeJump == nil)     } +    @Test func reportsSourceDeltaDriftAndSyncState() {+        let diagnostics = AVLagDiagnostics()+        diagnostics.recordVideo(arrival: 100, pts: 100, at: 100, queue: "q", queueOldestAge: nil)+        diagnostics.recordAudio(arrival: 100, pts: 99.97, at: 100, isMic: false)+        let baseline = diagnostics.snapshot(session: "s", now: 100)+        #expect(abs((baseline?.sourcePTSDeltaDrift ?? 1) - 0) < 0.0001)+        #expect(baseline?.avSyncState == "stable")++        diagnostics.recordVideo(arrival: 160, pts: 160, at: 160, queue: "q", queueOldestAge: nil)+        diagnostics.recordAudio(arrival: 160, pts: 159.77, at: 160, isMic: false)+        let drifted = diagnostics.snapshot(session: "s", now: 160)+        #expect(abs((drifted?.sourcePTSDeltaDrift ?? 0) - 0.2) < 0.0001)+        #expect(abs((drifted?.sourcePTSDeltaPerMinute ?? 0) - 0.2) < 0.0001)+        #expect(abs((drifted?.avClockDrift ?? 0) - 0.23) < 0.0001)+        #expect(drifted?.avSyncState == "drifting")+    }+     @Test func noVideoMeansUnknownInsteadOfZero() {         let diagnostics = AVLagDiagnostics()         diagnostics.recordAudio(arrival: 1, pts: 1, at: 1, isMic: false)