|
|
@@ -77,14 +77,11 @@ final class MeetingRecordingManager: NSObject {
|
|
|
private var recordingSourceURLString: String?
|
|
|
private var pendingMeetingDisplayName: String?
|
|
|
|
|
|
- // Mic capture
|
|
|
- private let micEngine = AVAudioEngine()
|
|
|
- private var micFile: AVAudioFile?
|
|
|
+ /// Microphone capture (same approach as meetings_app: `AVAudioRecorder` → AAC `.m4a`).
|
|
|
+ private var micRecorder: AVAudioRecorder?
|
|
|
|
|
|
- // System audio capture (ScreenCaptureKit)
|
|
|
- // Stored as `AnyObject` so we can compile with lower deployment targets.
|
|
|
- private var scStream: AnyObject?
|
|
|
- private var scAudioOutput: AnyObject?
|
|
|
+ /// System audio capture (ScreenCaptureKit + `AVAssetWriter` AAC, aligned with meetings_app).
|
|
|
+ private var systemAudioRecorder: AnyObject?
|
|
|
|
|
|
// Output
|
|
|
private var outputAudioURL: URL?
|
|
|
@@ -139,8 +136,8 @@ final class MeetingRecordingManager: NSObject {
|
|
|
let startedAt = Date()
|
|
|
let dir = try recordingsDirectoryURL()
|
|
|
|
|
|
- let micURL = dir.appendingPathComponent("raw_mic_\(id).caf")
|
|
|
- let sysURL = dir.appendingPathComponent("raw_sys_\(id).caf")
|
|
|
+ let micURL = dir.appendingPathComponent("\(id)-mic.m4a")
|
|
|
+ let sysURL = dir.appendingPathComponent("\(id)-system.m4a")
|
|
|
let displayTitle = Self.resolvedMeetingTitle(explicit: meetingDisplayName, sourceURL: sourceURL)
|
|
|
let outBase = Self.uniqueSanitizedFileURL(directory: dir, baseName: displayTitle, idSuffix: id, extension: "m4a")
|
|
|
|
|
|
@@ -151,21 +148,24 @@ final class MeetingRecordingManager: NSObject {
|
|
|
outputAudioURL = outBase
|
|
|
|
|
|
do {
|
|
|
- try startMicCapture(to: micURL)
|
|
|
+ try startMicRecorder(to: micURL)
|
|
|
if #available(macOS 13.0, *) {
|
|
|
+ let recorder = ZoomMeetingSystemAudioRecorder(outputURL: sysURL)
|
|
|
+ systemAudioRecorder = recorder
|
|
|
do {
|
|
|
- try await startSystemAudioCapture(to: sysURL)
|
|
|
+ try await recorder.start()
|
|
|
} catch {
|
|
|
- scStream = nil
|
|
|
- scAudioOutput = nil
|
|
|
+ systemAudioRecorder = nil
|
|
|
}
|
|
|
} else {
|
|
|
- scStream = nil
|
|
|
- scAudioOutput = nil
|
|
|
+ systemAudioRecorder = nil
|
|
|
}
|
|
|
} catch {
|
|
|
- stopMicCapture()
|
|
|
- await stopSystemAudioCapture()
|
|
|
+ stopMicRecorder()
|
|
|
+ if #available(macOS 13.0, *), let r = systemAudioRecorder as? ZoomMeetingSystemAudioRecorder {
|
|
|
+ try? await r.stop()
|
|
|
+ }
|
|
|
+ systemAudioRecorder = nil
|
|
|
recordingID = nil
|
|
|
recordingStartedAt = nil
|
|
|
recordingSourceURLString = nil
|
|
|
@@ -186,16 +186,18 @@ final class MeetingRecordingManager: NSObject {
|
|
|
|
|
|
let endedAt = Date()
|
|
|
let dir = try recordingsDirectoryURL()
|
|
|
- let micURL = dir.appendingPathComponent("raw_mic_\(id).caf")
|
|
|
- let sysURL = dir.appendingPathComponent("raw_sys_\(id).caf")
|
|
|
+ let micURL = dir.appendingPathComponent("\(id)-mic.m4a")
|
|
|
+ let sysURL = dir.appendingPathComponent("\(id)-system.m4a")
|
|
|
|
|
|
- stopMicCapture()
|
|
|
- await stopSystemAudioCapture()
|
|
|
+ stopMicRecorder()
|
|
|
+ if #available(macOS 13.0, *), let r = systemAudioRecorder as? ZoomMeetingSystemAudioRecorder {
|
|
|
+ try? await r.stop()
|
|
|
+ }
|
|
|
+ systemAudioRecorder = nil
|
|
|
|
|
|
- // Export: prefer system (browser / app output) when present; otherwise mic-only (see mixAndExport).
|
|
|
- let mixedURL = try await mixAndExport(micURL: micURL, systemURL: sysURL, outputURL: outURL)
|
|
|
+ let mixedURL = try await finalizeMeetingAudio(systemURL: sysURL, microphoneURL: micURL, outputURL: outURL)
|
|
|
|
|
|
- // Cleanup raw files (ignore failures).
|
|
|
+ // Cleanup intermediates (ignore failures).
|
|
|
try? FileManager.default.removeItem(at: micURL)
|
|
|
try? FileManager.default.removeItem(at: sysURL)
|
|
|
|
|
|
@@ -249,144 +251,131 @@ final class MeetingRecordingManager: NSObject {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- // MARK: - Mic capture
|
|
|
-
|
|
|
- private func startMicCapture(to url: URL) throws {
|
|
|
- let input = micEngine.inputNode
|
|
|
- let format = input.outputFormat(forBus: 0)
|
|
|
- micFile = try AVAudioFile(forWriting: url, settings: format.settings)
|
|
|
-
|
|
|
- input.removeTap(onBus: 0)
|
|
|
- input.installTap(onBus: 0, bufferSize: 4096, format: format) { [weak self] buffer, _ in
|
|
|
- guard let self, let file = self.micFile else { return }
|
|
|
- do { try file.write(from: buffer) } catch { /* ignore */ }
|
|
|
- }
|
|
|
-
|
|
|
- micEngine.prepare()
|
|
|
- do {
|
|
|
- try micEngine.start()
|
|
|
- } catch {
|
|
|
- input.removeTap(onBus: 0)
|
|
|
- micFile = nil
|
|
|
- throw RecordingError.micCaptureFailed(error.localizedDescription)
|
|
|
+ // MARK: - Mic capture (meetings_app-style)
|
|
|
+
|
|
|
+ private func startMicRecorder(to outputURL: URL) throws {
|
|
|
+ try? FileManager.default.removeItem(at: outputURL)
|
|
|
+ let settings: [String: Any] = [
|
|
|
+ AVFormatIDKey: kAudioFormatMPEG4AAC,
|
|
|
+ AVSampleRateKey: 48_000,
|
|
|
+ AVNumberOfChannelsKey: 1,
|
|
|
+ AVEncoderBitRateKey: 128_000,
|
|
|
+ AVEncoderAudioQualityKey: AVAudioQuality.max.rawValue
|
|
|
+ ]
|
|
|
+ let recorder = try AVAudioRecorder(url: outputURL, settings: settings)
|
|
|
+ recorder.prepareToRecord()
|
|
|
+ guard recorder.record() else {
|
|
|
+ throw RecordingError.micCaptureFailed("AVAudioRecorder could not start.")
|
|
|
}
|
|
|
+ micRecorder = recorder
|
|
|
}
|
|
|
|
|
|
- private func stopMicCapture() {
|
|
|
- micEngine.inputNode.removeTap(onBus: 0)
|
|
|
- micEngine.stop()
|
|
|
- micFile = nil
|
|
|
+ private func stopMicRecorder() {
|
|
|
+ micRecorder?.stop()
|
|
|
+ micRecorder = nil
|
|
|
}
|
|
|
|
|
|
- // MARK: - System audio capture (ScreenCaptureKit)
|
|
|
-
|
|
|
- @available(macOS 13.0, *)
|
|
|
- private func startSystemAudioCapture(to url: URL) async throws {
|
|
|
- let content = try await SCShareableContent.current
|
|
|
- guard let display = content.displays.first else { throw RecordingError.noShareableContent }
|
|
|
+ // MARK: - Finalize / mix (meetings_app-style)
|
|
|
|
|
|
- let filter = SCContentFilter(display: display, excludingWindows: [])
|
|
|
- let config = SCStreamConfiguration()
|
|
|
- config.capturesAudio = true
|
|
|
- config.sampleRate = 48_000
|
|
|
- config.channelCount = 2
|
|
|
-
|
|
|
- let stream = SCStream(filter: filter, configuration: config, delegate: nil)
|
|
|
- scStream = stream
|
|
|
-
|
|
|
- let queue = DispatchQueue(label: "meeting.systemaudio.writer.queue")
|
|
|
- let sink = SystemAudioAssetWriterSink(outputURL: url, recordingActive: { [weak self] in
|
|
|
- self?.recordingID != nil
|
|
|
- }, queue: queue)
|
|
|
- scAudioOutput = sink
|
|
|
- try stream.addStreamOutput(sink, type: .audio, sampleHandlerQueue: queue)
|
|
|
- try await stream.startCapture()
|
|
|
+ private func fileSize(at url: URL) -> Int64 {
|
|
|
+ let attrs = try? FileManager.default.attributesOfItem(atPath: url.path)
|
|
|
+ return attrs?[.size] as? Int64 ?? 0
|
|
|
}
|
|
|
|
|
|
- private func stopSystemAudioCapture() async {
|
|
|
- guard #available(macOS 13.0, *) else { return }
|
|
|
- guard let stream = scStream as? SCStream else { return }
|
|
|
- let sink = scAudioOutput as? SystemAudioAssetWriterSink
|
|
|
- if let sink {
|
|
|
- try? stream.removeStreamOutput(sink, type: .audio)
|
|
|
- }
|
|
|
- try? await stream.stopCapture()
|
|
|
- await sink?.finalizeWriting()
|
|
|
- scStream = nil
|
|
|
- scAudioOutput = nil
|
|
|
+ private func hasAudioPayload(at url: URL, minBytes: Int64 = 10_000) -> Bool {
|
|
|
+ guard FileManager.default.fileExists(atPath: url.path) else { return false }
|
|
|
+ return fileSize(at: url) >= minBytes
|
|
|
}
|
|
|
|
|
|
- // MARK: - Mix/export
|
|
|
-
|
|
|
- private func mixAndExport(micURL: URL, systemURL: URL, outputURL: URL) async throws -> URL {
|
|
|
+ private func finalizeMeetingAudio(systemURL: URL, microphoneURL: URL, outputURL: URL) async throws -> URL {
|
|
|
let fm = FileManager.default
|
|
|
if fm.fileExists(atPath: outputURL.path) {
|
|
|
try? fm.removeItem(at: outputURL)
|
|
|
}
|
|
|
|
|
|
- let micAsset = AVURLAsset(url: micURL)
|
|
|
- let sysExists = fm.fileExists(atPath: systemURL.path)
|
|
|
- let sysAsset = sysExists ? AVURLAsset(url: systemURL) : nil
|
|
|
-
|
|
|
- // System capture carries the Zoom / browser meeting mix. Mixing it with the mic at full
|
|
|
- // level duplicates speaker output (very harsh noise). Prefer system audio whenever it
|
|
|
- // actually contains a track; fall back to mic-only if system capture failed or is empty.
|
|
|
- if let sysAsset,
|
|
|
- let sysSrc = sysAsset.tracks(withMediaType: .audio).first {
|
|
|
- let duration = sysAsset.duration
|
|
|
- let sysSeconds = (duration.isValid && !duration.isIndefinite) ? duration.seconds : 0
|
|
|
- if sysSeconds >= 0.25 {
|
|
|
- let sysOnly = AVMutableComposition()
|
|
|
- let t = sysOnly.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid)
|
|
|
- if duration.seconds > 0 {
|
|
|
- do {
|
|
|
- try t?.insertTimeRange(CMTimeRange(start: .zero, duration: duration), of: sysSrc, at: .zero)
|
|
|
- } catch {
|
|
|
- // Fall through to mic paths.
|
|
|
- }
|
|
|
- }
|
|
|
- if sysOnly.tracks(withMediaType: .audio).isEmpty == false {
|
|
|
- return try await exportAssetToM4A(asset: sysOnly, outputURL: outputURL)
|
|
|
- }
|
|
|
+ let hasSystem = hasAudioPayload(at: systemURL)
|
|
|
+ let hasMic = hasAudioPayload(at: microphoneURL)
|
|
|
+
|
|
|
+ if hasSystem && hasMic {
|
|
|
+ do {
|
|
|
+ try await mixAudioFiles(systemURL: systemURL, microphoneURL: microphoneURL, destinationURL: outputURL)
|
|
|
+ return outputURL
|
|
|
+ } catch {
|
|
|
+ // Fall through to single-track handling.
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- // Mic-only composition when system audio is missing or unusable.
|
|
|
- let composition = AVMutableComposition()
|
|
|
- let micTrack = composition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid)
|
|
|
- if let src = micAsset.tracks(withMediaType: .audio).first {
|
|
|
- let duration = micAsset.duration
|
|
|
- if duration.isValid && !duration.isIndefinite && duration.seconds > 0 {
|
|
|
- try micTrack?.insertTimeRange(CMTimeRange(start: .zero, duration: duration), of: src, at: .zero)
|
|
|
- }
|
|
|
+ let chosenURL: URL?
|
|
|
+ if hasSystem {
|
|
|
+ chosenURL = systemURL
|
|
|
+ } else if hasMic {
|
|
|
+ chosenURL = microphoneURL
|
|
|
+ } else {
|
|
|
+ chosenURL = nil
|
|
|
}
|
|
|
|
|
|
- if composition.tracks(withMediaType: .audio).isEmpty == false {
|
|
|
- return try await exportAssetToM4A(asset: composition, outputURL: outputURL)
|
|
|
+ guard let chosenURL else {
|
|
|
+ throw RecordingError.exportFailed
|
|
|
}
|
|
|
|
|
|
- return try await exportAssetToM4A(asset: micAsset, outputURL: outputURL)
|
|
|
+ if chosenURL.path != outputURL.path {
|
|
|
+ try? fm.removeItem(at: outputURL)
|
|
|
+ do {
|
|
|
+ try fm.copyItem(at: chosenURL, to: outputURL)
|
|
|
+ } catch {
|
|
|
+ return chosenURL
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return outputURL
|
|
|
}
|
|
|
|
|
|
- private func exportAssetToM4A(asset: AVAsset, outputURL: URL) async throws -> URL {
|
|
|
- let fm = FileManager.default
|
|
|
- if fm.fileExists(atPath: outputURL.path) {
|
|
|
- try? fm.removeItem(at: outputURL)
|
|
|
+ private func mixAudioFiles(systemURL: URL, microphoneURL: URL, destinationURL: URL) async throws {
|
|
|
+ try? FileManager.default.removeItem(at: destinationURL)
|
|
|
+
|
|
|
+ let composition = AVMutableComposition()
|
|
|
+ guard let systemTrack = composition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid),
|
|
|
+ let micTrack = composition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid) else {
|
|
|
+ throw RecordingError.exportFailed
|
|
|
}
|
|
|
- guard let exporter = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetAppleM4A) else {
|
|
|
+
|
|
|
+ let systemAsset = AVURLAsset(url: systemURL)
|
|
|
+ let micAsset = AVURLAsset(url: microphoneURL)
|
|
|
+
|
|
|
+ if let src = systemAsset.tracks(withMediaType: .audio).first {
|
|
|
+ let duration = systemAsset.duration
|
|
|
+ if duration.isValid && !duration.isIndefinite && duration.seconds > 0 {
|
|
|
+ try systemTrack.insertTimeRange(CMTimeRange(start: .zero, duration: duration), of: src, at: .zero)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if let src = micAsset.tracks(withMediaType: .audio).first {
|
|
|
+ let duration = micAsset.duration
|
|
|
+ if duration.isValid && !duration.isIndefinite && duration.seconds > 0 {
|
|
|
+ try micTrack.insertTimeRange(CMTimeRange(start: .zero, duration: duration), of: src, at: .zero)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ guard let export = AVAssetExportSession(asset: composition, presetName: AVAssetExportPresetAppleM4A) else {
|
|
|
throw RecordingError.exportFailed
|
|
|
}
|
|
|
- exporter.outputURL = outputURL
|
|
|
- exporter.outputFileType = .m4a
|
|
|
- return try await withCheckedThrowingContinuation { cont in
|
|
|
- exporter.exportAsynchronously {
|
|
|
- if exporter.status == .completed {
|
|
|
- cont.resume(returning: outputURL)
|
|
|
- } else {
|
|
|
- cont.resume(throwing: exporter.error ?? RecordingError.exportFailed)
|
|
|
- }
|
|
|
+
|
|
|
+ let systemMix = AVMutableAudioMixInputParameters(track: systemTrack)
|
|
|
+ systemMix.setVolume(1.0, at: .zero)
|
|
|
+ let micMix = AVMutableAudioMixInputParameters(track: micTrack)
|
|
|
+ micMix.setVolume(1.35, at: .zero)
|
|
|
+ let audioMix = AVMutableAudioMix()
|
|
|
+ audioMix.inputParameters = [systemMix, micMix]
|
|
|
+
|
|
|
+ export.outputURL = destinationURL
|
|
|
+ export.outputFileType = .m4a
|
|
|
+ export.audioMix = audioMix
|
|
|
+ await withCheckedContinuation { continuation in
|
|
|
+ export.exportAsynchronously {
|
|
|
+ continuation.resume()
|
|
|
}
|
|
|
}
|
|
|
+ if export.status != .completed {
|
|
|
+ throw export.error ?? RecordingError.exportFailed
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
// MARK: - Meeting title + filenames
|
|
|
@@ -452,76 +441,99 @@ final class MeetingRecordingManager: NSObject {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+// MARK: - System audio (meetings_app-style)
|
|
|
+
|
|
|
@available(macOS 13.0, *)
|
|
|
-private final class SystemAudioAssetWriterSink: NSObject, SCStreamOutput {
|
|
|
+private final class ZoomMeetingSystemAudioRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
|
|
|
private let outputURL: URL
|
|
|
- private let recordingActive: () -> Bool
|
|
|
- private let queue: DispatchQueue
|
|
|
+ private var stream: SCStream?
|
|
|
private var writer: AVAssetWriter?
|
|
|
- private var input: AVAssetWriterInput?
|
|
|
- private var sessionStarted = false
|
|
|
+ private var writerInput: AVAssetWriterInput?
|
|
|
+ private let outputQueue = DispatchQueue(label: "zoom_app.system.audio.capture")
|
|
|
+ private var didStartSession = false
|
|
|
|
|
|
- init(outputURL: URL, recordingActive: @escaping () -> Bool, queue: DispatchQueue) {
|
|
|
+ init(outputURL: URL) {
|
|
|
self.outputURL = outputURL
|
|
|
- self.recordingActive = recordingActive
|
|
|
- self.queue = queue
|
|
|
}
|
|
|
|
|
|
- func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) {
|
|
|
- guard type == .audio else { return }
|
|
|
- guard recordingActive() else { return }
|
|
|
- guard CMSampleBufferDataIsReady(sampleBuffer) else { return }
|
|
|
+ func start() async throws {
|
|
|
+ try? FileManager.default.removeItem(at: outputURL)
|
|
|
+ let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true)
|
|
|
+ guard let display = content.displays.first else {
|
|
|
+ throw MeetingRecordingManager.RecordingError.noShareableContent
|
|
|
+ }
|
|
|
|
|
|
- if writer == nil {
|
|
|
- guard let formatDesc = CMSampleBufferGetFormatDescription(sampleBuffer) else { return }
|
|
|
- do {
|
|
|
- try? FileManager.default.removeItem(at: outputURL)
|
|
|
- let writer = try AVAssetWriter(outputURL: outputURL, fileType: .caf)
|
|
|
- let input = AVAssetWriterInput(mediaType: .audio, outputSettings: nil, sourceFormatHint: formatDesc)
|
|
|
- input.expectsMediaDataInRealTime = true
|
|
|
- guard writer.canAdd(input) else { return }
|
|
|
- writer.add(input)
|
|
|
- self.writer = writer
|
|
|
- self.input = input
|
|
|
- } catch {
|
|
|
- return
|
|
|
- }
|
|
|
+ let writer = try AVAssetWriter(outputURL: outputURL, fileType: .m4a)
|
|
|
+ let inputSettings: [String: Any] = [
|
|
|
+ AVFormatIDKey: kAudioFormatMPEG4AAC,
|
|
|
+ AVSampleRateKey: 48_000,
|
|
|
+ AVNumberOfChannelsKey: 2,
|
|
|
+ AVEncoderBitRateKey: 128_000
|
|
|
+ ]
|
|
|
+ let input = AVAssetWriterInput(mediaType: .audio, outputSettings: inputSettings)
|
|
|
+ input.expectsMediaDataInRealTime = true
|
|
|
+ guard writer.canAdd(input) else {
|
|
|
+ throw MeetingRecordingManager.RecordingError.systemAudioUnavailable("Cannot add audio writer input.")
|
|
|
}
|
|
|
+ writer.add(input)
|
|
|
+ self.writer = writer
|
|
|
+ self.writerInput = input
|
|
|
+ didStartSession = false
|
|
|
+
|
|
|
+ let filter = SCContentFilter(display: display, excludingApplications: [], exceptingWindows: [])
|
|
|
+ let config = SCStreamConfiguration()
|
|
|
+ config.width = 2
|
|
|
+ config.height = 2
|
|
|
+ config.minimumFrameInterval = CMTime(value: 1, timescale: 2)
|
|
|
+ config.queueDepth = 1
|
|
|
+ config.capturesAudio = true
|
|
|
+ config.sampleRate = 48_000
|
|
|
+ config.channelCount = 2
|
|
|
+ config.excludesCurrentProcessAudio = true
|
|
|
|
|
|
- guard let writer = self.writer, let input = self.input else { return }
|
|
|
+ let stream = SCStream(filter: filter, configuration: config, delegate: self)
|
|
|
+ self.stream = stream
|
|
|
+ try stream.addStreamOutput(self, type: .audio, sampleHandlerQueue: outputQueue)
|
|
|
+ try await stream.startCapture()
|
|
|
+ }
|
|
|
|
|
|
- if writer.status == .unknown {
|
|
|
- writer.startWriting()
|
|
|
+ func stop() async throws {
|
|
|
+ if let stream {
|
|
|
+ try? await stream.stopCapture()
|
|
|
+ self.stream = nil
|
|
|
}
|
|
|
- guard writer.status == .writing else { return }
|
|
|
-
|
|
|
- if sessionStarted == false {
|
|
|
- let t = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
|
|
|
- writer.startSession(atSourceTime: t)
|
|
|
- sessionStarted = true
|
|
|
+ writerInput?.markAsFinished()
|
|
|
+ guard let writer else { return }
|
|
|
+ await withCheckedContinuation { continuation in
|
|
|
+ writer.finishWriting {
|
|
|
+ continuation.resume()
|
|
|
+ }
|
|
|
}
|
|
|
+ self.writer = nil
|
|
|
+ self.writerInput = nil
|
|
|
+ }
|
|
|
|
|
|
- guard input.isReadyForMoreMediaData else { return }
|
|
|
- input.append(sampleBuffer)
|
|
|
+ func stream(_ stream: SCStream, didStopWithError error: Error) {
|
|
|
+ // Capture may stop on permission changes or display changes; caller handles lifecycle.
|
|
|
}
|
|
|
|
|
|
- func finalizeWriting() async {
|
|
|
- await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
|
|
|
- queue.async { [weak self] in
|
|
|
- guard let self else {
|
|
|
- cont.resume()
|
|
|
- return
|
|
|
- }
|
|
|
- self.input?.markAsFinished()
|
|
|
- guard let writer = self.writer else {
|
|
|
- cont.resume()
|
|
|
- return
|
|
|
- }
|
|
|
- writer.finishWriting {
|
|
|
- cont.resume()
|
|
|
- }
|
|
|
+ func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of outputType: SCStreamOutputType) {
|
|
|
+ guard outputType == .audio else { return }
|
|
|
+ guard CMSampleBufferDataIsReady(sampleBuffer) else { return }
|
|
|
+ guard let writer = writer, let input = writerInput else { return }
|
|
|
+
|
|
|
+ if writer.status == .unknown {
|
|
|
+ writer.startWriting()
|
|
|
+ }
|
|
|
+ if writer.status == .writing {
|
|
|
+ let pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
|
|
|
+ if !didStartSession {
|
|
|
+ writer.startSession(atSourceTime: pts)
|
|
|
+ didStartSession = true
|
|
|
+ }
|
|
|
+ if input.isReadyForMoreMediaData {
|
|
|
+ input.append(sampleBuffer)
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
-
|