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 // === 诊断统计 === nonisolated(unsafe) var peakAmplitude: Float = 0 // 全局峰值 nonisolated(unsafe) var silentCallCount: Int = 0 // 静音回调次数 nonisolated(unsafe) var activeCallCount: Int = 0 // 有声音的回调次数 nonisolated(unsafe) var skippedNotRecording: Int = 0 // isRecording=false 跳过次数 nonisolated(unsafe) var skippedNoFormat: Int = 0 // formatDescription 为空跳过次数 nonisolated(unsafe) var skippedNoData: Int = 0 // buffer 无数据跳过次数 nonisolated(unsafe) var appendFailCount: Int = 0 // append 失败次数 nonisolated(unsafe) var notReadyCount: Int = 0 // writerInput not ready 次数 nonisolated(unsafe) var lastActiveTimestamp: Double = 0 // 最后一次有声音的时间(秒) nonisolated(unsafe) var recordStartTime: Date? // 录制开始时间 nonisolated(unsafe) var mixTrackID: Int32 = -1 // audioMix 绑定的 trackID 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 else { ctx.skippedNotRecording += 1; return } guard let _ = ctx.writerInput else { ctx.skippedNoFormat += 1; return } guard let _ = ctx.formatDescription else { ctx.skippedNoFormat += 1; return } let ablPtr = UnsafeMutableAudioBufferListPointer(bufferListInOut) // 计算总数据量 var totalSize: Int = 0 for i in 0.. 0 { totalSize += Int(ablPtr[i].mDataByteSize) } } guard totalSize > 0 else { ctx.skippedNoData += 1; return } // === 诊断: 计算峰值音量 === var bufferPeak: Float = 0 for i in 0.. 0 else { continue } let floatPtr = data.assumingMemoryBound(to: Float.self) let sampleCount = Int(ablPtr[i].mDataByteSize) / MemoryLayout.size for s in stride(from: 0, to: sampleCount, by: 16) { // 每16个采样检查一次,避免性能问题 let absVal = abs(floatPtr[s]) if absVal > bufferPeak { bufferPeak = absVal } } } if bufferPeak > ctx.peakAmplitude { ctx.peakAmplitude = bufferPeak } // 静音阈值: float PCM < 0.001 ≈ -60dB let isActive = bufferPeak > 0.001 if isActive { ctx.activeCallCount += 1 if let start = ctx.recordStartTime { ctx.lastActiveTimestamp = Date().timeIntervalSince(start) } } else { ctx.silentCallCount += 1 } // 双缓冲:写当前槽,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, peak=%.4f", pts.seconds, frameCount, totalSize, bufferPeak) } } else { ctx.appendFailCount += 1 } } else { ctx.notReadyCount += 1 } } } // MARK: - RecorderState enum RecorderState { static nonisolated(unsafe) var isFinalizing: Bool = false static nonisolated(unsafe) var pendingTerminate: Bool = false } // MARK: - 诊断统计快照 struct AudioDiagStats { let videoFrames: Int let processCallCount: Int let appendCount: Int let totalFramesWritten: Int64 let peakAmplitude: Float let activeCallCount: Int let silentCallCount: Int let skippedNotRecording: Int let skippedNoFormat: Int let skippedNoData: Int let appendFailCount: Int let notReadyCount: Int let lastActiveTimestamp: Double let sampleRate: Double let channelsPerFrame: UInt32 let mixTrackID: Int32 } // 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.tracks 获取轨道,支持 HLS 虚拟 asset) private func installAudioTap(on playerItem: AVPlayerItem) { // 确保全局上下文存在 if gTapContext == nil { gTapContext = AudioTapContext() } guard let ctx = gTapContext else { return } print("[Recorder:DIAG] === installAudioTap START ===") // HLS 虚拟 asset 的 asset.tracks 为空,必须用 playerItem.tracks let itemTracks = playerItem.tracks print("[Recorder:DIAG] playerItem.tracks count: \(itemTracks.count)") // 找到音频轨道 var audioTrackID: CMPersistentTrackID = kCMPersistentTrackID_Invalid for (i, t) in itemTracks.enumerated() { let tid = t.assetTrack?.trackID ?? -1 let mediaType = t.assetTrack?.mediaType.rawValue ?? "?" let enabled = t.isEnabled print("[Recorder:DIAG] itemTrack[\(i)]: trackID=\(tid) mediaType=\(mediaType) enabled=\(enabled)") if t.assetTrack?.mediaType == .audio && t.isEnabled { audioTrackID = tid } } guard audioTrackID != kCMPersistentTrackID_Invalid else { print("[Recorder:DIAG] ⚠️ No enabled audio track in playerItem.tracks!") writeDiagLog("No audio track in playerItem.tracks (count=\(itemTracks.count))") return } print("[Recorder:DIAG] Using audio trackID=\(audioTrackID)") // 创建 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 tapStatus = MTAudioProcessingTapCreate( kCFAllocatorDefault, &callbacks, kMTAudioProcessingTapCreationFlag_PreEffects, &tap ) print("[Recorder:DIAG] MTAudioProcessingTapCreate status=\(tapStatus) (noErr=\(noErr))") guard tapStatus == noErr, let tapRef = tap else { print("[Recorder:DIAG] ⚠️ Tap creation failed!") return } // 使用 kCMPersistentTrackID_Invalid 匹配所有音频轨道 // HLS variant 切换会导致 trackID 变化(如 2→10),绑死具体 ID 会失效 let inputParams = AVMutableAudioMixInputParameters() inputParams.trackID = kCMPersistentTrackID_Invalid inputParams.audioTapProcessor = tapRef let audioMix = AVMutableAudioMix() audioMix.inputParameters = [inputParams] playerItem.audioMix = audioMix // 验证 let verifyMix = playerItem.audioMix print("[Recorder:DIAG] playerItem.audioMix set=\(verifyMix != nil)") if let vMix = verifyMix, let params = vMix.inputParameters.first { print("[Recorder:DIAG] verified trackID=\(params.trackID)") } print("[Recorder:DIAG] === installAudioTap DONE ===") ctx.mixTrackID = kCMPersistentTrackID_Invalid // 写诊断日志 var diagLog = "=== installAudioTap Log ===\n" diagLog += "Time: \(Date())\n" diagLog += "Source: playerItem.tracks\n" diagLog += "playerItem.tracks count: \(itemTracks.count)\n" for (i, t) in itemTracks.enumerated() { diagLog += " itemTrack[\(i)]: trackID=\(t.assetTrack?.trackID ?? -1) mediaType=\(t.assetTrack?.mediaType.rawValue ?? "?") enabled=\(t.isEnabled)\n" } diagLog += "Audio trackID used: kCMPersistentTrackID_Invalid (wildcard, matches all)\n" diagLog += "Discovered audio trackID: \(audioTrackID)\n" diagLog += "TapCreate status: \(tapStatus)\n" diagLog += "audioMix set: \(verifyMix != nil)\n" writeDiagLog(diagLog) } private func writeDiagLog(_ content: String) { if let moviesDir = FileManager.default.urls(for: .moviesDirectory, in: .userDomainMask).first { let logPath = moviesDir.appendingPathComponent("MiniPlayer/tap_install_log.txt") try? content.write(to: logPath, atomically: true, encoding: .utf8) print("[Recorder:DIAG] Log written to: \(logPath.path)") } } // 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 // 重置诊断统计 ctx.peakAmplitude = 0 ctx.silentCallCount = 0 ctx.activeCallCount = 0 ctx.skippedNotRecording = 0 ctx.skippedNoFormat = 0 ctx.skippedNoData = 0 ctx.appendFailCount = 0 ctx.notReadyCount = 0 ctx.lastActiveTimestamp = 0 ctx.recordStartTime = Date() 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 // 快照诊断数据(tap 停止后不会再更新) let statsSnapshot = AudioDiagStats( videoFrames: captureFrameCount, processCallCount: tapContext?.processCallCount ?? 0, appendCount: tapContext?.appendCount ?? 0, totalFramesWritten: tapContext?.totalFramesWritten ?? 0, peakAmplitude: tapContext?.peakAmplitude ?? 0, activeCallCount: tapContext?.activeCallCount ?? 0, silentCallCount: tapContext?.silentCallCount ?? 0, skippedNotRecording: tapContext?.skippedNotRecording ?? 0, skippedNoFormat: tapContext?.skippedNoFormat ?? 0, skippedNoData: tapContext?.skippedNoData ?? 0, appendFailCount: tapContext?.appendFailCount ?? 0, notReadyCount: tapContext?.notReadyCount ?? 0, lastActiveTimestamp: tapContext?.lastActiveTimestamp ?? 0, sampleRate: tapContext?.sampleRate ?? 0, channelsPerFrame: tapContext?.channelsPerFrame ?? 0, mixTrackID: tapContext?.mixTrackID ?? -1 ) NSLog("[Recorder] Stopping: videoFrames=%d, audioProcessCalls=%d, audioAppended=%d, peak=%.4f, active=%d, silent=%d", statsSnapshot.videoFrames, statsSnapshot.processCallCount, statsSnapshot.appendCount, statsSnapshot.peakAmplitude, statsSnapshot.activeCallCount, statsSnapshot.silentCallCount) // 诊断:检查录制结束时 tracks 是否变化 if let pi = currentPlayerItem, let boundID = tapContext?.mixTrackID { let currentTracks = pi.tracks NSLog("[Recorder:DIAG] Stop-time tracks: count=%d", currentTracks.count) var stopLog = "\n=== Stop-time tracks check ===\n" stopLog += "Bound trackID: \(boundID)\n" stopLog += "Current tracks count: \(currentTracks.count)\n" var foundBoundTrack = false for (i, t) in currentTracks.enumerated() { let tid = t.assetTrack?.trackID ?? -1 let mtype = t.assetTrack?.mediaType.rawValue ?? "?" NSLog("[Recorder:DIAG] stopTrack[%d]: trackID=%d mediaType=%@ enabled=%d", i, tid, mtype, t.isEnabled) stopLog += " track[\(i)]: trackID=\(tid) mediaType=\(mtype) enabled=\(t.isEnabled)\n" if tid == boundID { foundBoundTrack = true } } stopLog += "Bound trackID \(boundID) still exists: \(foundBoundTrack)\n" NSLog("[Recorder:DIAG] Bound trackID=%d still exists: %d", boundID, foundBoundTrack) // 追加到日志文件 if let moviesDir = FileManager.default.urls(for: .moviesDirectory, in: .userDomainMask).first { let logPath = moviesDir.appendingPathComponent("MiniPlayer/tap_install_log.txt") if let handle = try? FileHandle(forWritingTo: logPath) { handle.seekToEndOfFile() handle.write(stopLog.data(using: .utf8)!) handle.closeFile() } } } 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 } NSLog("[Recorder] finishWriting: status=%d, error=%@, videoFrames=%d, audioFrames=%d", w.status.rawValue, w.error?.localizedDescription ?? "none", statsSnapshot.videoFrames, statsSnapshot.appendCount) 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) // 写诊断报告(与 mp4 同目录) self.writeDiagnosticReport(stats: statsSnapshot, videoURL: tempURL) 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: - 诊断报告 /// 录制完成后生成诊断报告文件(.txt),与 mp4 同目录 private func writeDiagnosticReport(stats: AudioDiagStats, videoURL: URL) { let reportURL = videoURL.deletingPathExtension().appendingPathExtension("txt") let videoDuration = Double(stats.videoFrames) / 30.0 // 30fps let audioDuration = stats.sampleRate > 0 ? Double(stats.totalFramesWritten) / stats.sampleRate : 0 let peakDB = stats.peakAmplitude > 0 ? 20 * log10(stats.peakAmplitude) : -Float.infinity var verdict: String if stats.processCallCount == 0 { verdict = "FAIL: tap never called — audioMix not attached or wrong trackID" } else if stats.activeCallCount == 0 && stats.silentCallCount > 0 { verdict = "FAIL: tap called \(stats.processCallCount)x but ALL silent — source is muted or tap bound to wrong track" } else if stats.appendCount == 0 { verdict = "FAIL: tap active (\(stats.activeCallCount) calls) but 0 appends — writerInput issue (skippedNotRecording=\(stats.skippedNotRecording), appendFail=\(stats.appendFailCount), notReady=\(stats.notReadyCount))" } else if stats.activeCallCount > 0 && audioDuration < videoDuration * 0.5 { verdict = "WARN: audio \(String(format: "%.1f", audioDuration))s < video \(String(format: "%.1f", videoDuration))s — tap stopped mid-recording (HLS variant switch?)" } else if stats.peakAmplitude < 0.001 { verdict = "FAIL: peak \(String(format: "%.6f", stats.peakAmplitude)) (\(String(format: "%.1f", peakDB))dB) — effectively silent" } else { verdict = "OK" } let lines = [ "=== MiniPlayer Recording Diagnostic Report ===", "Generated: \(Date())", "Video file: \(videoURL.lastPathComponent)", "", "--- Verdict ---", verdict, "", "--- Video ---", "Frames: \(stats.videoFrames)", "Duration: \(String(format: "%.2f", videoDuration))s (@ 30fps)", "", "--- Audio Tap Stats ---", "processCallCount: \(stats.processCallCount) (total tap callbacks)", "activeCallCount: \(stats.activeCallCount) (had audio signal, peak > -60dB)", "silentCallCount: \(stats.silentCallCount) (silence detected)", "appendCount: \(stats.appendCount) (samples written to AVAssetWriter)", "totalFramesWritten: \(stats.totalFramesWritten)", "audioDuration: \(String(format: "%.2f", audioDuration))s", "lastActiveAt: \(String(format: "%.2f", stats.lastActiveTimestamp))s (time from record start)", "", "--- Audio Format ---", "sampleRate: \(String(format: "%.0f", stats.sampleRate)) Hz", "channelsPerFrame: \(stats.channelsPerFrame)", "mixTrackID: \(stats.mixTrackID) (bound in audioMix, -1=not set)", "peakAmplitude: \(String(format: "%.6f", stats.peakAmplitude)) (\(String(format: "%.1f", peakDB)) dB)", "", "--- Skip/Error Counts ---", "skippedNotRecording: \(stats.skippedNotRecording) (isRecording was false)", "skippedNoFormat: \(stats.skippedNoFormat) (formatDescription was nil)", "skippedNoData: \(stats.skippedNoData) (buffer had no data)", "appendFailCount: \(stats.appendFailCount) (writerInput.append returned false)", "notReadyCount: \(stats.notReadyCount) (writerInput not ready for data)", "", "--- How to Read ---", "processCallCount=0 → audioMix never reached the tap (check trackID / variant switch)", "activeCallCount=0 + silentCallCount>0 → tap gets data but it's all zeros (wrong track / muted source)", "appendCount=0 + activeCallCount>0 → data captured but writer rejected it (format mismatch)", "audioDuration << videoDuration → tap worked initially then stopped (HLS variant switch mid-recording)", "peakAmplitude < 0.001 → effectively silent output", ] let report = lines.joined(separator: "\n") do { try report.write(to: reportURL, atomically: true, encoding: .utf8) NSLog("[Recorder] ✓ Diagnostic report: %@", reportURL.path) print(report) // 也输出到终端 } catch { NSLog("[Recorder] ⚠️ Failed to write diagnostic report: %@", error.localizedDescription) } } // MARK: - 保存对话框 private func showSaveDialog(tempURL: URL) { let reportURL = tempURL.deletingPathExtension().appendingPathExtension("txt") 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) // 诊断报告也移到同目录 if FileManager.default.fileExists(atPath: reportURL.path) { let reportDest = url.deletingPathExtension().appendingPathExtension("txt") if FileManager.default.fileExists(atPath: reportDest.path) { try FileManager.default.removeItem(at: reportDest) } try FileManager.default.moveItem(at: reportURL, to: reportDest) NSLog("[Recorder] ✓ Diagnostic report saved: %@", reportDest.path) } self?.onRecordingSaved?(url) } catch { NSLog("[Recorder] ✗ Save failed: %@", error.localizedDescription) self?.onError?("Save failed: \(error.localizedDescription)") } } else { try? FileManager.default.removeItem(at: tempURL) try? FileManager.default.removeItem(at: reportURL) } self?.checkPendingTerminate() } } private func checkPendingTerminate() { if RecorderState.pendingTerminate { RecorderState.pendingTerminate = false NSApp.reply(toApplicationShouldTerminate: true) } } } #endif