Explorar o código

Improve embedded Zoom recording with system audio and mic sync.

Capture web client audio via ScreenCaptureKit, defer recording until join, pause the mic track when muted, and harden export, meetings refresh, and notes flow.

Co-authored-by: Cursor <cursoragent@cursor.com>
huzaifahayat12 hai 2 meses
pai
achega
0b28333ff5
Modificáronse 2 ficheiros con 593 adicións e 115 borrados
  1. 288 64
      zoom_app/MeetingRecordingManager.swift
  2. 305 51
      zoom_app/ViewController.swift

+ 288 - 64
zoom_app/MeetingRecordingManager.swift

@@ -1,4 +1,5 @@
 import AppKit
+import CoreGraphics
 import Foundation
 import AVFoundation
 import ScreenCaptureKit
@@ -53,7 +54,7 @@ final class MeetingRecordingManager: NSObject {
             case .systemAudioUnavailable(let reason):
                 return "System audio capture failed (\(reason)). Microphone-only recording may still work."
             case .exportFailed:
-                return "Could not save the recording file. Check free disk space and try again."
+                return "No meeting audio was captured to save. Allow Screen Recording for system audio and Microphone if you plan to speak."
             case .micCaptureFailed(let reason):
                 return "Microphone recording failed: \(reason)"
             }
@@ -88,6 +89,8 @@ final class MeetingRecordingManager: NSObject {
 
     /// Microphone capture (same approach as meetings_app: `AVAudioRecorder` → AAC `.m4a`).
     private var micRecorder: AVAudioRecorder?
+    /// True while `AVAudioRecorder` is actively encoding (unmuted in the meeting).
+    private var micRecordingActive: Bool = false
 
     /// System audio capture (ScreenCaptureKit + `AVAssetWriter` AAC, aligned with meetings_app).
     private var systemAudioRecorder: AnyObject?
@@ -114,6 +117,8 @@ final class MeetingRecordingManager: NSObject {
     /// Set by the embedded `WKWebView` when its URL indicates an active join/in-call web client page.
     private var embeddedWebClientCallUIVisible: Bool = false
     private var stopRecordingInFlight: Bool = false
+    /// Zoom runs inside this app's `WKWebView`; system capture must include process audio.
+    private var captureEmbeddedZoomWebClient: Bool = false
 
     private override init() {
         super.init()
@@ -177,11 +182,15 @@ final class MeetingRecordingManager: NSObject {
         NSWorkspace.shared.open(url)
     }
 
+    /// Call before `startRecording`. When `true`, meeting audio comes from the in-app Zoom web client via ScreenCaptureKit (not the physical mic).
+    func configureCapture(embeddedZoomWebClient: Bool) {
+        captureEmbeddedZoomWebClient = embeddedZoomWebClient
+    }
+
     func startRecording(sourceURL: URL?, meetingDisplayName: String? = nil) async throws {
         if isRecording { throw RecordingError.alreadyRecording }
 
         let micGranted = await AVCaptureDevice.requestAccess(for: .audio)
-        if micGranted == false { throw RecordingError.microphonePermissionDenied }
 
         let id = UUID().uuidString
         let startedAt = Date()
@@ -199,20 +208,28 @@ final class MeetingRecordingManager: NSObject {
         outputAudioURL = outBase
 
         do {
-            try startMicRecorder(to: micURL)
+            if micGranted {
+                try? prepareMicRecorder(to: micURL)
+            }
             if #available(macOS 13.0, *) {
-                let recorder = ZoomMeetingSystemAudioRecorder(outputURL: sysURL)
+                try await ensureScreenCaptureAccess()
+                let target = resolveSystemAudioCaptureTarget()
+                let recorder = ZoomMeetingSystemAudioRecorder(outputURL: sysURL, captureTarget: target)
                 systemAudioRecorder = recorder
                 do {
                     try await recorder.start()
                 } catch {
                     systemAudioRecorder = nil
+                    NSLog("MeetingRecordingManager: system audio start failed — \(error.localizedDescription)")
+                    throw RecordingError.systemAudioUnavailable(error.localizedDescription)
                 }
+            } else if captureEmbeddedZoomWebClient {
+                throw RecordingError.systemAudioUnavailable("System audio capture requires macOS 13 or later.")
             } else {
                 systemAudioRecorder = nil
             }
         } catch {
-            stopMicRecorder()
+            stopMicRecorder(discardFile: true)
             if #available(macOS 13.0, *), let r = systemAudioRecorder as? ZoomMeetingSystemAudioRecorder {
                 try? await r.stop()
             }
@@ -242,45 +259,96 @@ final class MeetingRecordingManager: NSObject {
         }
     }
 
+    /// When `false`, pauses the Mac microphone track; system audio capture keeps running.
+    func setMeetingMicrophoneTransmitting(_ transmitting: Bool) {
+        guard isRecording else { return }
+        if transmitting {
+            resumeMicRecordingIfPrepared()
+        } else {
+            pauseMicRecording()
+        }
+    }
+
     func stopRecording() async throws {
         await MainActor.run {
             self.stopAutomaticStopMonitoringOnMain()
         }
-        guard stopRecordingInFlight == false else { return }
-        guard isRecording else { throw RecordingError.notRecording }
+        while stopRecordingInFlight {
+            try? await Task.sleep(nanoseconds: 100_000_000)
+        }
+        // Already saved (e.g. duplicate auto-stop) — treat as success.
+        guard isRecording else { return }
         stopRecordingInFlight = true
-        defer { stopRecordingInFlight = false }
+
         guard let id = recordingID, let startedAt = recordingStartedAt, let outURL = outputAudioURL else {
+            stopRecordingInFlight = false
+            clearRecordingSessionState()
             throw RecordingError.exportFailed
         }
 
         let endedAt = Date()
+        let displayName = pendingMeetingDisplayName
+        let sourceURLString = recordingSourceURLString
         let dir = try recordingsDirectoryURL()
         let micURL = dir.appendingPathComponent("\(id)-mic.m4a")
         let sysURL = dir.appendingPathComponent("\(id)-system.m4a")
 
+        defer {
+            stopRecordingInFlight = false
+            clearRecordingSessionState()
+        }
+
         stopMicRecorder()
+        var systemSampleCount = 0
         if #available(macOS 13.0, *), let r = systemAudioRecorder as? ZoomMeetingSystemAudioRecorder {
+            systemSampleCount = r.capturedSampleCount
             try? await r.stop()
         }
         systemAudioRecorder = nil
 
-        let mixedURL = try await finalizeMeetingAudio(systemURL: sysURL, microphoneURL: micURL, outputURL: outURL)
+        // Let ScreenCaptureKit / AVAssetWriter finish flushing to disk.
+        try? await Task.sleep(nanoseconds: 1_000_000_000)
+
+        if systemSampleCount == 0 {
+            NSLog("MeetingRecordingManager: warning — system audio capture received 0 samples.")
+        }
+
+        let exportResult = await exportMeetingAudio(
+            systemURL: sysURL,
+            microphoneURL: micURL,
+            outputURL: outURL
+        )
 
         // Cleanup intermediates (ignore failures).
         try? FileManager.default.removeItem(at: micURL)
         try? FileManager.default.removeItem(at: sysURL)
 
-        let displayName = pendingMeetingDisplayName
+        guard let savedURL = exportResult.url else {
+            NSLog(
+                "MeetingRecordingManager: export failed — system %lld bytes, mic %lld bytes, out %lld bytes",
+                fileSize(at: sysURL),
+                fileSize(at: micURL),
+                fileSize(at: outURL)
+            )
+            throw RecordingError.exportFailed
+        }
+
+        var transcriptWarning = exportResult.transcriptWarning
+        let hasSystem = hasUsableAudio(at: sysURL)
+        let hasMic = hasUsableAudio(at: micURL)
+        if hasSystem == false, hasMic {
+            transcriptWarning = "Meeting audio (other participants) was not captured. Turn on Screen Recording for this app in System Settings, then record again."
+        }
+
         let rec = RecordedMeeting(
             id: id,
             startedAt: startedAt,
             endedAt: endedAt,
-            sourceURLString: recordingSourceURLString,
+            sourceURLString: sourceURLString,
             meetingDisplayName: displayName,
-            audioFilename: mixedURL.lastPathComponent,
+            audioFilename: savedURL.lastPathComponent,
             transcriptText: nil,
-            transcriptError: nil,
+            transcriptError: transcriptWarning,
             notesStatusRaw: nil,
             notesText: nil,
             notesError: nil
@@ -288,16 +356,65 @@ final class MeetingRecordingManager: NSObject {
         recordings.insert(rec, at: 0)
         saveRecordingsToDisk()
         scheduleAppleTranscription(for: rec)
+        NotificationCenter.default.post(name: Self.recordingsDidChangeNotification, object: self)
+    }
 
+    private struct MeetingAudioExportResult {
+        let url: URL?
+        let transcriptWarning: String?
+    }
+
+    private func exportMeetingAudio(systemURL: URL, microphoneURL: URL, outputURL: URL) async -> MeetingAudioExportResult {
+        do {
+            let url = try await finalizeMeetingAudio(systemURL: systemURL, microphoneURL: microphoneURL, outputURL: outputURL)
+            return MeetingAudioExportResult(url: url, transcriptWarning: nil)
+        } catch {
+            if let salvaged = salvageMeetingAudio(systemURL: systemURL, microphoneURL: microphoneURL, outputURL: outputURL) {
+                return MeetingAudioExportResult(
+                    url: salvaged,
+                    transcriptWarning: "Audio was recovered from a partial export (\(error.localizedDescription))."
+                )
+            }
+            return MeetingAudioExportResult(url: nil, transcriptWarning: nil)
+        }
+    }
+
+    private func salvageMeetingAudio(systemURL: URL, microphoneURL: URL, outputURL: URL) -> URL? {
+        let fm = FileManager.default
+        let candidates = [systemURL, microphoneURL, outputURL]
+        for source in candidates {
+            guard fm.fileExists(atPath: source.path), fileSize(at: source) >= 128 else { continue }
+            if source.path == outputURL.path {
+                return outputURL
+            }
+            try? fm.removeItem(at: outputURL)
+            do {
+                try fm.copyItem(at: source, to: outputURL)
+                return outputURL
+            } catch {
+                return source
+            }
+        }
+        return nil
+    }
+
+    /// Ends an in-progress recording session and updates UI even when export fails.
+    private func clearRecordingSessionState() {
+        guard isRecording || recordingID != nil else { return }
         recordingID = nil
         recordingStartedAt = nil
         recordingSourceURLString = nil
         pendingMeetingDisplayName = nil
         outputAudioURL = nil
-
+        micRecorder = nil
+        micRecordingActive = false
+        systemAudioRecorder = nil
+        captureEmbeddedZoomWebClient = false
+        let wasRecording = isRecording
         isRecording = false
-        NotificationCenter.default.post(name: Self.recordingStateDidChangeNotification, object: self)
-        NotificationCenter.default.post(name: Self.recordingsDidChangeNotification, object: self)
+        if wasRecording {
+            NotificationCenter.default.post(name: Self.recordingStateDidChangeNotification, object: self)
+        }
     }
 
     @MainActor
@@ -473,9 +590,39 @@ final class MeetingRecordingManager: NSObject {
         }
     }
 
+    @available(macOS 13.0, *)
+    private func resolveSystemAudioCaptureTarget() -> ZoomMeetingSystemAudioRecorder.CaptureTarget {
+        let zoomRunning = NSWorkspace.shared.runningApplications.contains {
+            $0.bundleIdentifier == Self.zoomDesktopBundleID
+        }
+        // Embedded web client: capture the full display audio mix (all participants from Zoom output).
+        if captureEmbeddedZoomWebClient {
+            return .display(excludesCurrentProcessAudio: false)
+        }
+        if zoomRunning {
+            return .zoomDesktop
+        }
+        return .display(excludesCurrentProcessAudio: false)
+    }
+
+    @available(macOS 13.0, *)
+    private func ensureScreenCaptureAccess() async throws {
+        if CGPreflightScreenCaptureAccess() == false {
+            CGRequestScreenCaptureAccess()
+            try? await Task.sleep(nanoseconds: 800_000_000)
+        }
+        do {
+            _ = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: false)
+        } catch {
+            throw RecordingError.systemAudioUnavailable(
+                "Allow Screen Recording for this app in System Settings → Privacy & Security → Screen Recording."
+            )
+        }
+    }
+
     // MARK: - Mic capture (meetings_app-style)
 
-    private func startMicRecorder(to outputURL: URL) throws {
+    private func prepareMicRecorder(to outputURL: URL) throws {
         try? FileManager.default.removeItem(at: outputURL)
         let settings: [String: Any] = [
             AVFormatIDKey: kAudioFormatMPEG4AAC,
@@ -486,15 +633,39 @@ final class MeetingRecordingManager: NSObject {
         ]
         let recorder = try AVAudioRecorder(url: outputURL, settings: settings)
         recorder.prepareToRecord()
+        micRecorder = recorder
+        micRecordingActive = false
+    }
+
+    private func resumeMicRecordingIfPrepared() {
+        guard let recorder = micRecorder else { return }
+        if micRecordingActive { return }
         guard recorder.record() else {
-            throw RecordingError.micCaptureFailed("AVAudioRecorder could not start.")
+            NSLog("MeetingRecordingManager: mic recorder could not resume.")
+            return
         }
-        micRecorder = recorder
+        micRecordingActive = true
+    }
+
+    private func pauseMicRecording() {
+        guard let recorder = micRecorder, micRecordingActive else { return }
+        recorder.pause()
+        micRecordingActive = false
     }
 
-    private func stopMicRecorder() {
-        micRecorder?.stop()
+    private func stopMicRecorder(discardFile: Bool = false) {
+        guard let recorder = micRecorder else { return }
+        let url = recorder.url
+        let bytesBeforeStop = fileSize(at: url)
+        let shouldFinalize = micRecordingActive || recorder.isRecording || bytesBeforeStop > 256
+        if shouldFinalize {
+            recorder.stop()
+        }
+        if discardFile || fileSize(at: url) < 256 {
+            try? FileManager.default.removeItem(at: url)
+        }
         micRecorder = nil
+        micRecordingActive = false
     }
 
     // MARK: - Finalize / mix (meetings_app-style)
@@ -504,9 +675,16 @@ final class MeetingRecordingManager: NSObject {
         return attrs?[.size] as? Int64 ?? 0
     }
 
-    private func hasAudioPayload(at url: URL, minBytes: Int64 = 10_000) -> Bool {
+    private func hasUsableAudio(at url: URL) -> Bool {
         guard FileManager.default.fileExists(atPath: url.path) else { return false }
-        return fileSize(at: url) >= minBytes
+        let size = fileSize(at: url)
+        if size < 128 { return false }
+        // Freshly finalized AAC may not report duration immediately; trust file size.
+        if size >= 1_024 { return true }
+        let asset = AVURLAsset(url: url)
+        let seconds = CMTimeGetSeconds(asset.duration)
+        if seconds.isFinite && seconds > 0.05 { return true }
+        return size >= 512
     }
 
     private func finalizeMeetingAudio(systemURL: URL, microphoneURL: URL, outputURL: URL) async throws -> URL {
@@ -515,8 +693,8 @@ final class MeetingRecordingManager: NSObject {
             try? fm.removeItem(at: outputURL)
         }
 
-        let hasSystem = hasAudioPayload(at: systemURL)
-        let hasMic = hasAudioPayload(at: microphoneURL)
+        let hasSystem = hasUsableAudio(at: systemURL)
+        let hasMic = hasUsableAudio(at: microphoneURL)
 
         if hasSystem && hasMic {
             do {
@@ -583,7 +761,7 @@ final class MeetingRecordingManager: NSObject {
         let systemMix = AVMutableAudioMixInputParameters(track: systemTrack)
         systemMix.setVolume(1.0, at: .zero)
         let micMix = AVMutableAudioMixInputParameters(track: micTrack)
-        micMix.setVolume(1.35, at: .zero)
+        micMix.setVolume(0.9, at: .zero)
         let audioMix = AVMutableAudioMix()
         audioMix.inputParameters = [systemMix, micMix]
 
@@ -663,55 +841,60 @@ final class MeetingRecordingManager: NSObject {
     }
 }
 
-// MARK: - System audio (meetings_app-style)
+// MARK: - System audio (ScreenCaptureKit)
 
 @available(macOS 13.0, *)
 private final class ZoomMeetingSystemAudioRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
+    enum CaptureTarget {
+        case hostApplication
+        case zoomDesktop
+        case display(excludesCurrentProcessAudio: Bool)
+    }
+
+    private static let zoomDesktopBundleID = "us.zoom.xos"
+
     private let outputURL: URL
+    private let captureTarget: CaptureTarget
     private var stream: SCStream?
     private var writer: AVAssetWriter?
     private var writerInput: AVAssetWriterInput?
     private let outputQueue = DispatchQueue(label: "zoom_app.system.audio.capture")
     private var didStartSession = false
+    private var appendedSampleCount: Int = 0
 
-    init(outputURL: URL) {
+    var capturedSampleCount: Int { appendedSampleCount }
+
+    init(outputURL: URL, captureTarget: CaptureTarget) {
         self.outputURL = outputURL
+        self.captureTarget = captureTarget
     }
 
     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
-        }
-
+        let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: false)
+        let filter = try makeContentFilter(from: content)
         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
+        writerInput = nil
         didStartSession = false
+        appendedSampleCount = 0
 
-        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.queueDepth = 8
         config.capturesAudio = true
         config.sampleRate = 48_000
         config.channelCount = 2
-        config.excludesCurrentProcessAudio = true
+        switch captureTarget {
+        case .hostApplication, .display(excludesCurrentProcessAudio: false):
+            config.excludesCurrentProcessAudio = false
+        case .zoomDesktop:
+            config.excludesCurrentProcessAudio = true
+        case .display(excludesCurrentProcessAudio: true):
+            config.excludesCurrentProcessAudio = true
+        }
 
         let stream = SCStream(filter: filter, configuration: config, delegate: self)
         self.stream = stream
@@ -719,43 +902,84 @@ private final class ZoomMeetingSystemAudioRecorder: NSObject, SCStreamOutput, SC
         try await stream.startCapture()
     }
 
+    private func makeContentFilter(from content: SCShareableContent) throws -> SCContentFilter {
+        guard let display = content.displays.first else {
+            throw MeetingRecordingManager.RecordingError.noShareableContent
+        }
+        let emptyWindows: [SCWindow] = []
+        switch captureTarget {
+        case .hostApplication:
+            if let app = content.applications.first(where: { $0.bundleIdentifier == Bundle.main.bundleIdentifier }) {
+                return SCContentFilter(display: display, including: [app], exceptingWindows: emptyWindows)
+            }
+        case .zoomDesktop:
+            if let zoom = content.applications.first(where: { $0.bundleIdentifier == Self.zoomDesktopBundleID }) {
+                return SCContentFilter(display: display, including: [zoom], exceptingWindows: emptyWindows)
+            }
+        case .display:
+            break
+        }
+        return SCContentFilter(display: display, excludingApplications: [], exceptingWindows: emptyWindows)
+    }
+
     func stop() async throws {
         if let stream {
             try? await stream.stopCapture()
             self.stream = nil
         }
-        writerInput?.markAsFinished()
         guard let writer else { return }
-        await withCheckedContinuation { continuation in
-            writer.finishWriting {
-                continuation.resume()
+        if didStartSession, writer.status == .writing {
+            writerInput?.markAsFinished()
+            await withCheckedContinuation { continuation in
+                writer.finishWriting {
+                    continuation.resume()
+                }
             }
+        } else {
+            try? FileManager.default.removeItem(at: outputURL)
         }
         self.writer = nil
         self.writerInput = nil
+        didStartSession = false
+        appendedSampleCount = 0
     }
 
     func stream(_ stream: SCStream, didStopWithError error: Error) {
-        // Capture may stop on permission changes or display changes; caller handles lifecycle.
+        NSLog("ZoomMeetingSystemAudioRecorder: capture stopped — \(error.localizedDescription)")
     }
 
     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 }
+        guard let writer else { return }
 
+        if writerInput == nil {
+            guard let formatDescription = CMSampleBufferGetFormatDescription(sampleBuffer) else { return }
+            let input = AVAssetWriterInput(mediaType: .audio, outputSettings: nil, sourceFormatHint: formatDescription)
+            input.expectsMediaDataInRealTime = true
+            guard writer.canAdd(input) else {
+                NSLog("ZoomMeetingSystemAudioRecorder: cannot add audio input for captured format.")
+                return
+            }
+            writer.add(input)
+            writerInput = input
+        }
+
+        guard 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)
-            }
+        guard writer.status == .writing else { return }
+
+        let pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
+        if !didStartSession {
+            writer.startSession(atSourceTime: pts)
+            didStartSession = true
+        }
+
+        guard input.isReadyForMoreMediaData else { return }
+        if input.append(sampleBuffer) {
+            appendedSampleCount += 1
         }
     }
 }

+ 305 - 51
zoom_app/ViewController.swift

@@ -39,6 +39,7 @@ protocol EmbeddedOAuthPresenting: AnyObject {
 
 class ViewController: NSViewController {
     private static let embeddedZoomMeetingLifecycleHandlerName = "zoomAppMeetingLifecycle"
+    private static let embeddedZoomMicStateHandlerName = "zoomAppMicState"
     private static let embeddedZoomMeetingLifecycleJavaScript = """
     (function() {
       function notify() {
@@ -55,6 +56,85 @@ class ViewController: NSViewController {
       setInterval(notify, 1500);
     })();
     """
+    private static let embeddedZoomMicStateJavaScript = """
+    (function() {
+      function visible(el) {
+        if (!el || !el.getBoundingClientRect) return false;
+        var r = el.getBoundingClientRect();
+        return r.width > 0 && r.height > 0;
+      }
+      function isMicrophoneControl(label) {
+        if (!label) return false;
+        if (label.indexOf('video') !== -1 || label.indexOf('camera') !== -1) return false;
+        if (label.indexOf('speaker') !== -1 || label.indexOf('headphone') !== -1) return false;
+        if (label.indexOf('share') !== -1 || label.indexOf('screen') !== -1) return false;
+        if (label.indexOf('record') !== -1 || label.indexOf('chat') !== -1) return false;
+        if (label.indexOf('participant') !== -1 || label.indexOf('reaction') !== -1) return false;
+        return label.indexOf('mic') !== -1 || label.indexOf('audio') !== -1
+          || label === 'mute' || label === 'unmute';
+      }
+      function readMicMuted() {
+        var nodes = document.querySelectorAll('button,[role="button"]');
+        for (var i = 0; i < nodes.length; i++) {
+          var el = nodes[i];
+          if (!visible(el)) continue;
+          var label = (el.getAttribute('aria-label') || el.getAttribute('title') || '').toLowerCase().trim();
+          if (!isMicrophoneControl(label)) continue;
+          if (label.indexOf('unmute') !== -1) return true;
+          if (label.indexOf('mute') !== -1) return false;
+        }
+        return null;
+      }
+      var lastMuted = null;
+      function notify() {
+        var muted = readMicMuted();
+        if (muted === null || muted === lastMuted) return;
+        lastMuted = muted;
+        try {
+          window.webkit.messageHandlers.\(embeddedZoomMicStateHandlerName).postMessage({ muted: muted });
+        } catch (e) {}
+      }
+      var observer = new MutationObserver(function() { notify(); });
+      function startObserver() {
+        if (!document.body) return;
+        observer.observe(document.body, { subtree: true, childList: true, attributes: true, attributeFilter: ['aria-label', 'aria-pressed', 'title', 'class'] });
+      }
+      if (document.body) startObserver();
+      else document.addEventListener('DOMContentLoaded', startObserver);
+      setInterval(notify, 500);
+      notify();
+    })();
+    """
+    /// Poll script for the embedded meeting monitor timer (returns `{ muted: boolean }` or `null`).
+    static let embeddedZoomMicStatePollJavaScript = """
+    (function() {
+      function visible(el) {
+        if (!el || !el.getBoundingClientRect) return false;
+        var r = el.getBoundingClientRect();
+        return r.width > 0 && r.height > 0;
+      }
+      function isMicrophoneControl(label) {
+        if (!label) return false;
+        if (label.indexOf('video') !== -1 || label.indexOf('camera') !== -1) return false;
+        if (label.indexOf('speaker') !== -1 || label.indexOf('headphone') !== -1) return false;
+        if (label.indexOf('share') !== -1 || label.indexOf('screen') !== -1) return false;
+        if (label.indexOf('record') !== -1 || label.indexOf('chat') !== -1) return false;
+        if (label.indexOf('participant') !== -1 || label.indexOf('reaction') !== -1) return false;
+        return label.indexOf('mic') !== -1 || label.indexOf('audio') !== -1
+          || label === 'mute' || label === 'unmute';
+      }
+      var nodes = document.querySelectorAll('button,[role="button"]');
+      for (var i = 0; i < nodes.length; i++) {
+        var el = nodes[i];
+        if (!visible(el)) continue;
+        var label = (el.getAttribute('aria-label') || el.getAttribute('title') || '').toLowerCase().trim();
+        if (!isMicrophoneControl(label)) continue;
+        if (label.indexOf('unmute') !== -1) return { muted: true };
+        if (label.indexOf('mute') !== -1) return { muted: false };
+      }
+      return null;
+    })();
+    """
 
     enum HomeSection: String {
         case home = "Home"
@@ -473,6 +553,9 @@ class ViewController: NSViewController {
     /// True after the embedded web client loaded a join/in-call URL during the current recording session.
     private var embeddedMeetingReachedInCallPage = false
     private var embeddedMeetingRecordingMonitorTimer: Timer?
+    private var endMeetingRecordingStopInFlight = false
+    /// Recording consent given; capture starts when the embedded client enters an active meeting (not the join lobby).
+    private var pendingEmbeddedRecording: (sourceURL: URL, meetingDisplayName: String?)?
 
     private enum LocalRecordingNotesStatus: String {
         case notRequested
@@ -524,8 +607,8 @@ class ViewController: NSViewController {
     private var lastScrollEdgeRefreshAt = Date.distantPast
     private var lastMeetingsPageAutoLoadAt = Date.distantPast
     // Keep this conservative to avoid Zoom API rate limits.
-    private let meetingsRefreshInterval: TimeInterval = 60
-    private let scrollRefreshCooldown: TimeInterval = 3
+    private let meetingsRefreshInterval: TimeInterval = 120
+    private let scrollRefreshCooldown: TimeInterval = 90
     private let meetingsPageAutoLoadCooldown: TimeInterval = 0.25
     private var meetingsRateLimitedUntil: Date?
     private var zoomAccountTimeZone: TimeZone?
@@ -764,7 +847,9 @@ class ViewController: NSViewController {
             Task { @MainActor [weak self] in
                 guard let self else { return }
                 self.aiCompanionTranscriptionActiveRecordingIDs.remove(id)
+                self.recordedMeetings = MeetingRecordingManager.shared.recordings
                 self.renderAiCompanionList()
+                self.autoGenerateMeetingNotesIfNeeded(forRecordingId: id)
             }
         }
         NotificationCenter.default.addObserver(
@@ -2107,21 +2192,28 @@ class ViewController: NSViewController {
         return false
     }
 
-    /// Active join / in-call pages in the embedded Zoom web client (not Workplace hub routes under `/wc/`).
-    private func isZoomEmbeddedWebClientInCallURL(_ url: URL) -> Bool {
-        guard let host = url.host?.lowercased(), host.contains("zoom.") else { return false }
-        if isZoomMeetingLeaveOrEndURL(url) { return false }
-        let path = url.path.lowercased()
-        if path.contains("/wc/join") { return true }
-        if path.contains("/wc/inmeeting") { return true }
-        if path.contains("/wc/video") { return true }
-        // Non–web-client join links opened in the embedded panel (rare).
-        if path.contains("/wc/") == false {
-            return isZoomMeetingJoinOrStartURL(url)
+    /// True when the embedded web client is in a live meeting (not lobby, hub, or loading).
+    private func isEmbeddedZoomActiveMeeting(url: URL?, webViewTitle: String?) -> Bool {
+        if let title = webViewTitle, embeddedWebViewTitleIndicatesActiveMeeting(title) { return true }
+        if let url {
+            if isZoomMeetingLeaveOrEndURL(url) { return false }
+            let path = url.path.lowercased()
+            if path.contains("/wc/inmeeting") || path.contains("/wc/video") { return true }
+            if path.contains("/wc/join") { return true }
         }
         return false
     }
 
+    private func embeddedWebViewTitleIndicatesActiveMeeting(_ title: String) -> Bool {
+        let t = title.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+        guard t.isEmpty == false else { return false }
+        if t.contains("waiting for the host") || t.contains("waiting for host") { return false }
+        if t.contains("sign in") || t.contains("sign-in") { return false }
+        if t.contains("zoom meeting") || t.contains("zoom webinar") { return true }
+        if t.contains("zoom workplace") && t.contains("|") == false { return true }
+        return false
+    }
+
     /// Workplace hub, sign-in, or explicit post-meeting pages — not an active call.
     private func isZoomMeetingLeaveOrEndURL(_ url: URL) -> Bool {
         guard let host = url.host?.lowercased(), host.contains("zoom.") else { return false }
@@ -2156,38 +2248,78 @@ class ViewController: NSViewController {
 
     @MainActor
     private func updateEmbeddedMeetingRecordingPresence(for url: URL?, webViewTitle: String? = nil) {
-        guard MeetingRecordingManager.shared.isRecording else {
+        let monitoring = MeetingRecordingManager.shared.isRecording || pendingEmbeddedRecording != nil
+        guard monitoring else {
             stopEmbeddedMeetingRecordingMonitor()
             return
         }
 
-        let inCallURL = url.map { isZoomEmbeddedWebClientInCallURL($0) } ?? false
         let leftByURL = url.map { isZoomMeetingLeaveOrEndURL($0) } ?? false
         let leftByTitle = webViewTitle.map { embeddedWebViewTitleIndicatesLeftMeeting($0) } ?? false
-        let inCall = inCallURL && !leftByURL
-
-        if inCall {
+        let detectedInMeeting = isEmbeddedZoomActiveMeeting(url: url, webViewTitle: webViewTitle)
+        if detectedInMeeting {
             embeddedMeetingReachedInCallPage = true
         }
-        MeetingRecordingManager.shared.setEmbeddedWebClientCallUIVisible(inCall)
+        // Stay "in meeting" across mute UI changes until an explicit leave URL/title.
+        let inActiveMeeting = detectedInMeeting
+            || (embeddedMeetingReachedInCallPage && !leftByURL && !leftByTitle)
+
+        if inActiveMeeting {
+            startPendingEmbeddedRecordingIfNeeded()
+            if MeetingRecordingManager.shared.isRecording {
+                pollEmbeddedZoomMicMuteState()
+            }
+        }
+        MeetingRecordingManager.shared.setEmbeddedWebClientCallUIVisible(inActiveMeeting)
 
         let shouldStopNow = leftByURL || leftByTitle
-            || (embeddedMeetingReachedInCallPage && !inCall)
         if shouldStopNow {
-            endZoomMeetingRecordingAutomatically()
+            pendingEmbeddedRecording = nil
+            if MeetingRecordingManager.shared.isRecording {
+                endZoomMeetingRecordingAutomatically()
+            } else {
+                stopEmbeddedMeetingRecordingMonitor()
+                updateAiCompanionRecordingUI()
+            }
+        }
+    }
+
+    @MainActor
+    private func startPendingEmbeddedRecordingIfNeeded() {
+        guard let pending = pendingEmbeddedRecording else { return }
+        guard MeetingRecordingManager.shared.isRecording == false else {
+            pendingEmbeddedRecording = nil
+            return
+        }
+        pendingEmbeddedRecording = nil
+        Task { @MainActor in
+            do {
+                MeetingRecordingManager.shared.configureCapture(embeddedZoomWebClient: true)
+                try await MeetingRecordingManager.shared.startRecording(
+                    sourceURL: pending.sourceURL,
+                    meetingDisplayName: pending.meetingDisplayName
+                )
+                self.updateAiCompanionRecordingUI()
+            } catch {
+                self.showSimpleAlert(title: "Recording could not start", message: error.localizedDescription)
+                self.updateAiCompanionRecordingUI()
+            }
         }
     }
 
     @MainActor
     private func startEmbeddedMeetingRecordingMonitorIfNeeded() {
         stopEmbeddedMeetingRecordingMonitor()
-        guard MeetingRecordingManager.shared.isRecording else { return }
+        guard MeetingRecordingManager.shared.isRecording || pendingEmbeddedRecording != nil else { return }
         guard embeddedBrowserWebView != nil else { return }
         embeddedMeetingReachedInCallPage = false
         let timer = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in
             guard let self else { return }
             let webView = self.embeddedBrowserWebView
             self.updateEmbeddedMeetingRecordingPresence(for: webView?.url, webViewTitle: webView?.title)
+            if MeetingRecordingManager.shared.isRecording {
+                self.pollEmbeddedZoomMicMuteState()
+            }
         }
         RunLoop.main.add(timer, forMode: .common)
         embeddedMeetingRecordingMonitorTimer = timer
@@ -2198,22 +2330,58 @@ class ViewController: NSViewController {
         embeddedMeetingRecordingMonitorTimer?.invalidate()
         embeddedMeetingRecordingMonitorTimer = nil
         embeddedMeetingReachedInCallPage = false
+        pendingEmbeddedRecording = nil
+    }
+
+    @MainActor
+    private func pollEmbeddedZoomMicMuteState() {
+        guard MeetingRecordingManager.shared.isRecording else { return }
+        guard let webView = embeddedBrowserWebView else { return }
+        webView.evaluateJavaScript(Self.embeddedZoomMicStatePollJavaScript) { result, _ in
+            guard let muted = Self.parseEmbeddedZoomMicMuted(from: result) else { return }
+            Task { @MainActor in
+                MeetingRecordingManager.shared.setMeetingMicrophoneTransmitting(!muted)
+            }
+        }
+    }
+
+    private static func parseEmbeddedZoomMicMuted(from result: Any?) -> Bool? {
+        if let dict = result as? [String: Any], let muted = dict["muted"] as? Bool {
+            return muted
+        }
+        if let muted = result as? Bool {
+            return muted
+        }
+        return nil
+    }
+
+    @MainActor
+    private func applyEmbeddedZoomMicStateMessage(_ body: Any) {
+        guard MeetingRecordingManager.shared.isRecording else { return }
+        guard let muted = Self.parseEmbeddedZoomMicMuted(from: body) else { return }
+        MeetingRecordingManager.shared.setMeetingMicrophoneTransmitting(!muted)
     }
 
     @MainActor
     private func endZoomMeetingRecordingAutomatically() {
         guard MeetingRecordingManager.shared.isRecording else { return }
+        guard endMeetingRecordingStopInFlight == false else { return }
+        endMeetingRecordingStopInFlight = true
         stopEmbeddedMeetingRecordingMonitor()
         Task { @MainActor in
-            guard MeetingRecordingManager.shared.isRecording else { return }
+            defer { self.endMeetingRecordingStopInFlight = false }
             do {
                 try await MeetingRecordingManager.shared.stopRecording()
-                self.recordedMeetings = MeetingRecordingManager.shared.recordings
-                self.renderAiCompanionList()
-                self.updateAiCompanionRecordingUI()
             } catch {
                 NSLog("ViewController: auto-stop recording failed — \(error.localizedDescription)")
+                self.showSimpleAlert(
+                    title: "Recording could not be saved",
+                    message: error.localizedDescription
+                )
             }
+            self.recordedMeetings = MeetingRecordingManager.shared.recordings
+            self.renderAiCompanionList()
+            self.updateAiCompanionRecordingUI()
         }
     }
 
@@ -2221,15 +2389,24 @@ class ViewController: NSViewController {
     private func beginZoomMeetingRecordingIfAppropriate(sourceURL: URL, meetingDisplayName: String?) {
         guard isZoomMeetingJoinOrStartURL(sourceURL) else { return }
         guard MeetingRecordingManager.shared.isRecording == false else { return }
+        let usesEmbeddedWebClient = embeddedBrowserWebView != nil
+            && (embeddedBrowserPanelWidthConstraint?.constant ?? 0) > 0.5
+
         Task { @MainActor in
             do {
-                try await MeetingRecordingManager.shared.startRecording(sourceURL: sourceURL, meetingDisplayName: meetingDisplayName)
+                MeetingRecordingManager.shared.configureCapture(embeddedZoomWebClient: usesEmbeddedWebClient)
+                try await MeetingRecordingManager.shared.startRecording(
+                    sourceURL: sourceURL,
+                    meetingDisplayName: meetingDisplayName
+                )
+                MeetingRecordingManager.shared.setMeetingMicrophoneTransmitting(false)
+                self.pendingEmbeddedRecording = nil
                 self.updateAiCompanionRecordingUI()
-                if self.embeddedBrowserWebView != nil,
-                   (self.embeddedBrowserPanelWidthConstraint?.constant ?? 0) > 0.5 {
+                if usesEmbeddedWebClient {
                     self.startEmbeddedMeetingRecordingMonitorIfNeeded()
                 }
             } catch {
+                self.pendingEmbeddedRecording = nil
                 self.showSimpleAlert(title: "Recording could not start", message: error.localizedDescription)
             }
         }
@@ -2602,15 +2779,29 @@ class ViewController: NSViewController {
         guard storeKitCoordinator.hasPremiumAccess else { return }
         let now = Date()
         if let until = meetingsRateLimitedUntil, now < until {
+            Task { @MainActor in
+                self.meetingsStatusLabel?.stringValue = self.meetingsListStatusMessage()
+                self.meetingsPageStatusLabel?.stringValue = self.meetingsListStatusMessage()
+                self.applyFilteredMeetings()
+            }
             return
         }
-        if force == false, now.timeIntervalSince(lastMeetingsRefreshAt) < meetingsRefreshInterval {
+        if now.timeIntervalSince(lastMeetingsRefreshAt) < meetingsRefreshInterval {
             return
         }
         lastMeetingsRefreshAt = now
         Task { await self.loadScheduledMeetings() }
     }
 
+    @MainActor
+    private func meetingsListStatusMessage() -> String {
+        if let until = meetingsRateLimitedUntil, Date() < until {
+            let minutes = max(1, Int(ceil(until.timeIntervalSince(Date()) / 60.0)))
+            return "Zoom rate limit reached. Please try again in \(minutes) min."
+        }
+        return "Upcoming meetings"
+    }
+
     private func clearMeetingsScrollObserver() {
         if let meetingsScrollObserver {
             NotificationCenter.default.removeObserver(meetingsScrollObserver)
@@ -2891,7 +3082,7 @@ class ViewController: NSViewController {
             return
         }
         lastScrollEdgeRefreshAt = now
-        triggerMeetingsRefresh(force: true)
+        triggerMeetingsRefresh(force: false)
     }
 
     private func observeMeetingsPageInfiniteScroll(in scrollView: NSScrollView) {
@@ -3018,7 +3209,9 @@ class ViewController: NSViewController {
 
         meetingsRefreshTimer?.invalidate()
         meetingsRefreshTimer = nil
-        meetingsRateLimitedUntil = nil
+        if loggedIn == false {
+            meetingsRateLimitedUntil = nil
+        }
         if loggedIn {
             if storeKitCoordinator.hasPremiumAccess {
                 startMeetingsAutoRefresh()
@@ -3147,10 +3340,10 @@ class ViewController: NSViewController {
         if ordered.isEmpty {
             emptyMeetingLabel?.isHidden = showingHomeSection == false
             if source.isEmpty {
-                meetingsStatusLabel?.stringValue = "Upcoming meetings"
+                meetingsStatusLabel?.stringValue = meetingsListStatusMessage()
                 emptyMeetingLabel?.stringValue = "No meetings scheduled for \(meetingsDayDisplayName(for: dayStart))."
             } else {
-                meetingsStatusLabel?.stringValue = "Upcoming meetings"
+                meetingsStatusLabel?.stringValue = meetingsListStatusMessage()
                 emptyMeetingLabel?.stringValue = "No meetings match your search."
             }
             applySchedulerMeetingsForSelectedDate()
@@ -3159,7 +3352,7 @@ class ViewController: NSViewController {
         }
 
         emptyMeetingLabel?.isHidden = true
-        meetingsStatusLabel?.stringValue = "Upcoming meetings"
+        meetingsStatusLabel?.stringValue = meetingsListStatusMessage()
         for meeting in ordered {
             let card = makeMeetingRowCard(meeting)
             stack.addArrangedSubview(card)
@@ -3667,7 +3860,25 @@ class ViewController: NSViewController {
             }
         } catch {
             await MainActor.run {
-                self.applyMeetings([])
+                let preserveCachedMeetings: Bool
+                if case ZoomOAuthError.rateLimited(let retryAfterSeconds) = error {
+                    let seconds = max(retryAfterSeconds ?? 300, 30)
+                    self.meetingsRateLimitedUntil = Date().addingTimeInterval(TimeInterval(seconds))
+                    preserveCachedMeetings = true
+                } else if case ZoomOAuthError.missingClientId = error {
+                    preserveCachedMeetings = false
+                } else if case ZoomOAuthError.missingClientSecret = error {
+                    preserveCachedMeetings = false
+                } else if case ZoomOAuthError.missingRequiredScope = error {
+                    preserveCachedMeetings = false
+                } else {
+                    preserveCachedMeetings = self.allScheduledMeetings.isEmpty == false
+                }
+
+                if preserveCachedMeetings == false {
+                    self.applyMeetings([])
+                }
+
                 if case ZoomOAuthError.missingClientId = error {
                     self.meetingsStatusLabel?.stringValue = "Zoom OAuth app not configured."
                     self.meetingsPageStatusLabel?.stringValue = "Zoom OAuth app not configured."
@@ -3680,15 +3891,17 @@ class ViewController: NSViewController {
                     self.zoomOAuth.clearSavedTokens()
                     self.meetingsStatusLabel?.stringValue = "Zoom permissions are missing. Update your Zoom app scopes, then sign in again."
                     self.meetingsPageStatusLabel?.stringValue = "Zoom permissions are missing. Update your Zoom app scopes, then sign in again."
-                } else if case ZoomOAuthError.rateLimited(let retryAfterSeconds) = error {
-                    let seconds = max(retryAfterSeconds ?? 300, 30)
-                    self.meetingsRateLimitedUntil = Date().addingTimeInterval(TimeInterval(seconds))
-                    let minutes = Int(ceil(Double(seconds) / 60.0))
-                    self.meetingsStatusLabel?.stringValue = "Zoom rate limit reached. Please try again in \(minutes) min."
-                    self.meetingsPageStatusLabel?.stringValue = "Zoom rate limit reached. Please try again in \(minutes) min."
+                } else if case ZoomOAuthError.rateLimited = error {
+                    self.meetingsStatusLabel?.stringValue = self.meetingsListStatusMessage()
+                    self.meetingsPageStatusLabel?.stringValue = self.meetingsListStatusMessage()
                 } else {
-                    self.meetingsStatusLabel?.stringValue = "Unable to load meetings right now. Please try again shortly."
-                    self.meetingsPageStatusLabel?.stringValue = "Unable to load meetings right now. Please try again shortly."
+                    self.meetingsStatusLabel?.stringValue = "Unable to refresh meetings. Showing last loaded list."
+                    self.meetingsPageStatusLabel?.stringValue = "Unable to refresh meetings. Showing last loaded list."
+                }
+
+                if preserveCachedMeetings {
+                    self.applyFilteredMeetings()
+                    self.applyMeetingsPageFilters(resetVisible: false)
                 }
             }
         }
@@ -3941,7 +4154,7 @@ class ViewController: NSViewController {
             var components = URLComponents(string: "https://api.zoom.us/v2/users/me/meetings")!
             var items: [URLQueryItem] = [
                 URLQueryItem(name: "type", value: "scheduled"),
-                URLQueryItem(name: "page_size", value: "30")
+                URLQueryItem(name: "page_size", value: "300")
             ]
             if let nextPageToken, nextPageToken.isEmpty == false {
                 items.append(URLQueryItem(name: "next_page_token", value: nextPageToken))
@@ -6740,12 +6953,19 @@ class ViewController: NSViewController {
         embeddedBrowserConfiguration.mediaTypesRequiringUserActionForPlayback = []
         let meetingLifecycleController = WKUserContentController()
         meetingLifecycleController.add(self, name: Self.embeddedZoomMeetingLifecycleHandlerName)
+        meetingLifecycleController.add(self, name: Self.embeddedZoomMicStateHandlerName)
         let lifecycleScript = WKUserScript(
             source: Self.embeddedZoomMeetingLifecycleJavaScript,
             injectionTime: .atDocumentEnd,
             forMainFrameOnly: true
         )
         meetingLifecycleController.addUserScript(lifecycleScript)
+        let micStateScript = WKUserScript(
+            source: Self.embeddedZoomMicStateJavaScript,
+            injectionTime: .atDocumentEnd,
+            forMainFrameOnly: true
+        )
+        meetingLifecycleController.addUserScript(micStateScript)
         embeddedBrowserConfiguration.userContentController = meetingLifecycleController
         let embeddedWK = WKWebView(frame: .zero, configuration: embeddedBrowserConfiguration)
         embeddedWK.customUserAgent = embeddedZoomWebViewUserAgent
@@ -7585,6 +7805,11 @@ class ViewController: NSViewController {
                 "Recording… Stops automatically when you leave or the meeting ends."
             aiCompanionStatusLabel?.font = .systemFont(ofSize: 12, weight: .regular)
             aiCompanionStatusLabel?.textColor = secondaryText
+        } else if pendingEmbeddedRecording != nil {
+            aiCompanionStatusLabel?.stringValue =
+                "Waiting to join… Recording starts when you enter the meeting."
+            aiCompanionStatusLabel?.font = .systemFont(ofSize: 12, weight: .regular)
+            aiCompanionStatusLabel?.textColor = secondaryText
         } else {
             aiCompanionStatusLabel?.stringValue = "Previous meetings"
             aiCompanionStatusLabel?.font = .systemFont(ofSize: 15, weight: .semibold)
@@ -7754,7 +7979,12 @@ class ViewController: NSViewController {
             let msg = localRecordingNotesProgressByRecordingId[recording.id] ?? "Generating notes…"
             return (msg, secondaryText)
         case .ready:
-            return ("Notes ready", secondaryText)
+            if let notes = recording.notesText?.trimmingCharacters(in: .whitespacesAndNewlines), notes.isEmpty == false {
+                let preview = String(notes.prefix(120))
+                let suffix = notes.count > 120 ? "…" : ""
+                return ("Notes: \(preview)\(suffix)", secondaryText)
+            }
+            return ("Notes ready — tap View Notes", secondaryText)
         case .failed:
             let err = recording.notesError?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
             if err.isEmpty {
@@ -8099,6 +8329,22 @@ class ViewController: NSViewController {
         localRecordingNotesTaskByRecordingId[recordingId] = task
     }
 
+    @MainActor
+    private func autoGenerateMeetingNotesIfNeeded(forRecordingId recordingId: String) {
+        recordedMeetings = MeetingRecordingManager.shared.recordings
+        guard let recording = recordedMeetings.first(where: { $0.id == recordingId }) else { return }
+        guard localRecordingNotesStatus(for: recording) == .notRequested else { return }
+        let transcript = recording.transcriptText?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+        guard transcript.isEmpty == false else { return }
+        let requestId = UUID()
+        startLocalRecordingNotesProcessing(
+            recordingId: recording.id,
+            transcript: transcript,
+            requestId: requestId,
+            autoPresentOnReady: false
+        )
+    }
+
     @objc private func recordingGetNotesTapped(_ sender: NSButton) {
         let idx = sender.tag
         guard recordedMeetings.indices.contains(idx) else { return }
@@ -9690,11 +9936,19 @@ extension ViewController: EmbeddedOAuthPresenting {}
 
 extension ViewController: WKScriptMessageHandler {
     func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
-        guard message.name == Self.embeddedZoomMeetingLifecycleHandlerName else { return }
-        guard let href = message.body as? String, let url = URL(string: href) else { return }
-        Task { @MainActor in
-            let title = self.embeddedBrowserWebView?.title
-            self.updateEmbeddedMeetingRecordingPresence(for: url, webViewTitle: title)
+        switch message.name {
+        case Self.embeddedZoomMeetingLifecycleHandlerName:
+            guard let href = message.body as? String, let url = URL(string: href) else { return }
+            Task { @MainActor in
+                let title = self.embeddedBrowserWebView?.title
+                self.updateEmbeddedMeetingRecordingPresence(for: url, webViewTitle: title)
+            }
+        case Self.embeddedZoomMicStateHandlerName:
+            Task { @MainActor in
+                self.applyEmbeddedZoomMicStateMessage(message.body)
+            }
+        default:
+            break
         }
     }
 }