refactor: replace AVAssetWriter+Tap recording with StreamRecorderKit FFmpegRecorder

- PlayerRecorder.swift: 883 lines → ~150 lines
- Recording now uses FFmpeg to download stream directly from URL
- No longer depends on AVPlayerItemVideoOutput/copyPixelBuffer/MTAudioProcessingTap
- Recording decoupled from playback (pausing playback doesn't affect recording)
- Better quality: direct stream copy/transcode instead of re-encoding captured frames
- Added StreamRecorderKit as local SPM dependency
This commit is contained in:
yumoqing 2026-06-28 18:19:10 +08:00
parent 70337add95
commit 221075ce8a
3 changed files with 85 additions and 825 deletions

View File

@ -9,13 +9,15 @@ let package = Package(
.macOS(.v14)
],
dependencies: [
.package(path: "../swiftbricks")
.package(path: "../swiftbricks"),
.package(path: "../StreamRecorder")
],
targets: [
.executableTarget(
name: "MiniPlayer",
dependencies: [
.product(name: "SwiftBricks", package: "swiftbricks")
.product(name: "SwiftBricks", package: "swiftbricks"),
.product(name: "StreamRecorderKit", package: "StreamRecorder")
],
resources: [.process("Resources")]
)

View File

@ -85,7 +85,6 @@ final class PlayerBridge: ObservableObject {
private var playlistWindow: NSWindow?
private var fullscreenEventMonitor: Any?
let screenRecorder = PlayerRecorder()
var videoOutput: AVPlayerItemVideoOutput?
#endif
// MARK: -
@ -116,17 +115,12 @@ final class PlayerBridge: ObservableObject {
if screenRecorder.isRecording {
screenRecorder.stopRecording()
} else {
guard let output = videoOutput else {
showToast("No video playing")
guard !queue.isEmpty, currentIndex >= 0, currentIndex < queue.count else {
showToast("No media playing")
return
}
guard !queue.isEmpty else { return }
let startTime = player.currentTime()
guard let playerItem = player.currentItem else {
showToast("No player item")
return
}
screenRecorder.startRecording(from: output, playerItem: playerItem, startTime: startTime)
let item = queue[currentIndex]
screenRecorder.startRecording(url: item.url)
}
#endif
}
@ -178,16 +172,6 @@ final class PlayerBridge: ObservableObject {
let asset = AVURLAsset(url: item.url, options: ["AVURLAssetHTTPHeaderFieldsKey": headers])
let playerItem = AVPlayerItem(asset: asset)
#if os(macOS)
// video output
let pixelBufferAttributes: [String: Any] = [
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA
]
let output = AVPlayerItemVideoOutput(pixelBufferAttributes: pixelBufferAttributes)
playerItem.add(output)
videoOutput = output
#endif
player.replaceCurrentItem(with: playerItem)
player.play()
isPlaying = true

View File

@ -1,737 +1,100 @@
import AVFoundation
import CoreMedia
import MediaToolbox
import Foundation
import StreamRecorderKit
#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
/// AasyncB使
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<UnsafeMutableRawPointer?>
) {
tapStorageOut.pointee = clientInfo
}
private func tapFinalize(_ tap: MTAudioProcessingTap) {
// Context lifecycle managed elsewhere
}
private func tapPrepare(
_ tap: MTAudioProcessingTap,
_ maxFrames: CMItemCount,
_ processingFormat: UnsafePointer<AudioStreamBasicDescription>
) {
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<AudioTapContext>.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<AudioBufferList>,
_ bufferListSizeOut: UnsafeMutablePointer<Int>,
_ flagsOut: UnsafeMutablePointer<UInt32>
) {
bufferListSizeOut.pointee = 1
flagsOut.pointee = flags
let storage = MTAudioProcessingTapGetStorage(tap)
let ctx = Unmanaged<AudioTapContext>.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..<ablPtr.count {
if ablPtr[i].mData != nil && ablPtr[i].mDataByteSize > 0 {
totalSize += Int(ablPtr[i].mDataByteSize)
}
}
guard totalSize > 0 else { ctx.skippedNoData += 1; return }
// === : ===
var bufferPeak: Float = 0
for i in 0..<ablPtr.count {
guard let data = ablPtr[i].mData, ablPtr[i].mDataByteSize > 0 else { continue }
let floatPtr = data.assumingMemoryBound(to: Float.self)
let sampleCount = Int(ablPtr[i].mDataByteSize) / MemoryLayout<Float>.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..<ablPtr.count {
if let src = ablPtr[i].mData, ablPtr[i].mDataByteSize > 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
// 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
// MARK: - PlayerRecorder StreamRecorderKit FFmpegRecorder
/// 使 FFmpeg URL 使 AVAssetWriter + MTAudioProcessingTap
///
/// - /
/// - 883 ~150
/// - AVPlayerItemVideoOutput / copyPixelBuffer
/// -
@MainActor
final class PlayerRecorder: NSObject {
private var writer: AVAssetWriter?
nonisolated(unsafe) private var videoInput: AVAssetWriterInput?
private var audioInput: AVAssetWriterInput?
private var captureTimer: DispatchSourceTimer?
private let captureQueue = DispatchQueue(label: "miniplayer.videoCapture", qos: .userInteractive)
private var recorder: FFmpegRecorder?
private var recordingTask: Task<Void, Never>?
private var durationTimer: Timer?
private var startDate: Date?
nonisolated(unsafe) private weak var weakOutput: AVPlayerItemVideoOutput?
nonisolated(unsafe) private var isRunning = false
nonisolated(unsafe) private var lastPixelBuffer: CVPixelBuffer?
nonisolated(unsafe) 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
/// playerItem playIndex audioMix
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
}
}
if audioTrackID == kCMPersistentTrackID_Invalid {
print("[Recorder:DIAG] ⚠️ No enabled audio track found yet (tracks=\(itemTracks.count)), using wildcard")
}
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 210 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) {
func startRecording(url: URL) {
guard !isRecording else { return }
weakOutput = output
currentPlayerItem = playerItem
lastPixelBuffer = nil
captureFrameCount = 0
isRunning = false
recordStartTime = startTime
// Tap playIndex
if playerItem.audioMix == nil {
installAudioTap(on: playerItem)
} else {
print("[Recorder:DIAG] audioMix already installed, skipping")
}
let recorder = FFmpegRecorder()
self.recorder = recorder
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)
let tempURL = saveDirectory.appendingPathComponent(tempFilename)
try? FileManager.default.removeItem(at: tempURL)
isRecording = true
startDate = Date()
startDurationTimer()
NSLog("[Recorder] Starting FFmpeg recording: %@ → %@", url.lastPathComponent, tempFilename)
recordingTask = Task { [weak self] in
do {
try await recorder.start(url: url, outputPath: tempURL.path, duration: 0)
// duration=0 stop()
// start stop()
NSLog("[Recorder] ✓ Recording completed: %@", tempURL.path)
await MainActor.run {
RecorderState.isFinalizing = false
self?.stopDurationTimer()
self?.showSaveDialog(tempURL: tempURL)
}
} catch {
NSLog("[Recorder] ✗ Recording failed: %@", error.localizedDescription)
await MainActor.run {
RecorderState.isFinalizing = false
self?.stopDurationTimer()
self?.onError?(error.localizedDescription)
try? FileManager.default.removeItem(at: tempURL)
self?.checkPendingTerminate()
}
}
}
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?.cancel()
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
NSLog("[Recorder] Stopping recording...")
recorder?.stop()
}
// MARK: - (30fps, 线)
private func startCaptureLoop() {
let timer = DispatchSource.makeTimerSource(queue: captureQueue)
timer.schedule(deadline: .now(), repeating: .milliseconds(33), leeway: .milliseconds(2))
timer.setEventHandler { [weak self] in
self?.captureVideoFrame()
}
timer.resume()
captureTimer = timer
}
nonisolated 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
}
}
}
nonisolated 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
@ -740,12 +103,12 @@ final class PlayerRecorder: NSObject {
}
}
}
private func stopDurationTimer() {
durationTimer?.invalidate()
durationTimer = nil
}
private func updateDuration() {
guard let start = startDate else { return }
let elapsed = Int(Date().timeIntervalSince(start))
@ -753,126 +116,37 @@ final class PlayerRecorder: NSObject {
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)
Task { @MainActor 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: reportURL, to: reportDest)
NSLog("[Recorder] ✓ Diagnostic report saved: %@", reportDest.path)
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)")
}
self?.onRecordingSaved?(url)
} catch {
NSLog("[Recorder] ✗ Save failed: %@", error.localizedDescription)
self?.onError?("Save failed: \(error.localizedDescription)")
} else {
try? FileManager.default.removeItem(at: tempURL)
}
} else {
try? FileManager.default.removeItem(at: tempURL)
try? FileManager.default.removeItem(at: reportURL)
self?.checkPendingTerminate()
}
self?.checkPendingTerminate()
}
}
private func checkPendingTerminate() {
if RecorderState.pendingTerminate {
RecorderState.pendingTerminate = false