fix: 修复录制视频无音频问题
- audioInput 从 PCM pass-through 改为 AAC 编码 (MP4 不支持裸 Float32 PCM) - IOProc 优先读 inOutputData (播放数据) 而非 inInputData (麦克风) - 音频时间戳改为相对时间 (从首次回调开始计算), 与视频同步 - 复用 CMAudioFormatDescription 避免重复创建
This commit is contained in:
parent
f1ecbf2e91
commit
1733ecc7bc
@ -17,18 +17,17 @@ class AudioCaptureContext {
|
|||||||
nonisolated(unsafe) var appendCount: Int = 0
|
nonisolated(unsafe) var appendCount: Int = 0
|
||||||
nonisolated(unsafe) var firstAppendLogged: Bool = false
|
nonisolated(unsafe) var firstAppendLogged: Bool = false
|
||||||
nonisolated(unsafe) var processCallCount: Int = 0
|
nonisolated(unsafe) var processCallCount: Int = 0
|
||||||
|
nonisolated(unsafe) var firstAudioSampleTime: Int64 = -1
|
||||||
|
|
||||||
/// Serial queue for processing audio buffers off the real-time thread
|
|
||||||
let audioQueue = DispatchQueue(label: "miniplayer.audioCapture")
|
let audioQueue = DispatchQueue(label: "miniplayer.audioCapture")
|
||||||
/// Pre-allocated data buffer (avoids malloc in real-time audio thread)
|
|
||||||
var dataBuffer: UnsafeMutableRawPointer?
|
var dataBuffer: UnsafeMutableRawPointer?
|
||||||
var dataBufferSize: UInt32 = 0
|
var dataBufferSize: UInt32 = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - CoreAudio 输出设备 IOProc 回调
|
// MARK: - CoreAudio 输出设备 IOProc 回调
|
||||||
|
|
||||||
/// 从系统音频输出设备捕获 PCM 数据(适用于所有音频源:HLS、本地文件等)
|
/// 从系统音频输出设备捕获正在播放的 PCM 数据
|
||||||
/// 运行在高优先级音频线程,最小化工作
|
/// 关键:输出设备的播放数据在 inOutputData 参数中,不是 inInputData
|
||||||
private func audioDeviceIOProc(
|
private func audioDeviceIOProc(
|
||||||
_ inDevice: AudioDeviceID,
|
_ inDevice: AudioDeviceID,
|
||||||
_ inNow: UnsafePointer<AudioTimeStamp>,
|
_ inNow: UnsafePointer<AudioTimeStamp>,
|
||||||
@ -40,19 +39,51 @@ private func audioDeviceIOProc(
|
|||||||
) -> OSStatus {
|
) -> OSStatus {
|
||||||
guard let clientData = inClientData else { return noErr }
|
guard let clientData = inClientData else { return noErr }
|
||||||
let ctx = Unmanaged<AudioCaptureContext>.fromOpaque(clientData).takeUnretainedValue()
|
let ctx = Unmanaged<AudioCaptureContext>.fromOpaque(clientData).takeUnretainedValue()
|
||||||
guard ctx.isRunning,
|
guard ctx.isRunning else { return noErr }
|
||||||
let writerInput = ctx.writerInput,
|
|
||||||
writerInput.isReadyForMoreMediaData else { return noErr }
|
|
||||||
|
|
||||||
ctx.processCallCount += 1
|
ctx.processCallCount += 1
|
||||||
|
|
||||||
let bufferList = inInputData.pointee
|
// 从 inOutputData 读取正在播放的音频
|
||||||
guard bufferList.mNumberBuffers > 0 else { return noErr }
|
let outBufferList = inOutputData.pointee
|
||||||
|
let inBufferList = inInputData.pointee
|
||||||
|
let callNum = ctx.processCallCount
|
||||||
|
if callNum <= 3 {
|
||||||
|
print("[Recorder] IOProc#\(callNum): outBufs=\(outBufferList.mNumberBuffers), inBufs=\(inBufferList.mNumberBuffers)")
|
||||||
|
if outBufferList.mNumberBuffers > 0 {
|
||||||
|
let b = outBufferList.mBuffers
|
||||||
|
print("[Recorder] outBuf[0]: size=\(b.mDataByteSize), channels=\(b.mNumberChannels), data=\(b.mData != nil)")
|
||||||
|
}
|
||||||
|
if inBufferList.mNumberBuffers > 0 {
|
||||||
|
let b = inBufferList.mBuffers
|
||||||
|
print("[Recorder] inBuf[0]: size=\(b.mDataByteSize), channels=\(b.mNumberChannels), data=\(b.mData != nil)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优先读 inOutputData(系统正在播放的音频),这是 loopback 数据
|
||||||
|
let bufferList: AudioBufferList
|
||||||
|
if outBufferList.mNumberBuffers > 0 {
|
||||||
|
let b = outBufferList.mBuffers
|
||||||
|
if b.mDataByteSize > 0 && b.mData != nil {
|
||||||
|
bufferList = outBufferList
|
||||||
|
} else if inBufferList.mNumberBuffers > 0 {
|
||||||
|
bufferList = inBufferList
|
||||||
|
} else {
|
||||||
|
return noErr
|
||||||
|
}
|
||||||
|
} else if inBufferList.mNumberBuffers > 0 {
|
||||||
|
bufferList = inBufferList
|
||||||
|
} else {
|
||||||
|
return noErr
|
||||||
|
}
|
||||||
|
|
||||||
let buffer = bufferList.mBuffers
|
let buffer = bufferList.mBuffers
|
||||||
let dataSize = buffer.mDataByteSize
|
let dataSize = buffer.mDataByteSize
|
||||||
guard dataSize > 0, let srcData = buffer.mData else { return noErr }
|
guard dataSize > 0, let srcData = buffer.mData else {
|
||||||
|
if callNum <= 3 { print("[Recorder] IOProc#\(callNum): no data (size=\(buffer.mDataByteSize), data=\(buffer.mData != nil))") }
|
||||||
|
return noErr
|
||||||
|
}
|
||||||
|
|
||||||
// Copy to pre-allocated buffer (avoid malloc in real-time thread)
|
// 复制到预分配 buffer
|
||||||
if ctx.dataBufferSize < dataSize {
|
if ctx.dataBufferSize < dataSize {
|
||||||
if let existing = ctx.dataBuffer { free(existing) }
|
if let existing = ctx.dataBuffer { free(existing) }
|
||||||
ctx.dataBuffer = malloc(Int(dataSize))
|
ctx.dataBuffer = malloc(Int(dataSize))
|
||||||
@ -61,12 +92,12 @@ private func audioDeviceIOProc(
|
|||||||
guard let dest = ctx.dataBuffer else { return noErr }
|
guard let dest = ctx.dataBuffer else { return noErr }
|
||||||
memcpy(dest, srcData, Int(dataSize))
|
memcpy(dest, srcData, Int(dataSize))
|
||||||
|
|
||||||
let pts = inInputTime.pointee
|
let pts = inOutputTime.pointee
|
||||||
let callNum = ctx.processCallCount
|
|
||||||
|
|
||||||
// Process on serial queue (off the real-time audio thread)
|
// 在串行队列处理
|
||||||
ctx.audioQueue.async {
|
ctx.audioQueue.async {
|
||||||
guard ctx.isRunning, let fd = ctx.formatDescription else { return }
|
guard ctx.isRunning else { return }
|
||||||
|
guard let fd = ctx.formatDescription, let writerInput = ctx.writerInput else { return }
|
||||||
|
|
||||||
var blockBuffer: CMBlockBuffer?
|
var blockBuffer: CMBlockBuffer?
|
||||||
let blockStatus = CMBlockBufferCreateWithMemoryBlock(
|
let blockStatus = CMBlockBufferCreateWithMemoryBlock(
|
||||||
@ -77,7 +108,10 @@ private func audioDeviceIOProc(
|
|||||||
flags: kCMBlockBufferAssureMemoryNowFlag,
|
flags: kCMBlockBufferAssureMemoryNowFlag,
|
||||||
blockBufferOut: &blockBuffer
|
blockBufferOut: &blockBuffer
|
||||||
)
|
)
|
||||||
guard blockStatus == kCMBlockBufferNoErr, let bb = blockBuffer else { return }
|
guard blockStatus == kCMBlockBufferNoErr, let bb = blockBuffer else {
|
||||||
|
if callNum <= 3 { print("[Recorder] CMBlockBufferCreate failed: \(blockStatus)") }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let replaceStatus = CMBlockBufferReplaceDataBytes(
|
let replaceStatus = CMBlockBufferReplaceDataBytes(
|
||||||
with: dest, blockBuffer: bb,
|
with: dest, blockBuffer: bb,
|
||||||
@ -86,12 +120,15 @@ private func audioDeviceIOProc(
|
|||||||
guard replaceStatus == kCMBlockBufferNoErr else { return }
|
guard replaceStatus == kCMBlockBufferNoErr else { return }
|
||||||
|
|
||||||
var timingInfo = CMSampleTimingInfo()
|
var timingInfo = CMSampleTimingInfo()
|
||||||
// Convert AudioTimeStamp to CMTime
|
|
||||||
if pts.mFlags.contains(.sampleTimeValid) {
|
if pts.mFlags.contains(.sampleTimeValid) {
|
||||||
let sampleRate = ctx.sampleRate
|
// 使用相对于第一次音频回调的时间戳
|
||||||
|
if ctx.firstAudioSampleTime < 0 {
|
||||||
|
ctx.firstAudioSampleTime = Int64(pts.mSampleTime)
|
||||||
|
}
|
||||||
|
let relativeSampleTime = Int64(pts.mSampleTime) - ctx.firstAudioSampleTime
|
||||||
timingInfo.presentationTimeStamp = CMTime(
|
timingInfo.presentationTimeStamp = CMTime(
|
||||||
value: Int64(pts.mSampleTime),
|
value: relativeSampleTime,
|
||||||
timescale: Int32(sampleRate)
|
timescale: Int32(ctx.sampleRate)
|
||||||
)
|
)
|
||||||
} else if pts.mFlags.contains(.hostTimeValid) {
|
} else if pts.mFlags.contains(.hostTimeValid) {
|
||||||
var timebase = mach_timebase_info_data_t()
|
var timebase = mach_timebase_info_data_t()
|
||||||
@ -104,9 +141,10 @@ private func audioDeviceIOProc(
|
|||||||
timingInfo.duration = .invalid
|
timingInfo.duration = .invalid
|
||||||
timingInfo.decodeTimeStamp = .invalid
|
timingInfo.decodeTimeStamp = .invalid
|
||||||
|
|
||||||
var sampleBuffer: CMSampleBuffer?
|
|
||||||
let bytesPerFrame = Int(ctx.channelsPerFrame) * MemoryLayout<Float32>.size
|
let bytesPerFrame = Int(ctx.channelsPerFrame) * MemoryLayout<Float32>.size
|
||||||
let sampleCount = Int(dataSize) / max(bytesPerFrame, 1)
|
let sampleCount = Int(dataSize) / max(bytesPerFrame, 1)
|
||||||
|
|
||||||
|
var sampleBuffer: CMSampleBuffer?
|
||||||
let createStatus = CMSampleBufferCreateReady(
|
let createStatus = CMSampleBufferCreateReady(
|
||||||
allocator: kCFAllocatorDefault,
|
allocator: kCFAllocatorDefault,
|
||||||
dataBuffer: bb,
|
dataBuffer: bb,
|
||||||
@ -120,17 +158,24 @@ private func audioDeviceIOProc(
|
|||||||
)
|
)
|
||||||
guard createStatus == noErr, let sb = sampleBuffer else {
|
guard createStatus == noErr, let sb = sampleBuffer else {
|
||||||
if callNum <= 3 {
|
if callNum <= 3 {
|
||||||
NSLog("[Recorder] \u{26a0}\u{fe0f} Audio sample buffer create failed: %d", createStatus)
|
print("[Recorder] ⚠️ SampleBuffer create failed: \(createStatus), dataSize=\(dataSize), sampleCount=\(sampleCount)")
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if writerInput.isReadyForMoreMediaData && writerInput.append(sb) {
|
if writerInput.isReadyForMoreMediaData {
|
||||||
ctx.appendCount += 1
|
let appended = writerInput.append(sb)
|
||||||
if !ctx.firstAppendLogged {
|
if appended {
|
||||||
ctx.firstAppendLogged = true
|
ctx.appendCount += 1
|
||||||
NSLog("[Recorder] ✓ First audio captured: pts=%.2fs, size=%u", pts.mSampleTime / ctx.sampleRate, dataSize)
|
if !ctx.firstAppendLogged {
|
||||||
|
ctx.firstAppendLogged = true
|
||||||
|
print("[Recorder] ✓ First audio captured: pts=\(timingInfo.presentationTimeStamp.seconds)s, size=\(dataSize)")
|
||||||
|
}
|
||||||
|
} else if callNum <= 3 {
|
||||||
|
print("[Recorder] ⚠️ writerInput.append returned false")
|
||||||
}
|
}
|
||||||
|
} else if callNum <= 3 {
|
||||||
|
print("[Recorder] ⚠️ writerInput not ready for more data")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -146,7 +191,6 @@ enum RecorderState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 视频源录制器:AVPlayerItemVideoOutput 抓帧 + CoreAudio 输出设备捕获音频
|
/// 视频源录制器:AVPlayerItemVideoOutput 抓帧 + CoreAudio 输出设备捕获音频
|
||||||
/// 边录边写文件,录制结束后仅改名和移动
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class PlayerRecorder: NSObject {
|
final class PlayerRecorder: NSObject {
|
||||||
|
|
||||||
@ -159,18 +203,15 @@ final class PlayerRecorder: NSObject {
|
|||||||
private weak 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
|
||||||
|
|
||||||
// 音频 (CoreAudio 输出设备)
|
// 音频 (CoreAudio)
|
||||||
private var audioDeviceID: AudioDeviceID = 0
|
private var audioDeviceID: AudioDeviceID = 0
|
||||||
private var audioCaptureContext: AudioCaptureContext?
|
private var audioCaptureContext: AudioCaptureContext?
|
||||||
private var audioIOProc: AudioDeviceIOProc?
|
private var audioIOProc: AudioDeviceIOProc?
|
||||||
private weak var currentPlayerItem: AVPlayerItem?
|
private weak var currentPlayerItem: AVPlayerItem?
|
||||||
private var tracksObservation: NSKeyValueObservation?
|
|
||||||
|
|
||||||
// 录制起始时间(用于计算视频相对时间戳)
|
|
||||||
nonisolated(unsafe) private var recordStartTime: CMTime = .zero
|
nonisolated(unsafe) private var recordStartTime: CMTime = .zero
|
||||||
|
|
||||||
@Published var isRecording = false
|
@Published var isRecording = false
|
||||||
@ -179,10 +220,8 @@ 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)
|
||||||
@ -202,10 +241,8 @@ final class PlayerRecorder: NSObject {
|
|||||||
isRunning = false
|
isRunning = false
|
||||||
recordStartTime = startTime
|
recordStartTime = startTime
|
||||||
|
|
||||||
// 临时文件(边录边写,结束仅改名移动)
|
|
||||||
let tempFilename = "temp_recording_\(Int(Date().timeIntervalSince1970)).mp4"
|
let tempFilename = "temp_recording_\(Int(Date().timeIntervalSince1970)).mp4"
|
||||||
let url = saveDirectory.appendingPathComponent(tempFilename)
|
let url = saveDirectory.appendingPathComponent(tempFilename)
|
||||||
// 删除旧文件
|
|
||||||
try? FileManager.default.removeItem(at: url)
|
try? FileManager.default.removeItem(at: url)
|
||||||
tempURL = url
|
tempURL = url
|
||||||
|
|
||||||
@ -231,19 +268,7 @@ final class PlayerRecorder: NSObject {
|
|||||||
videoInput = vInput
|
videoInput = vInput
|
||||||
}
|
}
|
||||||
|
|
||||||
// 音频输入(AAC)
|
// 音频输入将在 startAudioCapture 中获取设备格式后用 AAC 编码创建
|
||||||
let audioSettings: [String: Any] = [
|
|
||||||
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
|
||||||
AVSampleRateKey: 44100,
|
|
||||||
AVNumberOfChannelsKey: 2,
|
|
||||||
AVEncoderBitRateKey: 128000
|
|
||||||
]
|
|
||||||
let aInput = AVAssetWriterInput(mediaType: .audio, outputSettings: audioSettings)
|
|
||||||
aInput.expectsMediaDataInRealTime = true
|
|
||||||
if writer.canAdd(aInput) {
|
|
||||||
writer.add(aInput)
|
|
||||||
audioInput = aInput
|
|
||||||
}
|
|
||||||
|
|
||||||
guard writer.startWriting() else {
|
guard writer.startWriting() else {
|
||||||
NSLog("[Recorder] startWriting failed: %@", writer.error?.localizedDescription ?? "unknown")
|
NSLog("[Recorder] startWriting failed: %@", writer.error?.localizedDescription ?? "unknown")
|
||||||
@ -251,18 +276,16 @@ final class PlayerRecorder: NSObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// session 起点为 0,视频和音频都用相对时间戳
|
|
||||||
writer.startSession(atSourceTime: .zero)
|
writer.startSession(atSourceTime: .zero)
|
||||||
NSLog("[Recorder] Writer started, writing to: %@", url.lastPathComponent)
|
NSLog("[Recorder] Writer started, writing to: %@", url.lastPathComponent)
|
||||||
|
|
||||||
// 启动视频抓帧(30fps)
|
|
||||||
isRecording = true
|
isRecording = true
|
||||||
isRunning = true
|
isRunning = true
|
||||||
startCaptureLoop()
|
startCaptureLoop()
|
||||||
startDurationTimer()
|
startDurationTimer()
|
||||||
|
|
||||||
// 启动音频捕获 (CoreAudio)
|
// 启动音频捕获 (CoreAudio 输出设备,内部创建 audioInput)
|
||||||
startAudioCapture(audioWriterInput: aInput)
|
startAudioCapture()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 停止录制
|
// MARK: - 停止录制
|
||||||
@ -271,12 +294,11 @@ final class PlayerRecorder: NSObject {
|
|||||||
guard isRecording else { return }
|
guard isRecording else { return }
|
||||||
isRecording = false
|
isRecording = false
|
||||||
isRunning = false
|
isRunning = false
|
||||||
RecorderState.isFinalizing = true // 阻止应用退出,直到 finishWriting 完成
|
RecorderState.isFinalizing = true
|
||||||
captureTimer?.invalidate()
|
captureTimer?.invalidate()
|
||||||
captureTimer = nil
|
captureTimer = nil
|
||||||
stopDurationTimer()
|
stopDurationTimer()
|
||||||
|
|
||||||
// 保存音频统计并停止捕获
|
|
||||||
let audioCalls = audioCaptureContext?.processCallCount ?? 0
|
let audioCalls = audioCaptureContext?.processCallCount ?? 0
|
||||||
let audioAppended = audioCaptureContext?.appendCount ?? 0
|
let audioAppended = audioCaptureContext?.appendCount ?? 0
|
||||||
NSLog("[Recorder] Stopping: videoFrames=%d, audioProcessCalls=%d, audioAppended=%d",
|
NSLog("[Recorder] Stopping: videoFrames=%d, audioProcessCalls=%d, audioAppended=%d",
|
||||||
@ -290,7 +312,7 @@ final class PlayerRecorder: NSObject {
|
|||||||
writer?.finishWriting { [weak self] in
|
writer?.finishWriting { [weak self] in
|
||||||
NSLog("[Recorder] finishWriting callback invoked")
|
NSLog("[Recorder] finishWriting callback invoked")
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
RecorderState.isFinalizing = false // 允许应用退出了
|
RecorderState.isFinalizing = false
|
||||||
|
|
||||||
guard let self = self, let w = self.writer else {
|
guard let self = self, let w = self.writer else {
|
||||||
NSLog("[Recorder] ⚠️ self or writer is nil in finishWriting callback")
|
NSLog("[Recorder] ⚠️ self or writer is nil in finishWriting callback")
|
||||||
@ -307,7 +329,6 @@ final class PlayerRecorder: NSObject {
|
|||||||
vFrames, audioAppended)
|
vFrames, audioAppended)
|
||||||
|
|
||||||
if w.status == .completed, let tempURL = self.tempURL {
|
if w.status == .completed, let tempURL = self.tempURL {
|
||||||
// 检查文件是否存在且有内容
|
|
||||||
if FileManager.default.fileExists(atPath: tempURL.path) {
|
if FileManager.default.fileExists(atPath: tempURL.path) {
|
||||||
let size = (try? FileManager.default.attributesOfItem(atPath: tempURL.path)[.size] as? UInt64) ?? 0
|
let size = (try? FileManager.default.attributesOfItem(atPath: tempURL.path)[.size] as? UInt64) ?? 0
|
||||||
NSLog("[Recorder] ✓ Temp file exists: %@, size=%llu bytes", tempURL.lastPathComponent, size)
|
NSLog("[Recorder] ✓ Temp file exists: %@, size=%llu bytes", tempURL.lastPathComponent, size)
|
||||||
@ -334,9 +355,9 @@ final class PlayerRecorder: NSObject {
|
|||||||
|
|
||||||
// MARK: - 音频捕获 (CoreAudio 输出设备)
|
// MARK: - 音频捕获 (CoreAudio 输出设备)
|
||||||
|
|
||||||
/// 从系统音频输出设备捕获音频(适用于 HLS、本地文件等所有音频源)
|
private func startAudioCapture() {
|
||||||
private func startAudioCapture(audioWriterInput: AVAssetWriterInput) {
|
print("[Recorder] startAudioCapture called")
|
||||||
// 获取默认音频输出设备
|
|
||||||
var deviceID: AudioDeviceID = 0
|
var deviceID: AudioDeviceID = 0
|
||||||
var size = UInt32(MemoryLayout<AudioDeviceID>.size)
|
var size = UInt32(MemoryLayout<AudioDeviceID>.size)
|
||||||
var address = AudioObjectPropertyAddress(
|
var address = AudioObjectPropertyAddress(
|
||||||
@ -347,8 +368,9 @@ final class PlayerRecorder: NSObject {
|
|||||||
let status = AudioObjectGetPropertyData(
|
let status = AudioObjectGetPropertyData(
|
||||||
AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &deviceID
|
AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &deviceID
|
||||||
)
|
)
|
||||||
|
print("[Recorder] Get default device status: \(status), deviceID: \(deviceID)")
|
||||||
guard status == noErr, deviceID != 0 else {
|
guard status == noErr, deviceID != 0 else {
|
||||||
NSLog("[Recorder] \u{274c} Cannot get default audio output device: %d", status)
|
print("[Recorder] ❌ Cannot get default audio output device")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
self.audioDeviceID = deviceID
|
self.audioDeviceID = deviceID
|
||||||
@ -362,23 +384,14 @@ final class PlayerRecorder: NSObject {
|
|||||||
var asbd = AudioStreamBasicDescription()
|
var asbd = AudioStreamBasicDescription()
|
||||||
size = UInt32(MemoryLayout<AudioStreamBasicDescription>.size)
|
size = UInt32(MemoryLayout<AudioStreamBasicDescription>.size)
|
||||||
let fmtStatus = AudioObjectGetPropertyData(deviceID, &streamFormatAddress, 0, nil, &size, &asbd)
|
let fmtStatus = AudioObjectGetPropertyData(deviceID, &streamFormatAddress, 0, nil, &size, &asbd)
|
||||||
|
print("[Recorder] Stream format: status=\(fmtStatus), rate=\(asbd.mSampleRate), ch=\(asbd.mChannelsPerFrame), bits=\(asbd.mBitsPerChannel), formatID=\(asbd.mFormatID)")
|
||||||
guard fmtStatus == noErr else {
|
guard fmtStatus == noErr else {
|
||||||
NSLog("[Recorder] \u{274c} Cannot get audio stream format: %d", fmtStatus)
|
print("[Recorder] ❌ Cannot get audio stream format")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
NSLog("[Recorder] Audio output format: %.1fHz, %uch, %ubit, formatID=%u",
|
// 用 AAC 编码创建音频输入(MP4 容器不支持裸 PCM pass-through)
|
||||||
asbd.mSampleRate, asbd.mChannelsPerFrame, asbd.mBitsPerChannel, asbd.mFormatID)
|
// 先创建 CMAudioFormatDescription
|
||||||
|
|
||||||
// 创建 context
|
|
||||||
let context = AudioCaptureContext()
|
|
||||||
context.writerInput = audioWriterInput
|
|
||||||
context.sampleRate = Double(asbd.mSampleRate)
|
|
||||||
context.channelsPerFrame = asbd.mChannelsPerFrame
|
|
||||||
context.isRunning = true
|
|
||||||
self.audioCaptureContext = context
|
|
||||||
|
|
||||||
// 创建格式描述
|
|
||||||
var fmtDesc: CMAudioFormatDescription?
|
var fmtDesc: CMAudioFormatDescription?
|
||||||
var mutableASBD = asbd
|
var mutableASBD = asbd
|
||||||
CMAudioFormatDescriptionCreate(
|
CMAudioFormatDescriptionCreate(
|
||||||
@ -389,27 +402,60 @@ final class PlayerRecorder: NSObject {
|
|||||||
extensions: nil,
|
extensions: nil,
|
||||||
formatDescriptionOut: &fmtDesc
|
formatDescriptionOut: &fmtDesc
|
||||||
)
|
)
|
||||||
context.formatDescription = fmtDesc
|
guard let sourceFmtDesc = fmtDesc else {
|
||||||
|
print("[Recorder] ❌ Cannot create CMAudioFormatDescription")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 创建 IOProc(使用 unmanaged pointer 传递 context)
|
let audioSettings: [String: Any] = [
|
||||||
|
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||||
|
AVSampleRateKey: asbd.mSampleRate,
|
||||||
|
AVNumberOfChannelsKey: asbd.mChannelsPerFrame,
|
||||||
|
AVEncoderBitRateKey: 128_000
|
||||||
|
]
|
||||||
|
let aInput = AVAssetWriterInput(mediaType: .audio, outputSettings: audioSettings, sourceFormatHint: sourceFmtDesc)
|
||||||
|
aInput.expectsMediaDataInRealTime = true
|
||||||
|
if let w = writer, w.canAdd(aInput) {
|
||||||
|
w.add(aInput)
|
||||||
|
audioInput = aInput
|
||||||
|
print("[Recorder] ✓ AudioInput created with AAC encoding (rate=\(asbd.mSampleRate), ch=\(asbd.mChannelsPerFrame))")
|
||||||
|
} else {
|
||||||
|
print("[Recorder] ❌ Cannot add audio input to writer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建 context
|
||||||
|
let context = AudioCaptureContext()
|
||||||
|
context.writerInput = aInput
|
||||||
|
context.sampleRate = Double(asbd.mSampleRate)
|
||||||
|
context.channelsPerFrame = asbd.mChannelsPerFrame
|
||||||
|
context.isRunning = true
|
||||||
|
self.audioCaptureContext = context
|
||||||
|
|
||||||
|
// 使用前面已创建的格式描述
|
||||||
|
context.formatDescription = sourceFmtDesc
|
||||||
|
print("[Recorder] formatDescription created: true")
|
||||||
|
|
||||||
|
// 创建 IOProc
|
||||||
let contextPtr = Unmanaged.passUnretained(context).toOpaque()
|
let contextPtr = Unmanaged.passUnretained(context).toOpaque()
|
||||||
var ioProc: AudioDeviceIOProc?
|
var ioProc: AudioDeviceIOProc?
|
||||||
let createStatus = AudioDeviceCreateIOProcID(deviceID, audioDeviceIOProc, contextPtr, &ioProc)
|
let createStatus = AudioDeviceCreateIOProcID(deviceID, audioDeviceIOProc, contextPtr, &ioProc)
|
||||||
|
print("[Recorder] CreateIOProcID status: \(createStatus)")
|
||||||
guard createStatus == noErr, let proc = ioProc else {
|
guard createStatus == noErr, let proc = ioProc else {
|
||||||
NSLog("[Recorder] \u{274c} AudioDeviceCreateIOProcID failed: %d", createStatus)
|
print("[Recorder] ❌ AudioDeviceCreateIOProcID failed: \(createStatus)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
self.audioIOProc = proc
|
self.audioIOProc = proc
|
||||||
|
|
||||||
// 启动音频捕获
|
|
||||||
let startStatus = AudioDeviceStart(deviceID, proc)
|
let startStatus = AudioDeviceStart(deviceID, proc)
|
||||||
|
print("[Recorder] AudioDeviceStart status: \(startStatus)")
|
||||||
guard startStatus == noErr else {
|
guard startStatus == noErr else {
|
||||||
NSLog("[Recorder] \u{274c} AudioDeviceStart failed: %d", startStatus)
|
print("[Recorder] ❌ AudioDeviceStart failed: \(startStatus)")
|
||||||
AudioDeviceDestroyIOProcID(deviceID, proc)
|
AudioDeviceDestroyIOProcID(deviceID, proc)
|
||||||
self.audioIOProc = nil
|
self.audioIOProc = nil
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
NSLog("[Recorder] \u{2713} CoreAudio output device capture started (deviceID=%u)", deviceID)
|
NSLog("[Recorder] ✓ CoreAudio output device capture started (deviceID=%u)", deviceID)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func stopAudioCapture() {
|
private func stopAudioCapture() {
|
||||||
@ -421,7 +467,6 @@ final class PlayerRecorder: NSObject {
|
|||||||
audioIOProc = nil
|
audioIOProc = nil
|
||||||
audioCaptureContext = nil
|
audioCaptureContext = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// MARK: - 视频抓帧 (30fps Timer)
|
// MARK: - 视频抓帧 (30fps Timer)
|
||||||
|
|
||||||
@ -440,19 +485,15 @@ 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 }
|
||||||
|
|
||||||
// 创建 sample buffer 并写入
|
|
||||||
if let sb = Self.createSampleBuffer(from: pb, time: relativeTime) {
|
if let sb = Self.createSampleBuffer(from: pb, time: relativeTime) {
|
||||||
if input.append(sb) {
|
if input.append(sb) {
|
||||||
captureFrameCount += 1
|
captureFrameCount += 1
|
||||||
@ -483,26 +524,17 @@ final class PlayerRecorder: NSObject {
|
|||||||
sampleTiming: &info,
|
sampleTiming: &info,
|
||||||
sampleBufferOut: &sampleBuffer
|
sampleBufferOut: &sampleBuffer
|
||||||
)
|
)
|
||||||
|
|
||||||
return sampleBuffer
|
return sampleBuffer
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 计时器
|
// MARK: - 录制时长显示
|
||||||
|
|
||||||
private func startDurationTimer() {
|
private func startDurationTimer() {
|
||||||
startDate = Date()
|
startDate = Date()
|
||||||
durationText = "00:00"
|
|
||||||
durationTimer = 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 }
|
self?.updateDuration()
|
||||||
let elapsed = Int(Date().timeIntervalSince(start))
|
|
||||||
let m = elapsed / 60
|
|
||||||
let s = elapsed % 60
|
|
||||||
let h = m / 60
|
|
||||||
if h > 0 {
|
|
||||||
self.durationText = String(format: "%d:%02d:%02d", h, m % 60, s)
|
|
||||||
} else {
|
|
||||||
self.durationText = String(format: "%02d:%02d", m, s)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -510,69 +542,49 @@ final class PlayerRecorder: NSObject {
|
|||||||
private func stopDurationTimer() {
|
private func stopDurationTimer() {
|
||||||
durationTimer?.invalidate()
|
durationTimer?.invalidate()
|
||||||
durationTimer = nil
|
durationTimer = nil
|
||||||
startDate = nil
|
|
||||||
durationText = "00:00"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 文件命名
|
private func updateDuration() {
|
||||||
|
guard let start = startDate else { return }
|
||||||
private func generateFinalFilename() -> String {
|
let elapsed = Int(Date().timeIntervalSince(start))
|
||||||
let formatter = DateFormatter()
|
let min = elapsed / 60
|
||||||
formatter.dateFormat = "yyyy-MM-dd_HH-mm-ss"
|
let sec = elapsed % 60
|
||||||
return "Recording_\(formatter.string(from: Date())).mp4"
|
durationText = String(format: "%02d:%02d", min, sec)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 保存对话框
|
// MARK: - 保存对话框
|
||||||
|
|
||||||
private func showSaveDialog(tempURL: URL) {
|
private func showSaveDialog(tempURL: URL) {
|
||||||
let panel = NSSavePanel()
|
let panel = NSSavePanel()
|
||||||
panel.nameFieldStringValue = generateFinalFilename()
|
|
||||||
panel.allowedContentTypes = [.mpeg4Movie]
|
panel.allowedContentTypes = [.mpeg4Movie]
|
||||||
|
panel.nameFieldStringValue = tempURL.lastPathComponent
|
||||||
panel.directoryURL = saveDirectory
|
panel.directoryURL = saveDirectory
|
||||||
panel.title = "保存录制文件"
|
|
||||||
panel.prompt = "保存"
|
|
||||||
|
|
||||||
panel.begin { [weak self] response in
|
panel.begin { [weak self] response in
|
||||||
Task { @MainActor in
|
if response == .OK, let url = panel.url {
|
||||||
guard let self = self else {
|
do {
|
||||||
if RecorderState.pendingTerminate {
|
if FileManager.default.fileExists(atPath: url.path) {
|
||||||
RecorderState.pendingTerminate = false
|
try FileManager.default.removeItem(at: url)
|
||||||
NSApp.reply(toApplicationShouldTerminate: true)
|
|
||||||
}
|
}
|
||||||
return
|
try FileManager.default.moveItem(at: tempURL, to: url)
|
||||||
|
NSLog("[Recorder] ✓ Saved: %@", url.path)
|
||||||
|
self?.onRecordingSaved?(url)
|
||||||
|
} catch {
|
||||||
|
NSLog("[Recorder] ✗ Save failed: %@", error.localizedDescription)
|
||||||
|
self?.onError?("Save failed: \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
if response == .OK, let finalURL = panel.url {
|
try? FileManager.default.removeItem(at: tempURL)
|
||||||
do {
|
|
||||||
// 如果目标文件已存在,先删除
|
|
||||||
if FileManager.default.fileExists(atPath: finalURL.path) {
|
|
||||||
try FileManager.default.removeItem(at: finalURL)
|
|
||||||
}
|
|
||||||
// 移动临时文件到最终位置
|
|
||||||
try FileManager.default.moveItem(at: tempURL, to: finalURL)
|
|
||||||
NSLog("[Recorder] ✓ Saved: %@", finalURL.path)
|
|
||||||
self.onRecordingSaved?(finalURL)
|
|
||||||
} catch {
|
|
||||||
self.onError?("Save failed: \(error.localizedDescription)")
|
|
||||||
try? FileManager.default.removeItem(at: tempURL)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 用户取消,删除临时文件
|
|
||||||
NSLog("[Recorder] User cancelled save, deleting temp file")
|
|
||||||
try? FileManager.default.removeItem(at: tempURL)
|
|
||||||
}
|
|
||||||
self.checkPendingTerminate()
|
|
||||||
}
|
}
|
||||||
|
self?.checkPendingTerminate()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func checkPendingTerminate() {
|
private func checkPendingTerminate() {
|
||||||
if RecorderState.pendingTerminate {
|
if RecorderState.pendingTerminate {
|
||||||
RecorderState.pendingTerminate = false
|
RecorderState.pendingTerminate = false
|
||||||
NSLog("[MiniPlayer] Pending terminate triggered, replying to terminate")
|
|
||||||
NSApp.reply(toApplicationShouldTerminate: true)
|
NSApp.reply(toApplicationShouldTerminate: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user