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:
yumoqing 2026-06-27 17:45:00 +08:00
parent fb1bd32bbf
commit 7cfeec1f15

View File

@ -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()
}