fix(iOS): add MTAudioProcessingTap for audio capture, fix PTS timing

- MTAudioProcessingTap captures audio samples from AVPlayer's audio track
- Audio samples written to AVAssetWriter alongside video frames
- First frame PTS used as session start (fixes duration stretch)
- Cleaned up unused properties
This commit is contained in:
yumoqing 2026-07-17 21:47:01 +08:00
parent bfe81cbc82
commit 1832672081

View File

@ -2,10 +2,9 @@
import Foundation
import AVFoundation
import UIKit
import Photos
import MediaToolbox
/// iOS AVPlayer MP4
/// HLS CBS DRM/CDN
/// iOS AVPlayer + MP4
@MainActor
final class HLSRecorder: ObservableObject {
@Published var isRecording = false
@ -22,18 +21,15 @@ final class HLSRecorder: ObservableObject {
private var startTime: Date?
private var timer: Timer?
private var outputURL: URL?
private var lastVideoSampleTime: CMTime?
private var audioEngine: AVAudioEngine?
private var audioFile: AVAudioFile?
private var audioTapInstalled = false
private var firstPTS: CMTime?
private var audioTapID: MTAudioProcessingTapID?
func startRecording(url: URL, player: AVPlayer) {
guard !isRecording, let item = player.currentItem else { return }
isRecording = true
startTime = Date()
lastVideoSampleTime = nil
firstPTS = nil
// Timer for duration display
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
Task { @MainActor in
guard let self, let start = self.startTime else { return }
@ -42,13 +38,10 @@ final class HLSRecorder: ObservableObject {
}
}
// Output file
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
outputURL = docs.appendingPathComponent("MiniPlayer_Recording_\(Int(Date().timeIntervalSince1970)).mp4")
Task {
await startCapture(item: item, player: player)
}
Task { await startCapture(item: item, player: player) }
}
func stopRecording() {
@ -56,8 +49,8 @@ final class HLSRecorder: ObservableObject {
isRecording = false
timer?.invalidate()
displayLink?.invalidate()
videoOutput = nil
// Finalize the writer
videoInput?.markAsFinished()
audioInput?.markAsFinished()
@ -65,23 +58,23 @@ final class HLSRecorder: ObservableObject {
DispatchQueue.main.async {
guard let self, let url = self.outputURL else { return }
let status = self.assetWriter?.status ?? .unknown
NSLog("[MiniPlayer] AVAssetWriter finished: %d, file: %@", status.rawValue, url.path)
NSLog("[MiniPlayer] AVAssetWriter finished: %d", status.rawValue)
if status == .completed {
let attrs = try? FileManager.default.attributesOfItem(atPath: url.path)
let size = (attrs?[.size] as? Int64) ?? 0
NSLog("[MiniPlayer] Recorded file size: %lld bytes", size)
NSLog("[MiniPlayer] Recorded: %lld bytes", size)
self.onRecordingSaved?(url)
} else {
let err = self.assetWriter?.error?.localizedDescription ?? "unknown"
self.onError?("Export failed: \(err)")
self.onError?("Export: \(err)")
try? FileManager.default.removeItem(at: url)
}
}
}
}
// MARK: - Capture
// MARK: - Capture setup
private func startCapture(item: AVPlayerItem, player: AVPlayer) async {
guard let outputURL else { return }
@ -89,12 +82,16 @@ final class HLSRecorder: ObservableObject {
do {
let writer = try AVAssetWriter(url: outputURL, fileType: .mp4)
// Video input: use source format from the asset when possible
let videoSettings: [String: Any]
let naturalSize = try? await item.asset.load(.tracks).first(where: { $0.mediaType == .video })?.naturalSize
let size = naturalSize ?? CGSize(width: 1280, height: 720)
// Natural size from asset
var size = CGSize(width: 1280, height: 720)
if let tracks = try? await item.asset.load(.tracks),
let videoTrack = tracks.first(where: { $0.mediaType == .video }) {
let ns = try? await videoTrack.load(.naturalSize)
if let ns, ns.width > 0 { size = ns }
}
videoSettings = [
// Video input
let videoSettings: [String: Any] = [
AVVideoCodecKey: AVVideoCodecType.h264,
AVVideoWidthKey: size.width,
AVVideoHeightKey: size.height,
@ -103,12 +100,10 @@ final class HLSRecorder: ObservableObject {
AVVideoProfileLevelKey: AVVideoProfileLevelH264HighAutoLevel
]
]
let vidInput = AVAssetWriterInput(mediaType: .video, outputSettings: videoSettings)
vidInput.expectsMediaDataInRealTime = true
vidInput.transform = CGAffineTransform(translationX: 0, y: 0)
guard writer.canAdd(vidInput) else {
throw NSError(domain: "Recorder", code: 1, userInfo: [NSLocalizedDescriptionKey: "Cannot add video input"])
throw NSError(domain: "Rec", code: 1, userInfo: [NSLocalizedDescriptionKey: "Cannot add video input"])
}
writer.add(vidInput)
self.videoInput = vidInput
@ -123,32 +118,34 @@ final class HLSRecorder: ObservableObject {
let audInput = AVAssetWriterInput(mediaType: .audio, outputSettings: audioSettings)
audInput.expectsMediaDataInRealTime = true
guard writer.canAdd(audInput) else {
throw NSError(domain: "Recorder", code: 2, userInfo: [NSLocalizedDescriptionKey: "Cannot add audio input"])
throw NSError(domain: "Rec", code: 2, userInfo: [NSLocalizedDescriptionKey: "Cannot add audio input"])
}
writer.add(audInput)
self.audioInput = audInput
// Start writing
writer.startWriting()
writer.startSession(atSourceTime: .zero)
self.assetWriter = writer
// MTAudioProcessingTap on the player item's audio track
installAudioTap(on: item)
// Video output from player
let vo = AVPlayerItemVideoOutput(pixelBufferAttributes: [
let pixAttrs: [String: Any] = [
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
kCVPixelBufferWidthKey as String: size.width,
kCVPixelBufferHeightKey as String: size.height
])
]
let vo = AVPlayerItemVideoOutput(pixelBufferAttributes: pixAttrs)
vo.suppressesPlayerRendering = false
item.add(vo)
self.videoOutput = vo
// Capture loop via CADisplayLink
writer.startWriting()
// session start will be set on first frame
self.assetWriter = writer
displayLink = CADisplayLink(target: self, selector: #selector(captureFrame))
displayLink?.preferredFrameRateRange = CAFrameRateRange(minimum: 24, maximum: 30, preferred: 30)
displayLink?.preferredFrameRateRange = CAFrameRateRange(minimum: 15, maximum: 30, preferred: 30)
displayLink?.add(to: .main, forMode: .common)
NSLog("[MiniPlayer] Capture started, output: %@", outputURL.path)
NSLog("[MiniPlayer] Capture started: %@", outputURL.path)
} catch {
await MainActor.run {
@ -159,50 +156,150 @@ final class HLSRecorder: ObservableObject {
}
}
@objc private func captureFrame() {
guard let writer = assetWriter, writer.status == .writing else { return }
// MARK: - Audio tap
// Video frame capture
if let vo = videoOutput, videoInput?.isReadyForMoreMediaData == true {
let itemTime = vo.itemTime(forHostTime: CACurrentMediaTime())
if vo.hasNewPixelBuffer(forItemTime: itemTime),
let buf = vo.copyPixelBuffer(forItemTime: itemTime, itemTimeForDisplay: nil) {
if lastVideoSampleTime == nil {
lastVideoSampleTime = itemTime
}
let sampleBuf = createSampleBuffer(from: buf, pts: itemTime)
if let sb = sampleBuf {
videoInput?.append(sb)
}
}
}
private func installAudioTap(on item: AVPlayerItem) {
guard let audioTrack = item.asset.tracks(withMediaType: .audio).first else {
NSLog("[MiniPlayer] No audio track found")
return
}
/// Create a CMSampleBuffer from a CVPixelBuffer (needed because AVAssetWriterInput
/// expects CMSampleBuffer but AVPlayerItemVideoOutput gives CVPixelBuffer directly)
private func createSampleBuffer(from pixelBuffer: CVPixelBuffer, pts: CMTime) -> CMSampleBuffer? {
var callbacks = MTAudioProcessingTapCallbacks(
version: kMTAudioProcessingTapCallbacksVersion_0,
clientInfo: Unmanaged.passUnretained(self).toOpaque(),
init: { tap, clientInfo, tapStorageOut in
tapStorageOut?.pointee = clientInfo
},
finalize: { _ in },
prepare: { _, _, _ in },
unprepare: { _, _, _ in },
process: { tap, numberFrames, flags, bufferListInOut, numberFramesOut, flagsOut in
guard let clientInfo = MTAudioProcessingTapGetStorage(tap) else {
numberFramesOut.pointee = 0
return
}
let recorder = Unmanaged<HLSRecorder>.fromOpaque(clientInfo).takeUnretainedValue()
recorder.processAudio(tap: tap, numberFrames: numberFrames, flags: flags,
bufferListInOut: bufferListInOut,
numberFramesOut: numberFramesOut, flagsOut: flagsOut)
}
)
var tap: Unmanaged<MTAudioProcessingTap>?
let status = MTAudioProcessingTapCreate(kCFAllocatorDefault, &callbacks,
kMTAudioProcessingTapCreationFlag_PreEffects, &tap)
guard status == noErr, let audioTap = tap?.takeRetainedValue() else {
NSLog("[MiniPlayer] MTAudioProcessingTapCreate failed: %d", status)
return
}
let inputParams = AVMutableAudioMixInputParameters(track: audioTrack)
inputParams.audioTapProcessor = audioTap
let audioMix = AVMutableAudioMix()
audioMix.inputParameters = [inputParams]
item.audioMix = audioMix
NSLog("[MiniPlayer] Audio tap installed")
}
private func processAudio(tap: MTAudioProcessingTap, numberFrames: CMItemCount, flags: MTAudioProcessingTapFlags,
bufferListInOut: UnsafeMutablePointer<AudioBufferList>,
numberFramesOut: UnsafeMutablePointer<CMItemCount>,
flagsOut: UnsafeMutablePointer<MTAudioProcessingTapFlags>) {
// Get source audio
var sourceFlags = flags
var timeRange = CMTimeRange()
let status = MTAudioProcessingTapGetSourceAudio(tap, numberFrames, bufferListInOut,
flagsOut, &timeRange, numberFramesOut)
guard status == noErr, let input = audioInput, input.isReadyForMoreMediaData else {
flagsOut.pointee = flags
return
}
// Create CMSampleBuffer from the audio buffer list
let pts = timeRange.start
var asbd = AudioStreamBasicDescription()
MTAudioProcessingTapGetStreamDescription(tap, &asbd)
var formatDesc: CMAudioFormatDescription?
CMAudioFormatDescriptionCreate(allocator: kCFAllocatorDefault, asbd: &asbd,
layoutSize: 0, layout: nil, 0, nil, nil,
formatDescriptionOut: &formatDesc)
guard let fd = formatDesc else { return }
var sampleBuffer: CMSampleBuffer?
var timingInfo = CMSampleTimingInfo(
let numSamples = numberFramesOut.pointee
var timing = CMSampleTimingInfo(duration: CMTime(value: CMTimeValue(numSamples), timescale: CMTimeScale(asbd.mSampleRate)),
presentationTimeStamp: pts, decodeTimeStamp: .invalid)
CMSampleBufferCreate(allocator: kCFAllocatorDefault, dataBuffer: nil, dataReady: false,
makeDataReadyCallback: nil, refcon: nil, formatDescription: fd,
sampleCount: numSamples, sampleTimingEntryCount: 1,
sampleTimingArray: &timing, sampleSizeEntryCount: 0,
sampleSizeArray: nil, sampleBufferOut: &sampleBuffer)
guard var sb = sampleBuffer else { return }
// Copy audio data into the sample buffer
let bufList = bufferListInOut.pointee
var blockBuffer: CMBlockBuffer?
CMBlockBufferCreateWithMemoryBlock(
allocator: kCFAllocatorDefault,
memoryBlock: nil,
blockLength: Int(bufList.mBuffers.mDataByteSize),
blockAllocator: kCFAllocatorDefault,
customBlockSource: nil,
offsetToData: 0,
dataLength: Int(bufList.mBuffers.mDataByteSize),
flags: 0,
blockBufferOut: &blockBuffer
)
if let bb = blockBuffer {
CMBlockBufferReplaceDataBytes(with: bufList.mBuffers.mData!, blockBuffer: bb,
offsetIntoDestination: 0, dataLength: Int(bufList.mBuffers.mDataByteSize))
CMSampleBufferSetDataBuffer(&sb, newValue: bb)
input.append(sb)
}
}
// MARK: - Video frame capture
@objc private func captureFrame() {
guard let writer = assetWriter, let vo = videoOutput, let vidInput = videoInput else { return }
let itemTime = vo.itemTime(forHostTime: CACurrentMediaTime())
guard vo.hasNewPixelBuffer(forItemTime: itemTime),
let buf = vo.copyPixelBuffer(forItemTime: itemTime, itemTimeForDisplay: nil)
else { return }
// Start session on first frame
if writer.status == .unknown {
writer.startSession(atSourceTime: itemTime)
firstPTS = itemTime
NSLog("[MiniPlayer] Session started at PTS: %.3f", itemTime.seconds)
}
guard vidInput.isReadyForMoreMediaData else { return }
var timing = CMSampleTimingInfo(
duration: CMTime(value: 1, timescale: 30),
presentationTimeStamp: pts,
presentationTimeStamp: itemTime,
decodeTimeStamp: .invalid
)
var formatDescription: CMFormatDescription?
CMVideoFormatDescriptionCreateForImageBuffer(
allocator: kCFAllocatorDefault,
imageBuffer: pixelBuffer,
formatDescriptionOut: &formatDescription
)
guard let fd = formatDescription else { return nil }
var formatDesc: CMFormatDescription?
CMVideoFormatDescriptionCreateForImageBuffer(allocator: kCFAllocatorDefault,
imageBuffer: buf, formatDescriptionOut: &formatDesc)
guard let fd = formatDesc else { return }
CMSampleBufferCreateReadyWithImageBuffer(
allocator: kCFAllocatorDefault,
imageBuffer: pixelBuffer,
formatDescription: fd,
sampleTiming: &timingInfo,
sampleBufferOut: &sampleBuffer
)
return sampleBuffer
var sampleBuffer: CMSampleBuffer?
CMSampleBufferCreateReadyWithImageBuffer(allocator: kCFAllocatorDefault, imageBuffer: buf,
formatDescription: fd, sampleTiming: &timing,
sampleBufferOut: &sampleBuffer)
if let sb = sampleBuffer {
vidInput.append(sb)
}
}
}
#endif