Browse Source

Improve meeting transcript reliability with dual-source capture and fallback transcription.

Persist transcript status per recording, auto-process transcripts after meetings end, and fix silent recordings by waiting for system audio finalization while mixing system and mic tracks.

Made-with: Cursor
huzaifahayat12 2 tháng trước cách đây
mục cha
commit
b590a3c5b8

+ 83 - 0
meetings_app/OpenAI/OpenAITranscriptionClient.swift

@@ -0,0 +1,83 @@
+import Foundation
+
+enum OpenAITranscriptionClientError: Error {
+    case invalidResponse
+    case httpStatus(Int, String)
+    case missingTranscript
+}
+
+final class OpenAITranscriptionClient {
+    private let session: URLSession
+
+    init(session: URLSession = .shared) {
+        self.session = session
+    }
+
+    func transcribeAudioFile(fileURL: URL, apiKey: String) async throws -> String {
+        let boundary = "Boundary-\(UUID().uuidString)"
+        var request = URLRequest(url: URL(string: "https://api.openai.com/v1/audio/transcriptions")!)
+        request.httpMethod = "POST"
+        request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
+        request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
+        request.httpBody = try makeMultipartBody(fileURL: fileURL, boundary: boundary)
+
+        let (data, response) = try await session.data(for: request)
+        guard let http = response as? HTTPURLResponse else {
+            throw OpenAITranscriptionClientError.invalidResponse
+        }
+        guard (200..<300).contains(http.statusCode) else {
+            let body = String(data: data, encoding: .utf8) ?? "<no body>"
+            throw OpenAITranscriptionClientError.httpStatus(http.statusCode, body)
+        }
+
+        struct TranscriptionResponse: Decodable {
+            let text: String?
+        }
+
+        let decoded = try JSONDecoder().decode(TranscriptionResponse.self, from: data)
+        guard let text = decoded.text?.trimmingCharacters(in: .whitespacesAndNewlines), text.isEmpty == false else {
+            throw OpenAITranscriptionClientError.missingTranscript
+        }
+        return text
+    }
+
+    private func makeMultipartBody(fileURL: URL, boundary: String) throws -> Data {
+        let fileData = try Data(contentsOf: fileURL)
+        var body = Data()
+        let filename = fileURL.lastPathComponent
+        let mimeType = "audio/mp4"
+
+        appendField(name: "model", value: "gpt-4o-mini-transcribe", boundary: boundary, into: &body)
+        appendField(name: "response_format", value: "json", boundary: boundary, into: &body)
+        appendFile(name: "file", filename: filename, mimeType: mimeType, data: fileData, boundary: boundary, into: &body)
+        body.append("--\(boundary)--\r\n".data(using: .utf8) ?? Data())
+        return body
+    }
+
+    private func appendField(name: String, value: String, boundary: String, into body: inout Data) {
+        body.append("--\(boundary)\r\n".data(using: .utf8) ?? Data())
+        body.append("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n".data(using: .utf8) ?? Data())
+        body.append("\(value)\r\n".data(using: .utf8) ?? Data())
+    }
+
+    private func appendFile(name: String, filename: String, mimeType: String, data: Data, boundary: String, into body: inout Data) {
+        body.append("--\(boundary)\r\n".data(using: .utf8) ?? Data())
+        body.append("Content-Disposition: form-data; name=\"\(name)\"; filename=\"\(filename)\"\r\n".data(using: .utf8) ?? Data())
+        body.append("Content-Type: \(mimeType)\r\n\r\n".data(using: .utf8) ?? Data())
+        body.append(data)
+        body.append("\r\n".data(using: .utf8) ?? Data())
+    }
+}
+
+extension OpenAITranscriptionClientError: LocalizedError {
+    var errorDescription: String? {
+        switch self {
+        case .invalidResponse:
+            return "OpenAI transcription returned an invalid response."
+        case let .httpStatus(status, body):
+            return "OpenAI transcription failed (\(status)): \(body)"
+        case .missingTranscript:
+            return "OpenAI transcription returned no text."
+        }
+    }
+}

+ 338 - 45
meetings_app/ViewController.swift

@@ -231,6 +231,18 @@ private final class StoreKitCoordinator {
 }
 
 final class ViewController: NSViewController {
+    private enum MeetingTranscriptStatus: String, Codable {
+        case notRequested
+        case processing
+        case ready
+        case failed
+    }
+
+    private enum MeetingTranscriptSource: String, Codable {
+        case meetApi
+        case localAudioOpenAI
+    }
+
     private enum PaywallFooterAction {
         case manageSubscription
         case restorePurchase
@@ -253,6 +265,10 @@ final class ViewController: NSViewController {
         let startedAt: Date
         let endedAt: Date
         let audioFilePath: String
+        var transcriptStatusRaw: String?
+        var transcriptSourceRaw: String?
+        var transcriptText: String?
+        var transcriptErrorMessage: String?
     }
 
     private struct ActiveMeetingRecordingSession {
@@ -260,7 +276,8 @@ final class ViewController: NSViewController {
         let title: String
         let meetURL: URL
         let startedAt: Date
-        let audioFileURL: URL
+        let systemAudioFileURL: URL
+        let microphoneAudioFileURL: URL
     }
 
     private var palette = Palette(isDarkMode: true)
@@ -290,10 +307,10 @@ final class ViewController: NSViewController {
     private var aiCompanionAudioStatusLabelByView = [ObjectIdentifier: NSTextField]()
     private var aiCompanionTranscriptMeetingIdByView = [ObjectIdentifier: String]()
     private var aiCompanionTranscriptStatusLabelByView = [ObjectIdentifier: NSTextField]()
-    private var aiCompanionTranscriptTextByMeetingId = [String: String]()
     private var aiCompanionTranscriptCurrentRequestId: UUID?
     private var aiCompanionTranscriptWindow: NSWindow?
     private weak var aiCompanionTranscriptTextView: NSTextView?
+    private var aiCompanionTranscriptTaskByMeetingId = [String: Task<Void, Never>]()
     private var aiCompanionAudioPlayer: AVPlayer?
     private var aiCompanionLocalAudioPlayer: AVAudioPlayer?
     private var aiCompanionCurrentlyPlayingURL: URL?
@@ -455,7 +472,10 @@ final class ViewController: NSViewController {
     private let ratingStateMigrationV2DoneDefaultsKey = "rating.stateMigrationV2Done"
     private let nonPremiumJoinTrialConsumedDefaultsKey = "join.nonPremiumTrialConsumed"
     private let aiCompanionLocalRecordingsDefaultsKey = "aiCompanion.localRecordings"
+    private let openAIAPIKeyDefaultsKey = "openai.apiKey"
+    private let openAIAPIKeyPlistKey = "OpenAIAPIKey"
     private let ratingEligibleUsageSeconds: TimeInterval = 30 * 60
+    private let openAITranscriptionClient = OpenAITranscriptionClient()
     private var darkModeEnabled: Bool {
         get {
             let hasValue = UserDefaults.standard.object(forKey: darkModeDefaultsKey) != nil
@@ -1284,6 +1304,73 @@ private extension ViewController {
         UserDefaults.standard.set(encoded, forKey: aiCompanionLocalRecordingsDefaultsKey)
     }
 
+    private func configuredOpenAIAPIKey() -> String? {
+        let value = UserDefaults.standard.string(forKey: openAIAPIKeyDefaultsKey)?
+            .trimmingCharacters(in: .whitespacesAndNewlines)
+        if let value, value.isEmpty == false {
+            return value
+        }
+        let plistValue = Bundle.main.object(forInfoDictionaryKey: openAIAPIKeyPlistKey) as? String
+        let trimmedPlist = plistValue?.trimmingCharacters(in: .whitespacesAndNewlines)
+        if let trimmedPlist, trimmedPlist.isEmpty == false {
+            return trimmedPlist
+        }
+        return nil
+    }
+
+    private func aiCompanionTranscriptStatus(for recording: MeetingRecordingSummary) -> MeetingTranscriptStatus {
+        guard let raw = recording.transcriptStatusRaw, let status = MeetingTranscriptStatus(rawValue: raw) else {
+            return .notRequested
+        }
+        return status
+    }
+
+    private func aiCompanionTranscriptStatusText(for recording: MeetingRecordingSummary) -> String {
+        switch aiCompanionTranscriptStatus(for: recording) {
+        case .notRequested:
+            return "Transcript not requested"
+        case .processing:
+            return "Transcript processing..."
+        case .ready:
+            return "Transcript ready"
+        case .failed:
+            let error = recording.transcriptErrorMessage?.trimmingCharacters(in: .whitespacesAndNewlines)
+            if let error, error.isEmpty == false {
+                return "Transcript unavailable (tap to retry)"
+            }
+            return "Transcript unavailable (tap to retry)"
+        }
+    }
+
+    private func aiCompanionMeetingFromRecording(_ recording: MeetingRecordingSummary) -> ScheduledMeeting? {
+        guard let meetURL = URL(string: recording.meetURLString) else { return nil }
+        return ScheduledMeeting(
+            id: recording.id,
+            title: recording.title,
+            subtitle: nil,
+            startDate: recording.startedAt,
+            endDate: recording.endedAt,
+            meetURL: meetURL,
+            isAllDay: false
+        )
+    }
+
+    @discardableResult
+    private func aiCompanionUpdateRecording(meetingId: String, mutate: (inout MeetingRecordingSummary) -> Void) -> MeetingRecordingSummary? {
+        guard let idx = aiCompanionLocalRecordings.firstIndex(where: { $0.id == meetingId }) else { return nil }
+        mutate(&aiCompanionLocalRecordings[idx])
+        persistAiCompanionLocalRecordings()
+        return aiCompanionLocalRecordings[idx]
+    }
+
+    private func aiCompanionRefreshTranscriptStatusLabels(forMeetingID meetingId: String) {
+        guard let recording = aiCompanionLocalRecordings.first(where: { $0.id == meetingId }) else { return }
+        let statusText = aiCompanionTranscriptStatusText(for: recording)
+        for (buttonId, linkedMeetingId) in aiCompanionTranscriptMeetingIdByView where linkedMeetingId == meetingId {
+            aiCompanionTranscriptStatusLabelByView[buttonId]?.stringValue = statusText
+        }
+    }
+
     private func localRecordingDirectoryURL() -> URL {
         let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
             ?? URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
@@ -1378,18 +1465,22 @@ private extension ViewController {
 
     private func startMeetingRecording(meetingTitle: String, meetingURL: URL) {
         let recordingID = UUID().uuidString
-        let outputURL = localRecordingDirectoryURL().appendingPathComponent("\(recordingID).m4a")
+        let outputURL = localRecordingDirectoryURL().appendingPathComponent("\(recordingID)-system.m4a")
+        let microphoneURL = localRecordingDirectoryURL().appendingPathComponent("\(recordingID)-mic.m4a")
 
         activeMeetingRecordingSession = ActiveMeetingRecordingSession(
             id: recordingID,
             title: meetingTitle,
             meetURL: meetingURL,
             startedAt: Date(),
-            audioFileURL: outputURL
+            systemAudioFileURL: outputURL,
+            microphoneAudioFileURL: microphoneURL
         )
         pageCache[.aiCompanion] = nil
         if selectedSidebarPage == .aiCompanion { showSidebarPage(.aiCompanion) }
 
+        startMicrophoneRecording(at: microphoneURL, showToast: false)
+
         if #available(macOS 13.0, *) {
             let systemRecorder = MeetingSystemAudioRecorder(outputURL: outputURL)
             activeMeetingSystemAudioStopper = { [systemRecorder] in
@@ -1400,22 +1491,22 @@ private extension ViewController {
                 do {
                     try await systemRecorder.start()
                     await MainActor.run {
-                        self.showTopToast(message: "Meeting recording started (meeting audio)", isError: false)
+                        self.showTopToast(message: "Meeting recording started (meeting + microphone)", isError: false)
                     }
                 } catch {
                     await MainActor.run {
                         self.activeMeetingSystemAudioStopper = nil
-                        self.startMicrophoneFallbackRecording(at: outputURL)
+                        self.showTopToast(message: "System audio unavailable. Recording microphone only.", isError: true)
                     }
                 }
             }
             return
         }
 
-        startMicrophoneFallbackRecording(at: outputURL)
+        showTopToast(message: "Meeting recording started (microphone only)", isError: false)
     }
 
-    private func startMicrophoneFallbackRecording(at outputURL: URL) {
+    private func startMicrophoneRecording(at outputURL: URL, showToast: Bool) {
         let settings: [String: Any] = [
             AVFormatIDKey: kAudioFormatMPEG4AAC,
             AVSampleRateKey: 44_100,
@@ -1427,7 +1518,9 @@ private extension ViewController {
             recorder.prepareToRecord()
             recorder.record()
             activeMeetingAudioRecorder = recorder
-            showTopToast(message: "Meeting recording started (microphone only)", isError: false)
+            if showToast {
+                showTopToast(message: "Meeting recording started (microphone only)", isError: false)
+            }
         } catch {
             activeMeetingRecordingSession = nil
             showSimpleAlert(title: "Could not start recording", message: error.localizedDescription)
@@ -1442,26 +1535,126 @@ private extension ViewController {
         guard let session = activeMeetingRecordingSession else { return }
         let stopSystemAudio = activeMeetingSystemAudioStopper
         activeMeetingSystemAudioStopper = nil
-        if let stopSystemAudio { Task { await stopSystemAudio() } }
         activeMeetingAudioRecorder?.stop()
         activeMeetingAudioRecorder = nil
         activeMeetingRecordingSession = nil
+        Task { [weak self] in
+            guard let self else { return }
+            if let stopSystemAudio { await stopSystemAudio() }
+            let finalURL = await self.finalizeMeetingAudioFile(
+                systemURL: session.systemAudioFileURL,
+                microphoneURL: session.microphoneAudioFileURL,
+                recordingID: session.id
+            )
+            await MainActor.run {
+                let summary = MeetingRecordingSummary(
+                    id: session.id,
+                    title: session.title,
+                    meetURLString: session.meetURL.absoluteString,
+                    startedAt: session.startedAt,
+                    endedAt: Date(),
+                    audioFilePath: finalURL.path,
+                    transcriptStatusRaw: MeetingTranscriptStatus.notRequested.rawValue,
+                    transcriptSourceRaw: nil,
+                    transcriptText: nil,
+                    transcriptErrorMessage: nil
+                )
+                self.aiCompanionLocalRecordings.insert(summary, at: 0)
+                self.aiCompanionLocalRecordings.sort(by: { $0.endedAt > $1.endedAt })
+                self.persistAiCompanionLocalRecordings()
+                self.aiCompanionStartTranscriptProcessing(forMeetingID: summary.id, requestId: nil, interactiveAuth: false, forceRegenerate: false)
+                self.pageCache[.aiCompanion] = nil
+                self.showTopToast(message: "Meeting recording saved", isError: false)
+                if self.selectedSidebarPage == .aiCompanion {
+                    self.showSidebarPage(.aiCompanion)
+                }
+            }
+        }
+    }
 
-        let summary = MeetingRecordingSummary(
-            id: session.id,
-            title: session.title,
-            meetURLString: session.meetURL.absoluteString,
-            startedAt: session.startedAt,
-            endedAt: Date(),
-            audioFilePath: session.audioFileURL.path
-        )
-        aiCompanionLocalRecordings.insert(summary, at: 0)
-        aiCompanionLocalRecordings.sort(by: { $0.endedAt > $1.endedAt })
-        persistAiCompanionLocalRecordings()
-        pageCache[.aiCompanion] = nil
-        showTopToast(message: "Meeting recording saved", isError: false)
-        if selectedSidebarPage == .aiCompanion {
-            showSidebarPage(.aiCompanion)
+    private func fileSize(at url: URL) -> Int64 {
+        let attrs = try? FileManager.default.attributesOfItem(atPath: url.path)
+        return attrs?[.size] as? Int64 ?? 0
+    }
+
+    private func hasAudioPayload(at url: URL, minBytes: Int64 = 10_000) -> Bool {
+        guard FileManager.default.fileExists(atPath: url.path) else { return false }
+        return fileSize(at: url) >= minBytes
+    }
+
+    private func finalizeMeetingAudioFile(systemURL: URL, microphoneURL: URL, recordingID: String) async -> URL {
+        let destinationURL = localRecordingDirectoryURL().appendingPathComponent("\(recordingID).m4a")
+        let hasSystem = hasAudioPayload(at: systemURL)
+        let hasMic = hasAudioPayload(at: microphoneURL)
+
+        if hasSystem && hasMic {
+            do {
+                try await mixAudioFiles(systemURL: systemURL, microphoneURL: microphoneURL, destinationURL: destinationURL)
+                try? FileManager.default.removeItem(at: systemURL)
+                try? FileManager.default.removeItem(at: microphoneURL)
+                return destinationURL
+            } catch {
+                // Fall back to best available single track.
+            }
+        }
+
+        let chosenURL: URL
+        if hasSystem {
+            chosenURL = systemURL
+        } else if hasMic {
+            chosenURL = microphoneURL
+        } else {
+            let systemSize = fileSize(at: systemURL)
+            let micSize = fileSize(at: microphoneURL)
+            chosenURL = systemSize >= micSize ? systemURL : microphoneURL
+        }
+
+        if chosenURL.path != destinationURL.path {
+            try? FileManager.default.removeItem(at: destinationURL)
+            do {
+                try FileManager.default.copyItem(at: chosenURL, to: destinationURL)
+            } catch {
+                return chosenURL
+            }
+        }
+        try? FileManager.default.removeItem(at: systemURL)
+        try? FileManager.default.removeItem(at: microphoneURL)
+        return destinationURL
+    }
+
+    private func mixAudioFiles(systemURL: URL, microphoneURL: URL, destinationURL: URL) async throws {
+        try? FileManager.default.removeItem(at: destinationURL)
+
+        let composition = AVMutableComposition()
+        guard let systemTrack = composition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid),
+              let micTrack = composition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid) else {
+            throw NSError(domain: "MeetingAudioMix", code: 1, userInfo: [NSLocalizedDescriptionKey: "Unable to create audio composition tracks."])
+        }
+
+        let systemAsset = AVURLAsset(url: systemURL)
+        let micAsset = AVURLAsset(url: microphoneURL)
+
+        if let src = try await systemAsset.loadTracks(withMediaType: .audio).first {
+            let duration = try await systemAsset.load(.duration)
+            try systemTrack.insertTimeRange(CMTimeRange(start: .zero, duration: duration), of: src, at: .zero)
+        }
+        if let src = try await micAsset.loadTracks(withMediaType: .audio).first {
+            let duration = try await micAsset.load(.duration)
+            try micTrack.insertTimeRange(CMTimeRange(start: .zero, duration: duration), of: src, at: .zero)
+        }
+
+        guard let export = AVAssetExportSession(asset: composition, presetName: AVAssetExportPresetAppleM4A) else {
+            throw NSError(domain: "MeetingAudioMix", code: 2, userInfo: [NSLocalizedDescriptionKey: "Unable to create audio export session."])
+        }
+        export.outputURL = destinationURL
+        export.outputFileType = .m4a
+        await withCheckedContinuation { continuation in
+            export.exportAsynchronously {
+                continuation.resume()
+            }
+        }
+        if export.status != .completed {
+            throw export.error ?? NSError(domain: "MeetingAudioMix", code: 3, userInfo: [NSLocalizedDescriptionKey: "Audio mix export failed."])
         }
     }
 
@@ -2228,7 +2421,8 @@ private extension ViewController {
         aiCompanionSpeechTextByView.removeAll()
         aiCompanionTranscriptMeetingIdByView.removeAll()
         aiCompanionTranscriptStatusLabelByView.removeAll()
-        aiCompanionTranscriptTextByMeetingId.removeAll()
+        aiCompanionTranscriptTaskByMeetingId.values.forEach { $0.cancel() }
+        aiCompanionTranscriptTaskByMeetingId.removeAll()
         aiCompanionTranscriptCurrentRequestId = nil
         aiCompanionTranscriptWindow?.close()
         aiCompanionTranscriptWindow = nil
@@ -2424,7 +2618,7 @@ private extension ViewController {
 
         aiCompanionTranscriptMeetingIdByView[ObjectIdentifier(transcriptButton)] = recording.id
 
-        let transcriptStatusLabel = textLabel("Transcript not loaded", font: typography.fieldLabel, color: palette.textMuted)
+        let transcriptStatusLabel = textLabel(aiCompanionTranscriptStatusText(for: recording), font: typography.fieldLabel, color: palette.textMuted)
         transcriptStatusLabel.alignment = .left
         transcriptStatusLabel.maximumNumberOfLines = 2
         transcriptStatusLabel.lineBreakMode = .byTruncatingTail
@@ -2663,47 +2857,144 @@ private extension ViewController {
     @objc private func aiCompanionTranscriptTapped(_ sender: NSButton) {
         let senderId = ObjectIdentifier(sender)
         guard let meetingId = aiCompanionTranscriptMeetingIdByView[senderId] else { return }
-        guard let meeting = scheduleCachedMeetings.first(where: { $0.id == meetingId }) else {
-            aiCompanionTranscriptStatusLabelByView[senderId]?.stringValue = "Transcript unavailable"
-            showSimpleAlert(title: "Transcript unavailable", message: "Transcript fetch is available for Google Calendar meetings only.")
+        guard let recording = aiCompanionLocalRecordings.first(where: { $0.id == meetingId }) else {
+            aiCompanionTranscriptStatusLabelByView[senderId]?.stringValue = "Transcript unavailable (tap to retry)"
+            showSimpleAlert(title: "Transcript unavailable", message: "Could not find recording details for this meeting.")
             return
         }
 
-        if let cached = aiCompanionTranscriptTextByMeetingId[meetingId] {
-            aiCompanionPresentTranscriptWindow(meetingTitle: meeting.title, initialText: cached)
-            aiCompanionTranscriptStatusLabelByView[senderId]?.stringValue = "Transcript ready"
+        if aiCompanionTranscriptStatus(for: recording) == .ready,
+           let cached = recording.transcriptText?.trimmingCharacters(in: .whitespacesAndNewlines),
+           cached.isEmpty == false {
+            aiCompanionPresentTranscriptWindow(meetingTitle: recording.title, initialText: cached)
+            aiCompanionTranscriptStatusLabelByView[senderId]?.stringValue = aiCompanionTranscriptStatusText(for: recording)
             return
         }
 
-        aiCompanionTranscriptStatusLabelByView[senderId]?.stringValue = "Loading transcript..."
+        aiCompanionTranscriptStatusLabelByView[senderId]?.stringValue = "Transcript processing..."
 
         let requestId = UUID()
         aiCompanionTranscriptCurrentRequestId = requestId
 
-        aiCompanionPresentTranscriptWindow(meetingTitle: meeting.title, initialText: "Loading transcript...")
+        aiCompanionPresentTranscriptWindow(meetingTitle: recording.title, initialText: "Transcript processing...")
+        aiCompanionStartTranscriptProcessing(forMeetingID: meetingId, requestId: requestId, interactiveAuth: true, forceRegenerate: true)
+    }
+
+    private func aiCompanionStartTranscriptProcessing(
+        forMeetingID meetingId: String,
+        requestId: UUID?,
+        interactiveAuth: Bool,
+        forceRegenerate: Bool
+    ) {
+        if !forceRegenerate,
+           let recording = aiCompanionLocalRecordings.first(where: { $0.id == meetingId }),
+           aiCompanionTranscriptStatus(for: recording) == .ready {
+            return
+        }
 
-        Task { [weak self] in
+        aiCompanionTranscriptTaskByMeetingId[meetingId]?.cancel()
+        _ = aiCompanionUpdateRecording(meetingId: meetingId) { recording in
+            recording.transcriptStatusRaw = MeetingTranscriptStatus.processing.rawValue
+            recording.transcriptErrorMessage = nil
+            if forceRegenerate {
+                recording.transcriptText = nil
+                recording.transcriptSourceRaw = nil
+            }
+        }
+        aiCompanionRefreshTranscriptStatusLabels(forMeetingID: meetingId)
+        if selectedSidebarPage == .aiCompanion {
+            pageCache[.aiCompanion] = nil
+            showSidebarPage(.aiCompanion)
+        }
+
+        let presentingWindow = view.window
+        let task = Task { [weak self] in
             guard let self else { return }
+            defer { Task { @MainActor [weak self] in self?.aiCompanionTranscriptTaskByMeetingId[meetingId] = nil } }
 
             do {
-                let token = try await self.googleOAuth.validAccessToken(presentingWindow: self.view.window)
-                let text = try await self.aiCompanionFetchTranscriptText(for: meeting, accessToken: token)
-
+                let result = try await self.aiCompanionFetchOrGenerateTranscript(
+                    meetingId: meetingId,
+                    interactiveAuth: interactiveAuth,
+                    presentingWindow: presentingWindow
+                )
                 await MainActor.run {
-                    guard self.aiCompanionTranscriptCurrentRequestId == requestId else { return } // stale request
-                    self.aiCompanionTranscriptTextByMeetingId[meetingId] = text
-                    self.aiCompanionTranscriptTextView?.string = text
-                    self.aiCompanionTranscriptStatusLabelByView[senderId]?.stringValue = "Transcript ready"
+                    guard requestId == nil || self.aiCompanionTranscriptCurrentRequestId == requestId else { return }
+                    _ = self.aiCompanionUpdateRecording(meetingId: meetingId) { recording in
+                        recording.transcriptStatusRaw = MeetingTranscriptStatus.ready.rawValue
+                        recording.transcriptSourceRaw = result.source.rawValue
+                        recording.transcriptText = result.text
+                        recording.transcriptErrorMessage = nil
+                    }
+                    self.aiCompanionTranscriptTextView?.string = result.text
+                    self.aiCompanionRefreshTranscriptStatusLabels(forMeetingID: meetingId)
+                    if self.selectedSidebarPage == .aiCompanion {
+                        self.pageCache[.aiCompanion] = nil
+                        self.showSidebarPage(.aiCompanion)
+                    }
                 }
             } catch {
                 await MainActor.run {
-                    guard self.aiCompanionTranscriptCurrentRequestId == requestId else { return }
+                    guard requestId == nil || self.aiCompanionTranscriptCurrentRequestId == requestId else { return }
                     let msg = error.localizedDescription.isEmpty ? "Failed to load transcript." : error.localizedDescription
+                    _ = self.aiCompanionUpdateRecording(meetingId: meetingId) { recording in
+                        recording.transcriptStatusRaw = MeetingTranscriptStatus.failed.rawValue
+                        recording.transcriptErrorMessage = msg
+                    }
                     self.aiCompanionTranscriptTextView?.string = "Transcript unavailable.\n\n\(msg)"
-                    self.aiCompanionTranscriptStatusLabelByView[senderId]?.stringValue = "Transcript unavailable"
+                    self.aiCompanionRefreshTranscriptStatusLabels(forMeetingID: meetingId)
+                    if self.selectedSidebarPage == .aiCompanion {
+                        self.pageCache[.aiCompanion] = nil
+                        self.showSidebarPage(.aiCompanion)
+                    }
+                }
+            }
+        }
+        aiCompanionTranscriptTaskByMeetingId[meetingId] = task
+    }
+
+    private func aiCompanionFetchOrGenerateTranscript(
+        meetingId: String,
+        interactiveAuth: Bool,
+        presentingWindow: NSWindow?
+    ) async throws -> (text: String, source: MeetingTranscriptSource) {
+        guard let recording = aiCompanionLocalRecordings.first(where: { $0.id == meetingId }) else {
+            throw NSError(domain: "AiCompanionTranscript", code: 10, userInfo: [NSLocalizedDescriptionKey: "Recording not found."])
+        }
+
+        if let meeting = aiCompanionMeetingFromRecording(recording) {
+            do {
+                let accessToken: String
+                if interactiveAuth {
+                    accessToken = try await googleOAuth.validAccessToken(presentingWindow: presentingWindow)
+                } else if let token = googleOAuth.loadTokens()?.accessToken {
+                    accessToken = token
+                } else {
+                    throw NSError(domain: "AiCompanionTranscript", code: 11, userInfo: [NSLocalizedDescriptionKey: "Google account not connected."])
                 }
+                let text = try await aiCompanionFetchTranscriptText(for: meeting, accessToken: accessToken)
+                let cleaned = text.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
+                if cleaned.isEmpty == false {
+                    return (cleaned, .meetApi)
+                }
+            } catch {
+                // Continue to OpenAI fallback below.
             }
         }
+
+        let audioURL = URL(fileURLWithPath: recording.audioFilePath)
+        guard FileManager.default.fileExists(atPath: audioURL.path) else {
+            throw NSError(domain: "AiCompanionTranscript", code: 12, userInfo: [NSLocalizedDescriptionKey: "Local meeting audio is missing."])
+        }
+        guard let apiKey = configuredOpenAIAPIKey() else {
+            throw NSError(domain: "AiCompanionTranscript", code: 13, userInfo: [NSLocalizedDescriptionKey: "OpenAI API key missing. Add OpenAIAPIKey to Info.plist or openai.apiKey to UserDefaults."])
+        }
+        let text = try await openAITranscriptionClient.transcribeAudioFile(fileURL: audioURL, apiKey: apiKey)
+        let cleaned = text.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
+        guard cleaned.isEmpty == false else {
+            throw NSError(domain: "AiCompanionTranscript", code: 14, userInfo: [NSLocalizedDescriptionKey: "Generated transcript was empty."])
+        }
+        return (cleaned, .localAudioOpenAI)
     }
 
     @objc private func aiCompanionStopRecordingTapped(_ sender: NSButton) {
@@ -5766,6 +6057,8 @@ private final class MeetingSystemAudioRecorder: NSObject, SCStreamOutput, SCStre
         config.minimumFrameInterval = CMTime(value: 1, timescale: 2)
         config.queueDepth = 1
         config.capturesAudio = true
+        config.sampleRate = 48_000
+        config.channelCount = 2
         if #available(macOS 13.0, *) {
             config.excludesCurrentProcessAudio = true
         }