diff --git a/Sources/PlayerRecorder.swift b/Sources/PlayerRecorder.swift index 4388ad9..0ec9711 100644 --- a/Sources/PlayerRecorder.swift +++ b/Sources/PlayerRecorder.swift @@ -5,151 +5,185 @@ import Foundation #if os(macOS) import AppKit -// MARK: - 全局音频上下文(C 回调无法访问 Swift 对象,用全局变量) -private nonisolated(unsafe) var gAudioContext: AudioCaptureContext? +// MARK: - 全局音频 Tap 上下文(C 回调无法访问 Swift 对象) -class AudioCaptureContext { - var writerInput: AVAssetWriterInput? - nonisolated(unsafe) var isRunning: Bool = true +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 = 44100.0 - nonisolated(unsafe) var channelsPerFrame: UInt32 = 2 + nonisolated(unsafe) var sampleRate: Double = 0 + nonisolated(unsafe) var channelsPerFrame: UInt32 = 0 nonisolated(unsafe) var appendCount: Int = 0 - nonisolated(unsafe) var firstAppendLogged: Bool = false nonisolated(unsafe) var processCallCount: Int = 0 - nonisolated(unsafe) var firstAudioSampleTime: Int64 = -1 + nonisolated(unsafe) var firstAppendLogged: Bool = false + nonisolated(unsafe) var totalFramesWritten: Int64 = 0 - let audioQueue = DispatchQueue(label: "miniplayer.audioCapture") - var dataBuffer: UnsafeMutableRawPointer? - var dataBufferSize: UInt32 = 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 + + let audioQueue = DispatchQueue(label: "miniplayer.audioTap") } -// MARK: - CoreAudio 输出设备 IOProc 回调 +// MARK: - MTAudioProcessingTap C 回调 -/// 从系统音频输出设备捕获正在播放的 PCM 数据 -/// 关键:输出设备的播放数据在 inOutputData 参数中,不是 inInputData -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 else { return noErr } +private func tapInit( + _ tap: MTAudioProcessingTap, + _ clientInfo: UnsafeMutableRawPointer?, + _ tapStorageOut: UnsafeMutablePointer +) { + tapStorageOut.pointee = clientInfo +} + +private func tapFinalize(_ tap: MTAudioProcessingTap) { + // Context lifecycle managed elsewhere +} + +private func tapPrepare( + _ tap: MTAudioProcessingTap, + _ maxFrames: CMItemCount, + _ processingFormat: UnsafePointer +) { + 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.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, + _ bufferListSizeOut: UnsafeMutablePointer, + _ flagsOut: UnsafeMutablePointer +) { + bufferListSizeOut.pointee = 1 + flagsOut.pointee = flags + + let storage = MTAudioProcessingTapGetStorage(tap) + let ctx = Unmanaged.fromOpaque(storage).takeUnretainedValue() ctx.processCallCount += 1 + guard ctx.isRecording, + let _ = ctx.writerInput, + let _ = ctx.formatDescription else { return } - // 从 inOutputData 读取正在播放的音频 - let outBufferList = inOutputData.pointee - let inBufferList = inInputData.pointee - let callNum = ctx.processCallCount - if callNum <= 3 { - print("[Recorder] IOProc#\(callNum): outBufs=\(outBufferList.mNumberBuffers), inBufs=\(inBufferList.mNumberBuffers)") - if outBufferList.mNumberBuffers > 0 { - let b = outBufferList.mBuffers - print("[Recorder] outBuf[0]: size=\(b.mDataByteSize), channels=\(b.mNumberChannels), data=\(b.mData != nil)") - } - if inBufferList.mNumberBuffers > 0 { - let b = inBufferList.mBuffers - print("[Recorder] inBuf[0]: size=\(b.mDataByteSize), channels=\(b.mNumberChannels), data=\(b.mData != nil)") + let ablPtr = UnsafeMutableAudioBufferListPointer(bufferListInOut) + + // 计算总数据量 + var totalSize: Int = 0 + for i in 0.. 0 { + totalSize += Int(ablPtr[i].mDataByteSize) } } + guard totalSize > 0 else { return } - // 优先读 inOutputData(系统正在播放的音频),这是 loopback 数据 - let bufferList: AudioBufferList - if outBufferList.mNumberBuffers > 0 { - let b = outBufferList.mBuffers - if b.mDataByteSize > 0 && b.mData != nil { - bufferList = outBufferList - } else if inBufferList.mNumberBuffers > 0 { - bufferList = inBufferList + // 双缓冲:写当前槽,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 { - return noErr + if let existing = ctx.dataBuffers.1 { free(existing) } + ctx.dataBuffers.1 = malloc(newSize) + ctx.dataBufferSizes.1 = newSize } - } else if inBufferList.mNumberBuffers > 0 { - bufferList = inBufferList - } else { - return noErr } - let buffer = bufferList.mBuffers - let dataSize = buffer.mDataByteSize - guard dataSize > 0, let srcData = buffer.mData else { - if callNum <= 3 { print("[Recorder] IOProc#\(callNum): no data (size=\(buffer.mDataByteSize), data=\(buffer.mData != nil))") } - return noErr + 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.. 0 { + let sz = Int(ablPtr[i].mDataByteSize) + memcpy(dest + offset, src, sz) + offset += sz + } } - // 复制到预分配 buffer - 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 frameCount = Int64(numberFrames) + let startFrame = ctx.totalFramesWritten + let readWi = wi // async 读取的是刚写完的槽 - let pts = inOutputTime.pointee - - // 在串行队列处理 + // 在串行队列创建 CMSampleBuffer 并写入 ctx.audioQueue.async { - guard ctx.isRunning else { return } - guard let fd = ctx.formatDescription, let writerInput = ctx.writerInput else { return } + 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: Int(dataSize), blockAllocator: kCFAllocatorDefault, + blockLength: totalSize, blockAllocator: kCFAllocatorDefault, customBlockSource: nil, offsetToData: 0, - dataLength: Int(dataSize), + dataLength: totalSize, flags: kCMBlockBufferAssureMemoryNowFlag, blockBufferOut: &blockBuffer ) - guard blockStatus == kCMBlockBufferNoErr, let bb = blockBuffer else { - if callNum <= 3 { print("[Recorder] CMBlockBufferCreate failed: \(blockStatus)") } - return - } + guard blockStatus == kCMBlockBufferNoErr, let bb = blockBuffer else { return } let replaceStatus = CMBlockBufferReplaceDataBytes( - with: dest, blockBuffer: bb, - offsetIntoDestination: 0, dataLength: Int(dataSize) + with: readDest, blockBuffer: bb, + offsetIntoDestination: 0, dataLength: totalSize ) guard replaceStatus == kCMBlockBufferNoErr else { return } - var timingInfo = CMSampleTimingInfo() - if pts.mFlags.contains(.sampleTimeValid) { - // 使用相对于第一次音频回调的时间戳 - if ctx.firstAudioSampleTime < 0 { - ctx.firstAudioSampleTime = Int64(pts.mSampleTime) - } - let relativeSampleTime = Int64(pts.mSampleTime) - ctx.firstAudioSampleTime - timingInfo.presentationTimeStamp = CMTime( - value: relativeSampleTime, - timescale: Int32(ctx.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 - } - timingInfo.duration = .invalid - timingInfo.decodeTimeStamp = .invalid - - let bytesPerFrame = Int(ctx.channelsPerFrame) * MemoryLayout.size - let sampleCount = Int(dataSize) / max(bytesPerFrame, 1) + // 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: sampleCount, + sampleCount: Int(frameCount), sampleTimingEntryCount: 1, sampleTimingArray: &timingInfo, sampleSizeEntryCount: 0, @@ -157,40 +191,33 @@ private func audioDeviceIOProc( sampleBufferOut: &sampleBuffer ) guard createStatus == noErr, let sb = sampleBuffer else { - if callNum <= 3 { - print("[Recorder] ⚠️ SampleBuffer create failed: \(createStatus), dataSize=\(dataSize), sampleCount=\(sampleCount)") - } + NSLog("[Recorder] ⚠️ SampleBuffer create failed: %d", createStatus) return } if writerInput.isReadyForMoreMediaData { - let appended = writerInput.append(sb) - if appended { + if writerInput.append(sb) { ctx.appendCount += 1 + ctx.totalFramesWritten += frameCount if !ctx.firstAppendLogged { ctx.firstAppendLogged = true - print("[Recorder] ✓ First audio captured: pts=\(timingInfo.presentationTimeStamp.seconds)s, size=\(dataSize)") + NSLog("[Recorder] ✓ First audio captured: pts=%.3fs, frames=%lld, size=%d", + pts.seconds, frameCount, totalSize) } - } else if callNum <= 3 { - print("[Recorder] ⚠️ writerInput.append returned false") } - } else if callNum <= 3 { - print("[Recorder] ⚠️ writerInput not ready for more data") } } - - return noErr } -// MARK: - PlayerRecorder +// MARK: - RecorderState -/// 全局状态:录制是否正在 finalize(阻止应用退出) enum RecorderState { static nonisolated(unsafe) var isFinalizing: Bool = false static nonisolated(unsafe) var pendingTerminate: Bool = false } -/// 视频源录制器:AVPlayerItemVideoOutput 抓帧 + CoreAudio 输出设备捕获音频 +// MARK: - PlayerRecorder + @MainActor final class PlayerRecorder: NSObject { @@ -206,10 +233,8 @@ final class PlayerRecorder: NSObject { private var lastPixelBuffer: CVPixelBuffer? private var captureFrameCount: Int = 0 - // 音频 (CoreAudio) - private var audioDeviceID: AudioDeviceID = 0 - private var audioCaptureContext: AudioCaptureContext? - private var audioIOProc: AudioDeviceIOProc? + // Audio Tap + private var tapContext: AudioTapContext? private weak var currentPlayerItem: AVPlayerItem? nonisolated(unsafe) private var recordStartTime: CMTime = .zero @@ -229,6 +254,61 @@ final class PlayerRecorder: NSObject { return dir } + // MARK: - Audio Tap Setup + + /// 在录制开始时安装音频 Tap 到 playerItem + private func installAudioTap(on playerItem: AVPlayerItem) { + // 确保全局上下文存在 + if gTapContext == nil { + gTapContext = AudioTapContext() + } + guard let ctx = gTapContext else { return } + + // 同步获取音频 track(录制时 asset 已经加载完毕) + let audioTracks = playerItem.asset.tracks(withMediaType: .audio) + guard let audioTrack = audioTracks.first else { + NSLog("[Recorder] ⚠️ No audio track found (tried %d tracks)", audioTracks.count) + return + } + + NSLog("[Recorder] Found audio track: ID=%d, formatDescriptions=%d", + audioTrack.trackID, audioTrack.formatDescriptions.count) + + // 创建 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 status = MTAudioProcessingTapCreate( + kCFAllocatorDefault, &callbacks, + kMTAudioProcessingTapCreationFlag_PreEffects, + &tap + ) + guard status == noErr, let tapRef = tap else { + NSLog("[Recorder] ⚠️ MTAudioProcessingTapCreate failed: %d", status) + return + } + + // 创建 AudioMix — 用 kCMPersistentTrackID_Invalid 匹配所有音频轨道 + // HLS 流在 variant 切换时 audio track ID 会变,绑定具体 ID 会导致 tap 失效 + let inputParams = AVMutableAudioMixInputParameters() + inputParams.trackID = kCMPersistentTrackID_Invalid + inputParams.audioTapProcessor = tapRef + + let audioMix = AVMutableAudioMix() + audioMix.inputParameters = [inputParams] + + playerItem.audioMix = audioMix + NSLog("[Recorder] ✓ Audio tap installed on playerItem, trackID=%d", audioTrack.trackID) + } + // MARK: - 开始录制 func startRecording(from output: AVPlayerItemVideoOutput, playerItem: AVPlayerItem, startTime: CMTime) { @@ -241,11 +321,19 @@ final class PlayerRecorder: NSObject { isRunning = false recordStartTime = startTime + // 安装音频 Tap(录制时 asset 已加载,同步获取 track) + installAudioTap(on: playerItem) + 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 { @@ -268,7 +356,50 @@ final class PlayerRecorder: NSObject { videoInput = vInput } - // 音频输入将在 startAudioCapture 中获取设备格式后用 AAC 编码创建 + // 音频输入(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") @@ -279,13 +410,18 @@ final class PlayerRecorder: NSObject { 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 + isRecording = true isRunning = true startCaptureLoop() startDurationTimer() - - // 启动音频捕获 (CoreAudio 输出设备,内部创建 audioInput) - startAudioCapture() } // MARK: - 停止录制 @@ -299,11 +435,14 @@ final class PlayerRecorder: NSObject { captureTimer = nil stopDurationTimer() - let audioCalls = audioCaptureContext?.processCallCount ?? 0 - let audioAppended = audioCaptureContext?.appendCount ?? 0 + // 停止音频 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) - stopAudioCapture() + currentPlayerItem = nil videoInput?.markAsFinished() @@ -352,121 +491,6 @@ final class PlayerRecorder: NSObject { lastPixelBuffer = nil } - - // MARK: - 音频捕获 (CoreAudio 输出设备) - - private func startAudioCapture() { - print("[Recorder] startAudioCapture called") - - 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 - ) - print("[Recorder] Get default device status: \(status), deviceID: \(deviceID)") - guard status == noErr, deviceID != 0 else { - print("[Recorder] ❌ Cannot get default audio output device") - return - } - self.audioDeviceID = deviceID - - // 获取输出音频流格式 - 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) - print("[Recorder] Stream format: status=\(fmtStatus), rate=\(asbd.mSampleRate), ch=\(asbd.mChannelsPerFrame), bits=\(asbd.mBitsPerChannel), formatID=\(asbd.mFormatID)") - guard fmtStatus == noErr else { - print("[Recorder] ❌ Cannot get audio stream format") - return - } - - // 用 AAC 编码创建音频输入(MP4 容器不支持裸 PCM pass-through) - // 先创建 CMAudioFormatDescription - var fmtDesc: CMAudioFormatDescription? - var mutableASBD = asbd - CMAudioFormatDescriptionCreate( - allocator: kCFAllocatorDefault, - asbd: &mutableASBD, - layoutSize: 0, layout: nil, - magicCookieSize: 0, magicCookie: nil, - extensions: nil, - formatDescriptionOut: &fmtDesc - ) - guard let sourceFmtDesc = fmtDesc else { - print("[Recorder] ❌ Cannot create CMAudioFormatDescription") - return - } - - let audioSettings: [String: Any] = [ - AVFormatIDKey: kAudioFormatMPEG4AAC, - AVSampleRateKey: asbd.mSampleRate, - AVNumberOfChannelsKey: asbd.mChannelsPerFrame, - AVEncoderBitRateKey: 128_000 - ] - let aInput = AVAssetWriterInput(mediaType: .audio, outputSettings: audioSettings, sourceFormatHint: sourceFmtDesc) - aInput.expectsMediaDataInRealTime = true - if let w = writer, w.canAdd(aInput) { - w.add(aInput) - audioInput = aInput - print("[Recorder] ✓ AudioInput created with AAC encoding (rate=\(asbd.mSampleRate), ch=\(asbd.mChannelsPerFrame))") - } else { - print("[Recorder] ❌ Cannot add audio input to writer") - return - } - - // 创建 context - let context = AudioCaptureContext() - context.writerInput = aInput - context.sampleRate = Double(asbd.mSampleRate) - context.channelsPerFrame = asbd.mChannelsPerFrame - context.isRunning = true - self.audioCaptureContext = context - - // 使用前面已创建的格式描述 - context.formatDescription = sourceFmtDesc - print("[Recorder] formatDescription created: true") - - // 创建 IOProc - let contextPtr = Unmanaged.passUnretained(context).toOpaque() - var ioProc: AudioDeviceIOProc? - let createStatus = AudioDeviceCreateIOProcID(deviceID, audioDeviceIOProc, contextPtr, &ioProc) - print("[Recorder] CreateIOProcID status: \(createStatus)") - guard createStatus == noErr, let proc = ioProc else { - print("[Recorder] ❌ AudioDeviceCreateIOProcID failed: \(createStatus)") - return - } - self.audioIOProc = proc - - let startStatus = AudioDeviceStart(deviceID, proc) - print("[Recorder] AudioDeviceStart status: \(startStatus)") - guard startStatus == noErr else { - print("[Recorder] ❌ AudioDeviceStart failed: \(startStatus)") - AudioDeviceDestroyIOProcID(deviceID, proc) - self.audioIOProc = nil - return - } - NSLog("[Recorder] ✓ 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)