fix: audio recording - double buffer + HLS variant switch support

- Replace single dataBuffer with double-buffer (dataBuffers tuple)
  to eliminate data race between real-time tapProcess thread and
  async dispatch queue reading the buffer
- Use kCMPersistentTrackID_Invalid for audioMix trackID so the tap
  survives HLS variant switches (track ID changes on quality change)
This commit is contained in:
yumoqing 2026-06-27 15:44:55 +08:00
parent 1733ecc7bc
commit fb1bd32bbf

View File

@ -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
/// AasyncB使
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<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 else { return noErr }
private func tapInit(
_ tap: MTAudioProcessingTap,
_ clientInfo: UnsafeMutableRawPointer?,
_ tapStorageOut: UnsafeMutablePointer<UnsafeMutableRawPointer?>
) {
tapStorageOut.pointee = clientInfo
}
private func tapFinalize(_ tap: MTAudioProcessingTap) {
// Context lifecycle managed elsewhere
}
private func tapPrepare(
_ tap: MTAudioProcessingTap,
_ maxFrames: CMItemCount,
_ processingFormat: UnsafePointer<AudioStreamBasicDescription>
) {
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<AudioTapContext>.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<AudioBufferList>,
_ bufferListSizeOut: UnsafeMutablePointer<Int>,
_ flagsOut: UnsafeMutablePointer<UInt32>
) {
bufferListSizeOut.pointee = 1
flagsOut.pointee = flags
let storage = MTAudioProcessingTapGetStorage(tap)
let ctx = Unmanaged<AudioTapContext>.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..<ablPtr.count {
if ablPtr[i].mData != nil && ablPtr[i].mDataByteSize > 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..<ablPtr.count {
if let src = ablPtr[i].mData, ablPtr[i].mDataByteSize > 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<Float32>.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<AudioDeviceID>.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<AudioStreamBasicDescription>.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)