From 7cfeec1f15981b9ab1debaf32a52005a26711387 Mon Sep 17 00:00:00 2001 From: yumoqing Date: Sat, 27 Jun 2026 17:45:00 +0800 Subject: [PATCH] feat: recording diagnostic report system - AudioTapContext tracks: peak amplitude, active/silent call counts, skip/error counters, last active timestamp - tapProcess computes peak per buffer (every 16th sample for perf) - writeDiagnosticReport() generates .txt report after each recording: * Verdict: OK / FAIL / WARN with specific diagnosis * Full tap stats, format info, skip/error breakdown * 'How to Read' section for quick interpretation - Report saved alongside mp4 (same name, .txt extension) - showSaveDialog moves both mp4 and report together --- Sources/PlayerRecorder.swift | 205 +++++++++++++++++++++++++++++++++-- 1 file changed, 193 insertions(+), 12 deletions(-) diff --git a/Sources/PlayerRecorder.swift b/Sources/PlayerRecorder.swift index 0ec9711..a66840e 100644 --- a/Sources/PlayerRecorder.swift +++ b/Sources/PlayerRecorder.swift @@ -25,6 +25,18 @@ class AudioTapContext { nonisolated(unsafe) var dataBufferSizes: (Int, Int) = (0, 0) nonisolated(unsafe) var writeIndex: Int = 0 + // === 诊断统计 === + nonisolated(unsafe) var peakAmplitude: Float = 0 // 全局峰值 + nonisolated(unsafe) var silentCallCount: Int = 0 // 静音回调次数 + nonisolated(unsafe) var activeCallCount: Int = 0 // 有声音的回调次数 + nonisolated(unsafe) var skippedNotRecording: Int = 0 // isRecording=false 跳过次数 + nonisolated(unsafe) var skippedNoFormat: Int = 0 // formatDescription 为空跳过次数 + nonisolated(unsafe) var skippedNoData: Int = 0 // buffer 无数据跳过次数 + nonisolated(unsafe) var appendFailCount: Int = 0 // append 失败次数 + nonisolated(unsafe) var notReadyCount: Int = 0 // writerInput not ready 次数 + nonisolated(unsafe) var lastActiveTimestamp: Double = 0 // 最后一次有声音的时间(秒) + nonisolated(unsafe) var recordStartTime: Date? // 录制开始时间 + let audioQueue = DispatchQueue(label: "miniplayer.audioTap") } @@ -89,9 +101,9 @@ private func tapProcess( let ctx = Unmanaged.fromOpaque(storage).takeUnretainedValue() ctx.processCallCount += 1 - guard ctx.isRecording, - let _ = ctx.writerInput, - let _ = ctx.formatDescription else { return } + guard ctx.isRecording else { ctx.skippedNotRecording += 1; return } + guard let _ = ctx.writerInput else { ctx.skippedNoFormat += 1; return } + guard let _ = ctx.formatDescription else { ctx.skippedNoFormat += 1; return } let ablPtr = UnsafeMutableAudioBufferListPointer(bufferListInOut) @@ -102,7 +114,31 @@ private func tapProcess( totalSize += Int(ablPtr[i].mDataByteSize) } } - guard totalSize > 0 else { return } + guard totalSize > 0 else { ctx.skippedNoData += 1; return } + + // === 诊断: 计算峰值音量 === + var bufferPeak: Float = 0 + for i in 0.. 0 else { continue } + let floatPtr = data.assumingMemoryBound(to: Float.self) + let sampleCount = Int(ablPtr[i].mDataByteSize) / MemoryLayout.size + for s in stride(from: 0, to: sampleCount, by: 16) { // 每16个采样检查一次,避免性能问题 + let absVal = abs(floatPtr[s]) + if absVal > bufferPeak { bufferPeak = absVal } + } + } + if bufferPeak > ctx.peakAmplitude { ctx.peakAmplitude = bufferPeak } + + // 静音阈值: float PCM < 0.001 ≈ -60dB + let isActive = bufferPeak > 0.001 + if isActive { + ctx.activeCallCount += 1 + if let start = ctx.recordStartTime { + ctx.lastActiveTimestamp = Date().timeIntervalSince(start) + } + } else { + ctx.silentCallCount += 1 + } // 双缓冲:写当前槽,async读另一个槽(消除数据竞争) let wi = ctx.writeIndex @@ -201,10 +237,14 @@ private func tapProcess( ctx.totalFramesWritten += frameCount if !ctx.firstAppendLogged { ctx.firstAppendLogged = true - NSLog("[Recorder] ✓ First audio captured: pts=%.3fs, frames=%lld, size=%d", - pts.seconds, frameCount, totalSize) + NSLog("[Recorder] ✓ First audio captured: pts=%.3fs, frames=%lld, size=%d, peak=%.4f", + pts.seconds, frameCount, totalSize, bufferPeak) } + } else { + ctx.appendFailCount += 1 } + } else { + ctx.notReadyCount += 1 } } } @@ -216,6 +256,26 @@ enum RecorderState { static nonisolated(unsafe) var pendingTerminate: Bool = false } +// MARK: - 诊断统计快照 + +struct AudioDiagStats { + let videoFrames: Int + let processCallCount: Int + let appendCount: Int + let totalFramesWritten: Int64 + let peakAmplitude: Float + let activeCallCount: Int + let silentCallCount: Int + let skippedNotRecording: Int + let skippedNoFormat: Int + let skippedNoData: Int + let appendFailCount: Int + let notReadyCount: Int + let lastActiveTimestamp: Double + let sampleRate: Double + let channelsPerFrame: UInt32 +} + // MARK: - PlayerRecorder @MainActor @@ -418,6 +478,18 @@ final class PlayerRecorder: NSObject { ctx.firstAppendLogged = false ctx.processCallCount = 0 + // 重置诊断统计 + ctx.peakAmplitude = 0 + ctx.silentCallCount = 0 + ctx.activeCallCount = 0 + ctx.skippedNotRecording = 0 + ctx.skippedNoFormat = 0 + ctx.skippedNoData = 0 + ctx.appendFailCount = 0 + ctx.notReadyCount = 0 + ctx.lastActiveTimestamp = 0 + ctx.recordStartTime = Date() + isRecording = true isRunning = true startCaptureLoop() @@ -437,11 +509,29 @@ final class PlayerRecorder: NSObject { // 停止音频 tap 写入 tapContext?.isRecording = false - let audioAppended = tapContext?.appendCount ?? 0 - let audioCalls = tapContext?.processCallCount ?? 0 - NSLog("[Recorder] Stopping: videoFrames=%d, audioProcessCalls=%d, audioAppended=%d", - captureFrameCount, audioCalls, audioAppended) + // 快照诊断数据(tap 停止后不会再更新) + let statsSnapshot = AudioDiagStats( + videoFrames: captureFrameCount, + processCallCount: tapContext?.processCallCount ?? 0, + appendCount: tapContext?.appendCount ?? 0, + totalFramesWritten: tapContext?.totalFramesWritten ?? 0, + peakAmplitude: tapContext?.peakAmplitude ?? 0, + activeCallCount: tapContext?.activeCallCount ?? 0, + silentCallCount: tapContext?.silentCallCount ?? 0, + skippedNotRecording: tapContext?.skippedNotRecording ?? 0, + skippedNoFormat: tapContext?.skippedNoFormat ?? 0, + skippedNoData: tapContext?.skippedNoData ?? 0, + appendFailCount: tapContext?.appendFailCount ?? 0, + notReadyCount: tapContext?.notReadyCount ?? 0, + lastActiveTimestamp: tapContext?.lastActiveTimestamp ?? 0, + sampleRate: tapContext?.sampleRate ?? 0, + channelsPerFrame: tapContext?.channelsPerFrame ?? 0 + ) + + NSLog("[Recorder] Stopping: videoFrames=%d, audioProcessCalls=%d, audioAppended=%d, peak=%.4f, active=%d, silent=%d", + statsSnapshot.videoFrames, statsSnapshot.processCallCount, statsSnapshot.appendCount, + statsSnapshot.peakAmplitude, statsSnapshot.activeCallCount, statsSnapshot.silentCallCount) currentPlayerItem = nil @@ -461,16 +551,17 @@ final class PlayerRecorder: NSObject { } return } - let vFrames = self.captureFrameCount NSLog("[Recorder] finishWriting: status=%d, error=%@, videoFrames=%d, audioFrames=%d", w.status.rawValue, w.error?.localizedDescription ?? "none", - vFrames, audioAppended) + statsSnapshot.videoFrames, statsSnapshot.appendCount) if w.status == .completed, let tempURL = self.tempURL { if FileManager.default.fileExists(atPath: tempURL.path) { let size = (try? FileManager.default.attributesOfItem(atPath: tempURL.path)[.size] as? UInt64) ?? 0 NSLog("[Recorder] ✓ Temp file exists: %@, size=%llu bytes", tempURL.lastPathComponent, size) + // 写诊断报告(与 mp4 同目录) + self.writeDiagnosticReport(stats: statsSnapshot, videoURL: tempURL) self.showSaveDialog(tempURL: tempURL) } else { NSLog("[Recorder] ⚠️ Temp file missing: %@", tempURL.path) @@ -576,9 +667,87 @@ final class PlayerRecorder: NSObject { durationText = String(format: "%02d:%02d", min, sec) } + // MARK: - 诊断报告 + + /// 录制完成后生成诊断报告文件(.txt),与 mp4 同目录 + private func writeDiagnosticReport(stats: AudioDiagStats, videoURL: URL) { + let reportURL = videoURL.deletingPathExtension().appendingPathExtension("txt") + + let videoDuration = Double(stats.videoFrames) / 30.0 // 30fps + let audioDuration = stats.sampleRate > 0 ? Double(stats.totalFramesWritten) / stats.sampleRate : 0 + + let peakDB = stats.peakAmplitude > 0 ? 20 * log10(stats.peakAmplitude) : -Float.infinity + + var verdict: String + if stats.processCallCount == 0 { + verdict = "FAIL: tap never called — audioMix not attached or wrong trackID" + } else if stats.activeCallCount == 0 && stats.silentCallCount > 0 { + verdict = "FAIL: tap called \(stats.processCallCount)x but ALL silent — source is muted or tap bound to wrong track" + } else if stats.appendCount == 0 { + verdict = "FAIL: tap active (\(stats.activeCallCount) calls) but 0 appends — writerInput issue (skippedNotRecording=\(stats.skippedNotRecording), appendFail=\(stats.appendFailCount), notReady=\(stats.notReadyCount))" + } else if stats.activeCallCount > 0 && audioDuration < videoDuration * 0.5 { + verdict = "WARN: audio \(String(format: "%.1f", audioDuration))s < video \(String(format: "%.1f", videoDuration))s — tap stopped mid-recording (HLS variant switch?)" + } else if stats.peakAmplitude < 0.001 { + verdict = "FAIL: peak \(String(format: "%.6f", stats.peakAmplitude)) (\(String(format: "%.1f", peakDB))dB) — effectively silent" + } else { + verdict = "OK" + } + + let lines = [ + "=== MiniPlayer Recording Diagnostic Report ===", + "Generated: \(Date())", + "Video file: \(videoURL.lastPathComponent)", + "", + "--- Verdict ---", + verdict, + "", + "--- Video ---", + "Frames: \(stats.videoFrames)", + "Duration: \(String(format: "%.2f", videoDuration))s (@ 30fps)", + "", + "--- Audio Tap Stats ---", + "processCallCount: \(stats.processCallCount) (total tap callbacks)", + "activeCallCount: \(stats.activeCallCount) (had audio signal, peak > -60dB)", + "silentCallCount: \(stats.silentCallCount) (silence detected)", + "appendCount: \(stats.appendCount) (samples written to AVAssetWriter)", + "totalFramesWritten: \(stats.totalFramesWritten)", + "audioDuration: \(String(format: "%.2f", audioDuration))s", + "lastActiveAt: \(String(format: "%.2f", stats.lastActiveTimestamp))s (time from record start)", + "", + "--- Audio Format ---", + "sampleRate: \(String(format: "%.0f", stats.sampleRate)) Hz", + "channelsPerFrame: \(stats.channelsPerFrame)", + "peakAmplitude: \(String(format: "%.6f", stats.peakAmplitude)) (\(String(format: "%.1f", peakDB)) dB)", + "", + "--- Skip/Error Counts ---", + "skippedNotRecording: \(stats.skippedNotRecording) (isRecording was false)", + "skippedNoFormat: \(stats.skippedNoFormat) (formatDescription was nil)", + "skippedNoData: \(stats.skippedNoData) (buffer had no data)", + "appendFailCount: \(stats.appendFailCount) (writerInput.append returned false)", + "notReadyCount: \(stats.notReadyCount) (writerInput not ready for data)", + "", + "--- How to Read ---", + "processCallCount=0 → audioMix never reached the tap (check trackID / variant switch)", + "activeCallCount=0 + silentCallCount>0 → tap gets data but it's all zeros (wrong track / muted source)", + "appendCount=0 + activeCallCount>0 → data captured but writer rejected it (format mismatch)", + "audioDuration << videoDuration → tap worked initially then stopped (HLS variant switch mid-recording)", + "peakAmplitude < 0.001 → effectively silent output", + ] + + let report = lines.joined(separator: "\n") + do { + try report.write(to: reportURL, atomically: true, encoding: .utf8) + NSLog("[Recorder] ✓ Diagnostic report: %@", reportURL.path) + print(report) // 也输出到终端 + } catch { + NSLog("[Recorder] ⚠️ Failed to write diagnostic report: %@", error.localizedDescription) + } + } + // MARK: - 保存对话框 private func showSaveDialog(tempURL: URL) { + let reportURL = tempURL.deletingPathExtension().appendingPathExtension("txt") let panel = NSSavePanel() panel.allowedContentTypes = [.mpeg4Movie] panel.nameFieldStringValue = tempURL.lastPathComponent @@ -592,6 +761,17 @@ final class PlayerRecorder: NSObject { } try FileManager.default.moveItem(at: tempURL, to: url) NSLog("[Recorder] ✓ Saved: %@", url.path) + + // 诊断报告也移到同目录 + if FileManager.default.fileExists(atPath: reportURL.path) { + let reportDest = url.deletingPathExtension().appendingPathExtension("txt") + if FileManager.default.fileExists(atPath: reportDest.path) { + try FileManager.default.removeItem(at: reportDest) + } + try FileManager.default.moveItem(at: reportURL, to: reportDest) + NSLog("[Recorder] ✓ Diagnostic report saved: %@", reportDest.path) + } + self?.onRecordingSaved?(url) } catch { NSLog("[Recorder] ✗ Save failed: %@", error.localizedDescription) @@ -599,6 +779,7 @@ final class PlayerRecorder: NSObject { } } else { try? FileManager.default.removeItem(at: tempURL) + try? FileManager.default.removeItem(at: reportURL) } self?.checkPendingTerminate() }