diff --git a/Sources/PlayerRecorder.swift b/Sources/PlayerRecorder.swift index 0f9119b..3afceb2 100644 --- a/Sources/PlayerRecorder.swift +++ b/Sources/PlayerRecorder.swift @@ -1,50 +1,43 @@ import AVFoundation import CoreMedia -import MediaToolbox #if os(macOS) import AppKit -// MARK: - 音频捕获上下文(通过 tapStorage 传给 C 回调) +// MARK: - 音频捕获上下文 class AudioCaptureContext { var writerInput: AVAssetWriterInput? nonisolated(unsafe) var isRunning: Bool = true - nonisolated(unsafe) var formatDescription: CMAudioFormatDescription? nonisolated(unsafe) var appendCount: Int = 0 + nonisolated(unsafe) var formatDescription: CMAudioFormatDescription? + nonisolated(unsafe) var firstAppendLogged: Bool = false } -// MARK: - MTAudioProcessingTap C 回调函数 - -private func tapInit(_ tap: MTAudioProcessingTap, - _ clientInfo: UnsafeMutableRawPointer?, - _ tapStorageOut: UnsafeMutablePointer) { - // 将 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.fromOpaque(ctxPtr).release() -} +/// 全局引用,供 C 回调访问(单实例录制,线程安全由 isRunning 保护) +private nonisolated(unsafe) var gAudioContext: AudioCaptureContext? +// MARK: - MTAudioProcessingTap C 回调 private func tapPrepare(_ tap: MTAudioProcessingTap, - _ maxFrames: Int, + _ maxFramesPerSlice: CMItemCount, _ processingFormat: UnsafePointer) { - guard let storage = Optional(MTAudioProcessingTapGetStorage(tap)), - let ctxPtr = storage.assumingMemoryBound(to: UnsafeMutableRawPointer?.self).pointee else { return } - let ctx = Unmanaged.fromOpaque(ctxPtr).takeUnretainedValue() - + print("[Recorder] tapPrepare called, maxFrames=\(maxFramesPerSlice)") + guard let ctx = gAudioContext else { + print("[Recorder] tapPrepare: gAudioContext is nil!") + return + } var asbd = processingFormat.pointee - var fmtDesc: CMAudioFormatDescription? + print("[Recorder] tapPrepare: sampleRate=\(asbd.mSampleRate), channels=\(asbd.mChannelsPerFrame)") 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)") + formatDescriptionOut: &ctx.formatDescription) +} + +private func tapInit(_ tap: MTAudioProcessingTap, + _ clientInfo: UnsafeMutableRawPointer?, + _ tapStorageOut: UnsafeMutablePointer) { + // 初始化 tap } private func tapUnprepare(_ tap: MTAudioProcessingTap) { @@ -52,7 +45,7 @@ private func tapUnprepare(_ tap: MTAudioProcessingTap) { } private func tapProcess(_ tap: MTAudioProcessingTap, - _ numberOfFrames: CMItemCount, + _ numberFrames: CMItemCount, _ flags: MTAudioProcessingTapFlags, _ bufferListInOut: UnsafeMutablePointer, _ numberFramesOut: UnsafeMutablePointer, @@ -62,18 +55,14 @@ private func tapProcess(_ tap: MTAudioProcessingTap, guard numberFrames > 0 else { return } - // 获取源音频数据 + 时间范围 + // 先获取源音频数据 var timeRange = CMTimeRange() - var srcFlags: UInt32 = 0 - var actualFrames: Int = 0 + var srcFlags: MTAudioProcessingTapFlags = 0 + var actualFrames: CMItemCount = 0 let status = MTAudioProcessingTapGetSourceAudio( - tap, - numberFrames, - bufferListInOut, - &srcFlags, - &timeRange, - &actualFrames + tap, numberFrames, bufferListInOut, + &srcFlags, &timeRange, &actualFrames ) numberFramesOut.pointee = actualFrames @@ -81,71 +70,71 @@ private func tapProcess(_ tap: MTAudioProcessingTap, 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.fromOpaque(ctxPtr).takeUnretainedValue() - - guard ctx.isRunning, let writerInput = ctx.writerInput, + // 通过全局变量获取 context + guard let ctx = gAudioContext, 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.. 0, let srcData = bufferList.mBuffers.mData else { return } + let buf = bufferList.mBuffers + let dataSize = Int(buf.mDataByteSize) + guard dataSize > 0, let srcData = buf.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), + let blockStatus = CMBlockBufferCreateWithMemoryBlock( + allocator: kCFAllocatorDefault, memoryBlock: nil, + blockLength: dataSize, blockAllocator: kCFAllocatorDefault, + customBlockSource: nil, offsetToData: 0, + dataLength: dataSize, flags: kCMBlockBufferAssureMemoryNowFlag, blockBufferOut: &blockBuffer - ) == kCMBlockBufferNoErr, let bb = blockBuffer else { return } + ) + guard blockStatus == kCMBlockBufferNoErr, let bb = blockBuffer else { return } - // 复制音频数据 - guard CMBlockBufferReplaceDataBytes( - with: srcData, - blockBuffer: bb, - offsetIntoDestination: 0, - dataLength: Int(totalBytes) - ) == kCMBlockBufferNoErr else { return } + let replaceStatus = CMBlockBufferReplaceDataBytes( + with: srcData, blockBuffer: bb, + offsetIntoDestination: 0, dataLength: dataSize + ) + guard replaceStatus == kCMBlockBufferNoErr else { return } - // 获取格式描述 - guard let fmtDesc = ctx.formatDescription else { return } + // 获取 format description + guard let fd = ctx.formatDescription else { return } + + // 用累积帧数计算 PTS + let sampleRate = Double(timeRange.duration.timescale) > 0 + ? Double(timeRange.duration.timescale) : 44100.0 + let ptsValue = Double(ctx.appendCount) / sampleRate + let pts = CMTime(seconds: ptsValue, preferredTimescale: Int32(sampleRate)) - // 创建 CMSampleBuffer(使用 timeRange 的 start 作为 PTS) var sampleBuffer: CMSampleBuffer? let createStatus = CMAudioSampleBufferCreateReadyWithPacketDescriptions( allocator: kCFAllocatorDefault, dataBuffer: bb, - formatDescription: fmtDesc, + formatDescription: fd, sampleCount: actualFrames, - presentationTimeStamp: timeRange.start, + presentationTimeStamp: pts, packetDescriptions: nil, sampleBufferOut: &sampleBuffer ) - guard createStatus == noErr, let sb = sampleBuffer else { return } if writerInput.append(sb) { ctx.appendCount += actualFrames + if !ctx.firstAppendLogged { + ctx.firstAppendLogged = true + print("[Recorder] ✓ First audio frame appended! pts=\(pts.seconds)s, frames=\(actualFrames)") + } } } -/// 视频源录制器:AVPlayerItemVideoOutput 抓帧 + MTAudioProcessingTap 捕获音频 +// 空的 init/finalize 回调 +private func tapInit(_ tap: MTAudioProcessingTap) {} +private func tapFinalize(_ tap: MTAudioProcessingTap) {} + +/// 视频源录制器:从 AVPlayerItemVideoOutput 抓帧 + MTAudioProcessingTap 捕获音频,写入 mp4 @MainActor final class PlayerRecorder: NSObject { @@ -157,12 +146,13 @@ final class PlayerRecorder: NSObject { 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 var audioContext: AudioCaptureContext? private weak var currentPlayerItem: AVPlayerItem? nonisolated(unsafe) private var recordStartTime: CMTime = .zero @@ -236,14 +226,15 @@ final class PlayerRecorder: NSObject { audioInput = aInput } - guard writer.startWriting() else { + let success = writer.startWriting() + if !success { 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") + print("[Recorder] Writer started, session at .zero") // 启动视频抓帧 isRecording = true @@ -251,7 +242,7 @@ final class PlayerRecorder: NSObject { startTimer() startCaptureLoop() - // 启动音频捕获 + // 启动音频捕获(MTAudioProcessingTap) setupAudioTap(playerItem: playerItem, audioWriterInput: aInput) } @@ -265,9 +256,16 @@ final class PlayerRecorder: NSObject { stopTimer() // 停止音频 tap + audioContext?.isRunning = false + + // 移除 audioMix(移除 tap) currentPlayerItem?.audioMix = nil currentPlayerItem = nil + + // 释放 tap 和 context audioTap = nil + audioContext = nil + gAudioContext = nil videoInput?.markAsFinished() audioInput?.markAsFinished() @@ -275,8 +273,8 @@ final class PlayerRecorder: NSObject { 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)") + let count = self.captureFrameCount + print("[Recorder] finishWriting status: \(w.status.rawValue), error: \(w.error?.localizedDescription ?? "none"), video frames: \(count)") if w.status == .completed, let url = self.tempURL { self.showSaveDialog(tempURL: url) } else { @@ -299,22 +297,29 @@ final class PlayerRecorder: NSObject { do { let audioTracks = try await asset.loadTracks(withMediaType: .audio) guard let audioTrack = audioTracks.first else { - print("[Recorder] No audio track for tap") + print("[Recorder] ❌ No audio track for tap") + return + } + guard self.isRunning else { + print("[Recorder] ❌ isRunning=false, aborting audio tap setup") return } - guard self.isRunning else { return } - // 创建 context,用 Unmanaged 传递给 C 回调 + print("[Recorder] Audio track found: \(audioTrack.trackID), setting up tap...") + + // 创建音频捕获上下文 let context = AudioCaptureContext() context.writerInput = audioWriterInput context.isRunning = true + self.audioContext = context - let contextPtr = Unmanaged.passRetained(context).toOpaque() + // ★ 设置全局引用,供 C 回调访问 + gAudioContext = context - // 创建回调结构体 + // 创建 MTAudioProcessingTap var callbacks = MTAudioProcessingTapCallbacks( version: kMTAudioProcessingTapCallbacksVersion_0, - clientInfo: contextPtr, + clientInfo: nil, init: tapInit, finalize: tapFinalize, prepare: tapPrepare, @@ -323,33 +328,31 @@ final class PlayerRecorder: NSObject { ) var tap: MTAudioProcessingTap? - let status = withUnsafePointer(to: &callbacks) { cbPtr in - MTAudioProcessingTapCreate( - kCFAllocatorDefault, - cbPtr, - kMTAudioProcessingTapCreationFlag_PostEffects, - &tap - ) - } + let status = MTAudioProcessingTapCreate( + kCFAllocatorDefault, + &callbacks, + kMTAudioProcessingTapCreationFlag_PostEffects, + &tap + ) guard status == noErr, let audioTap = tap else { - print("[Recorder] MTAudioProcessingTapCreate failed: \(status)") - Unmanaged.fromOpaque(contextPtr).release() + print("[Recorder] ❌ MTAudioProcessingTapCreate failed: \(status)") + gAudioContext = nil return } self.audioTap = audioTap - // 创建 audioMix + // 创建 audioMix 并挂上 tap let params = AVMutableAudioMixInputParameters(track: audioTrack) params.audioTapProcessor = audioTap let audioMix = AVMutableAudioMix() audioMix.inputParameters = [params] playerItem.audioMix = audioMix - print("[Recorder] Audio tap installed") + print("[Recorder] ✓ Audio tap installed on player item") } catch { - print("[Recorder] Audio tap setup failed: \(error)") + print("[Recorder] ❌ Audio tap setup failed: \(error)") } } } @@ -372,12 +375,14 @@ final class PlayerRecorder: NSObject { 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 } @@ -405,7 +410,7 @@ final class PlayerRecorder: NSObject { return sampleBuffer } - // MARK: - Timer + // MARK: - Timer (显示录制时长) private func startTimer() { startDate = Date() durationText = "00:00"