app · 6dff051

Dev index Recent changes / versions Changelog About TODO

改善音訊 PTS 缺口策略與事件診斷

6dff051 · 2026-11-11 22:27 · app
Docs/ChangeHistory.mdDocs/av-pipeline.mdPackage.resolvedReplyKIT/SampleHandler.swiftSharedCapture/StreamDiagnostics.swiftTODO.mdliveAPP.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolvedliveAPP/Capture/ScreenCaptureSource.swiftliveAPP/StreamDiagnosticsView.swift
commit 6dff0519afd61eb7101e8f153840b9c0776611a0Author: Nuclear0709 <agp05432@gmail.com>Date:   Wed Nov 11 22:27:01 2026 +0800     改善音訊 PTS 缺口策略與事件診斷 diff --git a/Docs/ChangeHistory.md b/Docs/ChangeHistory.mdindex a9abb8e..a446df5 100644--- a/Docs/ChangeHistory.md+++ b/Docs/ChangeHistory.md@@ -2,6 +2,7 @@  ## 2026.11.10 音訊主時鐘與 RTMP 診斷可讀性 +- 音訊 PTS 缺口改為大小分流:小於 250 ms 才補零;超過時重建來源時間軸(`rebase`),不再用數秒靜音塞滿環形緩衝並擠掉歷史音訊。`AudioRingBuffer` 產生最多 8 筆 `GapEvent`,含前後 PTS、gap samples、mode、緩衝前後量與 dropped samples;SCK/ReplayKit 會寫出 `[CaptureAudioGap]` event-level log,週期摘要也新增 `zeroFill`/`rebase` 計數。 - `CaptureMediaPipeline.audioSettings` 在麥克風啟用時改用 mic(track 1)作為 Mixer 主時鐘,輸出格式仍取 App 軌;避免 App 軌靜默但持續 callback 時,mic 被當成非主軌反覆 align/丟樣本而斷續。mic 停止或關閉時,底層 `activeClockTrack` 會 fallback 到 App 軌。 - 2ch mixer 測試現在會斷言輸出內容不是靜音(`channelRMS`),補上先前只檢查格式、未檢查實際混音的盲點。 - `AudioPipelineDiagnostics.Track` 新增每軌輸出 RMS;`ScreenCaptureSource` 的 `[CaptureAudioPipeline]` 同時記錄 `alignDrop`/`alignInsert`/`alignDiff`,可區分麥克風真正斷斷續續與只看到 mixed snapshot。diff --git a/Docs/av-pipeline.md b/Docs/av-pipeline.mdindex 6d12efb..5b39072 100644--- a/Docs/av-pipeline.md+++ b/Docs/av-pipeline.md@@ -736,6 +736,21 @@ App 目前分開設定:  設定在 `screenCaptureKitVideoBufferMB`/`screenCaptureKitVideoBufferDurationMS`、`replayKitVideoBufferMB`/`replayKitVideoBufferDurationMS`。診斷會顯示 `byteLimit`、`bytes`、`queued`、`peakBytes`、`oldestAge`、capacity/expired/oversized drops、input/output idle;若 `capacityDrops` 與 `queued ≈ limit` 增加才考慮放寬,若 `expiredDrops` 增加則應先查 encoder 或 worker 效率。 +### 音訊 PTS 缺口(2026-11-11)++來源 PTS 缺口不再一律補零:++- `<= 250 ms`:`zeroFill`,把精確長度的靜音補在尾端。+- `> 250 ms`:`rebase`,直接採用新來源時間,不把數秒靜音塞進環形緩衝;既有待消費音訊不會被 `appendZeros` 的 overflow 先擠掉。++每個缺口最多保留 8 筆 `AudioPipelineDiagnostics.GapEvent`,包含 sequence、前後 PTS、gapSamples、mode、緩衝前後量與 droppedSamples。SCK/ReplayKit 會寫出:++```text+[CaptureAudioGap] track=0 sequence=... previousPTS=... currentPTS=... gapSamples=... mode=... bufferedBefore=... bufferedAfter=... dropped=...+```++週期摘要新增 `zeroFill`/`rebase` 計數。這讓下一次長直播可以直接看到缺口發生點,而不是只看到累計 `gap` 數字;若 `rebase` 與 `CaptureCallbacks` 的 audio gap 同時增加,優先查來源 callback/系統負載,而不是 mixer align。+ ### 診斷邏輯  - `align-drop`:`AudioRingBuffer.align()` 丟棄非 main track 樣本。這是目前最可疑的 content-level 斷音來源(雙軌 PTS 基座差被當成 desync)。diff --git a/Package.resolved b/Package.resolvedindex 1d50992..da54460 100644--- a/Package.resolved+++ b/Package.resolved@@ -6,11 +6,10 @@       "location" : "https://github.com/TwhomeGH/HaishinKitFixSwfit.git",       "state" : {         "branch" : "main",-        "revision" : "46bec3ac2ef987cc45825a7382e4cb07f21077b9"+        "revision" : "eaf2fcbb18618cbe6d0688785fbc7713660d29b4"       }     }   ],   "version" : 2 } -diff --git a/ReplyKIT/SampleHandler.swift b/ReplyKIT/SampleHandler.swiftindex 9057d03..2b4b069 100644--- a/ReplyKIT/SampleHandler.swift+++ b/ReplyKIT/SampleHandler.swift@@ -1459,6 +1459,15 @@ class SampleHandler: RPBroadcastSampleHandler , @unchecked Sendable{                             sendlog(message: "[ReplayVideoProcessing] \(processing.summary) busyPercent=\(processing.load.map { String($0.busyPercent) } ?? "unknown")")                         }                         let audio = await self.mediaMixer.audioPipelineDiagnostics()+                        for track in audio.tracks {+                            let previousSequence = self.lastAudioGapSequence[track.trackId] ?? 0+                            for gap in track.gapEvents where gap.sequence > previousSequence {+                                sendlog(message: "[CaptureAudioGap] source=ReplayKit track=\(track.trackId) sequence=\(gap.sequence) previousPTS=\(gap.previousPTS) currentPTS=\(gap.currentPTS) gapSamples=\(gap.gapSamples) mode=\(gap.mode) bufferedBefore=\(gap.bufferedBefore) bufferedAfter=\(gap.bufferedAfter) dropped=\(gap.droppedSamples)")+                            }+                            if let last = track.gapEvents.last?.sequence {+                                self.lastAudioGapSequence[track.trackId] = max(previousSequence, last)+                            }+                        }                         snapshot.audioTracks = StreamDiagnosticsSnapshot.audioTrackDiagnostics(                             from: audio.tracks)                         SocketClient.shared.sendStreamDiagnostics(snapshot)@@ -1822,6 +1831,7 @@ class SampleHandler: RPBroadcastSampleHandler , @unchecked Sendable{     private var appAudioGapMax: Double = 0     private var micAudioGapMax: Double = 0     private var lastAudioDiagByTrack: [UInt8: AudioDiagCounter] = [:]+    private var lastAudioGapSequence: [UInt8: UInt64] = [:]     private var lastMixerOutputFrames: Int = 0     private var lastAudioDiagnosticsTime: Double?     private var lastAudioFormats: [String: (format: AudioStreamBasicDescription, samples: Int)] = [:]diff --git a/SharedCapture/StreamDiagnostics.swift b/SharedCapture/StreamDiagnostics.swiftindex db076ca..1ab437d 100644--- a/SharedCapture/StreamDiagnostics.swift+++ b/SharedCapture/StreamDiagnostics.swift@@ -93,10 +93,26 @@ struct StreamDiagnosticsSnapshot: Codable, Sendable {         let alignFireCount: Int         let lastAlignDiff: Int         let outputRMS: [Float]+        /// 來源 PTS 缺口事件;舊版來源沒有此欄位。+        let gapEvents: [AudioGapEvent]?+        let gapZeroFillCount: Int?+        let gapRebaseCount: Int?          var id: UInt8 { trackId }     } +    /// 單次音訊缺口的純量摘要;不攜帶 PCM。+    struct AudioGapEvent: Codable, Sendable {+        let sequence: UInt64+        let previousPTS: Double+        let currentPTS: Double+        let gapSamples: Int+        let mode: String+        let bufferedBefore: Int+        let bufferedAfter: Int+        let droppedSamples: Int+    }+     /// 將 Mixer 的 AVCon/緩衝診斷轉成可跨 Socket 傳送的純量快照。     static func audioTrackDiagnostics(         from tracks: [AudioPipelineDiagnostics.Track]@@ -120,7 +136,16 @@ struct StreamDiagnosticsSnapshot: Codable, Sendable {                 alignInsertedSamples: track.alignInsertedSamples,                 alignFireCount: track.alignFireCount,                 lastAlignDiff: track.lastAlignDiff,-                outputRMS: track.outputChannelRMS)+                outputRMS: track.outputChannelRMS,+                gapEvents: track.gapEvents.map {+                    AudioGapEvent(sequence: $0.sequence, previousPTS: $0.previousPTS,+                                  currentPTS: $0.currentPTS, gapSamples: $0.gapSamples,+                                  mode: $0.mode, bufferedBefore: $0.bufferedBefore,+                                  bufferedAfter: $0.bufferedAfter,+                                  droppedSamples: $0.droppedSamples)+                },+                gapZeroFillCount: track.gapZeroFillCount,+                gapRebaseCount: track.gapRebaseCount)         }     }     /// ScreenCaptureKit 畫面加工摘要;未走本機加工時為 nil。diff --git a/TODO.md b/TODO.mdindex 57ecb4e..f3e542c 100644--- a/TODO.md+++ b/TODO.md@@ -292,6 +292,7 @@ API 遷移屬後續技術評估,不作為以上功能完成的條件;需要  - [x] 移除強制揚聲器與失敗收尾的排他音訊還原,補上音訊生命週期診斷。 - [ ] 實機測試推流逾時及正常停止不打斷外部影片,涵蓋麥克風開關、耳機及 PiP/TTS。+- [ ] **音訊缺口新策略驗收**:下一次長直播確認 `[CaptureAudioGap]` 會記錄 `zeroFill`/`rebase`、前後 PTS、gap samples 與 dropped;小缺口仍補零,大缺口不再用數秒靜音擠掉歷史音訊。需與 `CaptureCallbacks` audio gap、App footprint 對照,確認是否為系統高負載造成 callback starvation。  ## 本地錄影資訊與分析 diff --git a/liveAPP.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/liveAPP.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolvedindex 75f7e60..e6dc59e 100644--- a/liveAPP.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved+++ b/liveAPP.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved@@ -6,7 +6,7 @@       "location" : "https://github.com/TwhomeGH/HaishinKitFixSwfit.git",       "state" : {         "branch" : "main",-        "revision" : "46bec3ac2ef987cc45825a7382e4cb07f21077b9"+        "revision" : "eaf2fcbb18618cbe6d0688785fbc7713660d29b4"       }     }   ],@@ -19,3 +19,4 @@   +diff --git a/liveAPP/Capture/ScreenCaptureSource.swift b/liveAPP/Capture/ScreenCaptureSource.swiftindex 698c2aa..1d67458 100644--- a/liveAPP/Capture/ScreenCaptureSource.swift+++ b/liveAPP/Capture/ScreenCaptureSource.swift@@ -178,6 +178,8 @@ private final class ScreenSamplePump: NSObject, SCStreamOutput, @unchecked Senda     }     private var audioProbe: ScreenAudioProbe?     private var streamDiagnosticsProbe: StreamDiagnosticsProbe?+    /// 每軌已記錄到的 gap 事件序號;避免同一個缺口每 5 秒重複寫 log。+    private var lastAudioGapSequence: [UInt8: UInt64] = [:]     private var telemetry = CaptureStreamTelemetry()     private let telemetryChanged: @MainActor (CaptureStreamTelemetry) -> Void     private var recording: ScreenRecordingSession?@@ -409,8 +411,17 @@ private final class ScreenSamplePump: NSObject, SCStreamOutput, @unchecked Senda                 if let mixer = self.pipeline?.mixer {                     let health = await mixer.audioPipelineDiagnostics()                     guard !Task.isCancelled else { return }+                    for track in health.tracks {+                        let previousSequence = lastAudioGapSequence[track.trackId] ?? 0+                        for event in track.gapEvents where event.sequence > previousSequence {+                            sendlog(message: "[CaptureAudioGap] session=\(token) track=\(track.trackId) sequence=\(event.sequence) previousPTS=\(event.previousPTS) currentPTS=\(event.currentPTS) gapSamples=\(event.gapSamples) mode=\(event.mode) bufferedBefore=\(event.bufferedBefore) bufferedAfter=\(event.bufferedAfter) dropped=\(event.droppedSamples)")+                        }+                        if let last = track.gapEvents.last?.sequence {+                            lastAudioGapSequence[track.trackId] = max(previousSequence, last)+                        }+                    }                     let tracks = health.tracks.map {-                        "track=\($0.trackId) rms=\($0.outputChannelRMS) converted=\($0.outputFrames) noData=\($0.resampleNoDataCount) buffered=\($0.ringBufferCounts) overflow=\($0.overflowDroppedSamples) gap=\($0.skipInsertedSamples) alignDrop=\($0.alignDroppedSamples) alignInsert=\($0.alignInsertedSamples) alignFire=\($0.alignFireCount) alignDiff=\($0.lastAlignDiff)"+                        "track=\($0.trackId) rms=\($0.outputChannelRMS) converted=\($0.outputFrames) noData=\($0.resampleNoDataCount) buffered=\($0.ringBufferCounts) overflow=\($0.overflowDroppedSamples) gap=\($0.skipInsertedSamples) zeroFill=\($0.gapZeroFillCount) rebase=\($0.gapRebaseCount) alignDrop=\($0.alignDroppedSamples) alignInsert=\($0.alignInsertedSamples) alignFire=\($0.alignFireCount) alignDiff=\($0.lastAlignDiff)"                     }.joined(separator: " | ")                     self.telemetry.mixerAudio = "mixed=\(health.mixerOutputFrames) ready=\(health.mixerReady) channels=\(health.outputChannels) \(self.audioProbe?.summary() ?? "probe=none")"                     audioTrackDetails = StreamDiagnosticsSnapshot.audioTrackDiagnostics(diff --git a/liveAPP/StreamDiagnosticsView.swift b/liveAPP/StreamDiagnosticsView.swiftindex 8971c31..a4c9c23 100644--- a/liveAPP/StreamDiagnosticsView.swift+++ b/liveAPP/StreamDiagnosticsView.swift@@ -132,6 +132,11 @@ struct MixerPipelineCard: View {                             .map { String(format: "%.4f", $0) }                             .joined(separator: ", "))                             .font(.caption2)+                        if let gap = track.gapEvents?.last {+                            Text("最近缺口:\(gap.mode) · \(gap.gapSamples) samples · PTS \(String(format: "%.3f", gap.previousPTS))→\(String(format: "%.3f", gap.currentPTS)) · dropped \(gap.droppedSamples)")+                                .font(.caption2)+                                .foregroundStyle(gap.mode == "rebase" ? Color.orange : Color.secondary)+                        }                         if let lastError = track.lastError {                             Text(lastError).font(.caption2).foregroundStyle(.red)                         }