記錄錄影音訊起點供 A/V origin 量測
Docs/ChangeHistory.mdliveAPP/Capture/CaptureSessionState.swiftliveAPP/Capture/RecordingLibrary.swiftliveAPP/Capture/SegmentedRecordingWriter.swift
commit c5c2cbaae69330bca38b97b44117ff78e71d7dbfAuthor: Nuclear0709 <agp05432@gmail.com>Date: Thu Nov 12 09:48:44 2026 +0800 記錄錄影音訊起點供 A/V origin 量測 diff --git a/Docs/ChangeHistory.md b/Docs/ChangeHistory.md--- a/Docs/ChangeHistory.md+++ b/Docs/ChangeHistory.md@@ -3,6 +3,7 @@ ## 2026.11.10 音訊主時鐘與 RTMP 診斷可讀性 - 錄影 manifest(sidecar)只在開始、完成或中斷等 phase 轉換時持久化 `sourceStartPTS`/`sourceEndPTS`;進度回報不把尾端累積在記憶體等待落盤,避免長錄影產生額外的狀態寫入。失敗收尾會主動釋放 `AVAssetWriter` 與檔案 URL,不讓失敗片段持有 handle。+- 錄影 manifest(sidecar)新增 `sourceAudioStartPTS`,用來量測第一個實際寫入音訊 sample 與 `sourceStartPTS`(第一個 video)的起點差;這只在 phase 轉換時落盤。 - 錄影 manifest(sidecar)新增 `sourceStartPTS`/`sourceEndPTS`/`fileStartPTS`,由 `SegmentedRecordingWriter` 在第一個 video sample 與完成時寫入;`filePTS = sourcePTS - sourceStartPTS + fileStartPTS` 可對照 App 擷取時鐘與檔案時間軸。此階段只建立對照,不改檔案時間戳。 - A/V Lag 新增 `sourcePTSDeltaDrift`/`sourcePTSDeltaPerMinute`/`avClockDrift`/`avSyncState`,以第一筆有效來源差為基準區分 stable/drifting/jumped;報告頁與 JSON 都能看到漂移趨勢,不直接用 `publish throughput avOffset` 判斷口型同步。 - 編碼輸入緩衝 UI 改稱「佇列保留」,新增 `received`/`consumed` 累計交接數;文件明確說明直接 handoff 時 `bytes=0`、`peakBytes=0` 是正常,不代表影格沒有進入編碼,也不代表沒有配置容量。diff --git a/liveAPP/Capture/CaptureSessionState.swift b/liveAPP/Capture/CaptureSessionState.swift--- a/liveAPP/Capture/CaptureSessionState.swift+++ b/liveAPP/Capture/CaptureSessionState.swift@@ -97,6 +97,8 @@ struct LocalRecording: Identifiable, Codable { var sourceStartPTS: Double? /// 本段最後成功寫入的 source PTS(影片與音訊尾端取較大者)。 var sourceEndPTS: Double?+ /// 本段第一個實際寫入的音訊 source PTS;用來量測 A/V 起點差。+ var sourceAudioStartPTS: Double? /// MP4 檔內第一個可播放 sample 的 PTS;正常由 writer 設為 0。 var fileStartPTS: Double? /// 合併產物所屬群組;與原始片段分開保存,不參與再次合併。diff --git a/liveAPP/Capture/RecordingLibrary.swift b/liveAPP/Capture/RecordingLibrary.swift--- a/liveAPP/Capture/RecordingLibrary.swift+++ b/liveAPP/Capture/RecordingLibrary.swift@@ -84,13 +84,17 @@ import Photos } /// 寫入此片段與 App 擷取時間軸的對照;manifest JSON 就是 sidecar。 /// sourceStartPTS 是本段第一個 video PTS,fileStartPTS 正常為 0。- func recordSourceRange(_ id: UUID, sourceStartPTS: Double?, sourceEndPTS: Double?, persist: Bool = true) {+ func recordSourceRange(_ id: UUID, sourceStartPTS: Double?, sourceEndPTS: Double?,+ sourceAudioStartPTS: Double? = nil, persist: Bool = true) { guard let index = recordings.firstIndex(where: { $0.id == id }) else { return } var item = recordings[index] if let sourceStartPTS, item.sourceStartPTS == nil { item.sourceStartPTS = sourceStartPTS item.fileStartPTS = item.fileStartPTS ?? 0 }+ if let sourceAudioStartPTS, item.sourceAudioStartPTS == nil {+ item.sourceAudioStartPTS = sourceAudioStartPTS+ } if let sourceEndPTS, sourceEndPTS.isFinite { item.sourceEndPTS = max(item.sourceEndPTS ?? sourceEndPTS, sourceEndPTS) }diff --git a/liveAPP/Capture/SegmentedRecordingWriter.swift b/liveAPP/Capture/SegmentedRecordingWriter.swift--- a/liveAPP/Capture/SegmentedRecordingWriter.swift+++ b/liveAPP/Capture/SegmentedRecordingWriter.swift@@ -119,6 +119,8 @@ actor RecordingSegmentWriter { private var lastVideo: CMTime = .invalid /// 本段最後成功寫入的音訊 PTS;尚未寫入時為 invalid。 private var lastAudio: CMTime = .invalid+ /// 本段第一個實際寫入的音訊 PTS;用來量測檔案 A/V 起點。+ private var firstAudio: CMTime = .invalid /// 最後一次報告的時間戳(秒)。 private var lastReport = -Double.infinity /// 本段最近成功讀取的檔案大小;讀取失敗時保留,不以零覆寫。@@ -205,6 +207,7 @@ actor RecordingSegmentWriter { lastVideo = pts videoCount += 1 } else {+ if !firstAudio.isValid { firstAudio = pts } lastAudio = pts audioCount += 1 audioEnd = sample.duration.isValid ? pts + sample.duration : pts@@ -352,6 +355,7 @@ actor RecordingSegmentWriter { id, sourceStartPTS: sourceStart, sourceEndPTS: persistSourceRange ? sourceEnd : nil,+ sourceAudioStartPTS: persistSourceRange && firstAudio.isValid ? firstAudio.seconds : nil, persist: persistSourceRange) await library.update(id, phase: phase, duration: duration, bytes: bytes, message: "可控錄製 · 第 \(number) 段 · \(bitrate) · 寫入端丟棄 \(dropped) 個樣本" + sizeWarning)