浏览代码

Add Apple Speech transcripts for Ai Companion local recordings

Introduce pre-join consent with preferred transcription languages and
optional local recording. Persist language choices in Settings and
UserDefaults. After each saved recording, transcribe mixed audio with
the same chunked SFSpeech approach as meetings_app, store transcript
text on the recording index, and show status and transcript in Ai
Companion. Add NSSpeechRecognitionUsageDescription for macOS.

Co-authored-by: Cursor <cursoragent@cursor.com>
huzaifahayat12 2 月之前
父节点
当前提交
b694d598d4

+ 2 - 0
zoom_app.xcodeproj/project.pbxproj

@@ -263,6 +263,7 @@
 				INFOPLIST_KEY_NSMicrophoneUsageDescription = "Zoom App records microphone audio during meetings so you can replay them in Ai Companion.";
 				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
 				INFOPLIST_KEY_NSScreenCaptureUsageDescription = "Zoom App captures system audio during meetings (when allowed) so recordings include computer sound in Ai Companion.";
+				INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "Zoom App uses Apple speech recognition to generate transcripts from your saved meeting audio in Ai Companion.";
 				INFOPLIST_KEY_ZoomOAuthClientId = isvIAKPhSPOhBxFUkiY2A;
 				LD_RUNPATH_SEARCH_PATHS = (
 					"$(inherited)",
@@ -301,6 +302,7 @@
 				INFOPLIST_KEY_NSMicrophoneUsageDescription = "Zoom App records microphone audio during meetings so you can replay them in Ai Companion.";
 				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
 				INFOPLIST_KEY_NSScreenCaptureUsageDescription = "Zoom App captures system audio during meetings (when allowed) so recordings include computer sound in Ai Companion.";
+				INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "Zoom App uses Apple speech recognition to generate transcripts from your saved meeting audio in Ai Companion.";
 				INFOPLIST_KEY_ZoomOAuthClientId = isvIAKPhSPOhBxFUkiY2A;
 				LD_RUNPATH_SEARCH_PATHS = (
 					"$(inherited)",

+ 67 - 1
zoom_app/MeetingRecordingManager.swift

@@ -15,6 +15,9 @@ final class MeetingRecordingManager: NSObject {
         /// Human-readable title for UI and for the saved file base name (older index entries omit this key).
         let meetingDisplayName: String?
         let audioFilename: String
+        /// Apple Speech transcript (timeline text). Omitted in older `index.json` entries.
+        let transcriptText: String?
+        let transcriptError: String?
 
         var audioFilenameURLSafe: String { audioFilename }
 
@@ -55,6 +58,8 @@ final class MeetingRecordingManager: NSObject {
 
     static let recordingsDidChangeNotification = Notification.Name("MeetingRecordingManager.recordingsDidChange")
     static let recordingStateDidChangeNotification = Notification.Name("MeetingRecordingManager.recordingStateDidChange")
+    static let transcriptionDidBeginNotification = Notification.Name("MeetingRecordingManager.transcriptionDidBegin")
+    static let transcriptionDidEndNotification = Notification.Name("MeetingRecordingManager.transcriptionDidEnd")
 
     private let encoder: JSONEncoder = {
         let e = JSONEncoder()
@@ -208,10 +213,13 @@ final class MeetingRecordingManager: NSObject {
             endedAt: endedAt,
             sourceURLString: recordingSourceURLString,
             meetingDisplayName: displayName,
-            audioFilename: mixedURL.lastPathComponent
+            audioFilename: mixedURL.lastPathComponent,
+            transcriptText: nil,
+            transcriptError: nil
         )
         recordings.insert(rec, at: 0)
         saveRecordingsToDisk()
+        scheduleAppleTranscription(for: rec)
 
         recordingID = nil
         recordingStartedAt = nil
@@ -241,6 +249,64 @@ final class MeetingRecordingManager: NSObject {
         }
     }
 
+    /// Runs Apple Speech on the saved mixed audio; updates `recordings` and posts `recordingsDidChangeNotification` when finished.
+    private func scheduleAppleTranscription(for recording: RecordedMeeting) {
+        let recordingId = recording.id
+        let filename = recording.audioFilename
+        let locales = TranscriptionLanguagePreferences.localesForAppleTranscription()
+        Task {
+            await performAppleTranscription(recordingId: recordingId, audioFilename: filename, locales: locales)
+        }
+    }
+
+    private func performAppleTranscription(recordingId: String, audioFilename: String, locales: [Locale]) async {
+        NotificationCenter.default.post(
+            name: Self.transcriptionDidBeginNotification,
+            object: self,
+            userInfo: ["recordingId": recordingId]
+        )
+        defer {
+            NotificationCenter.default.post(
+                name: Self.transcriptionDidEndNotification,
+                object: self,
+                userInfo: ["recordingId": recordingId]
+            )
+        }
+
+        let service = MeetingTranscriptionService()
+        do {
+            let dir = try recordingsDirectoryURL()
+            let audioURL = dir.appendingPathComponent(audioFilename)
+            let segments = try await service.transcribeMeeting(micURL: audioURL, systemURL: nil, locales: locales)
+            let text = segments.renderedTimelineText()
+            await MainActor.run {
+                applyTranscript(recordingId: recordingId, text: text.isEmpty ? nil : text, error: nil)
+            }
+        } catch {
+            await MainActor.run {
+                applyTranscript(recordingId: recordingId, text: nil, error: error.localizedDescription)
+            }
+        }
+    }
+
+    private func applyTranscript(recordingId: String, text: String?, error: String?) {
+        guard let idx = recordings.firstIndex(where: { $0.id == recordingId }) else { return }
+        let old = recordings[idx]
+        let updated = RecordedMeeting(
+            id: old.id,
+            startedAt: old.startedAt,
+            endedAt: old.endedAt,
+            sourceURLString: old.sourceURLString,
+            meetingDisplayName: old.meetingDisplayName,
+            audioFilename: old.audioFilename,
+            transcriptText: text,
+            transcriptError: error
+        )
+        recordings[idx] = updated
+        saveRecordingsToDisk()
+        NotificationCenter.default.post(name: Self.recordingsDidChangeNotification, object: self)
+    }
+
     private func saveRecordingsToDisk() {
         do {
             let url = try indexURL()

+ 460 - 0
zoom_app/MeetingTranscriptionService.swift

@@ -0,0 +1,460 @@
+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
+    }
+
+    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
+                    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")
+    }
+}

+ 100 - 0
zoom_app/TranscriptionLanguagePreferences.swift

@@ -0,0 +1,100 @@
+import Foundation
+import Speech
+
+struct SpeechLocaleOption: Hashable {
+    let identifier: String
+    let displayName: String
+}
+
+/// Persists preferred Apple Speech locales for meeting transcription (Settings + pre-join consent).
+enum TranscriptionLanguagePreferences {
+    private static let keyPrimary = "zoomApp.aiCompanion.preferredLanguage1"
+    private static let keySecondary = "zoomApp.aiCompanion.preferredLanguage2"
+
+    static func selectedPrimaryIdentifier() -> String? {
+        let v = UserDefaults.standard.string(forKey: keyPrimary)?.trimmingCharacters(in: .whitespacesAndNewlines)
+        return (v?.isEmpty == false) ? v : nil
+    }
+
+    static func selectedSecondaryIdentifier() -> String? {
+        let v = UserDefaults.standard.string(forKey: keySecondary)?.trimmingCharacters(in: .whitespacesAndNewlines)
+        return (v?.isEmpty == false) ? v : nil
+    }
+
+    static func supportedSpeechLocaleOptions() -> [SpeechLocaleOption] {
+        let supported = SFSpeechRecognizer.supportedLocales()
+        let currentIdentifier = Locale.current.identifier
+        let englishUSIdentifier = "en-US"
+        var candidates = Set<String>(supported.map { $0.identifier })
+        for preferred in Locale.preferredLanguages {
+            candidates.insert(preferred)
+        }
+        candidates.insert(currentIdentifier)
+        candidates.insert(englishUSIdentifier)
+
+        return candidates
+            .map { identifier -> SpeechLocaleOption in
+                let languageCode = Locale(identifier: identifier).languageCode
+                    ?? Locale.components(fromIdentifier: identifier)[NSLocale.Key.languageCode.rawValue]
+                let languageName = Locale.current.localizedString(forIdentifier: identifier)
+                    ?? languageCode.flatMap { Locale.current.localizedString(forLanguageCode: $0) }
+                    ?? identifier
+                return SpeechLocaleOption(identifier: identifier, displayName: "\(languageName) (\(identifier))")
+            }
+            .sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
+    }
+
+    /// Locales passed to `MeetingTranscriptionService` (primary, secondary, then safe fallbacks).
+    static func localesForAppleTranscription() -> [Locale] {
+        let defaults = UserDefaults.standard
+        let selectedIdentifiers = [
+            defaults.string(forKey: keyPrimary),
+            defaults.string(forKey: keySecondary)
+        ]
+            .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
+            .filter { $0.isEmpty == false }
+
+        var locales: [Locale] = []
+        var seen = Set<String>()
+
+        func appendIdentifier(_ identifier: String) {
+            let locale = Locale(identifier: identifier)
+            guard SFSpeechRecognizer(locale: locale)?.isAvailable == true else { return }
+            let normalized = identifier.replacingOccurrences(of: "_", with: "-").lowercased()
+            guard seen.contains(normalized) == false else { return }
+            seen.insert(normalized)
+            locales.append(locale)
+        }
+
+        for identifier in selectedIdentifiers {
+            appendIdentifier(identifier)
+        }
+
+        appendIdentifier(Locale.current.identifier)
+        appendIdentifier("en-US")
+
+        if locales.isEmpty {
+            return [Locale(identifier: "en-US")]
+        }
+        return locales
+    }
+
+    static func updatePreferredLanguages(primary: String, secondary: String?) {
+        let normalizedPrimary = primary.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard normalizedPrimary.isEmpty == false else { return }
+        UserDefaults.standard.set(normalizedPrimary, forKey: keyPrimary)
+
+        let cleanedSecondary = secondary?.trimmingCharacters(in: .whitespacesAndNewlines)
+        if let cleanedSecondary, cleanedSecondary.isEmpty == false {
+            let normalizedPrimaryKey = normalizedPrimary.replacingOccurrences(of: "_", with: "-").lowercased()
+            let normalizedSecondaryKey = cleanedSecondary.replacingOccurrences(of: "_", with: "-").lowercased()
+            if normalizedPrimaryKey == normalizedSecondaryKey {
+                UserDefaults.standard.removeObject(forKey: keySecondary)
+            } else {
+                UserDefaults.standard.set(cleanedSecondary, forKey: keySecondary)
+            }
+        } else {
+            UserDefaults.standard.removeObject(forKey: keySecondary)
+        }
+    }
+}

+ 403 - 12
zoom_app/ViewController.swift

@@ -9,6 +9,7 @@ import Cocoa
 import Combine
 import CryptoKit
 import AVFoundation
+import Speech
 import StoreKit
 import SwiftUI
 import UserNotifications
@@ -375,6 +376,8 @@ class ViewController: NSViewController {
     private weak var settingsUpgradeButton: NSButton?
     private weak var settingsUpgradeTitleLabel: NSTextField?
     private weak var settingsNotificationsBlock: NSStackView?
+    private weak var settingsPagePrimaryLanguagePopup: NSPopUpButton?
+    private weak var settingsPageSecondaryLanguagePopup: NSPopUpButton?
     private weak var settingsGoogleActionButton: NSButton?
     private weak var topBarPremiumButton: NSButton?
     private weak var topBarAuthButton: NSButton?
@@ -404,6 +407,13 @@ class ViewController: NSViewController {
     private weak var joinURLFieldsContainer: NSView?
     private weak var joinIDFieldsContainer: NSView?
     private weak var joinModeSegment: NSSegmentedControl?
+
+    private var didTapInlineMeetingConsentClose = false
+    private weak var activeMeetingConsentWindow: NSWindow?
+    private weak var meetingConsentPrimaryLanguagePopup: NSPopUpButton?
+    private weak var meetingConsentSecondaryLanguagePopup: NSPopUpButton?
+
+    private var aiCompanionTranscriptionActiveRecordingIDs: Set<String> = []
     private let paywallContentWidth: CGFloat = 520
     private var selectedPremiumPlan: PremiumPlan = .yearly
     private var paywallPlanViews: [PremiumPlan: [NSView]] = [:]
@@ -656,9 +666,36 @@ class ViewController: NSViewController {
             object: nil,
             queue: .main
         ) { [weak self] _ in
-            guard let self else { return }
-            self.recordedMeetings = MeetingRecordingManager.shared.recordings
-            Task { @MainActor in
+            Task { @MainActor [weak self] in
+                guard let self else { return }
+                self.recordedMeetings = MeetingRecordingManager.shared.recordings
+                for rec in self.recordedMeetings where rec.transcriptText != nil || rec.transcriptError != nil {
+                    self.aiCompanionTranscriptionActiveRecordingIDs.remove(rec.id)
+                }
+                self.renderAiCompanionList()
+            }
+        }
+        NotificationCenter.default.addObserver(
+            forName: MeetingRecordingManager.transcriptionDidBeginNotification,
+            object: nil,
+            queue: .main
+        ) { [weak self] note in
+            guard let id = note.userInfo?["recordingId"] as? String else { return }
+            Task { @MainActor [weak self] in
+                guard let self else { return }
+                self.aiCompanionTranscriptionActiveRecordingIDs.insert(id)
+                self.renderAiCompanionList()
+            }
+        }
+        NotificationCenter.default.addObserver(
+            forName: MeetingRecordingManager.transcriptionDidEndNotification,
+            object: nil,
+            queue: .main
+        ) { [weak self] note in
+            guard let id = note.userInfo?["recordingId"] as? String else { return }
+            Task { @MainActor [weak self] in
+                guard let self else { return }
+                self.aiCompanionTranscriptionActiveRecordingIDs.remove(id)
                 self.renderAiCompanionList()
             }
         }
@@ -667,8 +704,9 @@ class ViewController: NSViewController {
             object: nil,
             queue: .main
         ) { [weak self] _ in
-            Task { @MainActor in
-                self?.updateAiCompanionRecordingUI()
+            Task { @MainActor [weak self] in
+                guard let self else { return }
+                self.updateAiCompanionRecordingUI()
             }
         }
         setupUI()
@@ -1648,13 +1686,15 @@ class ViewController: NSViewController {
 
     @objc private func joinMeetingSubmitTapped() {
         guard let segment = joinModeSegment else { return }
+        let meetingName = joinMeetingDisplayNameField?.stringValue
+        let zoomURL: URL?
         if segment.selectedSegment == 0 {
             let raw = joinURLField?.stringValue ?? ""
             guard let url = parseZoomJoinURLFromUserInput(raw) else {
                 showSimpleAlert(title: "Invalid link", message: "Enter a full Zoom meeting link (for example, https://zoom.us/j/…).")
                 return
             }
-            openZoomMeetingInDefaultBrowser(url, meetingDisplayName: joinMeetingDisplayNameField?.stringValue)
+            zoomURL = url
         } else {
             let idRaw = joinMeetingIDField?.stringValue ?? ""
             let digits = idRaw.filter(\.isNumber)
@@ -1667,7 +1707,188 @@ class ViewController: NSViewController {
                 showSimpleAlert(title: "Unable to join", message: "Could not build a join link from that meeting ID.")
                 return
             }
-            openZoomMeetingInDefaultBrowser(url, meetingDisplayName: joinMeetingDisplayNameField?.stringValue)
+            zoomURL = url
+        }
+        guard let url = zoomURL else { return }
+
+        let consentChoice = requestMeetingRecordingConsent(title: "Join Meeting")
+        if consentChoice == .dismissed { return }
+        let shouldRecord = (consentChoice == .allowAndContinue)
+        openZoomMeetingInDefaultBrowser(url, meetingDisplayName: meetingName, recordLocally: shouldRecord)
+    }
+
+    private enum MeetingRecordingConsentChoice {
+        case allowAndContinue
+        case continueWithoutRecording
+        case dismissed
+    }
+
+    private func requestMeetingRecordingConsent(title: String) -> MeetingRecordingConsentChoice {
+        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 Continue")
+        alert.addButton(withTitle: "Continue Without Recording")
+        alert.window.title = title
+
+        let speechOptions = TranscriptionLanguagePreferences.supportedSpeechLocaleOptions()
+        let languageContainer = NSView(frame: NSRect(x: 0, y: 0, width: 320, height: 96))
+        languageContainer.translatesAutoresizingMaskIntoConstraints = false
+        languageContainer.widthAnchor.constraint(equalToConstant: 320).isActive = true
+        languageContainer.heightAnchor.constraint(equalToConstant: 96).isActive = true
+
+        let languageTitle = NSTextField(labelWithString: "Transcription Languages")
+        languageTitle.translatesAutoresizingMaskIntoConstraints = false
+        languageTitle.font = NSFont.systemFont(ofSize: 11, weight: .semibold)
+        languageTitle.textColor = NSColor.secondaryLabelColor
+
+        let language1Popup = NSPopUpButton(frame: .zero, pullsDown: false)
+        language1Popup.translatesAutoresizingMaskIntoConstraints = false
+        language1Popup.controlSize = .small
+        language1Popup.target = self
+        language1Popup.action = #selector(meetingConsentSpeechLanguageChanged(_:))
+        for option in speechOptions {
+            language1Popup.addItem(withTitle: option.displayName)
+            language1Popup.lastItem?.representedObject = option.identifier
+        }
+
+        let selectedPrimary = TranscriptionLanguagePreferences.selectedPrimaryIdentifier()
+            ?? speechOptions.first?.identifier
+            ?? Locale.current.identifier
+        selectTranscriptionLanguage(identifier: selectedPrimary, in: language1Popup)
+
+        let language2Popup = NSPopUpButton(frame: .zero, pullsDown: false)
+        language2Popup.translatesAutoresizingMaskIntoConstraints = false
+        language2Popup.controlSize = .small
+        language2Popup.target = self
+        language2Popup.action = #selector(meetingConsentSpeechLanguageChanged(_:))
+        language2Popup.addItem(withTitle: "None")
+        language2Popup.lastItem?.representedObject = ""
+        for option in speechOptions {
+            language2Popup.addItem(withTitle: option.displayName)
+            language2Popup.lastItem?.representedObject = option.identifier
+        }
+        if let selectedSecondary = TranscriptionLanguagePreferences.selectedSecondaryIdentifier() {
+            selectTranscriptionLanguage(identifier: selectedSecondary, in: language2Popup)
+        } else {
+            language2Popup.selectItem(at: 0)
+        }
+
+        let language1Label = NSTextField(labelWithString: "Preferred Language 1")
+        language1Label.translatesAutoresizingMaskIntoConstraints = false
+        language1Label.font = NSFont.systemFont(ofSize: 11, weight: .semibold)
+        language1Label.textColor = NSColor.secondaryLabelColor
+        language1Label.alignment = .center
+
+        let language2Label = NSTextField(labelWithString: "Preferred Language 2")
+        language2Label.translatesAutoresizingMaskIntoConstraints = false
+        language2Label.font = NSFont.systemFont(ofSize: 11, weight: .semibold)
+        language2Label.textColor = NSColor.secondaryLabelColor
+        language2Label.alignment = .center
+
+        languageContainer.addSubview(languageTitle)
+        languageContainer.addSubview(language1Label)
+        languageContainer.addSubview(language1Popup)
+        languageContainer.addSubview(language2Label)
+        languageContainer.addSubview(language2Popup)
+
+        NSLayoutConstraint.activate([
+            languageTitle.topAnchor.constraint(equalTo: languageContainer.topAnchor, constant: 0),
+            languageTitle.centerXAnchor.constraint(equalTo: languageContainer.centerXAnchor),
+
+            language1Label.topAnchor.constraint(equalTo: languageTitle.bottomAnchor, constant: 2),
+            language1Label.centerXAnchor.constraint(equalTo: languageContainer.centerXAnchor),
+            language1Popup.topAnchor.constraint(equalTo: language1Label.bottomAnchor, constant: 2),
+            language1Popup.centerXAnchor.constraint(equalTo: languageContainer.centerXAnchor),
+            language1Popup.widthAnchor.constraint(equalToConstant: 240),
+
+            language2Label.topAnchor.constraint(equalTo: language1Popup.bottomAnchor, constant: 3),
+            language2Label.centerXAnchor.constraint(equalTo: languageContainer.centerXAnchor),
+            language2Popup.topAnchor.constraint(equalTo: language2Label.bottomAnchor, constant: 2),
+            language2Popup.centerXAnchor.constraint(equalTo: languageContainer.centerXAnchor),
+            language2Popup.widthAnchor.constraint(equalToConstant: 240),
+            language2Popup.bottomAnchor.constraint(equalTo: languageContainer.bottomAnchor, constant: -1)
+        ])
+
+        alert.accessoryView = languageContainer
+        didTapInlineMeetingConsentClose = false
+        activeMeetingConsentWindow = alert.window
+        meetingConsentPrimaryLanguagePopup = language1Popup
+        meetingConsentSecondaryLanguagePopup = language2Popup
+        if let contentView = alert.window.contentView {
+            let topCloseButton = NSButton(title: "✕", target: self, action: #selector(meetingConsentInlineCloseTapped(_:)))
+            topCloseButton.translatesAutoresizingMaskIntoConstraints = false
+            topCloseButton.isBordered = false
+            topCloseButton.font = NSFont.systemFont(ofSize: 15, weight: .semibold)
+            topCloseButton.contentTintColor = NSColor.tertiaryLabelColor
+            topCloseButton.wantsLayer = true
+            topCloseButton.layer?.cornerRadius = 20
+            topCloseButton.layer?.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(0.35).cgColor
+            contentView.addSubview(topCloseButton)
+            NSLayoutConstraint.activate([
+                topCloseButton.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10),
+                topCloseButton.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -10),
+                topCloseButton.widthAnchor.constraint(equalToConstant: 40),
+                topCloseButton.heightAnchor.constraint(equalToConstant: 40)
+            ])
+        }
+        let response = alert.runModal()
+        activeMeetingConsentWindow = nil
+        meetingConsentPrimaryLanguagePopup = nil
+        meetingConsentSecondaryLanguagePopup = nil
+
+        if didTapInlineMeetingConsentClose {
+            return .dismissed
+        }
+
+        if let primary = language1Popup.selectedItem?.representedObject as? String,
+           primary.isEmpty == false {
+            var secondary = language2Popup.selectedItem?.representedObject as? String
+            if secondary?.isEmpty == true {
+                secondary = nil
+            }
+            TranscriptionLanguagePreferences.updatePreferredLanguages(primary: primary, secondary: secondary)
+        }
+
+        switch response {
+        case .alertFirstButtonReturn:
+            return .allowAndContinue
+        case .alertSecondButtonReturn:
+            return .continueWithoutRecording
+        default:
+            return .dismissed
+        }
+    }
+
+    @objc private func meetingConsentInlineCloseTapped(_ sender: NSButton) {
+        didTapInlineMeetingConsentClose = true
+        guard let consentWindow = activeMeetingConsentWindow else { return }
+        NSApp.stopModal(withCode: .abort)
+        consentWindow.orderOut(nil)
+    }
+
+    @objc private func meetingConsentSpeechLanguageChanged(_ sender: NSPopUpButton) {
+        guard let primary = meetingConsentPrimaryLanguagePopup?.selectedItem?.representedObject as? String,
+              primary.isEmpty == false else { return }
+
+        var secondary: String? = meetingConsentSecondaryLanguagePopup?.selectedItem?.representedObject as? String
+        if secondary?.isEmpty == true {
+            secondary = nil
+        }
+        if let secondaryValue = secondary,
+           secondaryValue.replacingOccurrences(of: "_", with: "-").lowercased() == primary.replacingOccurrences(of: "_", with: "-").lowercased() {
+            secondary = nil
+            meetingConsentSecondaryLanguagePopup?.selectItem(at: 0)
+        }
+        TranscriptionLanguagePreferences.updatePreferredLanguages(primary: primary, secondary: secondary)
+    }
+
+    private func selectTranscriptionLanguage(identifier: String, in popup: NSPopUpButton) {
+        for item in popup.itemArray {
+            if let value = item.representedObject as? String, value == identifier {
+                popup.select(item)
+                return
+            }
         }
     }
 
@@ -1740,11 +1961,13 @@ class ViewController: NSViewController {
     }
 
     @MainActor
-    private func openZoomMeetingInDefaultBrowser(_ url: URL, meetingDisplayName: String? = nil) {
+    private func openZoomMeetingInDefaultBrowser(_ url: URL, meetingDisplayName: String? = nil, recordLocally: Bool = true) {
         let opened = openURLInDefaultBrowser(url)
         if opened {
             joinMeetingWindow?.performClose(nil)
-            beginZoomMeetingRecordingIfAppropriate(sourceURL: url, meetingDisplayName: meetingDisplayName)
+            if recordLocally {
+                beginZoomMeetingRecordingIfAppropriate(sourceURL: url, meetingDisplayName: meetingDisplayName)
+            }
         } else {
             showSimpleAlert(title: "Unable to open", message: "Your default browser could not be opened.")
         }
@@ -3865,6 +4088,31 @@ class ViewController: NSViewController {
         settingsNotificationsBlock = notificationsBlock
         stack.setCustomSpacing(24, after: notificationsBlock)
 
+        let transcriptionTitle = textLabel("Ai Companion transcription", font: .systemFont(ofSize: 16, weight: .semibold), color: primaryText)
+        let transcriptionSubtitle = textLabel(
+            "Preferred languages are used for Apple on-device speech recognition after each meeting recording.",
+            font: .systemFont(ofSize: 12, weight: .regular),
+            color: secondaryText
+        )
+        transcriptionSubtitle.maximumNumberOfLines = 0
+        transcriptionSubtitle.lineBreakMode = .byWordWrapping
+        let transcriptionBlock = NSStackView()
+        transcriptionBlock.translatesAutoresizingMaskIntoConstraints = false
+        transcriptionBlock.orientation = .vertical
+        transcriptionBlock.spacing = 12
+        transcriptionBlock.alignment = .leading
+        transcriptionBlock.addArrangedSubview(transcriptionTitle)
+        transcriptionBlock.addArrangedSubview(transcriptionSubtitle)
+        let lang1Row = makeSettingsSpeechLanguageRow(isPrimary: true)
+        let lang2Row = makeSettingsSpeechLanguageRow(isPrimary: false)
+        transcriptionBlock.addArrangedSubview(lang1Row)
+        transcriptionBlock.addArrangedSubview(lang2Row)
+        lang1Row.widthAnchor.constraint(equalTo: transcriptionBlock.widthAnchor).isActive = true
+        lang2Row.widthAnchor.constraint(equalTo: transcriptionBlock.widthAnchor).isActive = true
+        stack.addArrangedSubview(transcriptionBlock)
+        transcriptionBlock.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
+        stack.setCustomSpacing(24, after: transcriptionBlock)
+
         let legalTitle = textLabel("Help & Legal", font: .systemFont(ofSize: 16, weight: .semibold), color: primaryText)
         stack.addArrangedSubview(legalTitle)
         let privacyButton = makeSettingsActionButton(icon: "🔒", title: "Privacy Policy", action: .privacyPolicy)
@@ -3961,6 +4209,74 @@ class ViewController: NSViewController {
         return row
     }
 
+    private func makeSettingsSpeechLanguageRow(isPrimary: Bool) -> NSView {
+        let row = roundedContainer(cornerRadius: 10, color: palette.inputBackground)
+        row.translatesAutoresizingMaskIntoConstraints = false
+        row.heightAnchor.constraint(equalToConstant: 72).isActive = true
+        styleSurface(row, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
+
+        let titleText = isPrimary ? "Preferred Language 1" : "Preferred Language 2"
+        let titleLabel = textLabel(titleText, font: .systemFont(ofSize: 13, weight: .semibold), color: primaryText)
+        let popup = NSPopUpButton(frame: .zero, pullsDown: false)
+        popup.translatesAutoresizingMaskIntoConstraints = false
+        popup.target = self
+        popup.action = #selector(settingsPageSpeechLanguageChanged(_:))
+
+        let options = TranscriptionLanguagePreferences.supportedSpeechLocaleOptions()
+        if isPrimary == false {
+            popup.addItem(withTitle: "None")
+            popup.lastItem?.representedObject = ""
+        }
+        for option in options {
+            popup.addItem(withTitle: option.displayName)
+            popup.lastItem?.representedObject = option.identifier
+        }
+
+        if isPrimary {
+            let selected = TranscriptionLanguagePreferences.selectedPrimaryIdentifier()
+                ?? options.first?.identifier
+                ?? Locale.current.identifier
+            selectTranscriptionLanguage(identifier: selected, in: popup)
+            settingsPagePrimaryLanguagePopup = popup
+        } else {
+            if let selected = TranscriptionLanguagePreferences.selectedSecondaryIdentifier() {
+                selectTranscriptionLanguage(identifier: selected, in: popup)
+            } else {
+                popup.selectItem(at: 0)
+            }
+            settingsPageSecondaryLanguagePopup = popup
+        }
+
+        row.addSubview(titleLabel)
+        row.addSubview(popup)
+        NSLayoutConstraint.activate([
+            titleLabel.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 14),
+            titleLabel.topAnchor.constraint(equalTo: row.topAnchor, constant: 10),
+            titleLabel.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -14),
+
+            popup.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 14),
+            popup.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -14),
+            popup.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 8),
+            popup.heightAnchor.constraint(equalToConstant: 28)
+        ])
+        return row
+    }
+
+    @objc private func settingsPageSpeechLanguageChanged(_ sender: NSPopUpButton) {
+        guard let primary = settingsPagePrimaryLanguagePopup?.selectedItem?.representedObject as? String,
+              primary.isEmpty == false else { return }
+        var secondary: String? = settingsPageSecondaryLanguagePopup?.selectedItem?.representedObject as? String
+        if secondary?.isEmpty == true {
+            secondary = nil
+        }
+        if let secondaryValue = secondary,
+           secondaryValue.replacingOccurrences(of: "_", with: "-").lowercased() == primary.replacingOccurrences(of: "_", with: "-").lowercased() {
+            secondary = nil
+            settingsPageSecondaryLanguagePopup?.selectItem(at: 0)
+        }
+        TranscriptionLanguagePreferences.updatePreferredLanguages(primary: primary, secondary: secondary)
+    }
+
     private func makeSettingsRemindersSection() -> NSView {
         let container = NSStackView()
         container.translatesAutoresizingMaskIntoConstraints = false
@@ -6608,7 +6924,7 @@ class ViewController: NSViewController {
 
         // Header row: recordings list (AI Companion mode).
         let title = makeLabel("Ai Companion", size: 28, color: primaryText, weight: .bold, centered: false)
-        let subtitle = makeLabel("Recorded meetings (audio)", size: 13, color: secondaryText, weight: .regular, centered: false)
+        let subtitle = makeLabel("Recorded meetings and Apple transcripts", size: 13, color: secondaryText, weight: .regular, centered: false)
         let count = makeLabel("0 recordings", size: 12, color: secondaryText, weight: .medium, centered: false)
         let titleStack = NSStackView(views: [title, subtitle, count])
         titleStack.orientation = .vertical
@@ -6880,6 +7196,78 @@ class ViewController: NSViewController {
         card.addSubview(dateLabel)
         card.addSubview(play)
 
+        var lastStackBottom: NSView = dateLabel
+        var pinnedTranscriptScrollToCardBottom = false
+
+        if aiCompanionTranscriptionActiveRecordingIDs.contains(recording.id) {
+            let busy = makeLabel("Generating transcript…", size: 11, color: secondaryText, weight: .regular, centered: false)
+            busy.translatesAutoresizingMaskIntoConstraints = false
+            busy.maximumNumberOfLines = 1
+            card.addSubview(busy)
+            NSLayoutConstraint.activate([
+                busy.topAnchor.constraint(equalTo: dateLabel.bottomAnchor, constant: 6),
+                busy.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 12),
+                busy.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -12)
+            ])
+            lastStackBottom = busy
+        } else if let err = recording.transcriptError?.trimmingCharacters(in: .whitespacesAndNewlines), err.isEmpty == false {
+            let errLabel = makeLabel("Transcript: \(err)", size: 11, color: NSColor.systemRed, weight: .regular, centered: false)
+            errLabel.translatesAutoresizingMaskIntoConstraints = false
+            errLabel.maximumNumberOfLines = 3
+            errLabel.lineBreakMode = .byWordWrapping
+            if let cell = errLabel.cell as? NSTextFieldCell {
+                cell.wraps = true
+            }
+            card.addSubview(errLabel)
+            NSLayoutConstraint.activate([
+                errLabel.topAnchor.constraint(equalTo: dateLabel.bottomAnchor, constant: 6),
+                errLabel.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 12),
+                errLabel.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -12)
+            ])
+            lastStackBottom = errLabel
+        }
+
+        if let transcript = recording.transcriptText?.trimmingCharacters(in: .whitespacesAndNewlines), transcript.isEmpty == false {
+            let header = makeLabel("Transcript", size: 11, color: secondaryText, weight: .semibold, centered: false)
+            header.translatesAutoresizingMaskIntoConstraints = false
+            card.addSubview(header)
+
+            let scroll = NSScrollView()
+            scroll.translatesAutoresizingMaskIntoConstraints = false
+            scroll.drawsBackground = false
+            scroll.hasVerticalScroller = true
+            scroll.borderType = .noBorder
+            scroll.autohidesScrollers = true
+            scroll.scrollerStyle = .overlay
+
+            let tv = NSTextView()
+            tv.translatesAutoresizingMaskIntoConstraints = false
+            tv.string = transcript
+            tv.isEditable = false
+            tv.isSelectable = true
+            tv.drawsBackground = false
+            tv.textColor = primaryText
+            tv.font = .systemFont(ofSize: 11)
+            tv.textContainerInset = NSSize(width: 4, height: 4)
+            tv.textContainer?.widthTracksTextView = true
+            scroll.documentView = tv
+
+            card.addSubview(scroll)
+
+            NSLayoutConstraint.activate([
+                header.topAnchor.constraint(equalTo: lastStackBottom.bottomAnchor, constant: 8),
+                header.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 12),
+                header.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -12),
+
+                scroll.topAnchor.constraint(equalTo: header.bottomAnchor, constant: 4),
+                scroll.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 10),
+                scroll.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -10),
+                scroll.heightAnchor.constraint(equalToConstant: 120),
+                scroll.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -10)
+            ])
+            pinnedTranscriptScrollToCardBottom = true
+        }
+
         NSLayoutConstraint.activate([
             title.topAnchor.constraint(equalTo: card.topAnchor, constant: 10),
             title.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 12),
@@ -6888,14 +7276,17 @@ class ViewController: NSViewController {
             dateLabel.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 4),
             dateLabel.leadingAnchor.constraint(equalTo: title.leadingAnchor),
             dateLabel.trailingAnchor.constraint(equalTo: title.trailingAnchor),
-            dateLabel.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -10),
 
-            play.centerYAnchor.constraint(equalTo: card.centerYAnchor),
+            play.centerYAnchor.constraint(equalTo: title.centerYAnchor),
             play.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -12),
             play.heightAnchor.constraint(equalToConstant: 30),
             play.widthAnchor.constraint(greaterThanOrEqualToConstant: 72)
         ])
 
+        if pinnedTranscriptScrollToCardBottom == false {
+            lastStackBottom.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -10).isActive = true
+        }
+
         return card
     }