Kaynağa Gözat

Implement local AI Companion recording and notes

Adds local meeting recording (mic + system audio), Apple Speech transcription, and GPT notes generation. Stores meeting history locally and updates AI Companion UI; also sets required macOS permissions and adjusts default window sizing.

Co-authored-by: Cursor <cursoragent@cursor.com>
huzaifahayat12 2 ay önce
ebeveyn
işleme
4981ca4c22

+ 6 - 0
zoom_app.xcodeproj/project.pbxproj

@@ -259,8 +259,11 @@
 				INFOPLIST_KEY_CFBundleDisplayName = "Zoom App";
 				INFOPLIST_KEY_CFBundleName = "Zoom App";
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
+				INFOPLIST_KEY_NSMicrophoneUsageDescription = "This app records your microphone to generate meeting transcripts and AI notes.";
 				INFOPLIST_KEY_NSMainStoryboardFile = Main;
 				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
+				INFOPLIST_KEY_NSScreenCaptureUsageDescription = "This app captures system audio during meetings to generate transcripts and AI notes.";
+				INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "This app uses Apple Speech Recognition to transcribe meeting recordings into text.";
 				INFOPLIST_KEY_ZoomOAuthClientId = isvIAKPhSPOhBxFUkiY2A;
 				LD_RUNPATH_SEARCH_PATHS = (
 					"$(inherited)",
@@ -295,8 +298,11 @@
 				INFOPLIST_KEY_CFBundleDisplayName = "Zoom App";
 				INFOPLIST_KEY_CFBundleName = "Zoom App";
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
+				INFOPLIST_KEY_NSMicrophoneUsageDescription = "This app records your microphone to generate meeting transcripts and AI notes.";
 				INFOPLIST_KEY_NSMainStoryboardFile = Main;
 				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
+				INFOPLIST_KEY_NSScreenCaptureUsageDescription = "This app captures system audio during meetings to generate transcripts and AI notes.";
+				INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "This app uses Apple Speech Recognition to transcribe meeting recordings into text.";
 				INFOPLIST_KEY_ZoomOAuthClientId = isvIAKPhSPOhBxFUkiY2A;
 				LD_RUNPATH_SEARCH_PATHS = (
 					"$(inherited)",

+ 187 - 0
zoom_app/AI/MeetingNotesService.swift

@@ -0,0 +1,187 @@
+import Foundation
+
+enum MeetingNotesError: Error, LocalizedError {
+    case missingAPIKey
+    case invalidResponse
+    case httpStatus(Int, String)
+    case emptyNotes
+
+    var errorDescription: String? {
+        switch self {
+        case .missingAPIKey:
+            return "OpenAI API key is missing. Set OPENAI_API_KEY in the environment."
+        case .invalidResponse:
+            return "Notes generation returned an invalid response."
+        case let .httpStatus(code, body):
+            return "Notes generation failed (\(code)): \(body)"
+        case .emptyNotes:
+            return "Notes generation returned empty text."
+        }
+    }
+}
+
+/// Generates concise meeting notes from transcript text.
+final class MeetingNotesService {
+    private let session: URLSession
+
+    init(session: URLSession = .shared) {
+        self.session = session
+    }
+
+    func generateNotes(
+        from transcript: String,
+        preferredLanguageIdentifier1: String? = nil,
+        preferredLanguageIdentifier2: String? = nil,
+        apiKey: String? = nil
+    ) async throws -> String {
+        let key = normalizedAPIKey(from: apiKey)
+            ?? normalizedAPIKey(from: ProcessInfo.processInfo.environment["OPENAI_API_KEY"])
+        guard let key, key.isEmpty == false else { throw MeetingNotesError.missingAPIKey }
+
+        struct Message: Encodable {
+            let role: String
+            let content: String
+        }
+        struct Body: Encodable {
+            let model: String
+            let messages: [Message]
+            let temperature: Double
+        }
+
+        let preference = preferredNotesLanguage(
+            primary: preferredLanguageIdentifier1,
+            secondary: preferredLanguageIdentifier2
+        )
+        let notesLanguage: DominantLanguage = preference ?? DominantLanguage(tag: "und", name: "the dominant language of the transcript")
+
+        let prompt = buildNotesPrompt(
+            transcript: transcript,
+            dominantLanguage: notesLanguage,
+            preferredLanguage: preference
+        )
+        let body = Body(
+            model: "gpt-4.1-mini",
+            messages: [
+                Message(role: "system", content: "You create practical meeting notes from imperfect transcripts."),
+                Message(role: "user", content: prompt)
+            ],
+            temperature: 0.2
+        )
+        return try await requestChatCompletionText(body: body, apiKey: key)
+    }
+
+    private func requestChatCompletionText(body: Encodable, apiKey: String) async throws -> String {
+        var request = URLRequest(url: URL(string: "https://api.openai.com/v1/chat/completions")!)
+        request.httpMethod = "POST"
+        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+        request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
+        request.httpBody = try JSONEncoder().encode(AnyEncodable(body))
+
+        let (data, response) = try await session.data(for: request)
+        guard let http = response as? HTTPURLResponse else { throw MeetingNotesError.invalidResponse }
+        guard (200..<300).contains(http.statusCode) else {
+            let bodyText = String(data: data, encoding: .utf8) ?? "<no body>"
+            throw MeetingNotesError.httpStatus(http.statusCode, bodyText)
+        }
+
+        struct ChoiceMessage: Decodable {
+            let content: String?
+        }
+        struct Choice: Decodable {
+            let message: ChoiceMessage
+        }
+        struct ChatResponse: Decodable {
+            let choices: [Choice]
+        }
+
+        let decoded = try JSONDecoder().decode(ChatResponse.self, from: data)
+        let notes = decoded.choices.first?.message.content?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+        guard notes.isEmpty == false else { throw MeetingNotesError.emptyNotes }
+        return notes
+    }
+
+    private struct DominantLanguage: Sendable {
+        let tag: String
+        let name: String
+    }
+
+    private func preferredNotesLanguage(primary: String?, secondary: String?) -> DominantLanguage? {
+        let cleaned = [
+            primary?.trimmingCharacters(in: .whitespacesAndNewlines),
+            secondary?.trimmingCharacters(in: .whitespacesAndNewlines)
+        ]
+            .compactMap { $0 }
+            .filter { $0.isEmpty == false }
+
+        guard let identifier = cleaned.first else { return nil }
+        let tag = identifier.replacingOccurrences(of: "_", with: "-")
+        let english = Locale(identifier: "en")
+        let languageName = english.localizedString(forIdentifier: identifier)
+            ?? english.localizedString(forIdentifier: tag)
+            ?? identifier
+        return DominantLanguage(tag: tag, name: languageName)
+    }
+
+    private func buildNotesPrompt(
+        transcript: String,
+        dominantLanguage: DominantLanguage,
+        preferredLanguage: DominantLanguage?
+    ) -> String {
+        let preferenceLine: String = {
+            guard let preferredLanguage else { return "" }
+            return "\nPreferred language selected by the user: \(preferredLanguage.name) (\(preferredLanguage.tag)). Follow this preference."
+        }()
+
+        return """
+        You are a meeting assistant. Generate structured notes from the transcript.
+        Important: the transcript may have missing words, dropped sentences, or minor recognition errors.
+        Infer likely intent conservatively and produce useful notes without inventing specific facts.
+        \(preferenceLine)
+
+        Output language:
+        - Write ALL notes (including headings) strictly in \(dominantLanguage.name) (\(dominantLanguage.tag)).
+        - Do NOT respond in English unless \(dominantLanguage.name) is English.
+        - Keep proper nouns, people names, product names, acronyms, URLs, and code tokens exactly as-is.
+
+        Output sections (translate these section headings into \(dominantLanguage.name)):
+        1) Summary (3-5 bullets)
+        2) Decisions
+        3) Action Items (include owner if identifiable; otherwise write the equivalent of "Unassigned" in \(dominantLanguage.name))
+        4) Risks / Open Questions
+
+        Transcript:
+        \(transcript)
+        """
+    }
+
+    private func normalizedConfigValue(from value: String?) -> String? {
+        guard var cleaned = value?.trimmingCharacters(in: .whitespacesAndNewlines), cleaned.isEmpty == false else {
+            return nil
+        }
+        cleaned = cleaned.trimmingCharacters(in: CharacterSet(charactersIn: "\"'`"))
+        return cleaned.isEmpty ? nil : cleaned
+    }
+
+    private func normalizedAPIKey(from value: String?) -> String? {
+        guard var cleaned = normalizedConfigValue(from: value) else { return nil }
+        cleaned = cleaned.replacingOccurrences(of: "—", with: "-")
+        cleaned = cleaned.replacingOccurrences(of: "–", with: "-")
+        cleaned = cleaned.replacingOccurrences(of: " ", with: "")
+        cleaned = cleaned.replacingOccurrences(of: "\u{200B}", with: "")
+        cleaned = cleaned.replacingOccurrences(of: "\u{FEFF}", with: "")
+        return cleaned.isEmpty ? nil : cleaned
+    }
+}
+
+private struct AnyEncodable: Encodable {
+    private let encodeBlock: (Encoder) throws -> Void
+
+    init(_ wrapped: Encodable) {
+        self.encodeBlock = wrapped.encode(to:)
+    }
+
+    func encode(to encoder: Encoder) throws {
+        try encodeBlock(encoder)
+    }
+}
+

+ 37 - 0
zoom_app/AICompanion/AICompanionLocalModels.swift

@@ -0,0 +1,37 @@
+import Foundation
+
+enum LocalMeetingTranscriptStatus: String, Codable {
+    case notRequested
+    case processing
+    case ready
+    case failed
+}
+
+enum LocalMeetingNotesStatus: String, Codable {
+    case notRequested
+    case processing
+    case ready
+    case failed
+}
+
+struct LocalMeetingRecording: Codable, Equatable, Identifiable {
+    let id: String
+    let title: String
+    let meetingURLString: String?
+    let startedAt: Date
+    let endedAt: Date
+
+    let mixedAudioFilePath: String
+    var microphoneAudioFilePath: String?
+    var systemAudioFilePath: String?
+
+    var transcriptStatusRaw: String?
+    var transcriptText: String?
+    var transcriptSegmentsJSON: String?
+    var transcriptErrorMessage: String?
+
+    var notesStatusRaw: String?
+    var notesText: String?
+    var notesErrorMessage: String?
+}
+

+ 25 - 3
zoom_app/AppDelegate.swift

@@ -32,7 +32,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
     }
 
     private enum DefaultWindowSize {
-        static let width: CGFloat = 1020
+        static let width: CGFloat = 940
         static let height: CGFloat = 690
     }
     
@@ -51,6 +51,10 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
         configureStatusItem()
 
         showSplashThenMainWindow()
+        // Enforce default size even if macOS restores a previous frame.
+        DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in
+            self?.applyDefaultWindowSizeIfNeeded()
+        }
     }
 
     func applicationWillTerminate(_ aNotification: Notification) {
@@ -71,8 +75,25 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
             guard window.contentViewController is ViewController else { continue }
 
             window.isRestorable = false
-            window.setContentSize(targetSize)
-            window.center()
+            // If the window is zoomed, setContentSize may be ignored.
+            if window.isZoomed {
+                window.zoom(nil)
+            }
+            // Avoid fighting full-screen.
+            if window.styleMask.contains(.fullScreen) {
+                continue
+            }
+
+            let contentRect = NSRect(origin: .zero, size: targetSize)
+            let frameRect = window.frameRect(forContentRect: contentRect)
+            var newFrame = window.frame
+            newFrame.size = frameRect.size
+            // Center on the current screen.
+            if let screenFrame = window.screen?.visibleFrame ?? NSScreen.main?.visibleFrame {
+                newFrame.origin.x = screenFrame.midX - (newFrame.size.width / 2)
+                newFrame.origin.y = screenFrame.midY - (newFrame.size.height / 2)
+            }
+            window.setFrame(newFrame, display: true)
         }
     }
 
@@ -141,6 +162,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
         window.setContentSize(NSSize(width: DefaultWindowSize.width, height: DefaultWindowSize.height))
         window.center()
         window.makeKeyAndOrderFront(nil)
+        applyDefaultWindowSizeIfNeeded()
 
         mainOriginalContentViewController = nil
         mainWindow = nil

+ 104 - 0
zoom_app/Recording/MeetingAudioMixer.swift

@@ -0,0 +1,104 @@
+import Foundation
+import AVFoundation
+
+struct FinalizedMeetingAudio: Sendable {
+    let mixedURL: URL
+    let microphoneURL: URL?
+    let systemURL: URL?
+}
+
+enum MeetingAudioMixer {
+    static func hasAudioPayload(at url: URL) -> Bool {
+        guard FileManager.default.fileExists(atPath: url.path) else { return false }
+        if let size = (try? FileManager.default.attributesOfItem(atPath: url.path)[.size]) as? NSNumber {
+            return size.intValue > 4_096
+        }
+        return true
+    }
+
+    static func finalize(systemURL: URL, microphoneURL: URL, destinationURL: URL) async -> FinalizedMeetingAudio {
+        try? FileManager.default.removeItem(at: destinationURL)
+
+        let hasSystem = hasAudioPayload(at: systemURL)
+        let hasMic = hasAudioPayload(at: microphoneURL)
+
+        if !hasSystem { try? FileManager.default.removeItem(at: systemURL) }
+        if !hasMic { try? FileManager.default.removeItem(at: microphoneURL) }
+
+        let savedMicURL: URL? = hasMic ? microphoneURL : nil
+        let savedSystemURL: URL? = hasSystem ? systemURL : nil
+
+        if hasSystem && hasMic {
+            do {
+                try await mixAudioFiles(systemURL: systemURL, microphoneURL: microphoneURL, destinationURL: destinationURL)
+                return FinalizedMeetingAudio(mixedURL: destinationURL, microphoneURL: savedMicURL, systemURL: savedSystemURL)
+            } catch {
+                // Fall back to best available single track for playback; per-channel files stay intact.
+            }
+        }
+
+        let chosenURL: URL?
+        if hasSystem {
+            chosenURL = systemURL
+        } else if hasMic {
+            chosenURL = microphoneURL
+        } else {
+            chosenURL = nil
+        }
+
+        if let chosenURL {
+            do {
+                try FileManager.default.copyItem(at: chosenURL, to: destinationURL)
+                return FinalizedMeetingAudio(mixedURL: destinationURL, microphoneURL: savedMicURL, systemURL: savedSystemURL)
+            } catch {
+                return FinalizedMeetingAudio(mixedURL: chosenURL, microphoneURL: savedMicURL, systemURL: savedSystemURL)
+            }
+        }
+
+        return FinalizedMeetingAudio(mixedURL: destinationURL, microphoneURL: nil, systemURL: nil)
+    }
+
+    static 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 NSError(domain: "MeetingAudioMixer", code: 1, userInfo: [NSLocalizedDescriptionKey: "Unable to create composition tracks."])
+        }
+
+        let systemAsset = AVURLAsset(url: systemURL)
+        let micAsset = AVURLAsset(url: microphoneURL)
+
+        if let src = try await systemAsset.loadTracks(withMediaType: .audio).first {
+            let range = try await src.load(.timeRange)
+            try systemTrack.insertTimeRange(range, of: src, at: .zero)
+        }
+        if let src = try await micAsset.loadTracks(withMediaType: .audio).first {
+            let range = try await src.load(.timeRange)
+            try micTrack.insertTimeRange(range, of: src, at: .zero)
+        }
+
+        // Give the local microphone a small speech-focused boost while preserving meeting audio.
+        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]
+
+        guard let exporter = AVAssetExportSession(asset: composition, presetName: AVAssetExportPresetAppleM4A) else {
+            throw NSError(domain: "MeetingAudioMixer", code: 2, userInfo: [NSLocalizedDescriptionKey: "Unable to create audio export session."])
+        }
+        exporter.outputURL = destinationURL
+        exporter.outputFileType = .m4a
+        exporter.audioMix = audioMix
+
+        await exporter.export()
+        if let error = exporter.error {
+            throw error
+        }
+    }
+}
+

+ 102 - 0
zoom_app/Recording/MeetingSystemAudioRecorder.swift

@@ -0,0 +1,102 @@
+import Foundation
+import AVFoundation
+import CoreMedia
+import ScreenCaptureKit
+
+@available(macOS 13.0, *)
+final class MeetingSystemAudioRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
+    private let outputURL: URL
+    private var stream: SCStream?
+    private var writer: AVAssetWriter?
+    private var writerInput: AVAssetWriterInput?
+    private var didStartWriting = false
+    private let outputQueue = DispatchQueue(label: "MeetingSystemAudioRecorder.output")
+
+    init(outputURL: URL) {
+        self.outputURL = outputURL
+        super.init()
+    }
+
+    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 NSError(domain: "MeetingSystemAudioRecorder", code: 1, userInfo: [NSLocalizedDescriptionKey: "No display available for system audio capture."])
+        }
+
+        let filter = SCContentFilter(display: display, excludingApplications: [], exceptingWindows: [])
+        let config = SCStreamConfiguration()
+        // Tiny frame size; we're capturing audio only.
+        config.width = 2
+        config.height = 2
+        config.minimumFrameInterval = CMTime(value: 1, timescale: 1)
+        config.queueDepth = 8
+        config.capturesAudio = true
+
+        let writer = try AVAssetWriter(outputURL: outputURL, fileType: .m4a)
+        let audioSettings: [String: Any] = [
+            AVFormatIDKey: kAudioFormatMPEG4AAC,
+            AVSampleRateKey: 44_100,
+            AVNumberOfChannelsKey: 2,
+            AVEncoderBitRateKey: 128_000
+        ]
+        let input = AVAssetWriterInput(mediaType: .audio, outputSettings: audioSettings)
+        input.expectsMediaDataInRealTime = true
+        guard writer.canAdd(input) else {
+            throw NSError(domain: "MeetingSystemAudioRecorder", code: 2, userInfo: [NSLocalizedDescriptionKey: "Unable to add audio writer input."])
+        }
+        writer.add(input)
+
+        self.writer = writer
+        self.writerInput = input
+        self.didStartWriting = false
+
+        let stream = SCStream(filter: filter, configuration: config, delegate: self)
+        self.stream = stream
+        try stream.addStreamOutput(self, type: .audio, sampleHandlerQueue: outputQueue)
+        try await stream.startCapture()
+    }
+
+    func stop() async {
+        do {
+            try await stream?.stopCapture()
+        } catch {
+            // Ignore; we still attempt to finish the writer.
+        }
+
+        stream = nil
+
+        guard let writer else { return }
+        writerInput?.markAsFinished()
+        await withCheckedContinuation { continuation in
+            writer.finishWriting {
+                continuation.resume()
+            }
+        }
+        self.writer = nil
+        self.writerInput = nil
+    }
+
+    func stream(_ stream: SCStream, didStopWithError error: Error) {
+        // Caller handles lifecycle; this often triggers when permission changes.
+    }
+
+    func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of outputType: SCStreamOutputType) {
+        guard outputType == .audio else { return }
+        guard CMSampleBufferDataIsReady(sampleBuffer) else { return }
+        guard let writer, let input = writerInput else { return }
+
+        if didStartWriting == false {
+            didStartWriting = true
+            let pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
+            writer.startWriting()
+            writer.startSession(atSourceTime: pts)
+        }
+
+        if input.isReadyForMoreMediaData {
+            input.append(sampleBuffer)
+        }
+    }
+}
+

+ 463 - 0
zoom_app/Transcription/MeetingTranscriptionService.swift

@@ -0,0 +1,463 @@
+import Foundation
+import AVFoundation
+import Speech
+
+/// A single piece of transcript text attributed to a channel and a time range
+/// (offsets are in seconds from the start of the recording).
+struct TranscriptSegment: Codable, Hashable {
+    let speaker: String
+    let startOffset: TimeInterval
+    let endOffset: TimeInterval
+    let text: String
+}
+
+/// Logical speaker labels used when merging per-channel transcripts.
+enum TranscriptSpeaker: String {
+    case microphone = "You"
+    case system = "Meeting"
+}
+
+/// Progress snapshot for UI status updates.
+struct MeetingTranscriptionProgress: Sendable {
+    let totalChunks: Int
+    let completedChunks: Int
+}
+
+enum MeetingTranscriptionError: Error, LocalizedError {
+    case authorizationDenied
+    case authorizationRestricted
+    case recognizerUnavailable(locale: String)
+    case noAudioToTranscribe
+
+    var errorDescription: String? {
+        switch self {
+        case .authorizationDenied:
+            return "Speech recognition permission denied. Enable it in System Settings and try again."
+        case .authorizationRestricted:
+            return "Speech recognition is restricted on this Mac."
+        case .recognizerUnavailable(let locale):
+            return "Speech recognizer is unavailable for \(locale)."
+        case .noAudioToTranscribe:
+            return "No audio was available to transcribe."
+        }
+    }
+}
+
+/// Transcribes meeting audio by running Apple Speech on per-channel files
+/// in fixed-size chunks, falling back across a list of locales per chunk.
+final class MeetingTranscriptionService {
+    private struct ChunkPlan {
+        let index: Int
+        let startFrame: AVAudioFramePosition
+        let frameCount: AVAudioFrameCount
+        let startOffset: TimeInterval
+        let endOffset: TimeInterval
+    }
+
+    /// Shared progress counter used across concurrent channels.
+    private actor ProgressCounter {
+        private let total: Int
+        private var completed: Int = 0
+        private let onProgress: (@Sendable (MeetingTranscriptionProgress) -> Void)?
+
+        init(total: Int, onProgress: (@Sendable (MeetingTranscriptionProgress) -> Void)?) {
+            self.total = total
+            self.onProgress = onProgress
+        }
+
+        func emitInitial() {
+            onProgress?(MeetingTranscriptionProgress(totalChunks: total, completedChunks: 0))
+        }
+
+        func increment() {
+            completed += 1
+            onProgress?(MeetingTranscriptionProgress(totalChunks: total, completedChunks: completed))
+        }
+    }
+
+    func requestAuthorization() async throws {
+        switch SFSpeechRecognizer.authorizationStatus() {
+        case .authorized:
+            return
+        case .notDetermined:
+            let status: SFSpeechRecognizerAuthorizationStatus = await withCheckedContinuation { continuation in
+                SFSpeechRecognizer.requestAuthorization { continuation.resume(returning: $0) }
+            }
+            guard status == .authorized else { throw MeetingTranscriptionError.authorizationDenied }
+        case .denied:
+            throw MeetingTranscriptionError.authorizationDenied
+        case .restricted:
+            throw MeetingTranscriptionError.authorizationRestricted
+        @unknown default:
+            throw MeetingTranscriptionError.authorizationDenied
+        }
+    }
+
+    /// Transcribes the mic and system channel audio (either may be nil) and
+    /// returns a flat, time-ordered list of transcript segments labeled with
+    /// the speaker channel.
+    func transcribeMeeting(
+        micURL: URL?,
+        systemURL: URL?,
+        chunkSeconds: TimeInterval = 7,
+        overlapSeconds: TimeInterval = 1.2,
+        locales: [Locale] = [],
+        onProgress: (@Sendable (MeetingTranscriptionProgress) -> Void)? = nil
+    ) async throws -> [TranscriptSegment] {
+        try await requestAuthorization()
+
+        let micPlan: (URL, [ChunkPlan])? = try micURL.flatMap { url -> (URL, [ChunkPlan])? in
+            guard FileManager.default.fileExists(atPath: url.path) else { return nil }
+            let chunks = try planChunks(for: url, chunkSeconds: chunkSeconds, overlapSeconds: overlapSeconds)
+            return chunks.isEmpty ? nil : (url, chunks)
+        }
+        let systemPlan: (URL, [ChunkPlan])? = try systemURL.flatMap { url -> (URL, [ChunkPlan])? in
+            guard FileManager.default.fileExists(atPath: url.path) else { return nil }
+            let chunks = try planChunks(for: url, chunkSeconds: chunkSeconds, overlapSeconds: overlapSeconds)
+            return chunks.isEmpty ? nil : (url, chunks)
+        }
+
+        let totalChunks = (micPlan?.1.count ?? 0) + (systemPlan?.1.count ?? 0)
+        guard totalChunks > 0 else {
+            throw MeetingTranscriptionError.noAudioToTranscribe
+        }
+
+        let counter = ProgressCounter(total: totalChunks, onProgress: onProgress)
+        await counter.emitInitial()
+
+        let effectiveLocales = locales.isEmpty ? preferredLocalesForGlobalRecognition() : locales
+
+        async let micSegments: [TranscriptSegment] = {
+            guard let plan = micPlan else { return [] }
+            return try await self.transcribeChannel(
+                url: plan.0,
+                chunks: plan.1,
+                speaker: .microphone,
+                locales: effectiveLocales,
+                counter: counter
+            )
+        }()
+        async let systemSegments: [TranscriptSegment] = {
+            guard let plan = systemPlan else { return [] }
+            return try await self.transcribeChannel(
+                url: plan.0,
+                chunks: plan.1,
+                speaker: .system,
+                locales: effectiveLocales,
+                counter: counter
+            )
+        }()
+
+        let combined = try await micSegments + systemSegments
+        return combined
+            .filter { $0.text.isEmpty == false }
+            .sorted { $0.startOffset < $1.startOffset }
+    }
+
+    // MARK: - Chunk planning
+
+    private func planChunks(for url: URL, chunkSeconds: TimeInterval, overlapSeconds: TimeInterval) throws -> [ChunkPlan] {
+        let audioFile = try AVAudioFile(forReading: url)
+        let sampleRate = audioFile.processingFormat.sampleRate
+        guard sampleRate > 0 else { return [] }
+        let totalFrames = audioFile.length
+        guard totalFrames > 0 else { return [] }
+
+        let chunkSamples = AVAudioFramePosition(max(1, chunkSeconds * sampleRate))
+        let overlapSamples = AVAudioFramePosition(max(0, overlapSeconds * sampleRate))
+        let step = max(AVAudioFramePosition(1), chunkSamples - overlapSamples)
+
+        var plans: [ChunkPlan] = []
+        var start: AVAudioFramePosition = 0
+        var index = 0
+        while start < totalFrames {
+            let end = min(start + chunkSamples, totalFrames)
+            let frameCount = AVAudioFrameCount(end - start)
+            let startOffset = Double(start) / sampleRate
+            let endOffset = Double(end) / sampleRate
+            plans.append(ChunkPlan(
+                index: index,
+                startFrame: start,
+                frameCount: frameCount,
+                startOffset: startOffset,
+                endOffset: endOffset
+            ))
+            index += 1
+            if end >= totalFrames { break }
+            start += step
+        }
+        return plans
+    }
+
+    // MARK: - Per-channel transcription
+
+    private func transcribeChannel(
+        url: URL,
+        chunks: [ChunkPlan],
+        speaker: TranscriptSpeaker,
+        locales: [Locale],
+        counter: ProgressCounter
+    ) async throws -> [TranscriptSegment] {
+        var segments: [TranscriptSegment] = []
+        segments.reserveCapacity(chunks.count)
+        var preferredLocale: Locale?
+
+        for plan in chunks {
+            try Task.checkCancellation()
+            let buffer = try readChunkBuffer(url: url, startFrame: plan.startFrame, frameCount: plan.frameCount)
+            let result = await transcribeBufferWithLocaleFallback(
+                buffer: buffer,
+                locales: locales,
+                preferredLocale: preferredLocale
+            )
+            await counter.increment()
+            preferredLocale = result.locale ?? preferredLocale
+            let trimmed = result.text.trimmingCharacters(in: .whitespacesAndNewlines)
+            if trimmed.isEmpty { continue }
+            let textForSegment: String
+            if let previous = segments.last {
+                textForSegment = removeRepeatedPrefix(from: trimmed, previousText: previous.text)
+            } else {
+                textForSegment = trimmed
+            }
+            let normalized = textForSegment.trimmingCharacters(in: .whitespacesAndNewlines)
+            if normalized.isEmpty { continue }
+            segments.append(TranscriptSegment(
+                speaker: speaker.rawValue,
+                startOffset: plan.startOffset,
+                endOffset: plan.endOffset,
+                text: normalized
+            ))
+        }
+        return segments
+    }
+
+    private func readChunkBuffer(url: URL, startFrame: AVAudioFramePosition, frameCount: AVAudioFrameCount) throws -> AVAudioPCMBuffer {
+        let audioFile = try AVAudioFile(forReading: url)
+        audioFile.framePosition = startFrame
+        guard let buffer = AVAudioPCMBuffer(pcmFormat: audioFile.processingFormat, frameCapacity: frameCount) else {
+            throw NSError(domain: "MeetingTranscriptionService", code: 1, userInfo: [NSLocalizedDescriptionKey: "Unable to allocate audio buffer."])
+        }
+        try audioFile.read(into: buffer, frameCount: frameCount)
+        return buffer
+    }
+
+    private func transcribeBufferWithLocaleFallback(
+        buffer: AVAudioPCMBuffer,
+        locales: [Locale],
+        preferredLocale: Locale?
+    ) async -> (text: String, locale: Locale?) {
+        var orderedLocales: [Locale] = []
+        if let preferredLocale {
+            orderedLocales.append(preferredLocale)
+        }
+        for locale in locales where orderedLocales.contains(where: { $0.identifier == locale.identifier }) == false {
+            orderedLocales.append(locale)
+        }
+
+        var bestCandidate: (text: String, locale: Locale, score: Double)?
+        for locale in orderedLocales {
+            guard let recognizer = SFSpeechRecognizer(locale: locale), recognizer.isAvailable else { continue }
+            do {
+                let text = try await transcribeBuffer(buffer: buffer, recognizer: recognizer)
+                let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
+                guard trimmed.isEmpty == false else { continue }
+                let score = scoreTranscriptCandidate(trimmed, locale: locale, preferredLocale: preferredLocale)
+                if let current = bestCandidate {
+                    if score > current.score {
+                        bestCandidate = (trimmed, locale, score)
+                    }
+                } else {
+                    bestCandidate = (trimmed, locale, score)
+                }
+            } catch {
+                // One transient retry before moving on to the next locale.
+                try? await Task.sleep(nanoseconds: 500_000_000)
+                if let text = try? await transcribeBuffer(buffer: buffer, recognizer: recognizer) {
+                    let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
+                    guard trimmed.isEmpty == false else { continue }
+                    let score = scoreTranscriptCandidate(trimmed, locale: locale, preferredLocale: preferredLocale)
+                    if let current = bestCandidate {
+                        if score > current.score {
+                            bestCandidate = (trimmed, locale, score)
+                        }
+                    } else {
+                        bestCandidate = (trimmed, locale, score)
+                    }
+                }
+                continue
+            }
+        }
+        if let bestCandidate {
+            return (bestCandidate.text, bestCandidate.locale)
+        }
+        return ("", nil)
+    }
+
+    private func preferredLocalesForGlobalRecognition() -> [Locale] {
+        let supported = SFSpeechRecognizer.supportedLocales()
+        guard supported.isEmpty == false else {
+            return [Locale.current, Locale(identifier: "en-US")]
+        }
+
+        let normalizedSupportedByIdentifier: [String: Locale] = Dictionary(
+            uniqueKeysWithValues: supported.map { locale in
+                (normalizeLocaleIdentifier(locale.identifier), locale)
+            }
+        )
+
+        var ordered: [Locale] = []
+        var seen = Set<String>()
+
+        func appendIfSupportedIdentifier(_ identifier: String) {
+            let normalized = normalizeLocaleIdentifier(identifier)
+            guard seen.contains(normalized) == false else { return }
+            guard let supportedLocale = normalizedSupportedByIdentifier[normalized] else { return }
+            seen.insert(normalized)
+            ordered.append(supportedLocale)
+        }
+
+        appendIfSupportedIdentifier(Locale.current.identifier)
+        for identifier in Locale.preferredLanguages {
+            appendIfSupportedIdentifier(identifier)
+        }
+        for locale in supported.sorted(by: { $0.identifier < $1.identifier }) {
+            appendIfSupportedIdentifier(locale.identifier)
+        }
+
+        if ordered.isEmpty {
+            return [Locale(identifier: "en-US")]
+        }
+        return ordered
+    }
+
+    private func normalizeLocaleIdentifier(_ identifier: String) -> String {
+        identifier.replacingOccurrences(of: "_", with: "-").lowercased()
+    }
+
+    private func scoreTranscriptCandidate(_ text: String, locale: Locale, preferredLocale: Locale?) -> Double {
+        let normalizedWords = text
+            .components(separatedBy: CharacterSet.letters.inverted)
+            .filter { $0.isEmpty == false }
+        guard normalizedWords.isEmpty == false else { return 0 }
+
+        let totalChars = max(1, text.count)
+        let letterCount = text.unicodeScalars.filter { CharacterSet.letters.contains($0) }.count
+        let letterRatio = Double(letterCount) / Double(totalChars)
+
+        var repeatedWordPenalty = 0.0
+        if normalizedWords.count >= 3 {
+            var longestRun = 1
+            var run = 1
+            for index in 1..<normalizedWords.count {
+                if normalizedWords[index].caseInsensitiveCompare(normalizedWords[index - 1]) == .orderedSame {
+                    run += 1
+                    longestRun = max(longestRun, run)
+                } else {
+                    run = 1
+                }
+            }
+            if longestRun >= 3 {
+                repeatedWordPenalty = Double(longestRun - 2) * 1.8
+            }
+        }
+
+        let localeBias: Double
+        if let preferredLocale,
+           normalizeLocaleIdentifier(preferredLocale.identifier) == normalizeLocaleIdentifier(locale.identifier) {
+            localeBias = 1.0
+        } else {
+            localeBias = 0.0
+        }
+
+        return Double(normalizedWords.count) + (letterRatio * 4.0) + localeBias - repeatedWordPenalty
+    }
+
+    /// Removes repeated leading words from a chunk that overlap with the
+    /// trailing words of the previous chunk.
+    private func removeRepeatedPrefix(from text: String, previousText: String) -> String {
+        let separators = CharacterSet.whitespacesAndNewlines.union(.punctuationCharacters)
+        let currentWords = text.components(separatedBy: separators).filter { $0.isEmpty == false }
+        let previousWords = previousText.components(separatedBy: separators).filter { $0.isEmpty == false }
+        guard currentWords.isEmpty == false, previousWords.isEmpty == false else { return text }
+
+        let maxOverlap = min(12, currentWords.count, previousWords.count)
+        guard maxOverlap > 0 else { return text }
+
+        for overlap in stride(from: maxOverlap, through: 1, by: -1) {
+            let previousSuffix = Array(previousWords.suffix(overlap)).map { $0.lowercased() }
+            let currentPrefix = Array(currentWords.prefix(overlap)).map { $0.lowercased() }
+            if previousSuffix == currentPrefix {
+                let remainder = currentWords.dropFirst(overlap)
+                return remainder.joined(separator: " ")
+            }
+        }
+        return text
+    }
+
+    private func transcribeBuffer(buffer: AVAudioPCMBuffer, recognizer: SFSpeechRecognizer) async throws -> String {
+        let request = SFSpeechAudioBufferRecognitionRequest()
+        request.shouldReportPartialResults = false
+        if #available(macOS 13.0, *) {
+            request.addsPunctuation = true
+        }
+
+        return try await withCheckedThrowingContinuation { continuation in
+            var hasResumed = false
+            let lock = NSLock()
+            func resumeOnce(with result: Result<String, Error>) {
+                lock.lock()
+                defer { lock.unlock() }
+                if hasResumed { return }
+                hasResumed = true
+                switch result {
+                case .success(let text):
+                    continuation.resume(returning: text)
+                case .failure(let error):
+                    continuation.resume(throwing: error)
+                }
+            }
+
+            let task = recognizer.recognitionTask(with: request) { result, error in
+                if let error {
+                    let nsError = error as NSError
+                    // "No speech detected" is a normal empty-chunk outcome.
+                    if nsError.domain == "kAFAssistantErrorDomain" && (nsError.code == 203 || nsError.code == 1110) {
+                        resumeOnce(with: .success(""))
+                        return
+                    }
+                    resumeOnce(with: .failure(error))
+                    return
+                }
+                if let result, result.isFinal {
+                    resumeOnce(with: .success(result.bestTranscription.formattedString))
+                }
+            }
+
+            request.append(buffer)
+            request.endAudio()
+            _ = task
+        }
+    }
+}
+
+extension Array where Element == TranscriptSegment {
+    /// Renders segments as a human-readable timeline like:
+    /// `[00:12] Hello everyone.`
+    func renderedTimelineText() -> String {
+        let formatter: (TimeInterval) -> String = { seconds in
+            let total = Int(seconds.rounded(.down))
+            let h = total / 3600
+            let m = (total % 3600) / 60
+            let s = total % 60
+            if h > 0 {
+                return String(format: "%02d:%02d:%02d", h, m, s)
+            }
+            return String(format: "%02d:%02d", m, s)
+        }
+        return self.map { segment in
+            "[\(formatter(segment.startOffset))] \(segment.text)"
+        }.joined(separator: "\n")
+    }
+}
+

+ 462 - 22
zoom_app/ViewController.swift

@@ -6,6 +6,7 @@
 //
 
 import Cocoa
+import AVFoundation
 import Combine
 import CryptoKit
 import StoreKit
@@ -15,7 +16,7 @@ import WebKit
 
 /// Default main window **content** size; OAuth sign-in uses the same dimensions and aligns to this content area.
 private enum AppWindowMetrics {
-    static let defaultContentWidth: CGFloat = 1020
+    static let defaultContentWidth: CGFloat = 940
     static let defaultContentHeight: CGFloat = 690
 }
 
@@ -41,6 +42,15 @@ class ViewController: NSViewController {
         case settings = "Settings"
     }
 
+    private struct ActiveMeetingRecordingSession {
+        let id: String
+        let title: String
+        let meetingURL: URL?
+        let startedAt: Date
+        let systemAudioFileURL: URL
+        let microphoneAudioFileURL: URL
+    }
+
     private let googleOAuth = GoogleOAuthService.shared
     private let zoomOAuth = ZoomOAuthService.shared
     private let loginStateKey = "zoom_app.isLoggedIn"
@@ -55,6 +65,10 @@ class ViewController: NSViewController {
     private let usageSessionStartedAtKey = "zoom_app.usageSessionStartedAt"
     private let meetingTimeZoneOverrideKey = "zoom_app.meetingTimeZoneOverrideByMeetingID"
 
+    private let aiCompanionLocalRecordingsDefaultsKey = "aiCompanion.localRecordings"
+    private let aiCompanionPreferredLanguage1DefaultsKey = "aiCompanion.preferredLanguage1"
+    private let aiCompanionPreferredLanguage2DefaultsKey = "aiCompanion.preferredLanguage2"
+
     private struct Palette {
         let isDarkMode: Bool
 
@@ -328,6 +342,14 @@ class ViewController: NSViewController {
     private var aiCompanionIsLoading: Bool = false
     private var aiCompanionLastLoadedAt: Date?
     private var aiCompanionSelectedRange: String = "Last 30 days"
+    private var aiCompanionLocalRecordings: [LocalMeetingRecording] = []
+    private var activeMeetingRecordingSession: ActiveMeetingRecordingSession?
+    private var activeMeetingAudioRecorder: AVAudioRecorder?
+    private var activeMeetingSystemAudioStopper: (() async -> Void)?
+    private weak var aiCompanionStopRecordingButton: NSButton?
+
+    private let meetingTranscriptionService = MeetingTranscriptionService()
+    private let meetingNotesService = MeetingNotesService()
     private weak var meetingsPageCategoryFilter: NSPopUpButton?
     private weak var meetingsPageFromDatePicker: NSDatePicker?
     private weak var meetingsPageToDatePicker: NSDatePicker?
@@ -639,6 +661,7 @@ class ViewController: NSViewController {
         NSApp.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua)
         view.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua)
         meetingTimeZoneOverrides = UserDefaults.standard.dictionary(forKey: meetingTimeZoneOverrideKey) as? [String: String] ?? [:]
+        loadAiCompanionLocalRecordings()
         ensureUsageSessionStartDate()
         scheduleUsageBasedRatingPromptIfNeeded()
         setupUI()
@@ -649,9 +672,7 @@ class ViewController: NSViewController {
     override func viewDidAppear() {
         super.viewDidAppear()
         if let window = view.window {
-            window.setContentSize(
-                NSSize(width: AppWindowMetrics.defaultContentWidth, height: AppWindowMetrics.defaultContentHeight)
-            )
+            enforceDefaultWindowFrame(window)
             applyWindowBackgroundForCurrentTheme(window)
             // Use full-size content view so custom top chrome sits in the titlebar region.
             window.titleVisibility = .hidden
@@ -695,6 +716,31 @@ class ViewController: NSViewController {
         ])
     }
 
+    private func enforceDefaultWindowFrame(_ window: NSWindow) {
+        // Avoid fighting full-screen.
+        if window.styleMask.contains(.fullScreen) { return }
+        // If the window is zoomed, setContentSize may be ignored.
+        if window.isZoomed {
+            window.zoom(nil)
+        }
+
+        let targetContentSize = NSSize(width: AppWindowMetrics.defaultContentWidth, height: AppWindowMetrics.defaultContentHeight)
+        // Keep the main window at a consistent size (matches the reference app behavior).
+        window.contentMinSize = targetContentSize
+        window.contentMaxSize = targetContentSize
+
+        let contentRect = NSRect(origin: .zero, size: targetContentSize)
+        let frameRect = window.frameRect(forContentRect: contentRect)
+        var newFrame = window.frame
+        newFrame.size = frameRect.size
+
+        if let screenFrame = window.screen?.visibleFrame ?? NSScreen.main?.visibleFrame {
+            newFrame.origin.x = screenFrame.midX - (newFrame.size.width / 2)
+            newFrame.origin.y = screenFrame.midY - (newFrame.size.height / 2)
+        }
+        window.setFrame(newFrame, display: true)
+    }
+
     private func installWindowFullScreenObservers(for window: NSWindow) {
         guard windowFullScreenObservers.isEmpty else { return }
 
@@ -1666,11 +1712,298 @@ class ViewController: NSViewController {
         let opened = openURLInDefaultBrowser(url)
         if opened {
             joinMeetingWindow?.performClose(nil)
+            maybeStartLocalMeetingRecording(meetingTitle: "Zoom meeting", meetingURL: url)
         } else {
             showSimpleAlert(title: "Unable to open", message: "Your default browser could not be opened.")
         }
     }
 
+    // MARK: - Local meeting recording (AI Companion)
+
+    private func maybeStartLocalMeetingRecording(meetingTitle: String, meetingURL: URL) {
+        // Avoid double-start if user clicks multiple times.
+        if activeMeetingRecordingSession != nil { return }
+
+        let alert = NSAlert()
+        alert.messageText = "Record this meeting locally?"
+        alert.informativeText = "With your consent, the app will record meeting audio on this Mac and show it in AI Companion after the meeting."
+        alert.addButton(withTitle: "Allow and Start Recording")
+        alert.addButton(withTitle: "Continue Without Recording")
+        alert.window.title = meetingTitle
+        let response = alert.runModal()
+        if response != .alertFirstButtonReturn { return }
+
+        Task { @MainActor in
+            await startLocalMeetingRecordingFlow(meetingTitle: meetingTitle, meetingURL: meetingURL)
+        }
+    }
+
+    @MainActor
+    private func startLocalMeetingRecordingFlow(meetingTitle: String, meetingURL: URL) async {
+        // Microphone permission
+        switch AVCaptureDevice.authorizationStatus(for: .audio) {
+        case .authorized:
+            startLocalMeetingRecording(meetingTitle: meetingTitle, meetingURL: meetingURL)
+        case .notDetermined:
+            let granted = await AVCaptureDevice.requestAccess(for: .audio)
+            if granted {
+                startLocalMeetingRecording(meetingTitle: meetingTitle, meetingURL: meetingURL)
+            } else {
+                showSimpleAlert(title: "Microphone permission denied", message: "Enable microphone access in System Settings to record meetings locally.")
+            }
+        case .denied, .restricted:
+            showSimpleAlert(title: "Microphone permission required", message: "Enable microphone access in System Settings to record meetings locally.")
+        @unknown default:
+            showSimpleAlert(title: "Microphone permission required", message: "Enable microphone access in System Settings to record meetings locally.")
+        }
+    }
+
+    @MainActor
+    private func startLocalMeetingRecording(meetingTitle: String, meetingURL: URL) {
+        let recordingID = UUID().uuidString
+        let systemURL = localRecordingDirectoryURL().appendingPathComponent("\(recordingID)-system.m4a")
+        let microphoneURL = localRecordingDirectoryURL().appendingPathComponent("\(recordingID)-mic.m4a")
+
+        activeMeetingRecordingSession = ActiveMeetingRecordingSession(
+            id: recordingID,
+            title: meetingTitle,
+            meetingURL: meetingURL,
+            startedAt: Date(),
+            systemAudioFileURL: systemURL,
+            microphoneAudioFileURL: microphoneURL
+        )
+
+        startMicrophoneRecording(at: microphoneURL)
+
+        if #available(macOS 13.0, *) {
+            Task { @MainActor [weak self] in
+                let recorder = MeetingSystemAudioRecorder(outputURL: systemURL)
+                do {
+                    try await recorder.start()
+                    self?.activeMeetingSystemAudioStopper = {
+                        await recorder.stop()
+                    }
+                    self?.showTopToast(message: "Meeting recording started (meeting + microphone)", isError: false)
+                    self?.updateAiCompanionRecordingControls()
+                } catch {
+                    self?.activeMeetingSystemAudioStopper = nil
+                    self?.showTopToast(message: "System audio unavailable. Recording microphone only.", isError: true)
+                    self?.updateAiCompanionRecordingControls()
+                }
+            }
+        } else {
+            showTopToast(message: "Meeting recording started (microphone only)", isError: false)
+            updateAiCompanionRecordingControls()
+        }
+    }
+
+    private func startMicrophoneRecording(at outputURL: URL) {
+        let settings: [String: Any] = [
+            AVFormatIDKey: kAudioFormatMPEG4AAC,
+            AVSampleRateKey: 44_100,
+            AVNumberOfChannelsKey: 1,
+            AVEncoderBitRateKey: 96_000
+        ]
+        do {
+            let recorder = try AVAudioRecorder(url: outputURL, settings: settings)
+            recorder.prepareToRecord()
+            recorder.record()
+            activeMeetingAudioRecorder = recorder
+        } catch {
+            showTopToast(message: "Microphone recording failed: \(error.localizedDescription)", isError: true)
+        }
+    }
+
+    @objc private func aiCompanionStopRecordingTapped() {
+        Task { @MainActor in
+            await stopLocalMeetingRecording()
+        }
+    }
+
+    @MainActor
+    private func stopLocalMeetingRecording() async {
+        guard let session = activeMeetingRecordingSession else { return }
+        activeMeetingRecordingSession = nil
+
+        activeMeetingAudioRecorder?.stop()
+        activeMeetingAudioRecorder = nil
+
+        if let stopSystem = activeMeetingSystemAudioStopper {
+            await stopSystem()
+        }
+        activeMeetingSystemAudioStopper = nil
+
+        updateAiCompanionRecordingControls()
+
+        let destinationURL = localRecordingDirectoryURL().appendingPathComponent("\(session.id).m4a")
+        let finalized = await MeetingAudioMixer.finalize(
+            systemURL: session.systemAudioFileURL,
+            microphoneURL: session.microphoneAudioFileURL,
+            destinationURL: destinationURL
+        )
+
+        let recording = LocalMeetingRecording(
+            id: session.id,
+            title: session.title,
+            meetingURLString: session.meetingURL?.absoluteString,
+            startedAt: session.startedAt,
+            endedAt: Date(),
+            mixedAudioFilePath: finalized.mixedURL.path,
+            microphoneAudioFilePath: finalized.microphoneURL?.path,
+            systemAudioFilePath: finalized.systemURL?.path,
+            transcriptStatusRaw: LocalMeetingTranscriptStatus.notRequested.rawValue,
+            transcriptText: nil,
+            transcriptSegmentsJSON: nil,
+            transcriptErrorMessage: nil,
+            notesStatusRaw: LocalMeetingNotesStatus.notRequested.rawValue,
+            notesText: nil,
+            notesErrorMessage: nil
+        )
+
+        aiCompanionLocalRecordings.insert(recording, at: 0)
+        aiCompanionLocalRecordings.sort { $0.endedAt > $1.endedAt }
+        persistAiCompanionLocalRecordings()
+
+        // Surface it immediately in the UI list.
+        rebuildAiCompanionSummariesFromLocalRecordings()
+        renderAiCompanionList()
+        renderAiCompanionDetail()
+
+        showTopToast(message: "Recording saved. Generating transcript + notes…", isError: false)
+        Task.detached { [weak self] in
+            await self?.generateTranscriptAndNotesForRecording(meetingId: session.id)
+        }
+    }
+
+    private func localRecordingDirectoryURL() -> URL {
+        let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
+        let bundleID = Bundle.main.bundleIdentifier ?? "zoom_app"
+        let dir = base.appendingPathComponent(bundleID).appendingPathComponent("AICompanionRecordings")
+        try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
+        return dir
+    }
+
+    private func aiCompanionPreferredLocales() -> [Locale] {
+        let defaults = UserDefaults.standard
+        let selectedIdentifiers = [
+            defaults.string(forKey: aiCompanionPreferredLanguage1DefaultsKey),
+            defaults.string(forKey: aiCompanionPreferredLanguage2DefaultsKey)
+        ]
+            .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
+            .filter { $0.isEmpty == false }
+
+        if selectedIdentifiers.isEmpty { return [] }
+        return selectedIdentifiers.map { Locale(identifier: $0) }
+    }
+
+    private func generateTranscriptAndNotesForRecording(meetingId: String) async {
+        func updateRecording(_ mutate: (inout LocalMeetingRecording) -> Void) {
+            if let idx = aiCompanionLocalRecordings.firstIndex(where: { $0.id == meetingId }) {
+                var copy = aiCompanionLocalRecordings[idx]
+                mutate(&copy)
+                aiCompanionLocalRecordings[idx] = copy
+            }
+        }
+
+        await MainActor.run {
+            updateRecording { r in
+                r.transcriptStatusRaw = LocalMeetingTranscriptStatus.processing.rawValue
+                r.transcriptErrorMessage = nil
+                r.notesStatusRaw = LocalMeetingNotesStatus.notRequested.rawValue
+                r.notesErrorMessage = nil
+            }
+            persistAiCompanionLocalRecordings()
+            rebuildAiCompanionSummariesFromLocalRecordings()
+            renderAiCompanionList()
+            renderAiCompanionDetail()
+        }
+
+        let recording = await MainActor.run { aiCompanionLocalRecordings.first(where: { $0.id == meetingId }) }
+        guard let recording else { return }
+
+        let micURL: URL? = recording.microphoneAudioFilePath
+            .map { URL(fileURLWithPath: $0) }
+            .flatMap { FileManager.default.fileExists(atPath: $0.path) ? $0 : nil }
+        let systemURL: URL? = recording.systemAudioFilePath
+            .map { URL(fileURLWithPath: $0) }
+            .flatMap { FileManager.default.fileExists(atPath: $0.path) ? $0 : nil }
+        let mixedURL = URL(fileURLWithPath: recording.mixedAudioFilePath)
+        let hasPerChannel = (micURL != nil || systemURL != nil)
+
+        do {
+            let locales = await MainActor.run { aiCompanionPreferredLocales() }
+            let segments: [TranscriptSegment]
+            if hasPerChannel {
+                segments = try await meetingTranscriptionService.transcribeMeeting(
+                    micURL: micURL,
+                    systemURL: systemURL,
+                    locales: locales
+                )
+            } else {
+                segments = try await meetingTranscriptionService.transcribeMeeting(
+                    micURL: nil,
+                    systemURL: (FileManager.default.fileExists(atPath: mixedURL.path) ? mixedURL : nil),
+                    locales: locales
+                )
+            }
+
+            let transcriptText = segments.renderedTimelineText()
+            let segmentsJSON = String(data: try JSONEncoder().encode(segments), encoding: .utf8)
+
+            await MainActor.run {
+                updateRecording { r in
+                    r.transcriptStatusRaw = LocalMeetingTranscriptStatus.ready.rawValue
+                    r.transcriptText = transcriptText
+                    r.transcriptSegmentsJSON = segmentsJSON
+                    r.transcriptErrorMessage = nil
+                    r.notesStatusRaw = LocalMeetingNotesStatus.processing.rawValue
+                    r.notesErrorMessage = nil
+                }
+                persistAiCompanionLocalRecordings()
+                rebuildAiCompanionSummariesFromLocalRecordings()
+                renderAiCompanionList()
+                renderAiCompanionDetail()
+            }
+
+            let defaults = UserDefaults.standard
+            let preferred1 = defaults.string(forKey: aiCompanionPreferredLanguage1DefaultsKey)
+            let preferred2 = defaults.string(forKey: aiCompanionPreferredLanguage2DefaultsKey)
+            let notes = try await meetingNotesService.generateNotes(
+                from: transcriptText,
+                preferredLanguageIdentifier1: preferred1,
+                preferredLanguageIdentifier2: preferred2
+            )
+
+            await MainActor.run {
+                updateRecording { r in
+                    r.notesStatusRaw = LocalMeetingNotesStatus.ready.rawValue
+                    r.notesText = notes
+                    r.notesErrorMessage = nil
+                }
+                persistAiCompanionLocalRecordings()
+                rebuildAiCompanionSummariesFromLocalRecordings()
+                renderAiCompanionList()
+                renderAiCompanionDetail()
+                aiCompanionStatusLabel?.stringValue = "Notes ready."
+            }
+        } catch {
+            await MainActor.run {
+                updateRecording { r in
+                    r.transcriptStatusRaw = LocalMeetingTranscriptStatus.failed.rawValue
+                    r.transcriptErrorMessage = error.localizedDescription
+                    r.notesStatusRaw = LocalMeetingNotesStatus.failed.rawValue
+                    r.notesErrorMessage = error.localizedDescription
+                }
+                persistAiCompanionLocalRecordings()
+                rebuildAiCompanionSummariesFromLocalRecordings()
+                renderAiCompanionList()
+                renderAiCompanionDetail()
+                aiCompanionStatusLabel?.stringValue = "Failed to generate notes."
+            }
+        }
+    }
+
+
     @discardableResult
     private func openURLInDefaultBrowser(_ url: URL) -> Bool {
         NSWorkspace.shared.open(url)
@@ -2455,6 +2788,15 @@ class ViewController: NSViewController {
         alert.runModal()
     }
 
+    @MainActor
+    private func showTopToast(message: String, isError: Bool) {
+        // Lightweight status surface (avoids interrupting the user with alerts).
+        aiCompanionStatusLabel?.stringValue = message
+        if isError {
+            NSSound.beep()
+        }
+    }
+
     private struct ScheduledMeeting {
         let title: String
         let start: Date
@@ -6545,6 +6887,16 @@ class ViewController: NSViewController {
         rangePopup.target = self
         rangePopup.action = #selector(aiCompanionRangeChanged(_:))
 
+        let stopRecording = NSButton(title: "Stop recording", target: self, action: #selector(aiCompanionStopRecordingTapped))
+        stopRecording.translatesAutoresizingMaskIntoConstraints = false
+        stopRecording.isBordered = false
+        stopRecording.wantsLayer = true
+        stopRecording.layer?.backgroundColor = NSColor.systemRed.withAlphaComponent(0.95).cgColor
+        stopRecording.layer?.cornerRadius = 10
+        stopRecording.contentTintColor = .white
+        stopRecording.font = .systemFont(ofSize: 13, weight: .semibold)
+        stopRecording.isHidden = true
+
         let refresh = NSButton(title: "Refresh", target: self, action: #selector(aiCompanionRefreshTapped))
         refresh.translatesAutoresizingMaskIntoConstraints = false
         refresh.isBordered = false
@@ -6561,7 +6913,7 @@ class ViewController: NSViewController {
         loadingIndicator.isDisplayedWhenStopped = false
         loadingIndicator.isIndeterminate = true
 
-        let toolbarRight = NSStackView(views: [loadingIndicator, rangePopup, refresh])
+        let toolbarRight = NSStackView(views: [loadingIndicator, rangePopup, stopRecording, refresh])
         toolbarRight.orientation = .horizontal
         toolbarRight.alignment = .centerY
         toolbarRight.spacing = 10
@@ -6571,7 +6923,7 @@ class ViewController: NSViewController {
         container.addSubview(toolbarRight)
 
         // Status label below header.
-        let status = makeLabel("Sign in with Zoom to view AI notes.", size: 12, color: secondaryText, weight: .regular, centered: false)
+        let status = makeLabel("Local recordings and AI notes live on this Mac.", size: 12, color: secondaryText, weight: .regular, centered: false)
         status.translatesAutoresizingMaskIntoConstraints = false
         container.addSubview(status)
 
@@ -6643,9 +6995,11 @@ class ViewController: NSViewController {
 
         NSLayoutConstraint.activate([
             container.topAnchor.constraint(equalTo: root.topAnchor, constant: 8),
-            container.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 8),
-            container.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -8),
             container.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -8),
+            container.centerXAnchor.constraint(equalTo: root.centerXAnchor),
+            container.widthAnchor.constraint(lessThanOrEqualToConstant: 980),
+            container.leadingAnchor.constraint(greaterThanOrEqualTo: root.leadingAnchor, constant: 12),
+            container.trailingAnchor.constraint(lessThanOrEqualTo: root.trailingAnchor, constant: -12),
 
             titleStack.topAnchor.constraint(equalTo: container.topAnchor, constant: 20),
             titleStack.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
@@ -6654,6 +7008,8 @@ class ViewController: NSViewController {
             toolbarRight.centerYAnchor.constraint(equalTo: titleStack.centerYAnchor),
             toolbarRight.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -24),
 
+            stopRecording.heightAnchor.constraint(equalToConstant: 30),
+            stopRecording.widthAnchor.constraint(greaterThanOrEqualToConstant: 120),
             refresh.heightAnchor.constraint(equalToConstant: 30),
             refresh.widthAnchor.constraint(greaterThanOrEqualToConstant: 84),
             rangePopup.heightAnchor.constraint(equalToConstant: 28),
@@ -6705,17 +7061,103 @@ class ViewController: NSViewController {
         aiCompanionCountLabel = count
         aiCompanionRefreshButton = refresh
         aiCompanionRangePopup = rangePopup
+        aiCompanionStopRecordingButton = stopRecording
         aiCompanionEmptyLabel = listEmpty
         aiCompanionLoadingIndicator = loadingIndicator
         aiCompanionDetailEmptyLabel = detailEmpty
 
         renderAiCompanionList()
         renderAiCompanionDetail()
+        updateAiCompanionRecordingControls()
         return root
     }
 
     // MARK: - AI Companion list / detail rendering
 
+    private func updateAiCompanionRecordingControls() {
+        aiCompanionStopRecordingButton?.isHidden = (activeMeetingRecordingSession == nil)
+    }
+
+    private func loadAiCompanionLocalRecordings() {
+        let defaults = UserDefaults.standard
+        guard let raw = defaults.data(forKey: aiCompanionLocalRecordingsDefaultsKey) else {
+            aiCompanionLocalRecordings = []
+            rebuildAiCompanionSummariesFromLocalRecordings()
+            return
+        }
+        if let decoded = try? JSONDecoder().decode([LocalMeetingRecording].self, from: raw) {
+            aiCompanionLocalRecordings = decoded.sorted(by: { $0.endedAt > $1.endedAt })
+        } else {
+            aiCompanionLocalRecordings = []
+        }
+        rebuildAiCompanionSummariesFromLocalRecordings()
+    }
+
+    private func persistAiCompanionLocalRecordings() {
+        guard let data = try? JSONEncoder().encode(aiCompanionLocalRecordings) else { return }
+        UserDefaults.standard.set(data, forKey: aiCompanionLocalRecordingsDefaultsKey)
+    }
+
+    private func rebuildAiCompanionSummariesFromLocalRecordings() {
+        let recordings = filteredLocalRecordingsForSelectedRange(aiCompanionLocalRecordings)
+        aiCompanionSummaries = recordings.map { recording in
+            let content: String? = {
+                let notes = recording.notesText?.trimmingCharacters(in: .whitespacesAndNewlines)
+                if let notes, notes.isEmpty == false { return notes }
+                let status = LocalMeetingNotesStatus(rawValue: recording.notesStatusRaw ?? "") ?? .notRequested
+                switch status {
+                case .processing:
+                    return "Notes are generating…"
+                case .failed:
+                    return recording.notesErrorMessage ?? "Notes generation failed."
+                case .ready:
+                    return notes
+                case .notRequested:
+                    return "Notes not requested."
+                }
+            }()
+
+            return AICompanionSummary(
+                meetingUUID: recording.id,
+                meetingID: nil,
+                meetingTopic: recording.title,
+                meetingHostEmail: nil,
+                meetingStartTime: recording.startedAt,
+                meetingEndTime: recording.endedAt,
+                summaryTitle: recording.title,
+                summaryOverview: nil,
+                summaryDetails: [],
+                nextSteps: [],
+                summaryContent: content,
+                summaryDocURL: nil,
+                summaryCreatedTime: recording.endedAt,
+                summaryLastModifiedTime: recording.endedAt
+            )
+        }
+
+        if let idx = aiCompanionSelectedIndex, idx >= aiCompanionSummaries.count {
+            aiCompanionSelectedIndex = nil
+        }
+    }
+
+    private func filteredLocalRecordingsForSelectedRange(_ recordings: [LocalMeetingRecording]) -> [LocalMeetingRecording] {
+        let now = Date()
+        let calendar = Calendar.current
+        switch aiCompanionSelectedRange {
+        case "Last 7 days":
+            let from = calendar.date(byAdding: .day, value: -7, to: now) ?? now
+            return recordings.filter { $0.endedAt >= from }
+        case "Last 30 days":
+            let from = calendar.date(byAdding: .day, value: -30, to: now) ?? now
+            return recordings.filter { $0.endedAt >= from }
+        case "Last 90 days":
+            let from = calendar.date(byAdding: .day, value: -90, to: now) ?? now
+            return recordings.filter { $0.endedAt >= from }
+        default:
+            return recordings
+        }
+    }
+
     @MainActor
     private func renderAiCompanionList() {
         guard let stack = aiCompanionListStack else { return }
@@ -6729,12 +7171,9 @@ class ViewController: NSViewController {
         aiCompanionCountLabel?.stringValue = count == 1 ? "1 summary" : "\(count) summaries"
 
         let showEmpty: Bool
-        if isUserLoggedIn() == false {
-            showEmpty = true
-            aiCompanionEmptyLabel?.stringValue = "Please sign in with Zoom to view your AI notes."
-        } else if count == 0 {
+        if count == 0 {
             showEmpty = true
-            aiCompanionEmptyLabel?.stringValue = "No AI notes yet for the selected range."
+            aiCompanionEmptyLabel?.stringValue = "No recordings yet. Start or join a meeting and allow recording to generate notes."
         } else {
             showEmpty = false
         }
@@ -6814,15 +7253,10 @@ class ViewController: NSViewController {
             view.removeFromSuperview()
         }
 
-        guard isUserLoggedIn() else {
-            aiCompanionDetailEmptyLabel?.stringValue = "Please sign in with Zoom to view your AI notes."
-            aiCompanionDetailEmptyLabel?.isHidden = false
-            return
-        }
         guard let index = aiCompanionSelectedIndex,
               aiCompanionSummaries.indices.contains(index) else {
             aiCompanionDetailEmptyLabel?.stringValue = aiCompanionSummaries.isEmpty
-                ? "Run an AI Companion-enabled meeting to see notes here."
+                ? "Record a meeting to see notes here."
                 : "Select a meeting on the left to view its AI notes."
             aiCompanionDetailEmptyLabel?.isHidden = false
             return
@@ -7055,17 +7489,23 @@ class ViewController: NSViewController {
             stale = true
         }
         if stale {
-            Task { await self.loadAICompanionSummaries(force: false) }
+            loadAiCompanionLocalRecordings()
+            renderAiCompanionList()
+            renderAiCompanionDetail()
         }
     }
 
     @objc private func aiCompanionRefreshTapped() {
-        Task { await self.loadAICompanionSummaries(force: true) }
+        loadAiCompanionLocalRecordings()
+        renderAiCompanionList()
+        renderAiCompanionDetail()
     }
 
     @objc private func aiCompanionRangeChanged(_ sender: NSPopUpButton) {
         aiCompanionSelectedRange = sender.titleOfSelectedItem ?? "Last 30 days"
-        Task { await self.loadAICompanionSummaries(force: true) }
+        rebuildAiCompanionSummariesFromLocalRecordings()
+        renderAiCompanionList()
+        renderAiCompanionDetail()
     }
 
     @objc private func aiCompanionRowTapped(_ sender: NSButton) {