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"