MiniPlayer/Sources/PlayerRecorder.swift
yumoqing 5ae4cc4e70 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
2026-06-24 23:14:47 +08:00

463 lines
17 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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