改善音訊 PTS 缺口策略與事件診斷
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.md--- 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.md--- 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.resolved--- 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.swift--- 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.swift--- 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.md--- 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.resolved--- 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.swift--- 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.swift--- 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) }