问题: 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
463 lines
17 KiB
Swift
463 lines
17 KiB
Swift
import AVFoundation
|
||
import CoreMedia
|
||
import MediaToolbox
|
||
#if os(macOS)
|
||
import AppKit
|
||
|
||
// 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
|
||
final class PlayerRecorder: NSObject {
|
||
|
||
private var writer: AVAssetWriter?
|
||
private var videoInput: AVAssetWriterInput?
|
||
private var audioInput: AVAssetWriterInput?
|
||
private var timer: Timer?
|
||
private var startDate: Date?
|
||
private var weakOutput: AVPlayerItemVideoOutput?
|
||
nonisolated(unsafe) private var isRunning = false
|
||
|
||
// 视频:缓存上一帧
|
||
private var lastPixelBuffer: CVPixelBuffer?
|
||
private var captureFrameCount: Int = 0
|
||
|
||
// 音频 (MTAudioProcessingTap)
|
||
private var audioTap: MTAudioProcessingTap?
|
||
private weak var currentPlayerItem: AVPlayerItem?
|
||
|
||
nonisolated(unsafe) private var recordStartTime: CMTime = .zero
|
||
|
||
@Published var isRecording = false
|
||
@Published var durationText = "00:00"
|
||
|
||
var onRecordingSaved: ((URL) -> Void)?
|
||
var onError: ((String) -> Void)?
|
||
|
||
private var tempURL: URL?
|
||
|
||
private var saveDirectory: URL {
|
||
let movies = FileManager.default.urls(for: .moviesDirectory, in: .userDomainMask).first!
|
||
let dir = movies.appendingPathComponent("MiniPlayer", isDirectory: true)
|
||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||
return dir
|
||
}
|
||
|
||
// MARK: - 开始录制
|
||
func startRecording(from output: AVPlayerItemVideoOutput, playerItem: AVPlayerItem, startTime: CMTime) {
|
||
guard !isRecording else { return }
|
||
|
||
weakOutput = output
|
||
currentPlayerItem = playerItem
|
||
lastPixelBuffer = nil
|
||
captureFrameCount = 0
|
||
isRunning = false
|
||
recordStartTime = startTime
|
||
|
||
// 临时文件
|
||
let formatter = DateFormatter()
|
||
formatter.dateFormat = "yyyy-MM-dd_HH-mm-ss"
|
||
let filename = "Recording_\(formatter.string(from: Date())).mp4"
|
||
let url = saveDirectory.appendingPathComponent(filename)
|
||
tempURL = url
|
||
|
||
do {
|
||
writer = try AVAssetWriter(outputURL: url, fileType: .mp4)
|
||
} catch {
|
||
onError?("Cannot create writer: \(error.localizedDescription)")
|
||
return
|
||
}
|
||
|
||
guard let writer = writer else { return }
|
||
|
||
// 视频输入
|
||
let videoSettings: [String: Any] = [
|
||
AVVideoCodecKey: AVVideoCodecType.h264,
|
||
AVVideoWidthKey: 1920,
|
||
AVVideoHeightKey: 1080
|
||
]
|
||
let vInput = AVAssetWriterInput(mediaType: .video, outputSettings: videoSettings)
|
||
vInput.expectsMediaDataInRealTime = true
|
||
if writer.canAdd(vInput) {
|
||
writer.add(vInput)
|
||
videoInput = vInput
|
||
}
|
||
|
||
// 音频输入
|
||
let audioSettings: [String: Any] = [
|
||
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||
AVSampleRateKey: 44100,
|
||
AVNumberOfChannelsKey: 2,
|
||
AVEncoderBitRateKey: 128000
|
||
]
|
||
let aInput = AVAssetWriterInput(mediaType: .audio, outputSettings: audioSettings)
|
||
aInput.expectsMediaDataInRealTime = true
|
||
if writer.canAdd(aInput) {
|
||
writer.add(aInput)
|
||
audioInput = aInput
|
||
}
|
||
|
||
guard writer.startWriting() else {
|
||
print("[Recorder] startWriting failed: \(writer.error?.localizedDescription ?? "unknown")")
|
||
onError?("Cannot start writer: \(writer.error?.localizedDescription ?? "unknown")")
|
||
return
|
||
}
|
||
|
||
writer.startSession(atSourceTime: .zero)
|
||
print("[Recorder] Writer started")
|
||
|
||
// 启动视频抓帧
|
||
isRecording = true
|
||
isRunning = true
|
||
startTimer()
|
||
startCaptureLoop()
|
||
|
||
// 启动音频捕获
|
||
setupAudioTap(playerItem: playerItem, audioWriterInput: aInput)
|
||
}
|
||
|
||
// MARK: - 停止录制
|
||
func stopRecording() {
|
||
guard isRecording else { return }
|
||
isRecording = false
|
||
isRunning = false
|
||
captureTimer?.invalidate()
|
||
captureTimer = nil
|
||
stopTimer()
|
||
|
||
// 停止音频 tap
|
||
currentPlayerItem?.audioMix = nil
|
||
currentPlayerItem = nil
|
||
audioTap = nil
|
||
|
||
videoInput?.markAsFinished()
|
||
audioInput?.markAsFinished()
|
||
|
||
writer?.finishWriting { [weak self] in
|
||
Task { @MainActor in
|
||
guard let self = self, let w = self.writer else { return }
|
||
let vFrames = self.captureFrameCount
|
||
print("[Recorder] finishWriting status: \(w.status.rawValue), error: \(w.error?.localizedDescription ?? "none"), video frames: \(vFrames)")
|
||
if w.status == .completed, let url = self.tempURL {
|
||
self.showSaveDialog(tempURL: url)
|
||
} else {
|
||
self.onError?("Recording failed: \(w.error?.localizedDescription ?? "unknown (status=\(w.status.rawValue))")")
|
||
if let url = self.tempURL {
|
||
try? FileManager.default.removeItem(at: url)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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)
|
||
private var captureTimer: Timer?
|
||
|
||
private func startCaptureLoop() {
|
||
captureTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 30.0, repeats: true) { [weak self] _ in
|
||
Task { @MainActor in
|
||
self?.captureVideoFrame()
|
||
}
|
||
}
|
||
}
|
||
|
||
private func captureVideoFrame() {
|
||
guard isRunning, let output = weakOutput, let input = videoInput,
|
||
input.isReadyForMoreMediaData else { return }
|
||
|
||
let hostTime = CACurrentMediaTime()
|
||
let itemTime = output.itemTime(forHostTime: hostTime)
|
||
|
||
if let pb = output.copyPixelBuffer(forItemTime: itemTime, itemTimeForDisplay: nil) {
|
||
lastPixelBuffer = pb
|
||
}
|
||
|
||
guard let pb = lastPixelBuffer else { return }
|
||
|
||
let relativeTime = CMTimeSubtract(itemTime, recordStartTime)
|
||
guard relativeTime.seconds >= 0 else { return }
|
||
|
||
let sampleBuffer = Self.createSampleBuffer(from: pb, time: relativeTime)
|
||
if let sb = sampleBuffer {
|
||
if input.append(sb) {
|
||
captureFrameCount += 1
|
||
}
|
||
}
|
||
}
|
||
|
||
private static func createSampleBuffer(from pixelBuffer: CVPixelBuffer, time: CMTime) -> CMSampleBuffer? {
|
||
var info = CMSampleTimingInfo()
|
||
info.presentationTimeStamp = time
|
||
info.duration = CMTime(value: 1, timescale: 30)
|
||
info.decodeTimeStamp = .invalid
|
||
|
||
var formatDescription: CMFormatDescription?
|
||
CMVideoFormatDescriptionCreateForImageBuffer(allocator: kCFAllocatorDefault, imageBuffer: pixelBuffer, formatDescriptionOut: &formatDescription)
|
||
|
||
guard let fd = formatDescription else { return nil }
|
||
|
||
var sampleBuffer: CMSampleBuffer?
|
||
CMSampleBufferCreateReadyWithImageBuffer(allocator: kCFAllocatorDefault, imageBuffer: pixelBuffer, formatDescription: fd, sampleTiming: &info, sampleBufferOut: &sampleBuffer)
|
||
return sampleBuffer
|
||
}
|
||
|
||
// MARK: - Timer
|
||
private func startTimer() {
|
||
startDate = Date()
|
||
durationText = "00:00"
|
||
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
|
||
Task { @MainActor in
|
||
guard let self = self, let start = self.startDate else { return }
|
||
let elapsed = Int(Date().timeIntervalSince(start))
|
||
let m = elapsed / 60
|
||
let s = elapsed % 60
|
||
let h = m / 60
|
||
if h > 0 {
|
||
self.durationText = String(format: "%d:%02d:%02d", h, m % 60, s)
|
||
} else {
|
||
self.durationText = String(format: "%02d:%02d", m, s)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func stopTimer() {
|
||
timer?.invalidate()
|
||
timer = nil
|
||
startDate = nil
|
||
durationText = "00:00"
|
||
}
|
||
|
||
// MARK: - 保存对话框
|
||
private func showSaveDialog(tempURL: URL) {
|
||
let panel = NSSavePanel()
|
||
panel.nameFieldStringValue = tempURL.lastPathComponent
|
||
panel.allowedContentTypes = [.mpeg4Movie]
|
||
panel.directoryURL = saveDirectory
|
||
panel.title = "Save Recording"
|
||
|
||
panel.begin { [weak self] response in
|
||
guard let self = self else { return }
|
||
if response == .OK, let destURL = panel.url {
|
||
do {
|
||
if FileManager.default.fileExists(atPath: destURL.path) {
|
||
try FileManager.default.removeItem(at: destURL)
|
||
}
|
||
try FileManager.default.moveItem(at: tempURL, to: destURL)
|
||
self.onRecordingSaved?(destURL)
|
||
} catch {
|
||
self.onError?("Save failed: \(error.localizedDescription)")
|
||
}
|
||
} else {
|
||
try? FileManager.default.removeItem(at: tempURL)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#endif
|