fix: HLS音频录制+边录边写+结束仅改名移动

1. 修复HLS无声音:
   - 统一回调函数签名,删除重复定义
   - 回调函数名改为 tapInitCallback/tapFinalizeCallback 等避免歧义
   - 添加NSLog诊断日志

2. 边录边写文件:
   - AVAssetWriter直接写入临时文件(边录边写,不缓存内存)
   - 临时文件名用时间戳,避免冲突

3. 录制结束仅改名移动:
   - finishWriting完成后直接moveItem到最终文件名
   - 不再弹NSSavePanel
   - 最终文件名格式:Recording_yyyy-MM-dd_HH-mm-ss.mp4
   - 保存到 ~/Movies/MiniPlayer/
This commit is contained in:
yumoqing 2026-06-25 08:29:39 +08:00
parent d9f12ced06
commit ff20ffe07f

View File

@ -1,61 +1,75 @@
import AVFoundation import AVFoundation
import CoreMedia import CoreMedia
import MediaToolbox
import Foundation
#if os(macOS) #if os(macOS)
import AppKit import AppKit
// MARK: - // MARK: - C 访 Swift
private nonisolated(unsafe) var gAudioContext: AudioCaptureContext?
class AudioCaptureContext { class AudioCaptureContext {
var writerInput: AVAssetWriterInput? var writerInput: AVAssetWriterInput?
nonisolated(unsafe) var isRunning: Bool = true nonisolated(unsafe) var isRunning: Bool = true
nonisolated(unsafe) var appendCount: Int = 0
nonisolated(unsafe) var formatDescription: CMAudioFormatDescription? nonisolated(unsafe) var formatDescription: CMAudioFormatDescription?
nonisolated(unsafe) var appendCount: Int = 0
nonisolated(unsafe) var firstAppendLogged: Bool = false nonisolated(unsafe) var firstAppendLogged: Bool = false
nonisolated(unsafe) var processCallCount: Int = 0
} }
/// C 访线 isRunning
private nonisolated(unsafe) var gAudioContext: AudioCaptureContext?
// MARK: - MTAudioProcessingTap C // MARK: - MTAudioProcessingTap C
private func tapPrepare(_ tap: MTAudioProcessingTap,
_ maxFramesPerSlice: CMItemCount, private func tapInitCallback(
_ processingFormat: UnsafePointer<AudioStreamBasicDescription>) { _ tap: MTAudioProcessingTap,
print("[Recorder] tapPrepare called, maxFrames=\(maxFramesPerSlice)") _ clientInfo: UnsafeMutableRawPointer?,
guard let ctx = gAudioContext else { _ tapStorageOut: UnsafeMutablePointer<UnsafeMutableRawPointer?>
print("[Recorder] tapPrepare: gAudioContext is nil!") ) {
return // tapStorage context 访
} tapStorageOut.pointee = clientInfo
}
private func tapFinalizeCallback(_ tap: MTAudioProcessingTap) {
// stopRecording
}
private func tapPrepareCallback(
_ tap: MTAudioProcessingTap,
_ maxFrames: CMItemCount,
_ processingFormat: UnsafePointer<AudioStreamBasicDescription>
) {
guard let ctx = gAudioContext else { return }
var asbd = processingFormat.pointee var asbd = processingFormat.pointee
print("[Recorder] tapPrepare: sampleRate=\(asbd.mSampleRate), channels=\(asbd.mChannelsPerFrame)") var fmtDesc: CMAudioFormatDescription?
CMAudioFormatDescriptionCreate(allocator: kCFAllocatorDefault, CMAudioFormatDescriptionCreate(
asbd: &asbd, allocator: kCFAllocatorDefault,
layoutSize: 0, layout: nil, asbd: &asbd,
magicCookieSize: 0, magicCookie: nil, layoutSize: 0, layout: nil,
extensions: nil, magicCookieSize: 0, magicCookie: nil,
formatDescriptionOut: &ctx.formatDescription) extensions: nil,
formatDescriptionOut: &fmtDesc
)
ctx.formatDescription = fmtDesc
NSLog("[Recorder] tapPrepare: %.1fHz, %uch", asbd.mSampleRate, asbd.mChannelsPerFrame)
} }
private func tapInit(_ tap: MTAudioProcessingTap, private func tapUnprepareCallback(_ tap: MTAudioProcessingTap) {
_ clientInfo: UnsafeMutableRawPointer?,
_ tapStorageOut: UnsafeMutablePointer<UnsafeMutableRawPointer?>) {
// tap
}
private func tapUnprepare(_ tap: MTAudioProcessingTap) {
// Nothing to clean up // Nothing to clean up
} }
private func tapProcess(_ tap: MTAudioProcessingTap, private func tapProcessCallback(
_ numberFrames: CMItemCount, _ tap: MTAudioProcessingTap,
_ flags: MTAudioProcessingTapFlags, _ numberFrames: CMItemCount,
_ bufferListInOut: UnsafeMutablePointer<AudioBufferList>, _ flags: MTAudioProcessingTapFlags,
_ numberFramesOut: UnsafeMutablePointer<CMItemCount>, _ bufferListInOut: UnsafeMutablePointer<AudioBufferList>,
_ flagsOut: UnsafeMutablePointer<MTAudioProcessingTapFlags>) { _ numberFramesOut: UnsafeMutablePointer<CMItemCount>,
_ flagsOut: UnsafeMutablePointer<MTAudioProcessingTapFlags>
) {
numberFramesOut.pointee = 0 numberFramesOut.pointee = 0
flagsOut.pointee = 0 flagsOut.pointee = 0
guard numberFrames > 0 else { return } guard numberFrames > 0 else { return }
// // +
var timeRange = CMTimeRange() var timeRange = CMTimeRange()
var srcFlags: MTAudioProcessingTapFlags = 0 var srcFlags: MTAudioProcessingTapFlags = 0
var actualFrames: CMItemCount = 0 var actualFrames: CMItemCount = 0
@ -75,6 +89,8 @@ private func tapProcess(_ tap: MTAudioProcessingTap,
let writerInput = ctx.writerInput, let writerInput = ctx.writerInput,
writerInput.isReadyForMoreMediaData else { return } writerInput.isReadyForMoreMediaData else { return }
ctx.processCallCount += 1
let bufferList = bufferListInOut.pointee let bufferList = bufferListInOut.pointee
guard bufferList.mNumberBuffers > 0 else { return } guard bufferList.mNumberBuffers > 0 else { return }
@ -103,7 +119,7 @@ private func tapProcess(_ tap: MTAudioProcessingTap,
// format description // format description
guard let fd = ctx.formatDescription else { return } guard let fd = ctx.formatDescription else { return }
// PTS // PTS
let sampleRate = Double(timeRange.duration.timescale) > 0 let sampleRate = Double(timeRange.duration.timescale) > 0
? Double(timeRange.duration.timescale) : 44100.0 ? Double(timeRange.duration.timescale) : 44100.0
let ptsValue = Double(ctx.appendCount) / sampleRate let ptsValue = Double(ctx.appendCount) / sampleRate
@ -125,28 +141,28 @@ private func tapProcess(_ tap: MTAudioProcessingTap,
ctx.appendCount += actualFrames ctx.appendCount += actualFrames
if !ctx.firstAppendLogged { if !ctx.firstAppendLogged {
ctx.firstAppendLogged = true ctx.firstAppendLogged = true
print("[Recorder] ✓ First audio frame appended! pts=\(pts.seconds)s, frames=\(actualFrames)") NSLog("[Recorder] ✓ First audio appended: pts=%.2fs, frames=%ld", pts.seconds, actualFrames)
} }
} }
} }
// init/finalize // MARK: - PlayerRecorder
private func tapInit(_ tap: MTAudioProcessingTap) {}
private func tapFinalize(_ tap: MTAudioProcessingTap) {}
/// AVPlayerItemVideoOutput + MTAudioProcessingTap mp4 /// AVPlayerItemVideoOutput + MTAudioProcessingTap
///
@MainActor @MainActor
final class PlayerRecorder: NSObject { final class PlayerRecorder: NSObject {
private var writer: AVAssetWriter? private var writer: AVAssetWriter?
private var videoInput: AVAssetWriterInput? private var videoInput: AVAssetWriterInput?
private var audioInput: AVAssetWriterInput? private var audioInput: AVAssetWriterInput?
private var timer: Timer? private var captureTimer: Timer?
private var durationTimer: Timer?
private var startDate: Date? private var startDate: Date?
private var weakOutput: AVPlayerItemVideoOutput? private weak var weakOutput: AVPlayerItemVideoOutput?
nonisolated(unsafe) private var isRunning = false nonisolated(unsafe) private var isRunning = false
// //
private var lastPixelBuffer: CVPixelBuffer? private var lastPixelBuffer: CVPixelBuffer?
private var captureFrameCount: Int = 0 private var captureFrameCount: Int = 0
@ -155,6 +171,7 @@ final class PlayerRecorder: NSObject {
private var audioContext: AudioCaptureContext? private var audioContext: AudioCaptureContext?
private weak var currentPlayerItem: AVPlayerItem? private weak var currentPlayerItem: AVPlayerItem?
//
nonisolated(unsafe) private var recordStartTime: CMTime = .zero nonisolated(unsafe) private var recordStartTime: CMTime = .zero
@Published var isRecording = false @Published var isRecording = false
@ -163,8 +180,10 @@ final class PlayerRecorder: NSObject {
var onRecordingSaved: ((URL) -> Void)? var onRecordingSaved: ((URL) -> Void)?
var onError: ((String) -> Void)? var onError: ((String) -> Void)?
///
private var tempURL: URL? private var tempURL: URL?
///
private var saveDirectory: URL { private var saveDirectory: URL {
let movies = FileManager.default.urls(for: .moviesDirectory, in: .userDomainMask).first! let movies = FileManager.default.urls(for: .moviesDirectory, in: .userDomainMask).first!
let dir = movies.appendingPathComponent("MiniPlayer", isDirectory: true) let dir = movies.appendingPathComponent("MiniPlayer", isDirectory: true)
@ -173,6 +192,7 @@ final class PlayerRecorder: NSObject {
} }
// MARK: - // MARK: -
func startRecording(from output: AVPlayerItemVideoOutput, playerItem: AVPlayerItem, startTime: CMTime) { func startRecording(from output: AVPlayerItemVideoOutput, playerItem: AVPlayerItem, startTime: CMTime) {
guard !isRecording else { return } guard !isRecording else { return }
@ -183,11 +203,11 @@ final class PlayerRecorder: NSObject {
isRunning = false isRunning = false
recordStartTime = startTime recordStartTime = startTime
// //
let formatter = DateFormatter() let tempFilename = "temp_recording_\(Int(Date().timeIntervalSince1970)).mp4"
formatter.dateFormat = "yyyy-MM-dd_HH-mm-ss" let url = saveDirectory.appendingPathComponent(tempFilename)
let filename = "Recording_\(formatter.string(from: Date())).mp4" //
let url = saveDirectory.appendingPathComponent(filename) try? FileManager.default.removeItem(at: url)
tempURL = url tempURL = url
do { do {
@ -199,7 +219,7 @@ final class PlayerRecorder: NSObject {
guard let writer = writer else { return } guard let writer = writer else { return }
// // H264
let videoSettings: [String: Any] = [ let videoSettings: [String: Any] = [
AVVideoCodecKey: AVVideoCodecType.h264, AVVideoCodecKey: AVVideoCodecType.h264,
AVVideoWidthKey: 1920, AVVideoWidthKey: 1920,
@ -212,7 +232,7 @@ final class PlayerRecorder: NSObject {
videoInput = vInput videoInput = vInput
} }
// // AAC
let audioSettings: [String: Any] = [ let audioSettings: [String: Any] = [
AVFormatIDKey: kAudioFormatMPEG4AAC, AVFormatIDKey: kAudioFormatMPEG4AAC,
AVSampleRateKey: 44100, AVSampleRateKey: 44100,
@ -226,39 +246,44 @@ final class PlayerRecorder: NSObject {
audioInput = aInput audioInput = aInput
} }
let success = writer.startWriting() guard writer.startWriting() else {
if !success { NSLog("[Recorder] startWriting failed: %@", writer.error?.localizedDescription ?? "unknown")
print("[Recorder] startWriting failed: \(writer.error?.localizedDescription ?? "unknown")")
onError?("Cannot start writer: \(writer.error?.localizedDescription ?? "unknown")") onError?("Cannot start writer: \(writer.error?.localizedDescription ?? "unknown")")
return return
} }
// session 0
writer.startSession(atSourceTime: .zero) writer.startSession(atSourceTime: .zero)
print("[Recorder] Writer started, session at .zero") NSLog("[Recorder] Writer started, writing to: %@", url.lastPathComponent)
// // 30fps
isRecording = true isRecording = true
isRunning = true isRunning = true
startTimer()
startCaptureLoop() startCaptureLoop()
startDurationTimer()
// MTAudioProcessingTap // MTAudioProcessingTap
setupAudioTap(playerItem: playerItem, audioWriterInput: aInput) setupAudioTap(playerItem: playerItem, audioWriterInput: aInput)
} }
// MARK: - // MARK: -
func stopRecording() { func stopRecording() {
guard isRecording else { return } guard isRecording else { return }
isRecording = false isRecording = false
isRunning = false isRunning = false
captureTimer?.invalidate() captureTimer?.invalidate()
captureTimer = nil captureTimer = nil
stopTimer() stopDurationTimer()
// tap // tap
audioContext?.isRunning = false audioContext?.isRunning = false
let totalAudioFrames = audioContext?.appendCount ?? 0
let processCalls = audioContext?.processCallCount ?? 0
NSLog("[Recorder] Stopping: videoFrames=%d, audioProcessCalls=%d, audioAppended=%d",
captureFrameCount, processCalls, totalAudioFrames)
// audioMix tap // audioMix
currentPlayerItem?.audioMix = nil currentPlayerItem?.audioMix = nil
currentPlayerItem = nil currentPlayerItem = nil
@ -273,10 +298,23 @@ final class PlayerRecorder: NSObject {
writer?.finishWriting { [weak self] in writer?.finishWriting { [weak self] in
Task { @MainActor in Task { @MainActor in
guard let self = self, let w = self.writer else { return } guard let self = self, let w = self.writer else { return }
let count = self.captureFrameCount let vFrames = self.captureFrameCount
print("[Recorder] finishWriting status: \(w.status.rawValue), error: \(w.error?.localizedDescription ?? "none"), video frames: \(count)") NSLog("[Recorder] finishWriting: status=%d, error=%@, videoFrames=%d, audioFrames=%d",
if w.status == .completed, let url = self.tempURL { w.status.rawValue,
self.showSaveDialog(tempURL: url) w.error?.localizedDescription ?? "none",
vFrames, totalAudioFrames)
if w.status == .completed, let tempURL = self.tempURL {
//
let finalFilename = self.generateFinalFilename()
let finalURL = self.saveDirectory.appendingPathComponent(finalFilename)
do {
try FileManager.default.moveItem(at: tempURL, to: finalURL)
NSLog("[Recorder] ✓ Saved: %@", finalURL.path)
self.onRecordingSaved?(finalURL)
} catch {
self.onError?("Save failed: \(error.localizedDescription)")
}
} else { } else {
self.onError?("Recording failed: \(w.error?.localizedDescription ?? "unknown (status=\(w.status.rawValue))")") self.onError?("Recording failed: \(w.error?.localizedDescription ?? "unknown (status=\(w.status.rawValue))")")
if let url = self.tempURL { if let url = self.tempURL {
@ -290,6 +328,7 @@ final class PlayerRecorder: NSObject {
} }
// MARK: - Tap // MARK: - Tap
private func setupAudioTap(playerItem: AVPlayerItem, audioWriterInput: AVAssetWriterInput) { private func setupAudioTap(playerItem: AVPlayerItem, audioWriterInput: AVAssetWriterInput) {
let asset = playerItem.asset let asset = playerItem.asset
@ -297,15 +336,15 @@ final class PlayerRecorder: NSObject {
do { do {
let audioTracks = try await asset.loadTracks(withMediaType: .audio) let audioTracks = try await asset.loadTracks(withMediaType: .audio)
guard let audioTrack = audioTracks.first else { guard let audioTrack = audioTracks.first else {
print("[Recorder] ❌ No audio track for tap") NSLog("[Recorder] ❌ No audio track for tap")
return return
} }
guard self.isRunning else { guard self.isRunning else {
print("[Recorder] ❌ isRunning=false, aborting audio tap setup") NSLog("[Recorder] ❌ isRunning=false, aborting audio tap setup")
return return
} }
print("[Recorder] Audio track found: \(audioTrack.trackID), setting up tap...") NSLog("[Recorder] Audio track found: %d, setting up tap...", audioTrack.trackID)
// //
let context = AudioCaptureContext() let context = AudioCaptureContext()
@ -313,18 +352,18 @@ final class PlayerRecorder: NSObject {
context.isRunning = true context.isRunning = true
self.audioContext = context self.audioContext = context
// C 访 // C 访
gAudioContext = context gAudioContext = context
// MTAudioProcessingTap // MTAudioProcessingTap
var callbacks = MTAudioProcessingTapCallbacks( var callbacks = MTAudioProcessingTapCallbacks(
version: kMTAudioProcessingTapCallbacksVersion_0, version: kMTAudioProcessingTapCallbacksVersion_0,
clientInfo: nil, clientInfo: nil,
init: tapInit, init: tapInitCallback,
finalize: tapFinalize, finalize: tapFinalizeCallback,
prepare: tapPrepare, prepare: tapPrepareCallback,
unprepare: tapUnprepare, unprepare: tapUnprepareCallback,
process: tapProcess process: tapProcessCallback
) )
var tap: MTAudioProcessingTap? var tap: MTAudioProcessingTap?
@ -336,7 +375,7 @@ final class PlayerRecorder: NSObject {
) )
guard status == noErr, let audioTap = tap else { guard status == noErr, let audioTap = tap else {
print("[Recorder] ❌ MTAudioProcessingTapCreate failed: \(status)") NSLog("[Recorder] ❌ MTAudioProcessingTapCreate failed: %d", status)
gAudioContext = nil gAudioContext = nil
return return
} }
@ -350,15 +389,14 @@ final class PlayerRecorder: NSObject {
audioMix.inputParameters = [params] audioMix.inputParameters = [params]
playerItem.audioMix = audioMix playerItem.audioMix = audioMix
print("[Recorder] ✓ Audio tap installed on player item") NSLog("[Recorder] ✓ Audio tap installed on playerItem")
} catch { } catch {
print("[Recorder] ❌ Audio tap setup failed: \(error)") NSLog("[Recorder] Audio tap setup error: %@", error.localizedDescription)
} }
} }
} }
// MARK: - (30fps Timer) // MARK: - (30fps Timer)
private var captureTimer: Timer?
private func startCaptureLoop() { private func startCaptureLoop() {
captureTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 30.0, repeats: true) { [weak self] _ in captureTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 30.0, repeats: true) { [weak self] _ in
@ -375,19 +413,20 @@ final class PlayerRecorder: NSObject {
let hostTime = CACurrentMediaTime() let hostTime = CACurrentMediaTime()
let itemTime = output.itemTime(forHostTime: hostTime) let itemTime = output.itemTime(forHostTime: hostTime)
// //
if let pb = output.copyPixelBuffer(forItemTime: itemTime, itemTimeForDisplay: nil) { if let pb = output.copyPixelBuffer(forItemTime: itemTime, itemTimeForDisplay: nil) {
lastPixelBuffer = pb lastPixelBuffer = pb
} }
// 使
guard let pb = lastPixelBuffer else { return } guard let pb = lastPixelBuffer else { return }
// //
let relativeTime = CMTimeSubtract(itemTime, recordStartTime) let relativeTime = CMTimeSubtract(itemTime, recordStartTime)
guard relativeTime.seconds >= 0 else { return } guard relativeTime.seconds >= 0 else { return }
let sampleBuffer = Self.createSampleBuffer(from: pb, time: relativeTime) // sample buffer
if let sb = sampleBuffer { if let sb = Self.createSampleBuffer(from: pb, time: relativeTime) {
if input.append(sb) { if input.append(sb) {
captureFrameCount += 1 captureFrameCount += 1
} }
@ -401,20 +440,31 @@ final class PlayerRecorder: NSObject {
info.decodeTimeStamp = .invalid info.decodeTimeStamp = .invalid
var formatDescription: CMFormatDescription? var formatDescription: CMFormatDescription?
CMVideoFormatDescriptionCreateForImageBuffer(allocator: kCFAllocatorDefault, imageBuffer: pixelBuffer, formatDescriptionOut: &formatDescription) CMVideoFormatDescriptionCreateForImageBuffer(
allocator: kCFAllocatorDefault,
imageBuffer: pixelBuffer,
formatDescriptionOut: &formatDescription
)
guard let fd = formatDescription else { return nil } guard let fd = formatDescription else { return nil }
var sampleBuffer: CMSampleBuffer? var sampleBuffer: CMSampleBuffer?
CMSampleBufferCreateReadyWithImageBuffer(allocator: kCFAllocatorDefault, imageBuffer: pixelBuffer, formatDescription: fd, sampleTiming: &info, sampleBufferOut: &sampleBuffer) CMSampleBufferCreateReadyWithImageBuffer(
allocator: kCFAllocatorDefault,
imageBuffer: pixelBuffer,
formatDescription: fd,
sampleTiming: &info,
sampleBufferOut: &sampleBuffer
)
return sampleBuffer return sampleBuffer
} }
// MARK: - Timer () // MARK: -
private func startTimer() {
private func startDurationTimer() {
startDate = Date() startDate = Date()
durationText = "00:00" durationText = "00:00"
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in durationTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
Task { @MainActor in Task { @MainActor in
guard let self = self, let start = self.startDate else { return } guard let self = self, let start = self.startDate else { return }
let elapsed = Int(Date().timeIntervalSince(start)) let elapsed = Int(Date().timeIntervalSince(start))
@ -430,37 +480,19 @@ final class PlayerRecorder: NSObject {
} }
} }
private func stopTimer() { private func stopDurationTimer() {
timer?.invalidate() durationTimer?.invalidate()
timer = nil durationTimer = nil
startDate = nil startDate = nil
durationText = "00:00" durationText = "00:00"
} }
// MARK: - // MARK: -
private func showSaveDialog(tempURL: URL) {
let panel = NSSavePanel() private func generateFinalFilename() -> String {
panel.nameFieldStringValue = tempURL.lastPathComponent let formatter = DateFormatter()
panel.allowedContentTypes = [.mpeg4Movie] formatter.dateFormat = "yyyy-MM-dd_HH-mm-ss"
panel.directoryURL = saveDirectory return "Recording_\(formatter.string(from: Date())).mp4"
panel.title = "Save Recording"
panel.begin { [weak self] response in
guard let self = self else { return }
if response == .OK, let destURL = panel.url {
do {
if FileManager.default.fileExists(atPath: destURL.path) {
try FileManager.default.removeItem(at: destURL)
}
try FileManager.default.moveItem(at: tempURL, to: destURL)
self.onRecordingSaved?(destURL)
} catch {
self.onError?("Save failed: \(error.localizedDescription)")
}
} else {
try? FileManager.default.removeItem(at: tempURL)
}
}
} }
} }