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
This commit is contained in:
parent
fb1bd32bbf
commit
7cfeec1f15
@ -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<AudioTapContext>.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..<ablPtr.count {
|
||||
guard let data = ablPtr[i].mData, ablPtr[i].mDataByteSize > 0 else { continue }
|
||||
let floatPtr = data.assumingMemoryBound(to: Float.self)
|
||||
let sampleCount = Int(ablPtr[i].mDataByteSize) / MemoryLayout<Float>.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()
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user