소스 검색

Align meeting recording with meetings_app audio pipeline.

Use AVAudioRecorder for AAC microphone capture, ScreenCaptureKit with
AVAssetWriter AAC for system audio (excluding current process), and
mix system plus microphone with level boost instead of preferring
system-only export. Intermediate files use the same m4a naming pattern
before producing the final saved recording.

Co-authored-by: Cursor <cursoragent@cursor.com>
huzaifahayat12 2 달 전
부모
커밋
5ce73759f1
1개의 변경된 파일198개의 추가작업 그리고 186개의 파일을 삭제
  1. 198 186
      zoom_app/MeetingRecordingManager.swift

+ 198 - 186
zoom_app/MeetingRecordingManager.swift

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