|
@@ -9,6 +9,7 @@ import Cocoa
|
|
|
import QuartzCore
|
|
import QuartzCore
|
|
|
import AVFoundation
|
|
import AVFoundation
|
|
|
import AVKit
|
|
import AVKit
|
|
|
|
|
+import Speech
|
|
|
import WebKit
|
|
import WebKit
|
|
|
import AuthenticationServices
|
|
import AuthenticationServices
|
|
|
import StoreKit
|
|
import StoreKit
|
|
@@ -240,7 +241,7 @@ final class ViewController: NSViewController {
|
|
|
|
|
|
|
|
private enum MeetingTranscriptSource: String, Codable {
|
|
private enum MeetingTranscriptSource: String, Codable {
|
|
|
case meetApi
|
|
case meetApi
|
|
|
- case localAudioOpenAI
|
|
|
|
|
|
|
+ case localAudioAppleSpeech
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private enum PaywallFooterAction {
|
|
private enum PaywallFooterAction {
|
|
@@ -472,10 +473,7 @@ final class ViewController: NSViewController {
|
|
|
private let ratingStateMigrationV2DoneDefaultsKey = "rating.stateMigrationV2Done"
|
|
private let ratingStateMigrationV2DoneDefaultsKey = "rating.stateMigrationV2Done"
|
|
|
private let nonPremiumJoinTrialConsumedDefaultsKey = "join.nonPremiumTrialConsumed"
|
|
private let nonPremiumJoinTrialConsumedDefaultsKey = "join.nonPremiumTrialConsumed"
|
|
|
private let aiCompanionLocalRecordingsDefaultsKey = "aiCompanion.localRecordings"
|
|
private let aiCompanionLocalRecordingsDefaultsKey = "aiCompanion.localRecordings"
|
|
|
- private let openAIAPIKeyDefaultsKey = "openai.apiKey"
|
|
|
|
|
- private let openAIAPIKeyPlistKey = "OpenAIAPIKey"
|
|
|
|
|
private let ratingEligibleUsageSeconds: TimeInterval = 30 * 60
|
|
private let ratingEligibleUsageSeconds: TimeInterval = 30 * 60
|
|
|
- private let openAITranscriptionClient = OpenAITranscriptionClient()
|
|
|
|
|
private var darkModeEnabled: Bool {
|
|
private var darkModeEnabled: Bool {
|
|
|
get {
|
|
get {
|
|
|
let hasValue = UserDefaults.standard.object(forKey: darkModeDefaultsKey) != nil
|
|
let hasValue = UserDefaults.standard.object(forKey: darkModeDefaultsKey) != nil
|
|
@@ -1304,18 +1302,82 @@ private extension ViewController {
|
|
|
UserDefaults.standard.set(encoded, forKey: aiCompanionLocalRecordingsDefaultsKey)
|
|
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 {
|
|
private func aiCompanionTranscriptStatus(for recording: MeetingRecordingSummary) -> MeetingTranscriptStatus {
|
|
@@ -2969,40 +3031,19 @@ private extension ViewController {
|
|
|
guard let recording = aiCompanionLocalRecordings.first(where: { $0.id == meetingId }) else {
|
|
guard let recording = aiCompanionLocalRecordings.first(where: { $0.id == meetingId }) else {
|
|
|
throw NSError(domain: "AiCompanionTranscript", code: 10, userInfo: [NSLocalizedDescriptionKey: "Recording not found."])
|
|
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)
|
|
let audioURL = URL(fileURLWithPath: recording.audioFilePath)
|
|
|
guard FileManager.default.fileExists(atPath: audioURL.path) else {
|
|
guard FileManager.default.fileExists(atPath: audioURL.path) else {
|
|
|
throw NSError(domain: "AiCompanionTranscript", code: 12, userInfo: [NSLocalizedDescriptionKey: "Local meeting audio is missing."])
|
|
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)
|
|
let cleaned = text.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
|
|
|
guard cleaned.isEmpty == false else {
|
|
guard cleaned.isEmpty == false else {
|
|
|
throw NSError(domain: "AiCompanionTranscript", code: 14, userInfo: [NSLocalizedDescriptionKey: "Generated transcript was empty."])
|
|
throw NSError(domain: "AiCompanionTranscript", code: 14, userInfo: [NSLocalizedDescriptionKey: "Generated transcript was empty."])
|
|
|
}
|
|
}
|
|
|
- return (cleaned, .localAudioOpenAI)
|
|
|
|
|
|
|
+ return (cleaned, .localAudioAppleSpeech)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
@objc private func aiCompanionStopRecordingTapped(_ sender: NSButton) {
|
|
@objc private func aiCompanionStopRecordingTapped(_ sender: NSButton) {
|