MiniPlayer/Sources/HLSRecorder.swift
yumoqing 1832672081 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
2026-07-17 21:47:01 +08:00

306 lines
13 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.

#if os(iOS)
import Foundation
import AVFoundation
import UIKit
import MediaToolbox
/// iOS AVPlayer + MP4
@MainActor
final class HLSRecorder: ObservableObject {
@Published var isRecording = false
@Published var durationText = "00:00"
var onRecordingSaved: ((URL) -> Void)?
var onError: ((String) -> Void)?
private var assetWriter: AVAssetWriter?
private var videoInput: AVAssetWriterInput?
private var audioInput: AVAssetWriterInput?
private var videoOutput: AVPlayerItemVideoOutput?
private var displayLink: CADisplayLink?
private var startTime: Date?
private var timer: Timer?
private var outputURL: URL?
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()
firstPTS = nil
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
Task { @MainActor in
guard let self, let start = self.startTime else { return }
let elapsed = Int(Date().timeIntervalSince(start))
self.durationText = String(format: "%02d:%02d:%02d", elapsed / 3600, (elapsed % 3600) / 60, elapsed % 60)
}
}
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) }
}
func stopRecording() {
guard isRecording else { return }
isRecording = false
timer?.invalidate()
displayLink?.invalidate()
videoOutput = nil
videoInput?.markAsFinished()
audioInput?.markAsFinished()
assetWriter?.finishWriting { [weak self] in
DispatchQueue.main.async {
guard let self, let url = self.outputURL else { return }
let status = self.assetWriter?.status ?? .unknown
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: %lld bytes", size)
self.onRecordingSaved?(url)
} else {
let err = self.assetWriter?.error?.localizedDescription ?? "unknown"
self.onError?("Export: \(err)")
try? FileManager.default.removeItem(at: url)
}
}
}
}
// MARK: - Capture setup
private func startCapture(item: AVPlayerItem, player: AVPlayer) async {
guard let outputURL else { return }
do {
let writer = try AVAssetWriter(url: outputURL, fileType: .mp4)
// 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 }
}
// Video input
let videoSettings: [String: Any] = [
AVVideoCodecKey: AVVideoCodecType.h264,
AVVideoWidthKey: size.width,
AVVideoHeightKey: size.height,
AVVideoCompressionPropertiesKey: [
AVVideoAverageBitRateKey: 2_000_000,
AVVideoProfileLevelKey: AVVideoProfileLevelH264HighAutoLevel
]
]
let vidInput = AVAssetWriterInput(mediaType: .video, outputSettings: videoSettings)
vidInput.expectsMediaDataInRealTime = true
guard writer.canAdd(vidInput) else {
throw NSError(domain: "Rec", code: 1, userInfo: [NSLocalizedDescriptionKey: "Cannot add video input"])
}
writer.add(vidInput)
self.videoInput = vidInput
// Audio input
let audioSettings: [String: Any] = [
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVSampleRateKey: 44100,
AVNumberOfChannelsKey: 2,
AVEncoderBitRateKey: 128_000
]
let audInput = AVAssetWriterInput(mediaType: .audio, outputSettings: audioSettings)
audInput.expectsMediaDataInRealTime = true
guard writer.canAdd(audInput) else {
throw NSError(domain: "Rec", code: 2, userInfo: [NSLocalizedDescriptionKey: "Cannot add audio input"])
}
writer.add(audInput)
self.audioInput = audInput
// MTAudioProcessingTap on the player item's audio track
installAudioTap(on: item)
// Video output from player
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
writer.startWriting()
// session start will be set on first frame
self.assetWriter = writer
displayLink = CADisplayLink(target: self, selector: #selector(captureFrame))
displayLink?.preferredFrameRateRange = CAFrameRateRange(minimum: 15, maximum: 30, preferred: 30)
displayLink?.add(to: .main, forMode: .common)
NSLog("[MiniPlayer] Capture started: %@", outputURL.path)
} catch {
await MainActor.run {
self.isRecording = false
self.timer?.invalidate()
self.onError?(error.localizedDescription)
}
}
}
// MARK: - Audio tap
private func installAudioTap(on item: AVPlayerItem) {
guard let audioTrack = item.asset.tracks(withMediaType: .audio).first else {
NSLog("[MiniPlayer] No audio track found")
return
}
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?
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: itemTime,
decodeTimeStamp: .invalid
)
var formatDesc: CMFormatDescription?
CMVideoFormatDescriptionCreateForImageBuffer(allocator: kCFAllocatorDefault,
imageBuffer: buf, formatDescriptionOut: &formatDesc)
guard let fd = formatDesc else { return }
var sampleBuffer: CMSampleBuffer?
CMSampleBufferCreateReadyWithImageBuffer(allocator: kCFAllocatorDefault, imageBuffer: buf,
formatDescription: fd, sampleTiming: &timing,
sampleBufferOut: &sampleBuffer)
if let sb = sampleBuffer {
vidInput.append(sb)
}
}
}
#endif