feat: HLS流音频录制——MTAudioProcessingTap替代AVAssetReader

问题: AVAssetReader不支持HLS流(m3u8),录制HLS时音频丢失。

方案: 统一使用MTAudioProcessingTap捕获音频
- 从播放管道直接获取PCM音频数据,本地文件和HLS流通用
- tap回调通过MTAudioProcessingTapGetSourceAudio获取源音频+时间范围
- AudioCaptureContext通过Unmanaged传递给C回调
- init/finalize回调管理context生命周期
- 视频仍用AVPlayerItemVideoOutput(已验证稳定)

API变更:
- startRecording(from:playerItem:startTime:) 替代 sourceURL
- PlayerBridge传入playerItem而非URL
This commit is contained in:
yumoqing 2026-06-24 23:14:47 +08:00
parent c7bb63bb7e
commit 5ae4cc4e70
2 changed files with 230 additions and 171 deletions

View File

@ -121,9 +121,12 @@ final class PlayerBridge: ObservableObject {
return return
} }
guard !queue.isEmpty else { return } guard !queue.isEmpty else { return }
let sourceURL = queue[currentIndex].url
let startTime = player.currentTime() let startTime = player.currentTime()
screenRecorder.startRecording(from: output, sourceURL: sourceURL, startTime: startTime) guard let playerItem = player.currentItem else {
showToast("No player item")
return
}
screenRecorder.startRecording(from: output, playerItem: playerItem, startTime: startTime)
} }
#endif #endif
} }

View File

@ -1,8 +1,151 @@
import AVFoundation import AVFoundation
import CoreMedia
import MediaToolbox
#if os(macOS) #if os(macOS)
import AppKit import AppKit
/// 视频源录制器:从 AVPlayerItemVideoOutput 抓帧 + 源 URL 音频轨道,写入 mp4 // MARK: - 音频捕获上下文(通过 tapStorage 传给 C 回调)
class AudioCaptureContext {
var writerInput: AVAssetWriterInput?
nonisolated(unsafe) var isRunning: Bool = true
nonisolated(unsafe) var formatDescription: CMAudioFormatDescription?
nonisolated(unsafe) var appendCount: Int = 0
}
// MARK: - MTAudioProcessingTap C 回调函数
private func tapInit(_ tap: MTAudioProcessingTap,
_ clientInfo: UnsafeMutableRawPointer?,
_ tapStorageOut: UnsafeMutablePointer<UnsafeMutableRawPointer?>) {
// 将 clientInfo 存入 tapStorage
tapStorageOut.pointee = clientInfo
}
private func tapFinalize(_ tap: MTAudioProcessingTap) {
// 释放 context 的 Unmanaged 引用
guard let storage = Optional(MTAudioProcessingTapGetStorage(tap)),
let ctxPtr = storage.assumingMemoryBound(to: UnsafeMutableRawPointer?.self).pointee else { return }
Unmanaged<AudioCaptureContext>.fromOpaque(ctxPtr).release()
}
private func tapPrepare(_ tap: MTAudioProcessingTap,
_ maxFrames: Int,
_ processingFormat: UnsafePointer<AudioStreamBasicDescription>) {
guard let storage = Optional(MTAudioProcessingTapGetStorage(tap)),
let ctxPtr = storage.assumingMemoryBound(to: UnsafeMutableRawPointer?.self).pointee else { return }
let ctx = Unmanaged<AudioCaptureContext>.fromOpaque(ctxPtr).takeUnretainedValue()
var asbd = processingFormat.pointee
var fmtDesc: CMAudioFormatDescription?
CMAudioFormatDescriptionCreate(allocator: kCFAllocatorDefault,
asbd: &asbd,
layoutSize: 0, layout: nil,
magicCookieSize: 0, magicCookie: nil,
extensions: nil,
formatDescriptionOut: &fmtDesc)
ctx.formatDescription = fmtDesc
print("[Recorder] Audio tap prepared: \(asbd.mSampleRate)Hz, \(asbd.mChannelsPerFrame)ch, format=\(asbd.mFormatID)")
}
private func tapUnprepare(_ tap: MTAudioProcessingTap) {
// Nothing to clean up
}
private func tapProcess(_ tap: MTAudioProcessingTap,
_ numberOfFrames: CMItemCount,
_ flags: MTAudioProcessingTapFlags,
_ bufferListInOut: UnsafeMutablePointer<AudioBufferList>,
_ numberFramesOut: UnsafeMutablePointer<CMItemCount>,
_ flagsOut: UnsafeMutablePointer<MTAudioProcessingTapFlags>) {
numberFramesOut.pointee = 0
flagsOut.pointee = 0
guard numberFrames > 0 else { return }
// 获取源音频数据 + 时间范围
var timeRange = CMTimeRange()
var srcFlags: UInt32 = 0
var actualFrames: Int = 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 storage = Optional(MTAudioProcessingTapGetStorage(tap)),
let ctxPtr = storage.assumingMemoryBound(to: UnsafeMutableRawPointer?.self).pointee else { return }
let ctx = Unmanaged<AudioCaptureContext>.fromOpaque(ctxPtr).takeUnretainedValue()
guard ctx.isRunning, let writerInput = ctx.writerInput,
writerInput.isReadyForMoreMediaData else { return }
let bufferList = bufferListInOut.pointee
guard bufferList.mNumberBuffers > 0 else { return }
// 计算总字节数
var totalBytes: UInt32 = 0
for i in 0..<Int(bufferList.mNumberBuffers) {
let buf = bufferList.mBuffers // Note: only single buffer for interleaved
totalBytes = buf.mDataByteSize
break
}
guard totalBytes > 0, let srcData = bufferList.mBuffers.mData else { return }
// 创建 CMBlockBuffer
var blockBuffer: CMBlockBuffer?
guard CMBlockBufferCreateWithMemoryBlock(
allocator: kCFAllocatorDefault,
memoryBlock: nil,
blockLength: Int(totalBytes),
blockAllocator: kCFAllocatorDefault,
customBlockSource: nil,
offsetToData: 0,
dataLength: Int(totalBytes),
flags: kCMBlockBufferAssureMemoryNowFlag,
blockBufferOut: &blockBuffer
) == kCMBlockBufferNoErr, let bb = blockBuffer else { return }
// 复制音频数据
guard CMBlockBufferReplaceDataBytes(
with: srcData,
blockBuffer: bb,
offsetIntoDestination: 0,
dataLength: Int(totalBytes)
) == kCMBlockBufferNoErr else { return }
// 获取格式描述
guard let fmtDesc = ctx.formatDescription else { return }
// 创建 CMSampleBuffer(使用 timeRange 的 start 作为 PTS)
var sampleBuffer: CMSampleBuffer?
let createStatus = CMAudioSampleBufferCreateReadyWithPacketDescriptions(
allocator: kCFAllocatorDefault,
dataBuffer: bb,
formatDescription: fmtDesc,
sampleCount: actualFrames,
presentationTimeStamp: timeRange.start,
packetDescriptions: nil,
sampleBufferOut: &sampleBuffer
)
guard createStatus == noErr, let sb = sampleBuffer else { return }
if writerInput.append(sb) {
ctx.appendCount += actualFrames
}
}
/// 视频源录制器:AVPlayerItemVideoOutput 抓帧 + MTAudioProcessingTap 捕获音频
@MainActor @MainActor
final class PlayerRecorder: NSObject { final class PlayerRecorder: NSObject {
@ -14,19 +157,15 @@ final class PlayerRecorder: NSObject {
private var weakOutput: AVPlayerItemVideoOutput? private var weakOutput: AVPlayerItemVideoOutput?
nonisolated(unsafe) private var isRunning = false nonisolated(unsafe) private var isRunning = false
// 视频:缓存上一帧,确保静态场景也能持续写入 // 视频:缓存上一帧
private var lastPixelBuffer: CVPixelBuffer? private var lastPixelBuffer: CVPixelBuffer?
private var captureFrameCount: Int = 0 private var captureFrameCount: Int = 0
// 音频 // 音频 (MTAudioProcessingTap)
private var audioReader: AVAssetReader? private var audioTap: MTAudioProcessingTap?
private var audioReaderOutput: AVAssetReaderTrackOutput? private weak var currentPlayerItem: AVPlayerItem?
private nonisolated(unsafe) var audioFeedTimer: DispatchSourceTimer?
// 共享时间基准:player.currentTime() at record start
nonisolated(unsafe) private var recordStartTime: CMTime = .zero nonisolated(unsafe) private var recordStartTime: CMTime = .zero
nonisolated(unsafe) private var audioStartHostTime: CFTimeInterval = 0
nonisolated(unsafe) private var pendingAudioSample: CMSampleBuffer?
@Published var isRecording = false @Published var isRecording = false
@Published var durationText = "00:00" @Published var durationText = "00:00"
@ -44,16 +183,15 @@ final class PlayerRecorder: NSObject {
} }
// MARK: - 开始录制 // MARK: - 开始录制
func startRecording(from output: AVPlayerItemVideoOutput, sourceURL: URL, startTime: CMTime) { func startRecording(from output: AVPlayerItemVideoOutput, playerItem: AVPlayerItem, startTime: CMTime) {
guard !isRecording else { return } guard !isRecording else { return }
weakOutput = output weakOutput = output
currentPlayerItem = playerItem
lastPixelBuffer = nil lastPixelBuffer = nil
captureFrameCount = 0 captureFrameCount = 0
isRunning = false isRunning = false
recordStartTime = startTime // 共享时间基准 recordStartTime = startTime
audioStartHostTime = 0
pendingAudioSample = nil
// 临时文件 // 临时文件
let formatter = DateFormatter() let formatter = DateFormatter()
@ -98,16 +236,14 @@ final class PlayerRecorder: NSObject {
audioInput = aInput audioInput = aInput
} }
let success = writer.startWriting() guard writer.startWriting() else {
if !success {
print("[Recorder] startWriting failed: \(writer.error?.localizedDescription ?? "unknown")") print("[Recorder] startWriting failed: \(writer.error?.localizedDescription ?? "unknown")")
onError?("Cannot start writer: \(writer.error?.localizedDescription ?? "unknown")") onError?("Cannot start writer: \(writer.error?.localizedDescription ?? "unknown")")
return return
} }
// session 起点为 0,视频和音频都用相对时间戳
writer.startSession(atSourceTime: .zero) writer.startSession(atSourceTime: .zero)
print("[Recorder] Writer started, session at .zero") print("[Recorder] Writer started")
// 启动视频抓帧 // 启动视频抓帧
isRecording = true isRecording = true
@ -115,8 +251,8 @@ final class PlayerRecorder: NSObject {
startTimer() startTimer()
startCaptureLoop() startCaptureLoop()
// 启动音频读取 // 启动音频捕获
startAudioCapture(sourceURL: sourceURL, startTime: startTime, audioWriterInput: aInput) setupAudioTap(playerItem: playerItem, audioWriterInput: aInput)
} }
// MARK: - 停止录制 // MARK: - 停止录制
@ -128,15 +264,10 @@ final class PlayerRecorder: NSObject {
captureTimer = nil captureTimer = nil
stopTimer() stopTimer()
// 停止音频 timer // 停止音频 tap
audioFeedTimer?.cancel() currentPlayerItem?.audioMix = nil
audioFeedTimer = nil currentPlayerItem = nil
audioTap = nil
// 停止音频 reader
audioReader?.cancelReading()
audioReader = nil
audioReaderOutput = nil
pendingAudioSample = nil
videoInput?.markAsFinished() videoInput?.markAsFinished()
audioInput?.markAsFinished() audioInput?.markAsFinished()
@ -144,8 +275,8 @@ final class PlayerRecorder: NSObject {
writer?.finishWriting { [weak self] in writer?.finishWriting { [weak self] in
Task { @MainActor in Task { @MainActor in
guard let self = self, let w = self.writer else { return } guard let self = self, let w = self.writer else { return }
let count = self.captureFrameCount let vFrames = self.captureFrameCount
print("[Recorder] finishWriting status: \(w.status.rawValue), error: \(w.error?.localizedDescription ?? "none"), frames: \(count)") print("[Recorder] finishWriting status: \(w.status.rawValue), error: \(w.error?.localizedDescription ?? "none"), video frames: \(vFrames)")
if w.status == .completed, let url = self.tempURL { if w.status == .completed, let url = self.tempURL {
self.showSaveDialog(tempURL: url) self.showSaveDialog(tempURL: url)
} else { } else {
@ -160,6 +291,69 @@ final class PlayerRecorder: NSObject {
lastPixelBuffer = nil lastPixelBuffer = nil
} }
// MARK: - 音频 Tap 设置
private func setupAudioTap(playerItem: AVPlayerItem, audioWriterInput: AVAssetWriterInput) {
let asset = playerItem.asset
Task {
do {
let audioTracks = try await asset.loadTracks(withMediaType: .audio)
guard let audioTrack = audioTracks.first else {
print("[Recorder] No audio track for tap")
return
}
guard self.isRunning else { return }
// 创建 context,用 Unmanaged 传递给 C 回调
let context = AudioCaptureContext()
context.writerInput = audioWriterInput
context.isRunning = true
let contextPtr = Unmanaged.passRetained(context).toOpaque()
// 创建回调结构体
var callbacks = MTAudioProcessingTapCallbacks(
version: kMTAudioProcessingTapCallbacksVersion_0,
clientInfo: contextPtr,
init: tapInit,
finalize: tapFinalize,
prepare: tapPrepare,
unprepare: tapUnprepare,
process: tapProcess
)
var tap: MTAudioProcessingTap?
let status = withUnsafePointer(to: &callbacks) { cbPtr in
MTAudioProcessingTapCreate(
kCFAllocatorDefault,
cbPtr,
kMTAudioProcessingTapCreationFlag_PostEffects,
&tap
)
}
guard status == noErr, let audioTap = tap else {
print("[Recorder] MTAudioProcessingTapCreate failed: \(status)")
Unmanaged<AudioCaptureContext>.fromOpaque(contextPtr).release()
return
}
self.audioTap = audioTap
// 创建 audioMix
let params = AVMutableAudioMixInputParameters(track: audioTrack)
params.audioTapProcessor = audioTap
let audioMix = AVMutableAudioMix()
audioMix.inputParameters = [params]
playerItem.audioMix = audioMix
print("[Recorder] Audio tap installed")
} catch {
print("[Recorder] Audio tap setup failed: \(error)")
}
}
}
// MARK: - 视频抓帧 (30fps Timer) // MARK: - 视频抓帧 (30fps Timer)
private var captureTimer: Timer? private var captureTimer: Timer?
@ -178,14 +372,12 @@ final class PlayerRecorder: NSObject {
let hostTime = CACurrentMediaTime() let hostTime = CACurrentMediaTime()
let itemTime = output.itemTime(forHostTime: hostTime) let itemTime = output.itemTime(forHostTime: hostTime)
// 尝试获取新帧,否则复用上一帧
if let pb = output.copyPixelBuffer(forItemTime: itemTime, itemTimeForDisplay: nil) { if let pb = output.copyPixelBuffer(forItemTime: itemTime, itemTimeForDisplay: nil) {
lastPixelBuffer = pb lastPixelBuffer = pb
} }
guard let pb = lastPixelBuffer else { return } guard let pb = lastPixelBuffer else { return }
// 相对时间戳:itemTime - recordStartTime
let relativeTime = CMTimeSubtract(itemTime, recordStartTime) let relativeTime = CMTimeSubtract(itemTime, recordStartTime)
guard relativeTime.seconds >= 0 else { return } guard relativeTime.seconds >= 0 else { return }
@ -213,143 +405,7 @@ final class PlayerRecorder: NSObject {
return sampleBuffer return sampleBuffer
} }
// MARK: - 音频读取(DispatchSourceTimer 限速到实时) // MARK: - Timer
private func startAudioCapture(sourceURL: URL, startTime: CMTime, audioWriterInput: AVAssetWriterInput) {
let asset = AVURLAsset(url: sourceURL)
Task {
do {
let audioTracks = try await asset.loadTracks(withMediaType: .audio)
guard let audioTrack = audioTracks.first else {
print("[Recorder] No audio track in source")
return
}
guard self.isRunning else { return }
let reader = try AVAssetReader(asset: asset)
let readerOutput = AVAssetReaderTrackOutput(track: audioTrack, outputSettings: [
AVFormatIDKey: kAudioFormatLinearPCM,
AVSampleRateKey: 44100,
AVNumberOfChannelsKey: 2,
AVLinearPCMBitDepthKey: 16,
AVLinearPCMIsFloatKey: false,
AVLinearPCMIsBigEndianKey: false,
AVLinearPCMIsNonInterleaved: false
])
readerOutput.alwaysCopiesSampleData = false
// 从播放位置开始读取
reader.timeRange = CMTimeRange(start: startTime, duration: .positiveInfinity)
if reader.canAdd(readerOutput) {
reader.add(readerOutput)
reader.startReading()
self.audioReader = reader
self.audioReaderOutput = readerOutput
// 记录 wall-clock 起始时间,用于实时限速
self.audioStartHostTime = CACurrentMediaTime()
// 用 DispatchSourceTimer 每 20ms 喂一次音频,限速到实时
let queue = DispatchQueue(label: "recorder.audio.feed")
let feedTimer = DispatchSource.makeTimerSource(queue: queue)
feedTimer.schedule(deadline: .now() + .milliseconds(10), repeating: .milliseconds(20))
feedTimer.setEventHandler { [weak self] in
self?.feedAudioSamplesRealtime(writerInput: audioWriterInput,
readerOutput: readerOutput,
reader: reader)
}
self.audioFeedTimer = feedTimer
feedTimer.resume()
}
} catch {
print("[Recorder] Audio capture setup failed: \(error)")
}
}
}
/// 每 20ms 调用,只写入不超过当前实时进度的音频
private nonisolated func feedAudioSamplesRealtime(writerInput: AVAssetWriterInput,
readerOutput: AVAssetReaderTrackOutput,
reader: AVAssetReader) {
guard isRunning, reader.status == .reading else {
if isRunning && reader.status != .reading {
writerInput.markAsFinished()
audioFeedTimer?.cancel()
}
return
}
guard writerInput.isReadyForMoreMediaData else { return }
// 实时进度:录制开始以来经过的秒数
let elapsed = CACurrentMediaTime() - audioStartHostTime
let maxAudioTime = elapsed + 0.05 // 允许 50ms 预读
// 先处理上次缓存的超前 sample
if let pending = pendingAudioSample {
let pts = CMSampleBufferGetPresentationTimeStamp(pending)
let relative = CMTimeSubtract(pts, recordStartTime)
if relative.seconds > maxAudioTime {
return // 还没到时间,等下次 tick
}
pendingAudioSample = nil
if let nb = Self.remapAudioBuffer(pending, offset: recordStartTime) {
_ = writerInput.append(nb)
}
}
// 继续读取,直到追上实时进度
while isRunning, reader.status == .reading, writerInput.isReadyForMoreMediaData {
guard let sampleBuffer = readerOutput.copyNextSampleBuffer() else {
// 源文件音频读完
if reader.status != .reading {
writerInput.markAsFinished()
audioFeedTimer?.cancel()
}
return
}
let pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
let relative = CMTimeSubtract(pts, recordStartTime)
// 超出实时进度 → 缓存,等下次 tick
if relative.seconds > maxAudioTime {
pendingAudioSample = sampleBuffer
return
}
// 跳过负时间戳(音频 pts 在 recordStartTime 之前)
guard relative.seconds >= 0 else { continue }
if let nb = Self.remapAudioBuffer(sampleBuffer, offset: recordStartTime) {
if !writerInput.append(nb) {
return
}
}
}
}
/// 重映射音频 sample buffer 的时间戳为相对时间
private nonisolated static func remapAudioBuffer(_ sampleBuffer: CMSampleBuffer, offset: CMTime) -> CMSampleBuffer? {
var count: CMItemCount = 0
CMSampleBufferGetSampleTimingInfoArray(sampleBuffer, entryCount: 0, arrayToFill: nil, entriesNeededOut: &count)
guard count > 0 else { return sampleBuffer }
var timingInfo = [CMSampleTimingInfo](repeating: CMSampleTimingInfo(), count: count)
CMSampleBufferGetSampleTimingInfoArray(sampleBuffer, entryCount: count, arrayToFill: &timingInfo, entriesNeededOut: nil)
for i in 0..<count {
timingInfo[i].decodeTimeStamp = .invalid
timingInfo[i].presentationTimeStamp = CMTimeSubtract(timingInfo[i].presentationTimeStamp, offset)
}
var newBuffer: CMSampleBuffer?
CMSampleBufferCreateCopyWithNewTiming(allocator: kCFAllocatorDefault, sampleBuffer: sampleBuffer, sampleTimingEntryCount: count, sampleTimingArray: &timingInfo, sampleBufferOut: &newBuffer)
return newBuffer
}
// MARK: - Timer (显示录制时长)
private func startTimer() { private func startTimer() {
startDate = Date() startDate = Date()
durationText = "00:00" durationText = "00:00"