Fix: record audio from source URL, not microphone

This commit is contained in:
yumoqing 2026-06-24 20:28:31 +08:00
parent 5d9e93ba95
commit 6c31894a6d
2 changed files with 168 additions and 29 deletions

View File

@ -120,7 +120,10 @@ final class PlayerBridge: ObservableObject {
showToast("No video playing") showToast("No video playing")
return return
} }
screenRecorder.startRecording(from: output) guard !queue.isEmpty else { return }
let sourceURL = queue[currentIndex].url
let startTime = player.currentTime()
screenRecorder.startRecording(from: output, sourceURL: sourceURL, startTime: startTime)
} }
#endif #endif
} }

View File

@ -2,7 +2,7 @@ import AVFoundation
#if os(macOS) #if os(macOS)
import AppKit import AppKit
/// AVPlayerItemVideoOutput AVAssetWriter /// AVPlayerItemVideoOutput + URL mp4
@MainActor @MainActor
final class PlayerRecorder: NSObject { final class PlayerRecorder: NSObject {
@ -11,8 +11,12 @@ final class PlayerRecorder: NSObject {
private var audioInput: AVAssetWriterInput? private var audioInput: AVAssetWriterInput?
private var timer: Timer? private var timer: Timer?
private var startDate: Date? private var startDate: Date?
private var displayLink: CVDisplayLink?
private var weakOutput: AVPlayerItemVideoOutput? private var weakOutput: AVPlayerItemVideoOutput?
private var firstFrameTime: CMTime?
private var audioReader: AVAssetReader?
private var audioReaderOutput: AVAssetReaderTrackOutput?
private var audioCaptureQueue: DispatchQueue?
private var isRunning = false
@Published var isRecording = false @Published var isRecording = false
@Published var durationText = "00:00" @Published var durationText = "00:00"
@ -29,13 +33,15 @@ final class PlayerRecorder: NSObject {
return dir return dir
} }
/// videoOutput ///
func startRecording(from output: AVPlayerItemVideoOutput) { func startRecording(from output: AVPlayerItemVideoOutput, sourceURL: URL, startTime: CMTime) {
guard !isRecording else { return } guard !isRecording else { return }
weakOutput = output weakOutput = output
firstFrameTime = nil
isRunning = false
// //
let formatter = DateFormatter() let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd_HH-mm-ss" formatter.dateFormat = "yyyy-MM-dd_HH-mm-ss"
let filename = "Recording_\(formatter.string(from: Date())).mp4" let filename = "Recording_\(formatter.string(from: Date())).mp4"
@ -49,6 +55,8 @@ final class PlayerRecorder: NSObject {
return return
} }
guard let writer = writer else { return }
// //
let videoSettings: [String: Any] = [ let videoSettings: [String: Any] = [
AVVideoCodecKey: AVVideoCodecType.h264, AVVideoCodecKey: AVVideoCodecType.h264,
@ -57,25 +65,43 @@ final class PlayerRecorder: NSObject {
] ]
let vInput = AVAssetWriterInput(mediaType: .video, outputSettings: videoSettings) let vInput = AVAssetWriterInput(mediaType: .video, outputSettings: videoSettings)
vInput.expectsMediaDataInRealTime = true vInput.expectsMediaDataInRealTime = true
if writer?.canAdd(vInput) == true { if writer.canAdd(vInput) {
writer?.add(vInput) writer.add(vInput)
videoInput = vInput videoInput = vInput
} }
writer?.startWriting() //
writer?.startSession(atSourceTime: .zero) 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
}
writer.startWriting()
//
isRecording = true isRecording = true
startTimer() startTimer()
startCaptureLoop() startCaptureLoop()
// URL
startAudioCapture(sourceURL: sourceURL, startTime: startTime, audioWriterInput: aInput)
} }
/// ///
func stopRecording() { func stopRecording() {
guard isRecording else { return } guard isRecording else { return }
isRecording = false isRecording = false
isRunning = false
stopTimer() stopTimer()
stopCaptureLoop() stopAudioCapture()
videoInput?.markAsFinished() videoInput?.markAsFinished()
audioInput?.markAsFinished() audioInput?.markAsFinished()
@ -90,30 +116,51 @@ final class PlayerRecorder: NSObject {
} }
} }
// MARK: - // MARK: -
private func startCaptureLoop() { private func startCaptureLoop() {
let queue = DispatchQueue(label: "recorder.capture") guard let vInput = videoInput else { return }
videoInput?.requestMediaDataWhenReady(on: queue) { [weak self] in let queue = DispatchQueue(label: "recorder.video.capture")
self?.captureFrame() vInput.requestMediaDataWhenReady(on: queue) { [weak self] in
} guard let self = self, self.isRunning else { return }
} Task { @MainActor in
self.captureVideoFrame()
private func captureFrame() {
guard let output = weakOutput, let input = videoInput, input.isReadyForMoreMediaData else { return }
let time = output.itemTime(forHostTime: CACurrentMediaTime())
if output.hasNewPixelBuffer(forItemTime: time), let pb = output.copyPixelBuffer(forItemTime: time, itemTimeForDisplay: nil) {
let sampleBuffer = createSampleBuffer(from: pb, time: time)
if let sb = sampleBuffer {
input.append(sb)
} }
} }
} }
private func createSampleBuffer(from pixelBuffer: CVPixelBuffer, time: CMTime) -> CMSampleBuffer? { private func captureVideoFrame() {
guard let output = weakOutput, let input = videoInput, input.isReadyForMoreMediaData else { return }
let hostTime = CACurrentMediaTime()
let itemTime = output.itemTime(forHostTime: hostTime)
guard output.hasNewPixelBuffer(forItemTime: itemTime),
let pb = output.copyPixelBuffer(forItemTime: itemTime, itemTimeForDisplay: nil) else { return }
// session
if firstFrameTime == nil {
firstFrameTime = itemTime
writer?.startSession(atSourceTime: itemTime)
isRunning = true
}
//
guard let firstTime = firstFrameTime else { return }
let relativeTime = CMTimeSubtract(itemTime, firstTime)
guard relativeTime.seconds >= 0 else { return }
let sampleBuffer = Self.createSampleBuffer(from: pb, time: relativeTime)
if let sb = sampleBuffer {
_ = input.append(sb)
}
}
private static func createSampleBuffer(from pixelBuffer: CVPixelBuffer, time: CMTime) -> CMSampleBuffer? {
var info = CMSampleTimingInfo() var info = CMSampleTimingInfo()
info.presentationTimeStamp = time info.presentationTimeStamp = time
info.duration = CMTime(value: 1, timescale: 30) info.duration = CMTime(value: 1, timescale: 30)
info.decodeTimeStamp = .invalid
var formatDescription: CMFormatDescription? var formatDescription: CMFormatDescription?
CMVideoFormatDescriptionCreateForImageBuffer(allocator: kCFAllocatorDefault, imageBuffer: pixelBuffer, formatDescriptionOut: &formatDescription) CMVideoFormatDescriptionCreateForImageBuffer(allocator: kCFAllocatorDefault, imageBuffer: pixelBuffer, formatDescriptionOut: &formatDescription)
@ -125,8 +172,97 @@ final class PlayerRecorder: NSObject {
return sampleBuffer return sampleBuffer
} }
private func stopCaptureLoop() { // MARK: - URL
// requestMediaDataWhenReady isReadyForMoreMediaData=false private func startAudioCapture(sourceURL: URL, startTime: CMTime, audioWriterInput: AVAssetWriterInput) {
let asset = AVURLAsset(url: sourceURL)
Task {
do {
let audioTracks = try await asset.loadTracks(withMediaType: .audio)
guard let audioTrack = audioTracks.first else {
print("[Recorder] No audio track in source")
return
}
let reader = try AVAssetReader(asset: asset)
let output = AVAssetReaderTrackOutput(track: audioTrack, outputSettings: [
AVFormatIDKey: kAudioFormatLinearPCM,
AVSampleRateKey: 44100,
AVNumberOfChannelsKey: 2,
AVLinearPCMBitDepthKey: 16,
AVLinearPCMIsFloatKey: false,
AVLinearPCMIsBigEndianKey: false,
AVLinearPCMIsNonInterleaved: false
])
output.alwaysCopiesSampleData = false
//
reader.timeRange = CMTimeRange(start: startTime, duration: .positiveInfinity)
if reader.canAdd(output) {
reader.add(output)
reader.startReading()
self.audioReader = reader
self.audioReaderOutput = output
// 线
let queue = DispatchQueue(label: "recorder.audio.capture")
self.audioCaptureQueue = queue
queue.async { [weak self] in
self?.readAudioLoop(reader: reader, output: output)
}
}
} catch {
print("[Recorder] Audio capture setup failed: \(error)")
}
}
}
private nonisolated func readAudioLoop(reader: AVAssetReader, output: AVAssetReaderTrackOutput) {
while reader.status == .reading {
guard let sampleBuffer = output.copyNextSampleBuffer() else {
break
}
// MainActor
Task { @MainActor [weak self] in
guard let self = self, self.isRunning else { return }
guard let firstTime = self.firstFrameTime else { return }
guard let input = self.audioInput, input.isReadyForMoreMediaData else { return }
let pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
let relativeTime = CMTimeSubtract(pts, firstTime)
guard relativeTime.seconds >= 0 else { return }
// sample buffer with new time
var count: CMItemCount = 0
CMSampleBufferGetSampleTimingInfoArray(sampleBuffer, entryCount: 0, arrayToFill: nil, entriesNeededOut: &count)
var timingInfo = [CMSampleTimingInfo](repeating: CMSampleTimingInfo(), count: count)
CMSampleBufferGetSampleTimingInfoArray(sampleBuffer, entryCount: count, arrayToFill: &timingInfo, entriesNeededOut: nil)
for i in 0..<count {
timingInfo[i].decodeTimeStamp = .invalid
timingInfo[i].presentationTimeStamp = CMTimeSubtract(timingInfo[i].presentationTimeStamp, firstTime)
}
var newBuffer: CMSampleBuffer?
CMSampleBufferCreateCopyWithNewTiming(allocator: kCFAllocatorDefault, sampleBuffer: sampleBuffer, sampleTimingEntryCount: count, sampleTimingArray: &timingInfo, sampleBufferOut: &newBuffer)
if let nb = newBuffer {
_ = input.append(nb)
}
}
// MainActor
Thread.sleep(forTimeInterval: 0.005)
}
}
private func stopAudioCapture() {
audioReader?.cancelReading()
audioReader = nil
audioReaderOutput = nil
} }
// MARK: - Timer // MARK: - Timer