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:
parent
c7bb63bb7e
commit
5ae4cc4e70
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user