浏览代码

Switch AI Companion transcription to Apple Speech recognition and remove OpenAI dependency from the local transcript flow.

This keeps transcript text generation fully on-device APIs and adds required speech-recognition permission messaging for macOS.

Co-authored-by: Cursor <cursoragent@cursor.com>
huzaifahayat12 2 月之前
父节点
当前提交
3f29e14107
共有 2 个文件被更改,包括 82 次插入39 次删除
  1. 2 0
      Info.plist
  2. 80 39
      meetings_app/ViewController.swift

+ 2 - 0
Info.plist

@@ -28,6 +28,8 @@
 	<string>NSApplication</string>
 	<key>NSMicrophoneUsageDescription</key>
 	<string>This app records meeting audio locally with your consent so it can be shown in AI Companion after the meeting.</string>
+	<key>NSSpeechRecognitionUsageDescription</key>
+	<string>This app converts your saved meeting audio into text transcripts for AI Companion.</string>
 	<key>AppLaunchPlaceholderURL</key>
 	<string>https://example.com/app-link-coming-soon</string>
 	<key>AppShareURL</key>

+ 80 - 39
meetings_app/ViewController.swift

@@ -9,6 +9,7 @@ import Cocoa
 import QuartzCore
 import AVFoundation
 import AVKit
+import Speech
 import WebKit
 import AuthenticationServices
 import StoreKit
@@ -240,7 +241,7 @@ final class ViewController: NSViewController {
 
     private enum MeetingTranscriptSource: String, Codable {
         case meetApi
-        case localAudioOpenAI
+        case localAudioAppleSpeech
     }
 
     private enum PaywallFooterAction {
@@ -472,10 +473,7 @@ 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
@@ -1304,18 +1302,82 @@ 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
+    private func requestSpeechRecognitionAuthorizationIfNeeded() async throws {
+        switch SFSpeechRecognizer.authorizationStatus() {
+        case .authorized:
+            return
+        case .notDetermined:
+            let status = await withCheckedContinuation { continuation in
+                SFSpeechRecognizer.requestAuthorization { status in
+                    continuation.resume(returning: status)
+                }
+            }
+            guard status == .authorized else {
+                throw NSError(
+                    domain: "AiCompanionTranscript",
+                    code: 15,
+                    userInfo: [NSLocalizedDescriptionKey: "Speech recognition permission denied. Enable it in System Settings and try again."]
+                )
+            }
+        case .denied:
+            throw NSError(
+                domain: "AiCompanionTranscript",
+                code: 15,
+                userInfo: [NSLocalizedDescriptionKey: "Speech recognition permission denied. Enable it in System Settings and try again."]
+            )
+        case .restricted:
+            throw NSError(
+                domain: "AiCompanionTranscript",
+                code: 16,
+                userInfo: [NSLocalizedDescriptionKey: "Speech recognition is restricted on this Mac."]
+            )
+        @unknown default:
+            throw NSError(
+                domain: "AiCompanionTranscript",
+                code: 17,
+                userInfo: [NSLocalizedDescriptionKey: "Speech recognition authorization is unavailable."]
+            )
+        }
+    }
+
+    private func transcribeLocalAudioWithAppleSpeech(audioURL: URL) async throws -> String {
+        try await requestSpeechRecognitionAuthorizationIfNeeded()
+
+        guard let recognizer = SFSpeechRecognizer(locale: Locale.current) ?? SFSpeechRecognizer(locale: Locale(identifier: "en-US")) else {
+            throw NSError(
+                domain: "AiCompanionTranscript",
+                code: 18,
+                userInfo: [NSLocalizedDescriptionKey: "Speech recognizer is unavailable for the current locale."]
+            )
         }
-        let plistValue = Bundle.main.object(forInfoDictionaryKey: openAIAPIKeyPlistKey) as? String
-        let trimmedPlist = plistValue?.trimmingCharacters(in: .whitespacesAndNewlines)
-        if let trimmedPlist, trimmedPlist.isEmpty == false {
-            return trimmedPlist
+
+        let request = SFSpeechURLRecognitionRequest(url: audioURL)
+        request.shouldReportPartialResults = false
+        if #available(macOS 13.0, *) {
+            request.addsPunctuation = true
+        }
+
+        return try await withCheckedThrowingContinuation { continuation in
+            var hasResumed = false
+            var task: SFSpeechRecognitionTask?
+            task = recognizer.recognitionTask(with: request) { result, error in
+                if hasResumed { return }
+
+                if let error {
+                    hasResumed = true
+                    task?.cancel()
+                    continuation.resume(throwing: error)
+                    return
+                }
+
+                guard let result else { return }
+                if result.isFinal {
+                    hasResumed = true
+                    task?.finish()
+                    continuation.resume(returning: result.bestTranscription.formattedString)
+                }
+            }
         }
-        return nil
     }
 
     private func aiCompanionTranscriptStatus(for recording: MeetingRecordingSummary) -> MeetingTranscriptStatus {
@@ -2969,40 +3031,19 @@ private extension ViewController {
         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.
-            }
-        }
+        _ = interactiveAuth
+        _ = presentingWindow
 
         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 text = try await transcribeLocalAudioWithAppleSpeech(audioURL: audioURL)
         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)
+        return (cleaned, .localAudioAppleSpeech)
     }
 
     @objc private func aiCompanionStopRecordingTapped(_ sender: NSButton) {