import AVFoundation import CoreMedia import MediaToolbox import Foundation #if os(macOS) import AppKit // MARK: - 全局音频 Tap 上下文(C 回调无法访问 Swift 对象) private nonisolated(unsafe) var gTapContext: AudioTapContext? class AudioTapContext { nonisolated(unsafe) var writerInput: AVAssetWriterInput? nonisolated(unsafe) var isRecording: Bool = false nonisolated(unsafe) var formatDescription: CMAudioFormatDescription? nonisolated(unsafe) var sampleRate: Double = 0 nonisolated(unsafe) var channelsPerFrame: UInt32 = 0 nonisolated(unsafe) var appendCount: Int = 0 nonisolated(unsafe) var processCallCount: Int = 0 nonisolated(unsafe) var firstAppendLogged: Bool = false nonisolated(unsafe) var totalFramesWritten: Int64 = 0 /// 双缓冲区(消除数据竞争:写A时async读B,交替使用) nonisolated(unsafe) var dataBuffers: (UnsafeMutableRawPointer?, UnsafeMutableRawPointer?) = (nil, nil) nonisolated(unsafe) var dataBufferSizes: (Int, Int) = (0, 0) nonisolated(unsafe) var writeIndex: Int = 0 let audioQueue = DispatchQueue(label: "miniplayer.audioTap") } // MARK: - MTAudioProcessingTap C 回调 private func tapInit( _ tap: MTAudioProcessingTap, _ clientInfo: UnsafeMutableRawPointer?, _ tapStorageOut: UnsafeMutablePointer ) { tapStorageOut.pointee = clientInfo } private func tapFinalize(_ tap: MTAudioProcessingTap) { // Context lifecycle managed elsewhere } private func tapPrepare( _ tap: MTAudioProcessingTap, _ maxFrames: CMItemCount, _ processingFormat: UnsafePointer ) { let asbd = processingFormat.pointee NSLog("[Recorder] Tap prepare: rate=%.0f ch=%u formatFlags=0x%x", asbd.mSampleRate, asbd.mChannelsPerFrame, asbd.mFormatFlags) let storage = MTAudioProcessingTapGetStorage(tap) let ctx = Unmanaged.fromOpaque(storage).takeUnretainedValue() ctx.sampleRate = asbd.mSampleRate ctx.channelsPerFrame = asbd.mChannelsPerFrame var fmtDesc: CMAudioFormatDescription? var mutableASBD = asbd CMAudioFormatDescriptionCreate( allocator: kCFAllocatorDefault, asbd: &mutableASBD, layoutSize: 0, layout: nil, magicCookieSize: 0, magicCookie: nil, extensions: nil, formatDescriptionOut: &fmtDesc ) ctx.formatDescription = fmtDesc NSLog("[Recorder] Tap format ready: %@", fmtDesc != nil ? "OK" : "FAILED") } private func tapUnprepare(_ tap: MTAudioProcessingTap) { NSLog("[Recorder] Tap unprepare") } private func tapProcess( _ tap: MTAudioProcessingTap, _ numberFrames: Int, _ flags: UInt32, _ bufferListInOut: UnsafeMutablePointer, _ bufferListSizeOut: UnsafeMutablePointer, _ flagsOut: UnsafeMutablePointer ) { bufferListSizeOut.pointee = 1 flagsOut.pointee = flags let storage = MTAudioProcessingTapGetStorage(tap) let ctx = Unmanaged.fromOpaque(storage).takeUnretainedValue() ctx.processCallCount += 1 guard ctx.isRecording, let _ = ctx.writerInput, let _ = ctx.formatDescription else { return } let ablPtr = UnsafeMutableAudioBufferListPointer(bufferListInOut) // 计算总数据量 var totalSize: Int = 0 for i in 0.. 0 { totalSize += Int(ablPtr[i].mDataByteSize) } } guard totalSize > 0 else { return } // 双缓冲:写当前槽,async读另一个槽(消除数据竞争) let wi = ctx.writeIndex ctx.writeIndex = 1 - wi // 切换槽位 if ctx.dataBufferSizes.0 < totalSize || ctx.dataBufferSizes.1 < totalSize { // 只在需要时扩容(不会在实时路径频繁触发) let newSize = max(totalSize, 65536) // 至少64KB if wi == 0 { if let existing = ctx.dataBuffers.0 { free(existing) } ctx.dataBuffers.0 = malloc(newSize) ctx.dataBufferSizes.0 = newSize } else { if let existing = ctx.dataBuffers.1 { free(existing) } ctx.dataBuffers.1 = malloc(newSize) ctx.dataBufferSizes.1 = newSize } } let dest: UnsafeMutableRawPointer? if wi == 0 { dest = ctx.dataBuffers.0 } else { dest = ctx.dataBuffers.1 } guard let dest = dest else { return } var offset = 0 for i in 0.. 0 { let sz = Int(ablPtr[i].mDataByteSize) memcpy(dest + offset, src, sz) offset += sz } } let frameCount = Int64(numberFrames) let startFrame = ctx.totalFramesWritten let readWi = wi // async 读取的是刚写完的槽 // 在串行队列创建 CMSampleBuffer 并写入 ctx.audioQueue.async { guard ctx.isRecording, let writerInput = ctx.writerInput, let fd = ctx.formatDescription else { return } // 从已写完的槽读取(此时实时线程正在写另一个槽,安全) let readDest: UnsafeMutableRawPointer? if readWi == 0 { readDest = ctx.dataBuffers.0 } else { readDest = ctx.dataBuffers.1 } guard let readDest = readDest else { return } var blockBuffer: CMBlockBuffer? let blockStatus = CMBlockBufferCreateWithMemoryBlock( allocator: kCFAllocatorDefault, memoryBlock: nil, blockLength: totalSize, blockAllocator: kCFAllocatorDefault, customBlockSource: nil, offsetToData: 0, dataLength: totalSize, flags: kCMBlockBufferAssureMemoryNowFlag, blockBufferOut: &blockBuffer ) guard blockStatus == kCMBlockBufferNoErr, let bb = blockBuffer else { return } let replaceStatus = CMBlockBufferReplaceDataBytes( with: readDest, blockBuffer: bb, offsetIntoDestination: 0, dataLength: totalSize ) guard replaceStatus == kCMBlockBufferNoErr else { return } // PTS 基于累计帧数计算 let pts = CMTime( value: CMTimeValue(startFrame), timescale: Int32(ctx.sampleRate) ) var timingInfo = CMSampleTimingInfo( duration: CMTime(value: CMTimeValue(frameCount), timescale: Int32(ctx.sampleRate)), presentationTimeStamp: pts, decodeTimeStamp: .invalid ) var sampleBuffer: CMSampleBuffer? let createStatus = CMSampleBufferCreateReady( allocator: kCFAllocatorDefault, dataBuffer: bb, formatDescription: fd, sampleCount: Int(frameCount), sampleTimingEntryCount: 1, sampleTimingArray: &timingInfo, sampleSizeEntryCount: 0, sampleSizeArray: nil, sampleBufferOut: &sampleBuffer ) guard createStatus == noErr, let sb = sampleBuffer else { NSLog("[Recorder] ⚠️ SampleBuffer create failed: %d", createStatus) return } if writerInput.isReadyForMoreMediaData { if writerInput.append(sb) { ctx.appendCount += 1 ctx.totalFramesWritten += frameCount if !ctx.firstAppendLogged { ctx.firstAppendLogged = true NSLog("[Recorder] ✓ First audio captured: pts=%.3fs, frames=%lld, size=%d", pts.seconds, frameCount, totalSize) } } } } } // MARK: - RecorderState enum RecorderState { static nonisolated(unsafe) var isFinalizing: Bool = false static nonisolated(unsafe) var pendingTerminate: Bool = false } // MARK: - PlayerRecorder @MainActor final class PlayerRecorder: NSObject { private var writer: AVAssetWriter? private var videoInput: AVAssetWriterInput? private var audioInput: AVAssetWriterInput? private var captureTimer: Timer? private var durationTimer: Timer? private var startDate: Date? private weak var weakOutput: AVPlayerItemVideoOutput? nonisolated(unsafe) private var isRunning = false private var lastPixelBuffer: CVPixelBuffer? private var captureFrameCount: Int = 0 // Audio Tap private var tapContext: AudioTapContext? 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: - Audio Tap Setup /// 在录制开始时安装音频 Tap 到 playerItem private func installAudioTap(on playerItem: AVPlayerItem) { // 确保全局上下文存在 if gTapContext == nil { gTapContext = AudioTapContext() } guard let ctx = gTapContext else { return } // 同步获取音频 track(录制时 asset 已经加载完毕) let audioTracks = playerItem.asset.tracks(withMediaType: .audio) guard let audioTrack = audioTracks.first else { NSLog("[Recorder] ⚠️ No audio track found (tried %d tracks)", audioTracks.count) return } NSLog("[Recorder] Found audio track: ID=%d, formatDescriptions=%d", audioTrack.trackID, audioTrack.formatDescriptions.count) // 创建 MTAudioProcessingTap var callbacks = MTAudioProcessingTapCallbacks( version: kMTAudioProcessingTapCallbacksVersion_0, clientInfo: Unmanaged.passUnretained(ctx).toOpaque(), init: tapInit, finalize: tapFinalize, prepare: tapPrepare, unprepare: tapUnprepare, process: tapProcess ) var tap: MTAudioProcessingTap? let status = MTAudioProcessingTapCreate( kCFAllocatorDefault, &callbacks, kMTAudioProcessingTapCreationFlag_PreEffects, &tap ) guard status == noErr, let tapRef = tap else { NSLog("[Recorder] ⚠️ MTAudioProcessingTapCreate failed: %d", status) return } // 创建 AudioMix — 用 kCMPersistentTrackID_Invalid 匹配所有音频轨道 // HLS 流在 variant 切换时 audio track ID 会变,绑定具体 ID 会导致 tap 失效 let inputParams = AVMutableAudioMixInputParameters() inputParams.trackID = kCMPersistentTrackID_Invalid inputParams.audioTapProcessor = tapRef let audioMix = AVMutableAudioMix() audioMix.inputParameters = [inputParams] playerItem.audioMix = audioMix NSLog("[Recorder] ✓ Audio tap installed on playerItem, trackID=%d", audioTrack.trackID) } // 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 // 安装音频 Tap(录制时 asset 已加载,同步获取 track) installAudioTap(on: playerItem) let tempFilename = "temp_recording_\(Int(Date().timeIntervalSince1970)).mp4" let url = saveDirectory.appendingPathComponent(tempFilename) try? FileManager.default.removeItem(at: url) tempURL = url // 获取 tap context(可能还没有 format,先用默认值) let ctx = gTapContext ?? AudioTapContext() if gTapContext == nil { gTapContext = ctx } self.tapContext = ctx do { writer = try AVAssetWriter(outputURL: url, fileType: .mp4) } catch { onError?("Cannot create writer: \(error.localizedDescription)") return } guard let writer = writer else { return } // 视频输入(H264) 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 } // 音频输入(AAC 编码)— 在 startWriting 之前添加 // 使用 tap 已知的格式,或默认值 let audioRate = ctx.sampleRate > 0 ? ctx.sampleRate : 48000.0 let audioCh = ctx.channelsPerFrame > 0 ? ctx.channelsPerFrame : 2 var sourceFmtDesc: CMAudioFormatDescription? if let tapFmt = ctx.formatDescription { sourceFmtDesc = tapFmt } else { // Tap 还没有 prepare,创建默认格式描述 var asbd = AudioStreamBasicDescription( mSampleRate: audioRate, mFormatID: kAudioFormatLinearPCM, mFormatFlags: kAudioFormatFlagIsFloat | kAudioFormatFlagIsPacked | kAudioFormatFlagIsNonInterleaved, mBytesPerPacket: 4, mFramesPerPacket: 1, mBytesPerFrame: 4, mChannelsPerFrame: audioCh, mBitsPerChannel: 32, mReserved: 0 ) CMAudioFormatDescriptionCreate( allocator: kCFAllocatorDefault, asbd: &asbd, layoutSize: 0, layout: nil, magicCookieSize: 0, magicCookie: nil, extensions: nil, formatDescriptionOut: &sourceFmtDesc ) } if let fmtDesc = sourceFmtDesc { let audioSettings: [String: Any] = [ AVFormatIDKey: kAudioFormatMPEG4AAC, AVSampleRateKey: audioRate, AVNumberOfChannelsKey: audioCh, AVEncoderBitRateKey: 128_000 ] let aInput = AVAssetWriterInput(mediaType: .audio, outputSettings: audioSettings, sourceFormatHint: fmtDesc) aInput.expectsMediaDataInRealTime = true if writer.canAdd(aInput) { writer.add(aInput) audioInput = aInput NSLog("[Recorder] AudioInput added: AAC rate=%.0f ch=%u", audioRate, audioCh) } } guard writer.startWriting() else { NSLog("[Recorder] startWriting failed: %@", writer.error?.localizedDescription ?? "unknown") onError?("Cannot start writer: \(writer.error?.localizedDescription ?? "unknown")") return } writer.startSession(atSourceTime: .zero) NSLog("[Recorder] Writer started, writing to: %@", url.lastPathComponent) // 重置 tap context 录制状态 ctx.isRecording = true ctx.writerInput = audioInput ctx.appendCount = 0 ctx.totalFramesWritten = 0 ctx.firstAppendLogged = false ctx.processCallCount = 0 isRecording = true isRunning = true startCaptureLoop() startDurationTimer() } // MARK: - 停止录制 func stopRecording() { guard isRecording else { return } isRecording = false isRunning = false RecorderState.isFinalizing = true captureTimer?.invalidate() captureTimer = nil stopDurationTimer() // 停止音频 tap 写入 tapContext?.isRecording = false let audioAppended = tapContext?.appendCount ?? 0 let audioCalls = tapContext?.processCallCount ?? 0 NSLog("[Recorder] Stopping: videoFrames=%d, audioProcessCalls=%d, audioAppended=%d", captureFrameCount, audioCalls, audioAppended) currentPlayerItem = nil videoInput?.markAsFinished() audioInput?.markAsFinished() writer?.finishWriting { [weak self] in NSLog("[Recorder] finishWriting callback invoked") DispatchQueue.main.async { RecorderState.isFinalizing = false guard let self = self, let w = self.writer else { NSLog("[Recorder] ⚠️ self or writer is nil in finishWriting callback") if RecorderState.pendingTerminate { RecorderState.pendingTerminate = false NSApp.reply(toApplicationShouldTerminate: true) } return } let vFrames = self.captureFrameCount NSLog("[Recorder] finishWriting: status=%d, error=%@, videoFrames=%d, audioFrames=%d", w.status.rawValue, w.error?.localizedDescription ?? "none", vFrames, audioAppended) if w.status == .completed, let tempURL = self.tempURL { if FileManager.default.fileExists(atPath: tempURL.path) { let size = (try? FileManager.default.attributesOfItem(atPath: tempURL.path)[.size] as? UInt64) ?? 0 NSLog("[Recorder] ✓ Temp file exists: %@, size=%llu bytes", tempURL.lastPathComponent, size) self.showSaveDialog(tempURL: tempURL) } else { NSLog("[Recorder] ⚠️ Temp file missing: %@", tempURL.path) self.onError?("Recording file missing") self.checkPendingTerminate() } } else { NSLog("[Recorder] ✗ finishWriting failed: status=%d, error=%@", w.status.rawValue, w.error?.localizedDescription ?? "unknown") self.onError?("Recording failed: \(w.error?.localizedDescription ?? "unknown (status=\(w.status.rawValue))")") if let url = self.tempURL { try? FileManager.default.removeItem(at: url) } self.checkPendingTerminate() } } } lastPixelBuffer = nil } // MARK: - 视频抓帧 (30fps 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 } if let sb = Self.createSampleBuffer(from: pb, time: relativeTime) { 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: - 录制时长显示 private func startDurationTimer() { startDate = Date() durationTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in Task { @MainActor in self?.updateDuration() } } } private func stopDurationTimer() { durationTimer?.invalidate() durationTimer = nil } private func updateDuration() { guard let start = startDate else { return } let elapsed = Int(Date().timeIntervalSince(start)) let min = elapsed / 60 let sec = elapsed % 60 durationText = String(format: "%02d:%02d", min, sec) } // MARK: - 保存对话框 private func showSaveDialog(tempURL: URL) { let panel = NSSavePanel() panel.allowedContentTypes = [.mpeg4Movie] panel.nameFieldStringValue = tempURL.lastPathComponent panel.directoryURL = saveDirectory panel.begin { [weak self] response in if response == .OK, let url = panel.url { do { if FileManager.default.fileExists(atPath: url.path) { try FileManager.default.removeItem(at: url) } try FileManager.default.moveItem(at: tempURL, to: url) NSLog("[Recorder] ✓ Saved: %@", url.path) self?.onRecordingSaved?(url) } catch { NSLog("[Recorder] ✗ Save failed: %@", error.localizedDescription) self?.onError?("Save failed: \(error.localizedDescription)") } } else { try? FileManager.default.removeItem(at: tempURL) } self?.checkPendingTerminate() } } private func checkPendingTerminate() { if RecorderState.pendingTerminate { RecorderState.pendingTerminate = false NSApp.reply(toApplicationShouldTerminate: true) } } } #endif