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
}
// 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 tracksHLS
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
}
// trackmuxed
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 fallbackplayerItem.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.tracksHLS
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() {