fix: replace MTAudioProcessingTap with CoreAudio device capture for HLS audio recording

MTAudioProcessingTap with AVPlayerItem.audioMix doesn't fire its process
callback for HLS streams on macOS (known limitation). This caused recorded
videos to have no audio track when recording HLS content.

Replace with CoreAudio AudioDeviceCreateIOProcID approach that captures
PCM audio directly from the system output device. This works for all
audio sources (HLS, local files, etc).

Key changes:
- Remove MTAudioProcessingTap and all tap callbacks
- Add CoreAudio device IOProc for audio capture
- Pre-allocated buffer to avoid malloc in real-time audio thread
- Serial dispatch queue for CMSampleBuffer processing off audio thread
- AudioTimeStamp → CMTime conversion for proper timestamps
This commit is contained in:
yumoqing 2026-06-27 10:58:09 +08:00
parent f0e468122b
commit f1ecbf2e91

View File

@ -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
/// 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: - MTAudioProcessingTap C 回调
// MARK: - CoreAudio 输出设备 IOProc 回调
private func tapInitCallback(
_ tap: MTAudioProcessingTap,
_ clientInfo: UnsafeMutableRawPointer?,
_ tapStorageOut: UnsafeMutablePointer<UnsafeMutableRawPointer?>
) {
// tapStorage 不需要额外分配,context 通过全局变量访问
tapStorageOut.pointee = clientInfo
}
private func tapFinalizeCallback(_ tap: MTAudioProcessingTap) {
// 清理由 stopRecording 负责
}
private func tapPrepareCallback(
_ tap: MTAudioProcessingTap,
_ maxFrames: CMItemCount,
_ processingFormat: UnsafePointer<AudioStreamBasicDescription>
) {
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<AudioBufferList>,
_ numberFramesOut: UnsafeMutablePointer<CMItemCount>,
_ flagsOut: UnsafeMutablePointer<MTAudioProcessingTapFlags>
) {
// 始终先获取源音频(保证音频管道正常运行)
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,
/// 从系统音频输出设备捕获 PCM 数据(适用于所有音频源:HLS、本地文件等)
/// 运行在高优先级音频线程,最小化工作
private func audioDeviceIOProc(
_ inDevice: AudioDeviceID,
_ inNow: UnsafePointer<AudioTimeStamp>,
_ inInputData: UnsafePointer<AudioBufferList>,
_ inInputTime: UnsafePointer<AudioTimeStamp>,
_ inOutputData: UnsafeMutablePointer<AudioBufferList>,
_ inOutputTime: UnsafePointer<AudioTimeStamp>,
_ inClientData: UnsafeMutableRawPointer?
) -> OSStatus {
guard let clientData = inClientData else { return noErr }
let ctx = Unmanaged<AudioCaptureContext>.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 }
// Process on serial queue (off the real-time audio thread)
ctx.audioQueue.async {
guard ctx.isRunning, let fd = ctx.formatDescription 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 }
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: srcData, blockBuffer: bb,
offsetIntoDestination: 0, dataLength: dataSize
)
guard replaceStatus == kCMBlockBufferNoErr else { return }
let replaceStatus = CMBlockBufferReplaceDataBytes(
with: dest, blockBuffer: bb,
offsetIntoDestination: 0, dataLength: Int(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")
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
}
timingInfo.duration = .invalid
timingInfo.decodeTimeStamp = .invalid
// 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)
var sampleBuffer: CMSampleBuffer?
let bytesPerFrame = Int(ctx.channelsPerFrame) * MemoryLayout<Float32>.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<AudioDeviceID>.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<AudioStreamBasicDescription>.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() {