Przeglądaj źródła

Name recordings after meetings and fix noisy mixed audio

- Add optional meeting display name to RecordedMeeting; sanitize unique
  .m4a filenames from the resolved title plus a short id suffix.
- Join panel: optional Meeting name field; derive titles from Zoom URLs
  when blank (meeting id, instant meeting); menu new meeting uses
  Instant meeting.
- Replace ScreenCaptureKit PCM guessing with AVAssetWriter pass-through
  to CAF to preserve the encoder sample layout.
- Export system audio only when the system track is present (avoids
  full-level mic + speaker duplication); fall back to mic-only if not.

Co-authored-by: Cursor <cursoragent@cursor.com>
huzaifahayat12 2 miesięcy temu
rodzic
commit
ec464734a6
2 zmienionych plików z 218 dodań i 83 usunięć
  1. 198 75
      zoom_app/MeetingRecordingManager.swift
  2. 20 8
      zoom_app/ViewController.swift

+ 198 - 75
zoom_app/MeetingRecordingManager.swift

@@ -12,9 +12,16 @@ final class MeetingRecordingManager: NSObject {
         let startedAt: Date
         let endedAt: Date
         let sourceURLString: String?
+        /// Human-readable title for UI and for the saved file base name (older index entries omit this key).
+        let meetingDisplayName: String?
         let audioFilename: String
 
         var audioFilenameURLSafe: String { audioFilename }
+
+        var resolvedDisplayTitle: String {
+            let raw = meetingDisplayName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+            return raw.isEmpty ? "Meeting recording" : raw
+        }
     }
 
     enum RecordingError: Error, LocalizedError {
@@ -68,6 +75,7 @@ final class MeetingRecordingManager: NSObject {
     private var recordingID: String?
     private var recordingStartedAt: Date?
     private var recordingSourceURLString: String?
+    private var pendingMeetingDisplayName: String?
 
     // Mic capture
     private let micEngine = AVAudioEngine()
@@ -77,8 +85,6 @@ final class MeetingRecordingManager: NSObject {
     // Stored as `AnyObject` so we can compile with lower deployment targets.
     private var scStream: AnyObject?
     private var scAudioOutput: AnyObject?
-    fileprivate var systemAudioFile: AVAudioFile?
-    fileprivate var systemAudioFormat: AVAudioFormat?
 
     // Output
     private var outputAudioURL: URL?
@@ -123,7 +129,7 @@ final class MeetingRecordingManager: NSObject {
         NSWorkspace.shared.open(url)
     }
 
-    func startRecording(sourceURL: URL?) async throws {
+    func startRecording(sourceURL: URL?, meetingDisplayName: String? = nil) async throws {
         if isRecording { throw RecordingError.alreadyRecording }
 
         let micGranted = await AVCaptureDevice.requestAccess(for: .audio)
@@ -135,25 +141,37 @@ final class MeetingRecordingManager: NSObject {
 
         let micURL = dir.appendingPathComponent("raw_mic_\(id).caf")
         let sysURL = dir.appendingPathComponent("raw_sys_\(id).caf")
-        let outURL = dir.appendingPathComponent("meeting_\(id).m4a")
+        let displayTitle = Self.resolvedMeetingTitle(explicit: meetingDisplayName, sourceURL: sourceURL)
+        let outBase = Self.uniqueSanitizedFileURL(directory: dir, baseName: displayTitle, idSuffix: id, extension: "m4a")
 
         recordingID = id
         recordingStartedAt = startedAt
         recordingSourceURLString = sourceURL?.absoluteString
-        outputAudioURL = outURL
+        pendingMeetingDisplayName = displayTitle
+        outputAudioURL = outBase
 
-        try startMicCapture(to: micURL)
-        if #available(macOS 13.0, *) {
-            do {
-                try await startSystemAudioCapture(to: sysURL)
-            } catch {
-                // Best-effort: keep mic recording even if system audio fails.
-                systemAudioFile = nil
-                systemAudioFormat = nil
+        do {
+            try startMicCapture(to: micURL)
+            if #available(macOS 13.0, *) {
+                do {
+                    try await startSystemAudioCapture(to: sysURL)
+                } catch {
+                    scStream = nil
+                    scAudioOutput = nil
+                }
+            } else {
+                scStream = nil
+                scAudioOutput = nil
             }
-        } else {
-            systemAudioFile = nil
-            systemAudioFormat = nil
+        } catch {
+            stopMicCapture()
+            await stopSystemAudioCapture()
+            recordingID = nil
+            recordingStartedAt = nil
+            recordingSourceURLString = nil
+            pendingMeetingDisplayName = nil
+            outputAudioURL = nil
+            throw error
         }
 
         isRecording = true
@@ -174,18 +192,20 @@ final class MeetingRecordingManager: NSObject {
         stopMicCapture()
         await stopSystemAudioCapture()
 
-        // Mix mic + system (if available) into a single m4a.
+        // Export: prefer system (browser / app output) when present; otherwise mic-only (see mixAndExport).
         let mixedURL = try await mixAndExport(micURL: micURL, systemURL: sysURL, outputURL: outURL)
 
         // Cleanup raw files (ignore failures).
         try? FileManager.default.removeItem(at: micURL)
         try? FileManager.default.removeItem(at: sysURL)
 
+        let displayName = pendingMeetingDisplayName
         let rec = RecordedMeeting(
             id: id,
             startedAt: startedAt,
             endedAt: endedAt,
             sourceURLString: recordingSourceURLString,
+            meetingDisplayName: displayName,
             audioFilename: mixedURL.lastPathComponent
         )
         recordings.insert(rec, at: 0)
@@ -194,6 +214,7 @@ final class MeetingRecordingManager: NSObject {
         recordingID = nil
         recordingStartedAt = nil
         recordingSourceURLString = nil
+        pendingMeetingDisplayName = nil
         outputAudioURL = nil
 
         isRecording = false
@@ -273,34 +294,26 @@ final class MeetingRecordingManager: NSObject {
         let stream = SCStream(filter: filter, configuration: config, delegate: nil)
         scStream = stream
 
-        // Prepare file once we see the first audio buffer (format can vary).
-        systemAudioFile = nil
-        systemAudioFormat = nil
-
-        let output = SystemAudioOutput(owner: self)
-        scAudioOutput = output
-        try stream.addStreamOutput(output, type: .audio, sampleHandlerQueue: DispatchQueue(label: "meeting.systemaudio.queue"))
+        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()
-
-        // Pre-create an assumed format for writing (48k stereo float32).
-        let fmt = AVAudioFormat(commonFormat: .pcmFormatFloat32, sampleRate: 48_000, channels: 2, interleaved: false)
-        systemAudioFormat = fmt
-        if let fmt {
-            systemAudioFile = try? AVAudioFile(forWriting: url, settings: fmt.settings)
-        }
     }
 
     private func stopSystemAudioCapture() async {
         guard #available(macOS 13.0, *) else { return }
         guard let stream = scStream as? SCStream else { return }
-        if let output = scAudioOutput as? SystemAudioOutput {
-            try? stream.removeStreamOutput(output, type: .audio)
+        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
-        systemAudioFile = nil
-        systemAudioFormat = nil
     }
 
     // MARK: - Mix/export
@@ -315,6 +328,30 @@ final class MeetingRecordingManager: NSObject {
         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)
+                }
+            }
+        }
+
+        // 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 {
@@ -324,20 +361,8 @@ final class MeetingRecordingManager: NSObject {
             }
         }
 
-        if let sysAsset, let src = sysAsset.tracks(withMediaType: .audio).first {
-            let duration = sysAsset.duration
-            if duration.isValid && !duration.isIndefinite && duration.seconds > 0 {
-                let sysTrack = composition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid)
-                try sysTrack?.insertTimeRange(CMTimeRange(start: .zero, duration: duration), of: src, at: .zero)
-            }
-        }
-
         if composition.tracks(withMediaType: .audio).isEmpty == false {
-            do {
-                return try await exportAssetToM4A(asset: composition, outputURL: outputURL)
-            } catch {
-                // Fall through to mic-only export.
-            }
+            return try await exportAssetToM4A(asset: composition, outputURL: outputURL)
         }
 
         return try await exportAssetToM4A(asset: micAsset, outputURL: outputURL)
@@ -363,42 +388,140 @@ final class MeetingRecordingManager: NSObject {
             }
         }
     }
+
+    // MARK: - Meeting title + filenames
+
+    /// Trimmed explicit name from the join sheet, otherwise a stable label from the Zoom URL (meeting id, instant meeting, etc.).
+    static func resolvedMeetingTitle(explicit: String?, sourceURL: URL?) -> String {
+        if let t = explicit?.trimmingCharacters(in: .whitespacesAndNewlines), t.isEmpty == false {
+            return t
+        }
+        if let url = sourceURL {
+            return derivedDisplayTitle(from: url)
+        }
+        return "Zoom meeting"
+    }
+
+    static func derivedDisplayTitle(from url: URL) -> String {
+        let path = url.path.lowercased()
+        if path.contains("/start/") || path.contains("videomeeting") {
+            return "Instant meeting"
+        }
+        let parts = url.path.split(separator: "/").map(String.init)
+        for (i, segment) in parts.enumerated() {
+            let s = segment.lowercased()
+            if (s == "j" || s == "join" || s == "wc" || s == "my") && i + 1 < parts.count {
+                let next = parts[i + 1].filter(\.isNumber)
+                if next.count >= 9 { return "Meeting \(next)" }
+            }
+        }
+        if let last = parts.last {
+            let digits = last.filter(\.isNumber)
+            if digits.count >= 9 { return "Meeting \(digits)" }
+        }
+        return "Zoom meeting"
+    }
+
+    /// Safe file base: filesystem-safe, bounded length, then made unique in `directory` if needed.
+    static func uniqueSanitizedFileURL(directory: URL, baseName: String, idSuffix: String, extension ext: String) -> URL {
+        let sanitized = sanitizeFileBaseName(baseName)
+        let shortID = String(idSuffix.replacingOccurrences(of: "-", with: "").prefix(8))
+        let fm = FileManager.default
+        for attempt in 0..<50 {
+            let suffix = attempt == 0 ? "" : "_\(attempt)"
+            let name = "\(sanitized)_\(shortID)\(suffix).\(ext)"
+            let url = directory.appendingPathComponent(name)
+            if fm.fileExists(atPath: url.path) == false {
+                return url
+            }
+        }
+        return directory.appendingPathComponent("\(sanitized)_\(shortID)_\(UUID().uuidString.prefix(8)).\(ext)")
+    }
+
+    private static func sanitizeFileBaseName(_ raw: String) -> String {
+        let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
+        let collapsed = trimmed.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
+        let invalid = CharacterSet(charactersIn: "/:\\?%*|\"<>")
+        let cleaned = collapsed.unicodeScalars.map { invalid.contains($0) ? "_" : Character($0) }
+        let joined = String(cleaned).trimmingCharacters(in: CharacterSet(charactersIn: " ._"))
+        let base = joined.isEmpty ? "Zoom_meeting" : joined
+        let maxLen = 72
+        if base.count <= maxLen { return base }
+        let idx = base.index(base.startIndex, offsetBy: maxLen)
+        return String(base[..<idx]).trimmingCharacters(in: CharacterSet(charactersIn: " ._"))
+    }
 }
 
 @available(macOS 13.0, *)
-private final class SystemAudioOutput: NSObject, SCStreamOutput {
-    private weak var owner: MeetingRecordingManager?
-
-    init(owner: MeetingRecordingManager) {
-        self.owner = owner
+private final class SystemAudioAssetWriterSink: NSObject, SCStreamOutput {
+    private let outputURL: URL
+    private let recordingActive: () -> Bool
+    private let queue: DispatchQueue
+    private var writer: AVAssetWriter?
+    private var input: AVAssetWriterInput?
+    private var sessionStarted = false
+
+    init(outputURL: URL, recordingActive: @escaping () -> Bool, queue: DispatchQueue) {
+        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 let owner, owner.isRecording else { return }
-        guard let file = owner.systemAudioFile, let format = owner.systemAudioFormat else { return }
-
-        guard let blockBuffer = CMSampleBufferGetDataBuffer(sampleBuffer) else { return }
-        var length: Int = 0
-        var dataPointer: UnsafeMutablePointer<Int8>?
-        guard CMBlockBufferGetDataPointer(blockBuffer, atOffset: 0, lengthAtOffsetOut: nil, totalLengthOut: &length, dataPointerOut: &dataPointer) == kCMBlockBufferNoErr,
-              let dataPointer else { return }
-
-        let frameCount = AVAudioFrameCount(length) / format.streamDescription.pointee.mBytesPerFrame
-        guard let pcm = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameCount) else { return }
-        pcm.frameLength = frameCount
-
-        if format.isInterleaved { return }
-        if format.commonFormat != AVAudioCommonFormat.pcmFormatFloat32 { return }
-
-        let bytesPerChannel = Int(frameCount) * MemoryLayout<Float>.size
-        for ch in 0..<Int(format.channelCount) {
-            guard let dst = pcm.floatChannelData?[ch] else { continue }
-            let srcOffset = ch * bytesPerChannel
-            memcpy(dst, dataPointer.advanced(by: srcOffset), bytesPerChannel)
+        guard recordingActive() else { return }
+        guard CMSampleBufferDataIsReady(sampleBuffer) else { return }
+
+        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
+            }
+        }
+
+        guard let writer = self.writer, let input = self.input else { return }
+
+        if writer.status == .unknown {
+            writer.startWriting()
+        }
+        guard writer.status == .writing else { return }
+
+        if sessionStarted == false {
+            let t = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
+            writer.startSession(atSourceTime: t)
+            sessionStarted = true
         }
 
-        try? file.write(from: pcm)
+        guard input.isReadyForMoreMediaData else { return }
+        input.append(sampleBuffer)
+    }
+
+    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()
+                }
+            }
+        }
     }
 }
 

+ 20 - 8
zoom_app/ViewController.swift

@@ -400,6 +400,7 @@ class ViewController: NSViewController {
     private weak var joinURLField: NSTextField?
     private weak var joinMeetingIDField: NSTextField?
     private weak var joinPasscodeField: NSTextField?
+    private weak var joinMeetingDisplayNameField: NSTextField?
     private weak var joinURLFieldsContainer: NSView?
     private weak var joinIDFieldsContainer: NSView?
     private weak var joinModeSegment: NSSegmentedControl?
@@ -1429,7 +1430,7 @@ class ViewController: NSViewController {
         controller.view = content
 
         let panel = NSPanel(
-            contentRect: NSRect(x: 0, y: 0, width: 520, height: 430),
+            contentRect: NSRect(x: 0, y: 0, width: 520, height: 500),
             styleMask: [.titled, .closable, .fullSizeContentView],
             backing: .buffered,
             defer: false
@@ -1539,6 +1540,15 @@ class ViewController: NSViewController {
         idStack.isHidden = true
         joinIDFieldsContainer = idStack
 
+        let nameBox = makeJoinFormField(placeholder: "e.g. Weekly standup (optional — used for the saved file)")
+        joinMeetingDisplayNameField = nameBox.textField
+        let nameLabel = makeLabel("Meeting name", size: 12, color: secondaryText, weight: .medium, centered: false)
+        let nameStack = NSStackView(views: [nameLabel, nameBox])
+        nameStack.orientation = .vertical
+        nameStack.spacing = 8
+        nameStack.alignment = .leading
+        nameStack.translatesAutoresizingMaskIntoConstraints = false
+
         let formCard = NSView()
         formCard.translatesAutoresizingMaskIntoConstraints = false
         formCard.wantsLayer = true
@@ -1573,7 +1583,7 @@ class ViewController: NSViewController {
         buttons.alignment = .centerY
         buttons.distribution = .fillEqually
 
-        let innerStack = NSStackView(views: [mode, urlStack, idStack, buttons])
+        let innerStack = NSStackView(views: [mode, urlStack, idStack, nameStack, buttons])
         innerStack.orientation = .vertical
         innerStack.spacing = 18
         innerStack.alignment = .leading
@@ -1606,6 +1616,7 @@ class ViewController: NSViewController {
             urlBox.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
             idBox.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
             passBox.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
+            nameBox.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
             buttons.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
             joinButton.heightAnchor.constraint(equalToConstant: 40),
             cancelButton.heightAnchor.constraint(equalToConstant: 40)
@@ -1643,7 +1654,7 @@ class ViewController: NSViewController {
                 showSimpleAlert(title: "Invalid link", message: "Enter a full Zoom meeting link (for example, https://zoom.us/j/…).")
                 return
             }
-            openZoomMeetingInDefaultBrowser(url)
+            openZoomMeetingInDefaultBrowser(url, meetingDisplayName: joinMeetingDisplayNameField?.stringValue)
         } else {
             let idRaw = joinMeetingIDField?.stringValue ?? ""
             let digits = idRaw.filter(\.isNumber)
@@ -1656,7 +1667,7 @@ class ViewController: NSViewController {
                 showSimpleAlert(title: "Unable to join", message: "Could not build a join link from that meeting ID.")
                 return
             }
-            openZoomMeetingInDefaultBrowser(url)
+            openZoomMeetingInDefaultBrowser(url, meetingDisplayName: joinMeetingDisplayNameField?.stringValue)
         }
     }
 
@@ -1696,14 +1707,14 @@ class ViewController: NSViewController {
         _ = openURLInDefaultBrowser(url)
     }
 
-    private func openZoomMeetingInDefaultBrowser(_ url: URL) {
+    private func openZoomMeetingInDefaultBrowser(_ url: URL, meetingDisplayName: String? = nil) {
         let opened = openURLInDefaultBrowser(url)
         if opened {
             joinMeetingWindow?.performClose(nil)
             // AI Companion mode requirement: automatically record every meeting we start/join.
             Task { @MainActor in
                 do {
-                    try await MeetingRecordingManager.shared.startRecording(sourceURL: url)
+                    try await MeetingRecordingManager.shared.startRecording(sourceURL: url, meetingDisplayName: meetingDisplayName)
                     self.updateAiCompanionRecordingUI()
                 } catch {
                     self.showSimpleAlert(title: "Recording could not start", message: error.localizedDescription)
@@ -1842,6 +1853,7 @@ class ViewController: NSViewController {
         joinURLField = nil
         joinMeetingIDField = nil
         joinPasscodeField = nil
+        joinMeetingDisplayNameField = nil
         joinURLFieldsContainer = nil
         joinIDFieldsContainer = nil
         joinModeSegment = nil
@@ -6817,7 +6829,7 @@ class ViewController: NSViewController {
         card.layer?.borderColor = palette.inputBorder.cgColor
         card.layer?.backgroundColor = (palette.isDarkMode ? NSColor.white.withAlphaComponent(0.04) : NSColor.black.withAlphaComponent(0.03)).cgColor
 
-        let title = makeLabel("Meeting recording", size: 14, color: primaryText, weight: .semibold, centered: false)
+        let title = makeLabel(recording.resolvedDisplayTitle, size: 14, color: primaryText, weight: .semibold, centered: false)
         title.translatesAutoresizingMaskIntoConstraints = false
         title.maximumNumberOfLines = 1
         title.lineBreakMode = .byTruncatingTail
@@ -7750,7 +7762,7 @@ class ViewController: NSViewController {
         openWebURLPreferringInApp(url)
         Task { @MainActor in
             do {
-                try await MeetingRecordingManager.shared.startRecording(sourceURL: url)
+                try await MeetingRecordingManager.shared.startRecording(sourceURL: url, meetingDisplayName: "Instant meeting")
                 self.updateAiCompanionRecordingUI()
             } catch {
                 self.showSimpleAlert(title: "Recording could not start", message: error.localizedDescription)