Two fixes for video freeze during recording: 1. Video capture loop (30fps) moved from main-thread Timer to a dedicated DispatchSourceTimer on 'miniplayer.videoCapture' queue. copyPixelBuffer() on the main thread was competing with AVPlayer's rendering pipeline for CPU time, causing frame stalls. 2. AVPlayerItemDidPlayToEndTime observer now checks notification.object against player.currentItem. Previously object:nil caught stale notifications from replaced items, causing spurious playNext() calls that interrupted playback (visible as duplicate playIndex in logs).
884 lines
37 KiB
Swift
884 lines
37 KiB
Swift
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
|
||
|
||
/// 双缓冲区(消除数据竞争:写A时async读B,交替使用)
|
||
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?
|
||
nonisolated(unsafe) private var videoInput: AVAssetWriterInput?
|
||
private var audioInput: AVAssetWriterInput?
|
||
private var captureTimer: DispatchSourceTimer?
|
||
private let captureQueue = DispatchQueue(label: "miniplayer.videoCapture", qos: .userInteractive)
|
||
private var durationTimer: Timer?
|
||
private var startDate: Date?
|
||
nonisolated(unsafe) private weak var weakOutput: AVPlayerItemVideoOutput?
|
||
nonisolated(unsafe) private var isRunning = false
|
||
|
||
nonisolated(unsafe) private var lastPixelBuffer: CVPixelBuffer?
|
||
nonisolated(unsafe) 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 变化(如 2→10),绑死具体 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?.cancel()
|
||
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, 独立队列避免阻塞主线程渲染)
|
||
|
||
private func startCaptureLoop() {
|
||
let timer = DispatchSource.makeTimerSource(queue: captureQueue)
|
||
timer.schedule(deadline: .now(), repeating: .milliseconds(33), leeway: .milliseconds(2))
|
||
timer.setEventHandler { [weak self] in
|
||
self?.captureVideoFrame()
|
||
}
|
||
timer.resume()
|
||
captureTimer = timer
|
||
}
|
||
|
||
nonisolated 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
|
||
}
|
||
}
|
||
}
|
||
|
||
nonisolated 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
|