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:
parent
f0e468122b
commit
f1ecbf2e91
@ -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<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,
|
||||
/// 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<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 }
|
||||
|
||||
// 创建 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<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() {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user