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")
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
}

View File

@ -2,7 +2,7 @@ import AVFoundation
#if os(macOS)
import AppKit
/// AVPlayerItemVideoOutput AVAssetWriter
/// AVPlayerItemVideoOutput + URL mp4
@MainActor
final class PlayerRecorder: NSObject {
@ -11,8 +11,12 @@ final class PlayerRecorder: NSObject {
private var audioInput: AVAssetWriterInput?
private var timer: Timer?
private var startDate: Date?
private var displayLink: CVDisplayLink?
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 durationText = "00:00"
@ -29,13 +33,15 @@ final class PlayerRecorder: NSObject {
return dir
}
/// videoOutput
func startRecording(from output: AVPlayerItemVideoOutput) {
///
func startRecording(from output: AVPlayerItemVideoOutput, sourceURL: URL, startTime: CMTime) {
guard !isRecording else { return }
weakOutput = output
firstFrameTime = nil
isRunning = false
//
//
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd_HH-mm-ss"
let filename = "Recording_\(formatter.string(from: Date())).mp4"
@ -49,6 +55,8 @@ final class PlayerRecorder: NSObject {
return
}
guard let writer = writer else { return }
//
let videoSettings: [String: Any] = [
AVVideoCodecKey: AVVideoCodecType.h264,
@ -57,25 +65,43 @@ final class PlayerRecorder: NSObject {
]
let vInput = AVAssetWriterInput(mediaType: .video, outputSettings: videoSettings)
vInput.expectsMediaDataInRealTime = true
if writer?.canAdd(vInput) == true {
writer?.add(vInput)
if writer.canAdd(vInput) {
writer.add(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
startTimer()
startCaptureLoop()
// URL
startAudioCapture(sourceURL: sourceURL, startTime: startTime, audioWriterInput: aInput)
}
///
func stopRecording() {
guard isRecording else { return }
isRecording = false
isRunning = false
stopTimer()
stopCaptureLoop()
stopAudioCapture()
videoInput?.markAsFinished()
audioInput?.markAsFinished()
@ -90,30 +116,51 @@ final class PlayerRecorder: NSObject {
}
}
// MARK: -
// MARK: -
private func startCaptureLoop() {
let queue = DispatchQueue(label: "recorder.capture")
videoInput?.requestMediaDataWhenReady(on: queue) { [weak self] in
self?.captureFrame()
}
}
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)
guard let vInput = videoInput else { return }
let queue = DispatchQueue(label: "recorder.video.capture")
vInput.requestMediaDataWhenReady(on: queue) { [weak self] in
guard let self = self, self.isRunning else { return }
Task { @MainActor in
self.captureVideoFrame()
}
}
}
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()
info.presentationTimeStamp = time
info.duration = CMTime(value: 1, timescale: 30)
info.decodeTimeStamp = .invalid
var formatDescription: CMFormatDescription?
CMVideoFormatDescriptionCreateForImageBuffer(allocator: kCFAllocatorDefault, imageBuffer: pixelBuffer, formatDescriptionOut: &formatDescription)
@ -125,8 +172,97 @@ final class PlayerRecorder: NSObject {
return sampleBuffer
}
private func stopCaptureLoop() {
// requestMediaDataWhenReady isReadyForMoreMediaData=false
// MARK: - URL
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