MiniPlayer/Sources/PlayerRecorder.swift
yumoqing 9160b3ce2b fix: pre-install audioMix in playIndex to prevent video freeze on record start
Setting playerItem.audioMix while AVPlayer is actively playing causes
the audio/video pipeline to reconfigure, stalling video playback.

Fix: install the MTAudioProcessingTap audioMix at item creation time
(in playIndex, before replaceCurrentItem), so the pipeline is already
configured when playback begins. startRecording now only flips the
isRecording flag and skips re-installation if audioMix exists.

Also removed the guard that blocked tap installation when tracks
weren't available yet (HLS streams) — the wildcard trackID
(kCMPersistentTrackID_Invalid) handles this case.
2026-06-28 10:30:20 +08:00

881 lines
36 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import AVFoundation
import CoreMedia
import MediaToolbox
import Foundation
#if os(macOS)
import AppKit
// MARK: - Tap C 访 Swift
private nonisolated(unsafe) var gTapContext: AudioTapContext?
class AudioTapContext {
nonisolated(unsafe) var writerInput: AVAssetWriterInput?
nonisolated(unsafe) var isRecording: Bool = false
nonisolated(unsafe) var formatDescription: CMAudioFormatDescription?
nonisolated(unsafe) var sampleRate: Double = 0
nonisolated(unsafe) var channelsPerFrame: UInt32 = 0
nonisolated(unsafe) var appendCount: Int = 0
nonisolated(unsafe) var processCallCount: Int = 0
nonisolated(unsafe) var firstAppendLogged: Bool = false
nonisolated(unsafe) var totalFramesWritten: Int64 = 0
/// AasyncB使
nonisolated(unsafe) var dataBuffers: (UnsafeMutableRawPointer?, UnsafeMutableRawPointer?) = (nil, nil)
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? //
nonisolated(unsafe) var mixTrackID: Int32 = -1 // audioMix trackID
let audioQueue = DispatchQueue(label: "miniplayer.audioTap")
}
// MARK: - MTAudioProcessingTap C
private func tapInit(
_ tap: MTAudioProcessingTap,
_ clientInfo: UnsafeMutableRawPointer?,
_ tapStorageOut: UnsafeMutablePointer<UnsafeMutableRawPointer?>
) {
tapStorageOut.pointee = clientInfo
}
private func tapFinalize(_ tap: MTAudioProcessingTap) {
// Context lifecycle managed elsewhere
}
private func tapPrepare(
_ tap: MTAudioProcessingTap,
_ maxFrames: CMItemCount,
_ processingFormat: UnsafePointer<AudioStreamBasicDescription>
) {
let asbd = processingFormat.pointee
NSLog("[Recorder] Tap prepare: rate=%.0f ch=%u formatFlags=0x%x",
asbd.mSampleRate, asbd.mChannelsPerFrame, asbd.mFormatFlags)
let storage = MTAudioProcessingTapGetStorage(tap)
let ctx = Unmanaged<AudioTapContext>.fromOpaque(storage).takeUnretainedValue()
ctx.sampleRate = asbd.mSampleRate
ctx.channelsPerFrame = asbd.mChannelsPerFrame
var fmtDesc: CMAudioFormatDescription?
var mutableASBD = asbd
CMAudioFormatDescriptionCreate(
allocator: kCFAllocatorDefault,
asbd: &mutableASBD,
layoutSize: 0, layout: nil,
magicCookieSize: 0, magicCookie: nil,
extensions: nil,
formatDescriptionOut: &fmtDesc
)
ctx.formatDescription = fmtDesc
NSLog("[Recorder] Tap format ready: %@", fmtDesc != nil ? "OK" : "FAILED")
}
private func tapUnprepare(_ tap: MTAudioProcessingTap) {
NSLog("[Recorder] Tap unprepare")
}
private func tapProcess(
_ tap: MTAudioProcessingTap,
_ numberFrames: Int,
_ flags: UInt32,
_ bufferListInOut: UnsafeMutablePointer<AudioBufferList>,
_ bufferListSizeOut: UnsafeMutablePointer<Int>,
_ flagsOut: UnsafeMutablePointer<UInt32>
) {
bufferListSizeOut.pointee = 1
flagsOut.pointee = flags
let storage = MTAudioProcessingTapGetStorage(tap)
let ctx = Unmanaged<AudioTapContext>.fromOpaque(storage).takeUnretainedValue()
ctx.processCallCount += 1
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)
//
var totalSize: Int = 0
for i in 0..<ablPtr.count {
if ablPtr[i].mData != nil && ablPtr[i].mDataByteSize > 0 {
totalSize += Int(ablPtr[i].mDataByteSize)
}
}
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
ctx.writeIndex = 1 - wi //
if ctx.dataBufferSizes.0 < totalSize || ctx.dataBufferSizes.1 < totalSize {
//
let newSize = max(totalSize, 65536) // 64KB
if wi == 0 {
if let existing = ctx.dataBuffers.0 { free(existing) }
ctx.dataBuffers.0 = malloc(newSize)
ctx.dataBufferSizes.0 = newSize
} else {
if let existing = ctx.dataBuffers.1 { free(existing) }
ctx.dataBuffers.1 = malloc(newSize)
ctx.dataBufferSizes.1 = newSize
}
}
let dest: UnsafeMutableRawPointer?
if wi == 0 { dest = ctx.dataBuffers.0 } else { dest = ctx.dataBuffers.1 }
guard let dest = dest else { return }
var offset = 0
for i in 0..<ablPtr.count {
if let src = ablPtr[i].mData, ablPtr[i].mDataByteSize > 0 {
let sz = Int(ablPtr[i].mDataByteSize)
memcpy(dest + offset, src, sz)
offset += sz
}
}
let frameCount = Int64(numberFrames)
let startFrame = ctx.totalFramesWritten
let readWi = wi // async
// CMSampleBuffer
ctx.audioQueue.async {
guard ctx.isRecording,
let writerInput = ctx.writerInput,
let fd = ctx.formatDescription else { return }
// 线
let readDest: UnsafeMutableRawPointer?
if readWi == 0 { readDest = ctx.dataBuffers.0 } else { readDest = ctx.dataBuffers.1 }
guard let readDest = readDest else { return }
var blockBuffer: CMBlockBuffer?
let blockStatus = CMBlockBufferCreateWithMemoryBlock(
allocator: kCFAllocatorDefault, memoryBlock: nil,
blockLength: totalSize, blockAllocator: kCFAllocatorDefault,
customBlockSource: nil, offsetToData: 0,
dataLength: totalSize,
flags: kCMBlockBufferAssureMemoryNowFlag,
blockBufferOut: &blockBuffer
)
guard blockStatus == kCMBlockBufferNoErr, let bb = blockBuffer else { return }
let replaceStatus = CMBlockBufferReplaceDataBytes(
with: readDest, blockBuffer: bb,
offsetIntoDestination: 0, dataLength: totalSize
)
guard replaceStatus == kCMBlockBufferNoErr else { return }
// PTS
let pts = CMTime(
value: CMTimeValue(startFrame),
timescale: Int32(ctx.sampleRate)
)
var timingInfo = CMSampleTimingInfo(
duration: CMTime(value: CMTimeValue(frameCount), timescale: Int32(ctx.sampleRate)),
presentationTimeStamp: pts,
decodeTimeStamp: .invalid
)
var sampleBuffer: CMSampleBuffer?
let createStatus = CMSampleBufferCreateReady(
allocator: kCFAllocatorDefault,
dataBuffer: bb,
formatDescription: fd,
sampleCount: Int(frameCount),
sampleTimingEntryCount: 1,
sampleTimingArray: &timingInfo,
sampleSizeEntryCount: 0,
sampleSizeArray: nil,
sampleBufferOut: &sampleBuffer
)
guard createStatus == noErr, let sb = sampleBuffer else {
NSLog("[Recorder] ⚠️ SampleBuffer create failed: %d", createStatus)
return
}
if writerInput.isReadyForMoreMediaData {
if writerInput.append(sb) {
ctx.appendCount += 1
ctx.totalFramesWritten += frameCount
if !ctx.firstAppendLogged {
ctx.firstAppendLogged = true
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
}
}
}
// MARK: - RecorderState
enum RecorderState {
static nonisolated(unsafe) var isFinalizing: Bool = false
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
let mixTrackID: Int32
}
// MARK: - PlayerRecorder
@MainActor
final class PlayerRecorder: NSObject {
private var writer: AVAssetWriter?
private var videoInput: AVAssetWriterInput?
private var audioInput: AVAssetWriterInput?
private var captureTimer: Timer?
private var durationTimer: Timer?
private var startDate: Date?
private weak var weakOutput: AVPlayerItemVideoOutput?
nonisolated(unsafe) private var isRunning = false
private var lastPixelBuffer: CVPixelBuffer?
private var captureFrameCount: Int = 0
// Audio Tap
private var tapContext: AudioTapContext?
private weak var currentPlayerItem: AVPlayerItem?
nonisolated(unsafe) private var recordStartTime: CMTime = .zero
@Published var isRecording = false
@Published var durationText = "00:00"
var onRecordingSaved: ((URL) -> Void)?
var onError: ((String) -> Void)?
private var tempURL: URL?
private var saveDirectory: URL {
let movies = FileManager.default.urls(for: .moviesDirectory, in: .userDomainMask).first!
let dir = movies.appendingPathComponent("MiniPlayer", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir
}
// MARK: - Audio Tap Setup
/// Tap使 playerItem.tracks HLS asset
/// playerItem playIndex audioMix
func installAudioTap(on playerItem: AVPlayerItem) {
//
if gTapContext == nil {
gTapContext = AudioTapContext()
}
guard let ctx = gTapContext else { return }
print("[Recorder:DIAG] === installAudioTap START ===")
// HLS asset asset.tracks playerItem.tracks
let itemTracks = playerItem.tracks
print("[Recorder:DIAG] playerItem.tracks count: \(itemTracks.count)")
//
var audioTrackID: CMPersistentTrackID = kCMPersistentTrackID_Invalid
for (i, t) in itemTracks.enumerated() {
let tid = t.assetTrack?.trackID ?? -1
let mediaType = t.assetTrack?.mediaType.rawValue ?? "?"
let enabled = t.isEnabled
print("[Recorder:DIAG] itemTrack[\(i)]: trackID=\(tid) mediaType=\(mediaType) enabled=\(enabled)")
if t.assetTrack?.mediaType == .audio && t.isEnabled {
audioTrackID = tid
}
}
if audioTrackID == kCMPersistentTrackID_Invalid {
print("[Recorder:DIAG] ⚠️ No enabled audio track found yet (tracks=\(itemTracks.count)), using wildcard")
}
print("[Recorder:DIAG] Using audio trackID=\(audioTrackID)")
// MTAudioProcessingTap
var callbacks = MTAudioProcessingTapCallbacks(
version: kMTAudioProcessingTapCallbacksVersion_0,
clientInfo: Unmanaged.passUnretained(ctx).toOpaque(),
init: tapInit,
finalize: tapFinalize,
prepare: tapPrepare,
unprepare: tapUnprepare,
process: tapProcess
)
var tap: MTAudioProcessingTap?
let tapStatus = MTAudioProcessingTapCreate(
kCFAllocatorDefault, &callbacks,
kMTAudioProcessingTapCreationFlag_PreEffects,
&tap
)
print("[Recorder:DIAG] MTAudioProcessingTapCreate status=\(tapStatus) (noErr=\(noErr))")
guard tapStatus == noErr, let tapRef = tap else {
print("[Recorder:DIAG] ⚠️ Tap creation failed!")
return
}
// 使 kCMPersistentTrackID_Invalid
// HLS variant trackID 210 ID
let inputParams = AVMutableAudioMixInputParameters()
inputParams.trackID = kCMPersistentTrackID_Invalid
inputParams.audioTapProcessor = tapRef
let audioMix = AVMutableAudioMix()
audioMix.inputParameters = [inputParams]
playerItem.audioMix = audioMix
//
let verifyMix = playerItem.audioMix
print("[Recorder:DIAG] playerItem.audioMix set=\(verifyMix != nil)")
if let vMix = verifyMix, let params = vMix.inputParameters.first {
print("[Recorder:DIAG] verified trackID=\(params.trackID)")
}
print("[Recorder:DIAG] === installAudioTap DONE ===")
ctx.mixTrackID = kCMPersistentTrackID_Invalid
//
var diagLog = "=== installAudioTap Log ===\n"
diagLog += "Time: \(Date())\n"
diagLog += "Source: playerItem.tracks\n"
diagLog += "playerItem.tracks count: \(itemTracks.count)\n"
for (i, t) in itemTracks.enumerated() {
diagLog += " itemTrack[\(i)]: trackID=\(t.assetTrack?.trackID ?? -1) mediaType=\(t.assetTrack?.mediaType.rawValue ?? "?") enabled=\(t.isEnabled)\n"
}
diagLog += "Audio trackID used: kCMPersistentTrackID_Invalid (wildcard, matches all)\n"
diagLog += "Discovered audio trackID: \(audioTrackID)\n"
diagLog += "TapCreate status: \(tapStatus)\n"
diagLog += "audioMix set: \(verifyMix != nil)\n"
writeDiagLog(diagLog)
}
private func writeDiagLog(_ content: String) {
if let moviesDir = FileManager.default.urls(for: .moviesDirectory, in: .userDomainMask).first {
let logPath = moviesDir.appendingPathComponent("MiniPlayer/tap_install_log.txt")
try? content.write(to: logPath, atomically: true, encoding: .utf8)
print("[Recorder:DIAG] Log written to: \(logPath.path)")
}
}
// MARK: -
func startRecording(from output: AVPlayerItemVideoOutput, playerItem: AVPlayerItem, startTime: CMTime) {
guard !isRecording else { return }
weakOutput = output
currentPlayerItem = playerItem
lastPixelBuffer = nil
captureFrameCount = 0
isRunning = false
recordStartTime = startTime
// Tap playIndex
if playerItem.audioMix == nil {
installAudioTap(on: playerItem)
} else {
print("[Recorder:DIAG] audioMix already installed, skipping")
}
let tempFilename = "temp_recording_\(Int(Date().timeIntervalSince1970)).mp4"
let url = saveDirectory.appendingPathComponent(tempFilename)
try? FileManager.default.removeItem(at: url)
tempURL = url
// tap context format
let ctx = gTapContext ?? AudioTapContext()
if gTapContext == nil { gTapContext = ctx }
self.tapContext = ctx
do {
writer = try AVAssetWriter(outputURL: url, fileType: .mp4)
} catch {
onError?("Cannot create writer: \(error.localizedDescription)")
return
}
guard let writer = writer else { return }
// H264
let videoSettings: [String: Any] = [
AVVideoCodecKey: AVVideoCodecType.h264,
AVVideoWidthKey: 1920,
AVVideoHeightKey: 1080
]
let vInput = AVAssetWriterInput(mediaType: .video, outputSettings: videoSettings)
vInput.expectsMediaDataInRealTime = true
if writer.canAdd(vInput) {
writer.add(vInput)
videoInput = vInput
}
// AAC startWriting
// 使 tap
let audioRate = ctx.sampleRate > 0 ? ctx.sampleRate : 48000.0
let audioCh = ctx.channelsPerFrame > 0 ? ctx.channelsPerFrame : 2
var sourceFmtDesc: CMAudioFormatDescription?
if let tapFmt = ctx.formatDescription {
sourceFmtDesc = tapFmt
} else {
// Tap prepare
var asbd = AudioStreamBasicDescription(
mSampleRate: audioRate,
mFormatID: kAudioFormatLinearPCM,
mFormatFlags: kAudioFormatFlagIsFloat | kAudioFormatFlagIsPacked | kAudioFormatFlagIsNonInterleaved,
mBytesPerPacket: 4,
mFramesPerPacket: 1,
mBytesPerFrame: 4,
mChannelsPerFrame: audioCh,
mBitsPerChannel: 32,
mReserved: 0
)
CMAudioFormatDescriptionCreate(
allocator: kCFAllocatorDefault,
asbd: &asbd, layoutSize: 0, layout: nil,
magicCookieSize: 0, magicCookie: nil,
extensions: nil, formatDescriptionOut: &sourceFmtDesc
)
}
if let fmtDesc = sourceFmtDesc {
let audioSettings: [String: Any] = [
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVSampleRateKey: audioRate,
AVNumberOfChannelsKey: audioCh,
AVEncoderBitRateKey: 128_000
]
let aInput = AVAssetWriterInput(mediaType: .audio, outputSettings: audioSettings, sourceFormatHint: fmtDesc)
aInput.expectsMediaDataInRealTime = true
if writer.canAdd(aInput) {
writer.add(aInput)
audioInput = aInput
NSLog("[Recorder] AudioInput added: AAC rate=%.0f ch=%u", audioRate, audioCh)
}
}
guard writer.startWriting() else {
NSLog("[Recorder] startWriting failed: %@", writer.error?.localizedDescription ?? "unknown")
onError?("Cannot start writer: \(writer.error?.localizedDescription ?? "unknown")")
return
}
writer.startSession(atSourceTime: .zero)
NSLog("[Recorder] Writer started, writing to: %@", url.lastPathComponent)
// tap context
ctx.isRecording = true
ctx.writerInput = audioInput
ctx.appendCount = 0
ctx.totalFramesWritten = 0
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()
startDurationTimer()
}
// MARK: -
func stopRecording() {
guard isRecording else { return }
isRecording = false
isRunning = false
RecorderState.isFinalizing = true
captureTimer?.invalidate()
captureTimer = nil
stopDurationTimer()
// tap
tapContext?.isRecording = false
// 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,
mixTrackID: tapContext?.mixTrackID ?? -1
)
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)
// tracks
if let pi = currentPlayerItem, let boundID = tapContext?.mixTrackID {
let currentTracks = pi.tracks
NSLog("[Recorder:DIAG] Stop-time tracks: count=%d", currentTracks.count)
var stopLog = "\n=== Stop-time tracks check ===\n"
stopLog += "Bound trackID: \(boundID)\n"
stopLog += "Current tracks count: \(currentTracks.count)\n"
var foundBoundTrack = false
for (i, t) in currentTracks.enumerated() {
let tid = t.assetTrack?.trackID ?? -1
let mtype = t.assetTrack?.mediaType.rawValue ?? "?"
NSLog("[Recorder:DIAG] stopTrack[%d]: trackID=%d mediaType=%@ enabled=%d", i, tid, mtype, t.isEnabled)
stopLog += " track[\(i)]: trackID=\(tid) mediaType=\(mtype) enabled=\(t.isEnabled)\n"
if tid == boundID {
foundBoundTrack = true
}
}
stopLog += "Bound trackID \(boundID) still exists: \(foundBoundTrack)\n"
NSLog("[Recorder:DIAG] Bound trackID=%d still exists: %d", boundID, foundBoundTrack)
//
if let moviesDir = FileManager.default.urls(for: .moviesDirectory, in: .userDomainMask).first {
let logPath = moviesDir.appendingPathComponent("MiniPlayer/tap_install_log.txt")
if let handle = try? FileHandle(forWritingTo: logPath) {
handle.seekToEndOfFile()
handle.write(stopLog.data(using: .utf8)!)
handle.closeFile()
}
}
}
currentPlayerItem = nil
videoInput?.markAsFinished()
audioInput?.markAsFinished()
writer?.finishWriting { [weak self] in
NSLog("[Recorder] finishWriting callback invoked")
DispatchQueue.main.async {
RecorderState.isFinalizing = false
guard let self = self, let w = self.writer else {
NSLog("[Recorder] ⚠️ self or writer is nil in finishWriting callback")
if RecorderState.pendingTerminate {
RecorderState.pendingTerminate = false
NSApp.reply(toApplicationShouldTerminate: true)
}
return
}
NSLog("[Recorder] finishWriting: status=%d, error=%@, videoFrames=%d, audioFrames=%d",
w.status.rawValue,
w.error?.localizedDescription ?? "none",
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)
self.onError?("Recording file missing")
self.checkPendingTerminate()
}
} else {
NSLog("[Recorder] ✗ finishWriting failed: status=%d, error=%@",
w.status.rawValue, w.error?.localizedDescription ?? "unknown")
self.onError?("Recording failed: \(w.error?.localizedDescription ?? "unknown (status=\(w.status.rawValue))")")
if let url = self.tempURL {
try? FileManager.default.removeItem(at: url)
}
self.checkPendingTerminate()
}
}
}
lastPixelBuffer = nil
}
// MARK: - (30fps Timer)
private func startCaptureLoop() {
captureTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 30.0, repeats: true) { [weak self] _ in
Task { @MainActor in
self?.captureVideoFrame()
}
}
}
private func captureVideoFrame() {
guard isRunning, let output = weakOutput, let input = videoInput,
input.isReadyForMoreMediaData else { return }
let hostTime = CACurrentMediaTime()
let itemTime = output.itemTime(forHostTime: hostTime)
if let pb = output.copyPixelBuffer(forItemTime: itemTime, itemTimeForDisplay: nil) {
lastPixelBuffer = pb
}
guard let pb = lastPixelBuffer else { return }
let relativeTime = CMTimeSubtract(itemTime, recordStartTime)
guard relativeTime.seconds >= 0 else { return }
if let sb = Self.createSampleBuffer(from: pb, time: relativeTime) {
if input.append(sb) {
captureFrameCount += 1
}
}
}
private static func createSampleBuffer(from pixelBuffer: CVPixelBuffer, time: CMTime) -> CMSampleBuffer? {
var info = CMSampleTimingInfo()
info.presentationTimeStamp = time
info.duration = CMTime(value: 1, timescale: 30)
info.decodeTimeStamp = .invalid
var formatDescription: CMFormatDescription?
CMVideoFormatDescriptionCreateForImageBuffer(
allocator: kCFAllocatorDefault,
imageBuffer: pixelBuffer,
formatDescriptionOut: &formatDescription
)
guard let fd = formatDescription else { return nil }
var sampleBuffer: CMSampleBuffer?
CMSampleBufferCreateReadyWithImageBuffer(
allocator: kCFAllocatorDefault,
imageBuffer: pixelBuffer,
formatDescription: fd,
sampleTiming: &info,
sampleBufferOut: &sampleBuffer
)
return sampleBuffer
}
// MARK: -
private func startDurationTimer() {
startDate = Date()
durationTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
Task { @MainActor in
self?.updateDuration()
}
}
}
private func stopDurationTimer() {
durationTimer?.invalidate()
durationTimer = nil
}
private func updateDuration() {
guard let start = startDate else { return }
let elapsed = Int(Date().timeIntervalSince(start))
let min = elapsed / 60
let sec = elapsed % 60
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)",
"mixTrackID: \(stats.mixTrackID) (bound in audioMix, -1=not set)",
"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
panel.directoryURL = saveDirectory
panel.begin { [weak self] response in
if response == .OK, let url = panel.url {
do {
if FileManager.default.fileExists(atPath: url.path) {
try FileManager.default.removeItem(at: url)
}
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)
self?.onError?("Save failed: \(error.localizedDescription)")
}
} else {
try? FileManager.default.removeItem(at: tempURL)
try? FileManager.default.removeItem(at: reportURL)
}
self?.checkPendingTerminate()
}
}
private func checkPendingTerminate() {
if RecorderState.pendingTerminate {
RecorderState.pendingTerminate = false
NSApp.reply(toApplicationShouldTerminate: true)
}
}
}
#endif