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:
parent
1733ecc7bc
commit
fb1bd32bbf
@ -5,151 +5,185 @@ import Foundation
|
|||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
import AppKit
|
import AppKit
|
||||||
|
|
||||||
// MARK: - 全局音频上下文(C 回调无法访问 Swift 对象,用全局变量)
|
// MARK: - 全局音频 Tap 上下文(C 回调无法访问 Swift 对象)
|
||||||
private nonisolated(unsafe) var gAudioContext: AudioCaptureContext?
|
|
||||||
|
|
||||||
class AudioCaptureContext {
|
private nonisolated(unsafe) var gTapContext: AudioTapContext?
|
||||||
var writerInput: AVAssetWriterInput?
|
|
||||||
nonisolated(unsafe) var isRunning: Bool = true
|
class AudioTapContext {
|
||||||
|
nonisolated(unsafe) var writerInput: AVAssetWriterInput?
|
||||||
|
nonisolated(unsafe) var isRecording: Bool = false
|
||||||
nonisolated(unsafe) var formatDescription: CMAudioFormatDescription?
|
nonisolated(unsafe) var formatDescription: CMAudioFormatDescription?
|
||||||
nonisolated(unsafe) var sampleRate: Double = 44100.0
|
nonisolated(unsafe) var sampleRate: Double = 0
|
||||||
nonisolated(unsafe) var channelsPerFrame: UInt32 = 2
|
nonisolated(unsafe) var channelsPerFrame: UInt32 = 0
|
||||||
nonisolated(unsafe) var appendCount: Int = 0
|
nonisolated(unsafe) var appendCount: Int = 0
|
||||||
nonisolated(unsafe) var firstAppendLogged: Bool = false
|
|
||||||
nonisolated(unsafe) var processCallCount: Int = 0
|
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")
|
/// 双缓冲区(消除数据竞争:写A时async读B,交替使用)
|
||||||
var dataBuffer: UnsafeMutableRawPointer?
|
nonisolated(unsafe) var dataBuffers: (UnsafeMutableRawPointer?, UnsafeMutableRawPointer?) = (nil, nil)
|
||||||
var dataBufferSize: UInt32 = 0
|
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 数据
|
private func tapInit(
|
||||||
/// 关键:输出设备的播放数据在 inOutputData 参数中,不是 inInputData
|
_ tap: MTAudioProcessingTap,
|
||||||
private func audioDeviceIOProc(
|
_ clientInfo: UnsafeMutableRawPointer?,
|
||||||
_ inDevice: AudioDeviceID,
|
_ tapStorageOut: UnsafeMutablePointer<UnsafeMutableRawPointer?>
|
||||||
_ inNow: UnsafePointer<AudioTimeStamp>,
|
) {
|
||||||
_ inInputData: UnsafePointer<AudioBufferList>,
|
tapStorageOut.pointee = clientInfo
|
||||||
_ inInputTime: UnsafePointer<AudioTimeStamp>,
|
}
|
||||||
_ inOutputData: UnsafeMutablePointer<AudioBufferList>,
|
|
||||||
_ inOutputTime: UnsafePointer<AudioTimeStamp>,
|
private func tapFinalize(_ tap: MTAudioProcessingTap) {
|
||||||
_ inClientData: UnsafeMutableRawPointer?
|
// Context lifecycle managed elsewhere
|
||||||
) -> OSStatus {
|
}
|
||||||
guard let clientData = inClientData else { return noErr }
|
|
||||||
let ctx = Unmanaged<AudioCaptureContext>.fromOpaque(clientData).takeUnretainedValue()
|
private func tapPrepare(
|
||||||
guard ctx.isRunning else { return noErr }
|
_ 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
|
ctx.processCallCount += 1
|
||||||
|
guard ctx.isRecording,
|
||||||
|
let _ = ctx.writerInput,
|
||||||
|
let _ = ctx.formatDescription else { return }
|
||||||
|
|
||||||
// 从 inOutputData 读取正在播放的音频
|
let ablPtr = UnsafeMutableAudioBufferListPointer(bufferListInOut)
|
||||||
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)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 优先读 inOutputData(系统正在播放的音频),这是 loopback 数据
|
// 计算总数据量
|
||||||
let bufferList: AudioBufferList
|
var totalSize: Int = 0
|
||||||
if outBufferList.mNumberBuffers > 0 {
|
for i in 0..<ablPtr.count {
|
||||||
let b = outBufferList.mBuffers
|
if ablPtr[i].mData != nil && ablPtr[i].mDataByteSize > 0 {
|
||||||
if b.mDataByteSize > 0 && b.mData != nil {
|
totalSize += Int(ablPtr[i].mDataByteSize)
|
||||||
bufferList = outBufferList
|
}
|
||||||
} else if inBufferList.mNumberBuffers > 0 {
|
}
|
||||||
bufferList = inBufferList
|
guard totalSize > 0 else { return }
|
||||||
|
|
||||||
|
// 双缓冲:写当前槽,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 {
|
} 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 dest: UnsafeMutableRawPointer?
|
||||||
let dataSize = buffer.mDataByteSize
|
if wi == 0 { dest = ctx.dataBuffers.0 } else { dest = ctx.dataBuffers.1 }
|
||||||
guard dataSize > 0, let srcData = buffer.mData else {
|
guard let dest = dest else { return }
|
||||||
if callNum <= 3 { print("[Recorder] IOProc#\(callNum): no data (size=\(buffer.mDataByteSize), data=\(buffer.mData != nil))") }
|
|
||||||
return noErr
|
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
|
let frameCount = Int64(numberFrames)
|
||||||
if ctx.dataBufferSize < dataSize {
|
let startFrame = ctx.totalFramesWritten
|
||||||
if let existing = ctx.dataBuffer { free(existing) }
|
let readWi = wi // async 读取的是刚写完的槽
|
||||||
ctx.dataBuffer = malloc(Int(dataSize))
|
|
||||||
ctx.dataBufferSize = dataSize
|
|
||||||
}
|
|
||||||
guard let dest = ctx.dataBuffer else { return noErr }
|
|
||||||
memcpy(dest, srcData, Int(dataSize))
|
|
||||||
|
|
||||||
let pts = inOutputTime.pointee
|
// 在串行队列创建 CMSampleBuffer 并写入
|
||||||
|
|
||||||
// 在串行队列处理
|
|
||||||
ctx.audioQueue.async {
|
ctx.audioQueue.async {
|
||||||
guard ctx.isRunning else { return }
|
guard ctx.isRecording,
|
||||||
guard let fd = ctx.formatDescription, let writerInput = ctx.writerInput else { return }
|
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?
|
var blockBuffer: CMBlockBuffer?
|
||||||
let blockStatus = CMBlockBufferCreateWithMemoryBlock(
|
let blockStatus = CMBlockBufferCreateWithMemoryBlock(
|
||||||
allocator: kCFAllocatorDefault, memoryBlock: nil,
|
allocator: kCFAllocatorDefault, memoryBlock: nil,
|
||||||
blockLength: Int(dataSize), blockAllocator: kCFAllocatorDefault,
|
blockLength: totalSize, blockAllocator: kCFAllocatorDefault,
|
||||||
customBlockSource: nil, offsetToData: 0,
|
customBlockSource: nil, offsetToData: 0,
|
||||||
dataLength: Int(dataSize),
|
dataLength: totalSize,
|
||||||
flags: kCMBlockBufferAssureMemoryNowFlag,
|
flags: kCMBlockBufferAssureMemoryNowFlag,
|
||||||
blockBufferOut: &blockBuffer
|
blockBufferOut: &blockBuffer
|
||||||
)
|
)
|
||||||
guard blockStatus == kCMBlockBufferNoErr, let bb = blockBuffer else {
|
guard blockStatus == kCMBlockBufferNoErr, let bb = blockBuffer else { return }
|
||||||
if callNum <= 3 { print("[Recorder] CMBlockBufferCreate failed: \(blockStatus)") }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let replaceStatus = CMBlockBufferReplaceDataBytes(
|
let replaceStatus = CMBlockBufferReplaceDataBytes(
|
||||||
with: dest, blockBuffer: bb,
|
with: readDest, blockBuffer: bb,
|
||||||
offsetIntoDestination: 0, dataLength: Int(dataSize)
|
offsetIntoDestination: 0, dataLength: totalSize
|
||||||
)
|
)
|
||||||
guard replaceStatus == kCMBlockBufferNoErr else { return }
|
guard replaceStatus == kCMBlockBufferNoErr else { return }
|
||||||
|
|
||||||
var timingInfo = CMSampleTimingInfo()
|
// PTS 基于累计帧数计算
|
||||||
if pts.mFlags.contains(.sampleTimeValid) {
|
let pts = CMTime(
|
||||||
// 使用相对于第一次音频回调的时间戳
|
value: CMTimeValue(startFrame),
|
||||||
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)
|
timescale: Int32(ctx.sampleRate)
|
||||||
)
|
)
|
||||||
} else if pts.mFlags.contains(.hostTimeValid) {
|
var timingInfo = CMSampleTimingInfo(
|
||||||
var timebase = mach_timebase_info_data_t()
|
duration: CMTime(value: CMTimeValue(frameCount), timescale: Int32(ctx.sampleRate)),
|
||||||
mach_timebase_info(&timebase)
|
presentationTimeStamp: pts,
|
||||||
let nanos = pts.mHostTime * UInt64(timebase.numer) / UInt64(timebase.denom)
|
decodeTimeStamp: .invalid
|
||||||
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)
|
|
||||||
|
|
||||||
var sampleBuffer: CMSampleBuffer?
|
var sampleBuffer: CMSampleBuffer?
|
||||||
let createStatus = CMSampleBufferCreateReady(
|
let createStatus = CMSampleBufferCreateReady(
|
||||||
allocator: kCFAllocatorDefault,
|
allocator: kCFAllocatorDefault,
|
||||||
dataBuffer: bb,
|
dataBuffer: bb,
|
||||||
formatDescription: fd,
|
formatDescription: fd,
|
||||||
sampleCount: sampleCount,
|
sampleCount: Int(frameCount),
|
||||||
sampleTimingEntryCount: 1,
|
sampleTimingEntryCount: 1,
|
||||||
sampleTimingArray: &timingInfo,
|
sampleTimingArray: &timingInfo,
|
||||||
sampleSizeEntryCount: 0,
|
sampleSizeEntryCount: 0,
|
||||||
@ -157,40 +191,33 @@ private func audioDeviceIOProc(
|
|||||||
sampleBufferOut: &sampleBuffer
|
sampleBufferOut: &sampleBuffer
|
||||||
)
|
)
|
||||||
guard createStatus == noErr, let sb = sampleBuffer else {
|
guard createStatus == noErr, let sb = sampleBuffer else {
|
||||||
if callNum <= 3 {
|
NSLog("[Recorder] ⚠️ SampleBuffer create failed: %d", createStatus)
|
||||||
print("[Recorder] ⚠️ SampleBuffer create failed: \(createStatus), dataSize=\(dataSize), sampleCount=\(sampleCount)")
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if writerInput.isReadyForMoreMediaData {
|
if writerInput.isReadyForMoreMediaData {
|
||||||
let appended = writerInput.append(sb)
|
if writerInput.append(sb) {
|
||||||
if appended {
|
|
||||||
ctx.appendCount += 1
|
ctx.appendCount += 1
|
||||||
|
ctx.totalFramesWritten += frameCount
|
||||||
if !ctx.firstAppendLogged {
|
if !ctx.firstAppendLogged {
|
||||||
ctx.firstAppendLogged = true
|
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: - RecorderState
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - PlayerRecorder
|
|
||||||
|
|
||||||
/// 全局状态:录制是否正在 finalize(阻止应用退出)
|
|
||||||
enum RecorderState {
|
enum RecorderState {
|
||||||
static nonisolated(unsafe) var isFinalizing: Bool = false
|
static nonisolated(unsafe) var isFinalizing: Bool = false
|
||||||
static nonisolated(unsafe) var pendingTerminate: Bool = false
|
static nonisolated(unsafe) var pendingTerminate: Bool = false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 视频源录制器:AVPlayerItemVideoOutput 抓帧 + CoreAudio 输出设备捕获音频
|
// MARK: - PlayerRecorder
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class PlayerRecorder: NSObject {
|
final class PlayerRecorder: NSObject {
|
||||||
|
|
||||||
@ -206,10 +233,8 @@ final class PlayerRecorder: NSObject {
|
|||||||
private var lastPixelBuffer: CVPixelBuffer?
|
private var lastPixelBuffer: CVPixelBuffer?
|
||||||
private var captureFrameCount: Int = 0
|
private var captureFrameCount: Int = 0
|
||||||
|
|
||||||
// 音频 (CoreAudio)
|
// Audio Tap
|
||||||
private var audioDeviceID: AudioDeviceID = 0
|
private var tapContext: AudioTapContext?
|
||||||
private var audioCaptureContext: AudioCaptureContext?
|
|
||||||
private var audioIOProc: AudioDeviceIOProc?
|
|
||||||
private weak var currentPlayerItem: AVPlayerItem?
|
private weak var currentPlayerItem: AVPlayerItem?
|
||||||
|
|
||||||
nonisolated(unsafe) private var recordStartTime: CMTime = .zero
|
nonisolated(unsafe) private var recordStartTime: CMTime = .zero
|
||||||
@ -229,6 +254,61 @@ final class PlayerRecorder: NSObject {
|
|||||||
return dir
|
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: - 开始录制
|
// MARK: - 开始录制
|
||||||
|
|
||||||
func startRecording(from output: AVPlayerItemVideoOutput, playerItem: AVPlayerItem, startTime: CMTime) {
|
func startRecording(from output: AVPlayerItemVideoOutput, playerItem: AVPlayerItem, startTime: CMTime) {
|
||||||
@ -241,11 +321,19 @@ final class PlayerRecorder: NSObject {
|
|||||||
isRunning = false
|
isRunning = false
|
||||||
recordStartTime = startTime
|
recordStartTime = startTime
|
||||||
|
|
||||||
|
// 安装音频 Tap(录制时 asset 已加载,同步获取 track)
|
||||||
|
installAudioTap(on: playerItem)
|
||||||
|
|
||||||
let tempFilename = "temp_recording_\(Int(Date().timeIntervalSince1970)).mp4"
|
let tempFilename = "temp_recording_\(Int(Date().timeIntervalSince1970)).mp4"
|
||||||
let url = saveDirectory.appendingPathComponent(tempFilename)
|
let url = saveDirectory.appendingPathComponent(tempFilename)
|
||||||
try? FileManager.default.removeItem(at: url)
|
try? FileManager.default.removeItem(at: url)
|
||||||
tempURL = url
|
tempURL = url
|
||||||
|
|
||||||
|
// 获取 tap context(可能还没有 format,先用默认值)
|
||||||
|
let ctx = gTapContext ?? AudioTapContext()
|
||||||
|
if gTapContext == nil { gTapContext = ctx }
|
||||||
|
self.tapContext = ctx
|
||||||
|
|
||||||
do {
|
do {
|
||||||
writer = try AVAssetWriter(outputURL: url, fileType: .mp4)
|
writer = try AVAssetWriter(outputURL: url, fileType: .mp4)
|
||||||
} catch {
|
} catch {
|
||||||
@ -268,7 +356,50 @@ final class PlayerRecorder: NSObject {
|
|||||||
videoInput = vInput
|
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 {
|
guard writer.startWriting() else {
|
||||||
NSLog("[Recorder] startWriting failed: %@", writer.error?.localizedDescription ?? "unknown")
|
NSLog("[Recorder] startWriting failed: %@", writer.error?.localizedDescription ?? "unknown")
|
||||||
@ -279,13 +410,18 @@ final class PlayerRecorder: NSObject {
|
|||||||
writer.startSession(atSourceTime: .zero)
|
writer.startSession(atSourceTime: .zero)
|
||||||
NSLog("[Recorder] Writer started, writing to: %@", url.lastPathComponent)
|
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
|
isRecording = true
|
||||||
isRunning = true
|
isRunning = true
|
||||||
startCaptureLoop()
|
startCaptureLoop()
|
||||||
startDurationTimer()
|
startDurationTimer()
|
||||||
|
|
||||||
// 启动音频捕获 (CoreAudio 输出设备,内部创建 audioInput)
|
|
||||||
startAudioCapture()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 停止录制
|
// MARK: - 停止录制
|
||||||
@ -299,11 +435,14 @@ final class PlayerRecorder: NSObject {
|
|||||||
captureTimer = nil
|
captureTimer = nil
|
||||||
stopDurationTimer()
|
stopDurationTimer()
|
||||||
|
|
||||||
let audioCalls = audioCaptureContext?.processCallCount ?? 0
|
// 停止音频 tap 写入
|
||||||
let audioAppended = audioCaptureContext?.appendCount ?? 0
|
tapContext?.isRecording = false
|
||||||
|
let audioAppended = tapContext?.appendCount ?? 0
|
||||||
|
let audioCalls = tapContext?.processCallCount ?? 0
|
||||||
|
|
||||||
NSLog("[Recorder] Stopping: videoFrames=%d, audioProcessCalls=%d, audioAppended=%d",
|
NSLog("[Recorder] Stopping: videoFrames=%d, audioProcessCalls=%d, audioAppended=%d",
|
||||||
captureFrameCount, audioCalls, audioAppended)
|
captureFrameCount, audioCalls, audioAppended)
|
||||||
stopAudioCapture()
|
|
||||||
currentPlayerItem = nil
|
currentPlayerItem = nil
|
||||||
|
|
||||||
videoInput?.markAsFinished()
|
videoInput?.markAsFinished()
|
||||||
@ -353,121 +492,6 @@ final class PlayerRecorder: NSObject {
|
|||||||
lastPixelBuffer = nil
|
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)
|
// MARK: - 视频抓帧 (30fps Timer)
|
||||||
|
|
||||||
private func startCaptureLoop() {
|
private func startCaptureLoop() {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user