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