diff --git a/Sources/PlayerRecorder.swift b/Sources/PlayerRecorder.swift index e9e7d9b..faa27ce 100644 --- a/Sources/PlayerRecorder.swift +++ b/Sources/PlayerRecorder.swift @@ -12,155 +12,129 @@ class AudioCaptureContext { var writerInput: AVAssetWriterInput? nonisolated(unsafe) var isRunning: Bool = true nonisolated(unsafe) var formatDescription: CMAudioFormatDescription? + nonisolated(unsafe) var sampleRate: Double = 44100.0 + nonisolated(unsafe) var channelsPerFrame: UInt32 = 2 nonisolated(unsafe) var appendCount: Int = 0 nonisolated(unsafe) var firstAppendLogged: Bool = false nonisolated(unsafe) var processCallCount: Int = 0 - nonisolated(unsafe) var sampleRate: Double = 44100.0 - nonisolated(unsafe) var channelsPerFrame: UInt32 = 2 -} - -// MARK: - MTAudioProcessingTap C 回调 - -private func tapInitCallback( - _ tap: MTAudioProcessingTap, - _ clientInfo: UnsafeMutableRawPointer?, - _ tapStorageOut: UnsafeMutablePointer -) { - // tapStorage 不需要额外分配,context 通过全局变量访问 - tapStorageOut.pointee = clientInfo -} - -private func tapFinalizeCallback(_ tap: MTAudioProcessingTap) { - // 清理由 stopRecording 负责 -} - -private func tapPrepareCallback( - _ tap: MTAudioProcessingTap, - _ maxFrames: CMItemCount, - _ processingFormat: UnsafePointer -) { - guard let ctx = gAudioContext else { return } - var asbd = processingFormat.pointee - ctx.sampleRate = Double(asbd.mSampleRate) - ctx.channelsPerFrame = asbd.mChannelsPerFrame - var fmtDesc: CMAudioFormatDescription? - CMAudioFormatDescriptionCreate( - allocator: kCFAllocatorDefault, - asbd: &asbd, - layoutSize: 0, layout: nil, - magicCookieSize: 0, magicCookie: nil, - extensions: nil, - formatDescriptionOut: &fmtDesc - ) - ctx.formatDescription = fmtDesc - NSLog("[Recorder] tapPrepare: %.1fHz, %uch, mFormatID=%u", asbd.mSampleRate, asbd.mChannelsPerFrame, asbd.mFormatID) -} - -private func tapUnprepareCallback(_ tap: MTAudioProcessingTap) { - // Nothing to clean up -} - -private func tapProcessCallback( - _ tap: MTAudioProcessingTap, - _ numberFrames: CMItemCount, - _ flags: MTAudioProcessingTapFlags, - _ bufferListInOut: UnsafeMutablePointer, - _ numberFramesOut: UnsafeMutablePointer, - _ flagsOut: UnsafeMutablePointer -) { - // 始终先获取源音频(保证音频管道正常运行) - var timeRange = CMTimeRange() - var srcFlags: MTAudioProcessingTapFlags = 0 - var actualFrames: CMItemCount = 0 - let status = MTAudioProcessingTapGetSourceAudio( - tap, numberFrames, bufferListInOut, - &srcFlags, &timeRange, &actualFrames - ) - - // 必须始终设置输出帧数和标志(否则音频引擎可能阻塞) - numberFramesOut.pointee = actualFrames - flagsOut.pointee = srcFlags - - guard status == noErr, actualFrames > 0 else { return } - - // 通过全局变量获取 context — 未就绪时仅跳过录制,不影响播放 - guard let ctx = gAudioContext, ctx.isRunning, + /// Serial queue for processing audio buffers off the real-time thread + let audioQueue = DispatchQueue(label: "miniplayer.audioCapture") + /// Pre-allocated data buffer (avoids malloc in real-time audio thread) + var dataBuffer: UnsafeMutableRawPointer? + var dataBufferSize: UInt32 = 0 +} + +// MARK: - CoreAudio 输出设备 IOProc 回调 + +/// 从系统音频输出设备捕获 PCM 数据(适用于所有音频源:HLS、本地文件等) +/// 运行在高优先级音频线程,最小化工作 +private func audioDeviceIOProc( + _ inDevice: AudioDeviceID, + _ inNow: UnsafePointer, + _ inInputData: UnsafePointer, + _ inInputTime: UnsafePointer, + _ inOutputData: UnsafeMutablePointer, + _ inOutputTime: UnsafePointer, + _ inClientData: UnsafeMutableRawPointer? +) -> OSStatus { + guard let clientData = inClientData else { return noErr } + let ctx = Unmanaged.fromOpaque(clientData).takeUnretainedValue() + guard ctx.isRunning, let writerInput = ctx.writerInput, - writerInput.isReadyForMoreMediaData else { return } + writerInput.isReadyForMoreMediaData else { return noErr } ctx.processCallCount += 1 - // 定期记录回调活跃状态(每500次调用打一次日志) - if ctx.processCallCount == 1 || ctx.processCallCount % 500 == 0 { - NSLog("[Recorder] tapProcess #%d: frames=%ld, timescale=%d", - ctx.processCallCount, actualFrames, timeRange.duration.timescale) + let bufferList = inInputData.pointee + guard bufferList.mNumberBuffers > 0 else { return noErr } + let buffer = bufferList.mBuffers + let dataSize = buffer.mDataByteSize + guard dataSize > 0, let srcData = buffer.mData else { return noErr } + + // Copy to pre-allocated buffer (avoid malloc in real-time thread) + if ctx.dataBufferSize < dataSize { + if let existing = ctx.dataBuffer { free(existing) } + ctx.dataBuffer = malloc(Int(dataSize)) + ctx.dataBufferSize = dataSize } + guard let dest = ctx.dataBuffer else { return noErr } + memcpy(dest, srcData, Int(dataSize)) - let bufferList = bufferListInOut.pointee - guard bufferList.mNumberBuffers > 0 else { return } + let pts = inInputTime.pointee + let callNum = ctx.processCallCount - let buf = bufferList.mBuffers - let dataSize = Int(buf.mDataByteSize) - guard dataSize > 0, let srcData = buf.mData else { return } - - // 创建 CMBlockBuffer - var blockBuffer: CMBlockBuffer? - let blockStatus = CMBlockBufferCreateWithMemoryBlock( - allocator: kCFAllocatorDefault, memoryBlock: nil, - blockLength: dataSize, blockAllocator: kCFAllocatorDefault, - customBlockSource: nil, offsetToData: 0, - dataLength: dataSize, - flags: kCMBlockBufferAssureMemoryNowFlag, - blockBufferOut: &blockBuffer - ) - guard blockStatus == kCMBlockBufferNoErr, let bb = blockBuffer else { return } - - let replaceStatus = CMBlockBufferReplaceDataBytes( - with: srcData, blockBuffer: bb, - offsetIntoDestination: 0, dataLength: dataSize - ) - guard replaceStatus == kCMBlockBufferNoErr else { return } - - // 获取 format description - guard let fd = ctx.formatDescription else { - if ctx.processCallCount == 1 { - NSLog("[Recorder] ⚠️ formatDescription nil on first process call") + // Process on serial queue (off the real-time audio thread) + ctx.audioQueue.async { + guard ctx.isRunning, let fd = ctx.formatDescription else { return } + + var blockBuffer: CMBlockBuffer? + let blockStatus = CMBlockBufferCreateWithMemoryBlock( + allocator: kCFAllocatorDefault, memoryBlock: nil, + blockLength: Int(dataSize), blockAllocator: kCFAllocatorDefault, + customBlockSource: nil, offsetToData: 0, + dataLength: Int(dataSize), + flags: kCMBlockBufferAssureMemoryNowFlag, + blockBufferOut: &blockBuffer + ) + guard blockStatus == kCMBlockBufferNoErr, let bb = blockBuffer else { return } + + let replaceStatus = CMBlockBufferReplaceDataBytes( + with: dest, blockBuffer: bb, + offsetIntoDestination: 0, dataLength: Int(dataSize) + ) + guard replaceStatus == kCMBlockBufferNoErr else { return } + + var timingInfo = CMSampleTimingInfo() + // Convert AudioTimeStamp to CMTime + if pts.mFlags.contains(.sampleTimeValid) { + let sampleRate = ctx.sampleRate + timingInfo.presentationTimeStamp = CMTime( + value: Int64(pts.mSampleTime), + timescale: Int32(sampleRate) + ) + } else if pts.mFlags.contains(.hostTimeValid) { + var timebase = mach_timebase_info_data_t() + mach_timebase_info(&timebase) + let nanos = pts.mHostTime * UInt64(timebase.numer) / UInt64(timebase.denom) + timingInfo.presentationTimeStamp = CMTime(value: CMTimeValue(nanos), timescale: 1_000_000_000) + } else { + return } - return - } - - // PTS 基于累积帧数(与视频一样从0开始) - let sampleRate = ctx.sampleRate - let ptsValue = Double(ctx.appendCount) / sampleRate - let pts = CMTime(seconds: ptsValue, preferredTimescale: Int32(sampleRate)) - - var sampleBuffer: CMSampleBuffer? - let createStatus = CMAudioSampleBufferCreateReadyWithPacketDescriptions( - allocator: kCFAllocatorDefault, - dataBuffer: bb, - formatDescription: fd, - sampleCount: actualFrames, - presentationTimeStamp: pts, - packetDescriptions: nil, - sampleBufferOut: &sampleBuffer - ) - guard createStatus == noErr, let sb = sampleBuffer else { - if ctx.processCallCount <= 3 { - NSLog("[Recorder] ⚠️ CMAudioSampleBufferCreate failed: %d", createStatus) + timingInfo.duration = .invalid + timingInfo.decodeTimeStamp = .invalid + + var sampleBuffer: CMSampleBuffer? + let bytesPerFrame = Int(ctx.channelsPerFrame) * MemoryLayout.size + let sampleCount = Int(dataSize) / max(bytesPerFrame, 1) + let createStatus = CMSampleBufferCreateReady( + allocator: kCFAllocatorDefault, + dataBuffer: bb, + formatDescription: fd, + sampleCount: sampleCount, + sampleTimingEntryCount: 1, + sampleTimingArray: &timingInfo, + sampleSizeEntryCount: 0, + sampleSizeArray: nil, + sampleBufferOut: &sampleBuffer + ) + guard createStatus == noErr, let sb = sampleBuffer else { + if callNum <= 3 { + NSLog("[Recorder] \u{26a0}\u{fe0f} Audio sample buffer create failed: %d", createStatus) + } + return + } + + if writerInput.isReadyForMoreMediaData && writerInput.append(sb) { + ctx.appendCount += 1 + if !ctx.firstAppendLogged { + ctx.firstAppendLogged = true + NSLog("[Recorder] ✓ First audio captured: pts=%.2fs, size=%u", pts.mSampleTime / ctx.sampleRate, dataSize) + } } - return } - if writerInput.append(sb) { - ctx.appendCount += actualFrames - if !ctx.firstAppendLogged { - ctx.firstAppendLogged = true - NSLog("[Recorder] ✓ First audio appended: pts=%.2fs, frames=%ld", pts.seconds, actualFrames) - } - } else if ctx.processCallCount <= 3 { - NSLog("[Recorder] ⚠️ writerInput.append failed (input ready=%d)", writerInput.isReadyForMoreMediaData) - } + return noErr } // MARK: - PlayerRecorder @@ -171,7 +145,7 @@ enum RecorderState { static nonisolated(unsafe) var pendingTerminate: Bool = false } -/// 视频源录制器:AVPlayerItemVideoOutput 抓帧 + MTAudioProcessingTap 捕获音频 +/// 视频源录制器:AVPlayerItemVideoOutput 抓帧 + CoreAudio 输出设备捕获音频 /// 边录边写文件,录制结束后仅改名和移动 @MainActor final class PlayerRecorder: NSObject { @@ -189,9 +163,10 @@ final class PlayerRecorder: NSObject { private var lastPixelBuffer: CVPixelBuffer? private var captureFrameCount: Int = 0 - // 音频 (MTAudioProcessingTap) - private var audioTap: MTAudioProcessingTap? - private var audioContext: AudioCaptureContext? + // 音频 (CoreAudio 输出设备) + private var audioDeviceID: AudioDeviceID = 0 + private var audioCaptureContext: AudioCaptureContext? + private var audioIOProc: AudioDeviceIOProc? private weak var currentPlayerItem: AVPlayerItem? private var tracksObservation: NSKeyValueObservation? @@ -286,8 +261,8 @@ final class PlayerRecorder: NSObject { startCaptureLoop() startDurationTimer() - // 启动音频捕获(MTAudioProcessingTap) - setupAudioTap(playerItem: playerItem, audioWriterInput: aInput) + // 启动音频捕获 (CoreAudio) + startAudioCapture(audioWriterInput: aInput) } // MARK: - 停止录制 @@ -301,23 +276,14 @@ final class PlayerRecorder: NSObject { captureTimer = nil stopDurationTimer() - // 停止音频 tap - audioContext?.isRunning = false - tracksObservation = nil - let totalAudioFrames = audioContext?.appendCount ?? 0 - let processCalls = audioContext?.processCallCount ?? 0 + // 保存音频统计并停止捕获 + let audioCalls = audioCaptureContext?.processCallCount ?? 0 + let audioAppended = audioCaptureContext?.appendCount ?? 0 NSLog("[Recorder] Stopping: videoFrames=%d, audioProcessCalls=%d, audioAppended=%d", - captureFrameCount, processCalls, totalAudioFrames) - - // 移除 audioMix - currentPlayerItem?.audioMix = nil + captureFrameCount, audioCalls, audioAppended) + stopAudioCapture() currentPlayerItem = nil - // 释放 tap 和 context - audioTap = nil - audioContext = nil - gAudioContext = nil - videoInput?.markAsFinished() audioInput?.markAsFinished() @@ -338,7 +304,7 @@ final class PlayerRecorder: NSObject { NSLog("[Recorder] finishWriting: status=%d, error=%@, videoFrames=%d, audioFrames=%d", w.status.rawValue, w.error?.localizedDescription ?? "none", - vFrames, totalAudioFrames) + vFrames, audioAppended) if w.status == .completed, let tempURL = self.tempURL { // 检查文件是否存在且有内容 @@ -366,205 +332,97 @@ final class PlayerRecorder: NSObject { lastPixelBuffer = nil } - // MARK: - 音频 Tap 设置 + // MARK: - 音频捕获 (CoreAudio 输出设备) - private func setupAudioTap(playerItem: AVPlayerItem, audioWriterInput: AVAssetWriterInput) { - // HLS 流的 tracks 是异步加载的 - // 策略:先同步检查 → KVO 观察 → 定时轮询保底(含 async load 备选) - - // 先打印当前 track 状态(诊断) - let tracks = playerItem.tracks - NSLog("[Recorder] setupAudioTap: playerItem.tracks.count=%d", tracks.count) - for (i, track) in tracks.enumerated() { - if let at = track.assetTrack { - NSLog("[Recorder] track[%d]: mediaType=%@, trackID=%d, enabled=%d", - i, at.mediaType.rawValue as NSString, at.trackID, track.isEnabled) - } else { - NSLog("[Recorder] track[%d]: assetTrack=NIL (HLS not loaded yet?)", i) - } + /// 从系统音频输出设备捕获音频(适用于 HLS、本地文件等所有音频源) + private func startAudioCapture(audioWriterInput: AVAssetWriterInput) { + // 获取默认音频输出设备 + var deviceID: AudioDeviceID = 0 + var size = UInt32(MemoryLayout.size) + var address = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDefaultOutputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + let status = AudioObjectGetPropertyData( + AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &deviceID + ) + guard status == noErr, deviceID != 0 else { + NSLog("[Recorder] \u{274c} Cannot get default audio output device: %d", status) + return } + self.audioDeviceID = deviceID - // 先尝试直接获取(本地文件场景) - if let audioTrack = self.findAudioTrackID(in: playerItem) { - NSLog("[Recorder] ✓ Audio track immediately available: trackID=%d", audioTrack) - self.installTap(on: playerItem, trackID: audioTrack, audioWriterInput: audioWriterInput) + // 获取输出音频流格式 + var streamFormatAddress = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyStreamFormat, + mScope: kAudioDevicePropertyScopeOutput, + mElement: kAudioObjectPropertyElementMain + ) + var asbd = AudioStreamBasicDescription() + size = UInt32(MemoryLayout.size) + let fmtStatus = AudioObjectGetPropertyData(deviceID, &streamFormatAddress, 0, nil, &size, &asbd) + guard fmtStatus == noErr else { + NSLog("[Recorder] \u{274c} Cannot get audio stream format: %d", fmtStatus) return } - // tracks 尚未加载 — 用 KVO 观察变化 - NSLog("[Recorder] ⏳ Audio tracks not yet loaded, setting up KVO + polling...") + NSLog("[Recorder] Audio output format: %.1fHz, %uch, %ubit, formatID=%u", + asbd.mSampleRate, asbd.mChannelsPerFrame, asbd.mBitsPerChannel, asbd.mFormatID) - let observation = playerItem.observe(\.tracks, options: [.new]) { [weak self] item, _ in - guard let self, self.isRunning, self.audioTap == nil else { return } - - let tCount = item.tracks.count - NSLog("[Recorder] KVO tracks changed: count=%d", tCount) - - if let trackID = self.findAudioTrackID(in: item) { - NSLog("[Recorder] ✓ Audio track via KVO: trackID=%d", trackID) - Task { @MainActor in - guard self.isRunning, self.audioTap == nil else { return } - self.installTap(on: item, trackID: trackID, audioWriterInput: audioWriterInput) - } - self.tracksObservation = nil - } - } - self.tracksObservation = observation - - // 保底轮询:含 async asset.load(.tracks) 多次重试 - // 超时保护:20秒后放弃 - Task { - var attempts = 0 - var lastAsyncAttempt = -100 // 确保首次就能尝试 - while attempts < 200 && self.isRunning && self.audioTap == nil { // 20秒 - try await Task.sleep(nanoseconds: 100_000_000) // 0.1秒 - attempts += 1 - - // 方式1: 从 playerItem.tracks 查找 - if let trackID = self.findAudioTrackID(in: playerItem) { - NSLog("[Recorder] ✓ Audio track via polling (attempt %d): trackID=%d", attempts, trackID) - await MainActor.run { - guard self.isRunning, self.audioTap == nil else { return } - self.installTap(on: playerItem, trackID: trackID, audioWriterInput: audioWriterInput) - } - self.tracksObservation = nil - return - } - - // 方式2: 每3秒重试一次 async load asset tracks(HLS 关键路径) - if attempts - lastAsyncAttempt >= 30 { - lastAsyncAttempt = attempts - NSLog("[Recorder] Trying async asset.load(.tracks) (attempt at %d)...", attempts) - do { - let assetTracks = try await playerItem.asset.load(.tracks) - NSLog("[Recorder] async load returned %d tracks", assetTracks.count) - for (i, t) in assetTracks.enumerated() { - NSLog("[Recorder] assetTrack[%d]: mediaType=%@, trackID=%d", - i, t.mediaType.rawValue as NSString, t.trackID) - } - // 找音频 track - if let audioAssetTrack = assetTracks.first(where: { $0.mediaType == .audio }) { - let trackID = audioAssetTrack.trackID - NSLog("[Recorder] ✓ Audio track via async load: trackID=%d", trackID) - await MainActor.run { - guard self.isRunning, self.audioTap == nil else { return } - self.installTap(on: playerItem, trackID: trackID, audioWriterInput: audioWriterInput) - } - self.tracksObservation = nil - return - } - // 没找到音频,尝试非视频非音频 track(muxed) - if let muxedTrack = assetTracks.first(where: { $0.mediaType != .video && $0.mediaType != .audio }) { - let trackID = muxedTrack.trackID - NSLog("[Recorder] ✓ Muxed track via async load: trackID=%d, trying as audio", trackID) - await MainActor.run { - guard self.isRunning, self.audioTap == nil else { return } - self.installTap(on: playerItem, trackID: trackID, audioWriterInput: audioWriterInput) - } - self.tracksObservation = nil - return - } - } catch { - NSLog("[Recorder] async load failed: %@", error.localizedDescription) - } - } - - // 每5秒打一次状态日志 - if attempts % 50 == 0 { - let tc = playerItem.tracks.count - NSLog("[Recorder] polling attempt %d: playerItem.tracks.count=%d, tapInstalled=%d", - attempts, tc, self.audioTap != nil ? 1 : 0) - } - } - if self.audioTap == nil { - NSLog("[Recorder] ❌ No audio track found after %d polling attempts (20s timeout)", attempts) - self.tracksObservation = nil - } - } - } - - /// 从 playerItem.tracks 查找音频 trackID - /// 对 HLS 流,多路 fallback:playerItem.tracks → asset.tracks → 非视频推断 - private func findAudioTrackID(in playerItem: AVPlayerItem) -> CMPersistentTrackID? { - // 方式1: playerItem.tracks + assetTrack(本地文件 / 已加载的HLS) - let itemTracks = playerItem.tracks - for track in itemTracks { - if let assetTrack = track.assetTrack, assetTrack.mediaType == .audio { - NSLog("[Recorder] findTrack: audio via playerItem.tracks, trackID=%d", assetTrack.trackID) - return assetTrack.trackID - } - } - - // 方式2: asset.tracks(HLS 流可能通过这个路径能拿到) - let assetTracks = playerItem.asset.tracks - for track in assetTracks { - if track.mediaType == .audio { - NSLog("[Recorder] findTrack: audio via asset.tracks, trackID=%d", track.trackID) - return track.trackID - } - } - - // 方式3: 如果有 track 但不是音频,找非视频的 track(可能是 muxed 或音频) - for track in itemTracks { - if let assetTrack = track.assetTrack, - assetTrack.mediaType != .video, assetTrack.mediaType != .audio { - NSLog("[Recorder] findTrack: non-video track (mediaType=%@, trackID=%d), trying as audio", - assetTrack.mediaType.rawValue as NSString, assetTrack.trackID) - return assetTrack.trackID - } - } - - return nil - } - - private func installTap(on playerItem: AVPlayerItem, trackID: CMPersistentTrackID, audioWriterInput: AVAssetWriterInput) { - // 创建音频捕获上下文 + // 创建 context let context = AudioCaptureContext() context.writerInput = audioWriterInput + context.sampleRate = Double(asbd.mSampleRate) + context.channelsPerFrame = asbd.mChannelsPerFrame context.isRunning = true - self.audioContext = context + self.audioCaptureContext = context - // 全局引用,供 C 回调访问 - gAudioContext = context - - // 创建 MTAudioProcessingTap 回调结构体 - var callbacks = MTAudioProcessingTapCallbacks( - version: kMTAudioProcessingTapCallbacksVersion_0, - clientInfo: nil, - init: tapInitCallback, - finalize: tapFinalizeCallback, - prepare: tapPrepareCallback, - unprepare: tapUnprepareCallback, - process: tapProcessCallback + // 创建格式描述 + var fmtDesc: CMAudioFormatDescription? + var mutableASBD = asbd + CMAudioFormatDescriptionCreate( + allocator: kCFAllocatorDefault, + asbd: &mutableASBD, + layoutSize: 0, layout: nil, + magicCookieSize: 0, magicCookie: nil, + extensions: nil, + formatDescriptionOut: &fmtDesc ) + context.formatDescription = fmtDesc - var tap: MTAudioProcessingTap? - let status = MTAudioProcessingTapCreate( - kCFAllocatorDefault, - &callbacks, - kMTAudioProcessingTapCreationFlag_PostEffects, - &tap - ) - - guard status == noErr, let audioTap = tap else { - NSLog("[Recorder] ❌ MTAudioProcessingTapCreate failed: %d", status) - gAudioContext = nil + // 创建 IOProc(使用 unmanaged pointer 传递 context) + let contextPtr = Unmanaged.passUnretained(context).toOpaque() + var ioProc: AudioDeviceIOProc? + let createStatus = AudioDeviceCreateIOProcID(deviceID, audioDeviceIOProc, contextPtr, &ioProc) + guard createStatus == noErr, let proc = ioProc else { + NSLog("[Recorder] \u{274c} AudioDeviceCreateIOProcID failed: %d", createStatus) return } + self.audioIOProc = proc - self.audioTap = audioTap - - // 关键:用 playerItem 的 trackID 创建 audioMix input parameters - let params = AVMutableAudioMixInputParameters() - params.trackID = trackID - params.audioTapProcessor = audioTap - let audioMix = AVMutableAudioMix() - audioMix.inputParameters = [params] - - playerItem.audioMix = audioMix - NSLog("[Recorder] ✓ Audio tap installed on playerItem (trackID=%d)", trackID) + // 启动音频捕获 + let startStatus = AudioDeviceStart(deviceID, proc) + guard startStatus == noErr else { + NSLog("[Recorder] \u{274c} AudioDeviceStart failed: %d", startStatus) + AudioDeviceDestroyIOProcID(deviceID, proc) + self.audioIOProc = nil + return + } + NSLog("[Recorder] \u{2713} CoreAudio output device capture started (deviceID=%u)", deviceID) } + private func stopAudioCapture() { + audioCaptureContext?.isRunning = false + if let proc = audioIOProc, audioDeviceID != 0 { + AudioDeviceStop(audioDeviceID, proc) + AudioDeviceDestroyIOProcID(audioDeviceID, proc) + } + audioIOProc = nil + audioCaptureContext = nil + } + + // MARK: - 视频抓帧 (30fps Timer) private func startCaptureLoop() {