diff --git a/Sources/AirTranslate/Models/AppText.swift b/Sources/AirTranslate/Models/AppText.swift index d61ec24..c964207 100644 --- a/Sources/AirTranslate/Models/AppText.swift +++ b/Sources/AirTranslate/Models/AppText.swift @@ -210,6 +210,134 @@ enum AppText { english: "The stored OpenAI API key could not be read.", korean: "저장된 OpenAI API 키를 읽을 수 없습니다." ) + static let openAIProvider = localized( + english: "Provider", + korean: "공급자", + japanese: "プロバイダー", + chineseSimplified: "服务商" + ) + static let openAIProviderOpenAITitle = "OpenAI" + static let openAIProviderAzureTitle = "Azure OpenAI" + static let azureOpenAIEndpoint = localized( + english: "Azure Resource Endpoint", + korean: "Azure 리소스 엔드포인트", + japanese: "Azureリソースエンドポイント", + chineseSimplified: "Azure 资源终结点" + ) + static let azureOpenAIEndpointPlaceholder = localized( + english: "https://.cognitiveservices.azure.com/", + korean: "https://<리소스>.cognitiveservices.azure.com/", + japanese: "https://<リソース>.cognitiveservices.azure.com/", + chineseSimplified: "https://<资源名>.cognitiveservices.azure.com/" + ) + static let azureOpenAIEndpointFormatHint = localized( + english: "Use the Azure resource endpoint only. Do not include wss:// or /openai/v1/realtime.", + korean: "Azure 리소스 엔드포인트만 입력하세요. wss:// 또는 /openai/v1/realtime은 포함하지 마세요.", + japanese: "Azureリソースエンドポイントのみ入力してください。wss:// や /openai/v1/realtime は含めないでください。", + chineseSimplified: "只填写 Azure 资源终结点。不要包含 wss:// 或 /openai/v1/realtime。" + ) + static let azureOpenAIAPIKey = localized( + english: "Azure API Key", + korean: "Azure API 키", + japanese: "Azure APIキー", + chineseSimplified: "Azure API key" + ) + static let azureOpenAIAPIKeyPlaceholder = localized( + english: "Paste Azure resource key", + korean: "Azure 리소스 키 붙여넣기", + japanese: "Azureリソースキーを貼り付け", + chineseSimplified: "粘贴 Azure 资源 key" + ) + static let saveAzureOpenAIConfig = localized( + english: "Save Azure Config", + korean: "Azure 설정 저장", + japanese: "Azure設定を保存", + chineseSimplified: "保存 Azure 配置" + ) + static let removeAzureOpenAIConfig = localized( + english: "Remove Azure Config", + korean: "Azure 설정 삭제", + japanese: "Azure設定を削除", + chineseSimplified: "删除 Azure 配置" + ) + static let azureOpenAIConfigSaved = localized( + english: "Azure OpenAI configuration saved.", + korean: "Azure OpenAI 설정이 저장되었습니다.", + japanese: "Azure OpenAI設定を保存しました。", + chineseSimplified: "已保存 Azure OpenAI 配置。" + ) + static let azureOpenAIConfigSavedTitle = localized( + english: "Saved", + korean: "저장됨", + japanese: "保存しました", + chineseSimplified: "已保存" + ) + static let azureOpenAIConfigSaveFailedTitle = localized( + english: "Save Failed", + korean: "저장 실패", + japanese: "保存に失敗しました", + chineseSimplified: "保存失败" + ) + static let azureOpenAIConfigRemoved = localized( + english: "Azure OpenAI configuration removed.", + korean: "Azure OpenAI 설정이 삭제되었습니다.", + japanese: "Azure OpenAI設定を削除しました。", + chineseSimplified: "已删除 Azure OpenAI 配置。" + ) + static let azureOpenAIConfigConfigured = localized( + english: "Azure config saved", + korean: "Azure 설정 저장됨", + japanese: "Azure設定保存済み", + chineseSimplified: "Azure 配置已保存" + ) + static let azureOpenAIConfigNotConfigured = localized( + english: "Azure config required", + korean: "Azure 설정 필요", + japanese: "Azure設定が必要", + chineseSimplified: "需要 Azure 配置" + ) + static let azureOpenAIConfigRequiredForGPTMode = localized( + english: "Enter the Azure endpoint and key to use GPT mode.", + korean: "GPT 모드를 사용하려면 Azure 엔드포인트와 키를 입력하세요.", + japanese: "GPTモードを使うにはAzureエンドポイントとキーを入力してください。", + chineseSimplified: "要使用 GPT 模式,请输入 Azure 终结点和密钥。" + ) + static let azureOpenAIEndpointInvalid = localized( + english: "The Azure resource endpoint must be a URL such as https://.cognitiveservices.azure.com/.", + korean: "Azure 리소스 엔드포인트는 https://<리소스>.cognitiveservices.azure.com/ 같은 URL이어야 합니다.", + japanese: "Azureリソースエンドポイントは https://<リソース>.cognitiveservices.azure.com/ の形式のURLを入力してください。", + chineseSimplified: "Azure 资源终结点必须是类似 https://<资源名>.cognitiveservices.azure.com/ 的 URL。" + ) + static let azureOpenAIEndpointMissing = localized( + english: "Add an Azure resource endpoint in Settings before using Azure OpenAI.", + korean: "Azure OpenAI를 사용하기 전에 설정에서 Azure 리소스 엔드포인트를 입력하세요.", + japanese: "Azure OpenAIを使う前に設定でAzureリソースエンドポイントを入力してください。", + chineseSimplified: "使用 Azure OpenAI 前请先在设置中输入 Azure 资源终结点。" + ) + static let azureOpenAIAPIKeyMissing = localized( + english: "Add an Azure OpenAI API key in Settings before using Azure OpenAI.", + korean: "Azure OpenAI를 사용하기 전에 설정에서 Azure OpenAI API 키를 입력하세요.", + japanese: "Azure OpenAIを使う前に設定でAzure OpenAI APIキーを入力してください。", + chineseSimplified: "使用 Azure OpenAI 前请先在设置中输入 Azure OpenAI API key。" + ) + static let azureOpenAITranscriptionUnsupported = localized( + english: "Azure OpenAI only supports realtime translation. Switch to a translation model or use the OpenAI provider for transcription.", + korean: "Azure OpenAI는 실시간 번역만 지원합니다. 번역 모델로 전환하거나 전사를 위해 OpenAI 공급자를 사용하세요.", + japanese: "Azure OpenAIはリアルタイム翻訳のみサポートします。翻訳モデルに切り替えるか、文字起こしにはOpenAIプロバイダーを使ってください。", + chineseSimplified: "Azure OpenAI 仅支持实时翻译。请切换到翻译模型,或转写时改用 OpenAI 服务商。" + ) + static let azureOpenAIPlatformPrompt = localized( + english: "Need Azure OpenAI access?", + korean: "Azure OpenAI 액세스가 필요하신가요?", + japanese: "Azure OpenAIのアクセスが必要ですか?", + chineseSimplified: "需要 Azure OpenAI 访问权限?" + ) + static let azureOpenAIPlatformLink = localized( + english: "Open Azure AI Foundry", + korean: "Azure AI Foundry 열기", + japanese: "Azure AI Foundryを開く", + chineseSimplified: "打开 Azure AI Foundry" + ) static let appleProcessingMode = localized(english: "Apple Mode", korean: "Apple 기본 모드", japanese: "Apple標準モード", chineseSimplified: "Apple 默认模式") static let appleProcessingModeDescription = localized( english: "The default local workflow. Keep this as the base, then add OpenAI Realtime below only when needed.", @@ -222,6 +350,18 @@ enum AppText { japanese: "文字起こし", chineseSimplified: "转写" ) + static let gptTranscriptionLockedByRealtimeTitle = localized( + english: "Transcription handled by realtime translation", + korean: "전사는 실시간 번역이 처리합니다", + japanese: "文字起こしはリアルタイム翻訳が担当します", + chineseSimplified: "转写由实时翻译模型负责" + ) + static let gptTranscriptionLockedByRealtimeDetail = localized( + english: "gpt-realtime-translate works directly on audio, so a separate transcription model is not used.", + korean: "gpt-realtime-translate는 오디오를 직접 처리하므로 별도의 전사 모델은 사용되지 않습니다.", + japanese: "gpt-realtime-translateは音声を直接処理するため、別途の文字起こしモデルは使用しません。", + chineseSimplified: "gpt-realtime-translate 直接基于音频翻译,因此不再使用单独的转写模型。" + ) static let gptTranslationModel = localized( english: "Auto Translation", korean: "자동번역", @@ -232,6 +372,30 @@ enum AppText { english: "GPT mode uses OpenAI Realtime directly for the translated stream and bypasses local transcript cleanup.", korean: "GPT 모드는 OpenAI Realtime의 번역 스트림을 직접 사용하며 로컬 기록 다듬기를 건너뜁니다." ) + static let advancedSection = localized( + english: "Advanced", + korean: "고급", + japanese: "詳細", + chineseSimplified: "高级" + ) + static let customTranscriptionModelName = localized( + english: "Transcription model name", + korean: "전사 모델 이름", + japanese: "文字起こしモデル名", + chineseSimplified: "转写模型名称" + ) + static let customTranslationModelName = localized( + english: "Translation model name", + korean: "번역 모델 이름", + japanese: "翻訳モデル名", + chineseSimplified: "翻译模型名称" + ) + static let customModelNameFootnote = localized( + english: "Leave empty to use the default.", + korean: "기본값을 사용하려면 비워두세요.", + japanese: "デフォルトを使う場合は空のままにします。", + chineseSimplified: "留空则使用默认值。" + ) static let openAINativeOutput = localized( english: "OpenAI native output", korean: "OpenAI 본연의 출력", @@ -306,7 +470,7 @@ enum AppText { static let liveOutput = localized(english: "Live Output", korean: "실시간 출력", japanese: "リアルタイム出力", chineseSimplified: "实时输出") static let library = localized(english: "Library", korean: "저장소", japanese: "ライブラリ", chineseSimplified: "资料库") static let dubbing = localized(english: "Dubbing", korean: "더빙", japanese: "音声出力", chineseSimplified: "配音") - static let voiceOutput = localized(english: "Voice Output", korean: "음성 출력", japanese: "音声出力", chineseSimplified: "语音输出") + static let voiceOutput = localized(english: "System translated voice", korean: "시스템 번역 음성", japanese: "システム翻訳音声", chineseSimplified: "系统翻译语音") static let menuBarTitle = localized(english: "Captions", korean: "자막") static let menuBarRunningTitle = localized(english: "Live", korean: "기록 중") static let menuBarPausedTitle = localized(english: "Paused", korean: "일시정지") @@ -318,12 +482,38 @@ enum AppText { static let hideFloatingCaptions = localized(english: "Hide Floating Captions", korean: "플로팅 자막 숨기기") static let openMainWindow = localized(english: "Open Main Window", korean: "메인 창 열기") static let floatingDisplay = localized(english: "Floating Display", korean: "플로팅 표시") - static let floatingDisplayDescription = localized( - english: "Choose what appears in the detachable floating caption window.", - korean: "따로 띄우는 플로팅 자막 창에 표시할 내용을 선택합니다." + static let floatingTextAlignment = localized( + english: "Floating Text Alignment", + korean: "플로팅 텍스트 정렬", + japanese: "フローティングテキスト配置", + chineseSimplified: "浮动字幕对齐" + ) + static let textAlignmentLeading = localized( + english: "Left", + korean: "왼쪽", + japanese: "左", + chineseSimplified: "左对齐" + ) + static let textAlignmentCenter = localized( + english: "Center", + korean: "가운데", + japanese: "中央", + chineseSimplified: "居中" ) static let floatingTextSize = localized(english: "Floating Text Size", korean: "플로팅 글자 크기") static let floatingLineCount = localized(english: "Floating Lines", korean: "플로팅 표시 줄 수") + static let floatingImmediateDisplay = localized( + english: "Show Updates Immediately", + korean: "업데이트 즉시 표시", + japanese: "更新をすぐ表示", + chineseSimplified: "立即显示更新" + ) + static let floatingImmediateDisplayDescription = localized( + english: "Shows new caption text as soon as it arrives. It feels more realtime, but recognition revisions can make the floating window flicker.", + korean: "새 자막 텍스트가 도착하는 즉시 표시합니다. 더 실시간처럼 느껴지지만 인식 수정 때문에 플로팅 창이 깜박일 수 있습니다.", + japanese: "新しい字幕テキストを到着次第表示します。よりリアルタイムになりますが、認識の修正でフローティングウィンドウがちらつくことがあります。", + chineseSimplified: "新字幕到达后立即显示。实时性更好,但识别结果修订时浮动窗口可能会闪动。" + ) static let originalOnly = localized(english: "Original", korean: "원문", japanese: "原文", chineseSimplified: "原文") static let originalAndTranslation = localized(english: "Original + Translation", korean: "원문 + 번역", japanese: "原文 + 翻訳", chineseSimplified: "原文 + 译文") static let translationOnly = localized(english: "Translation", korean: "번역", japanese: "翻訳", chineseSimplified: "译文") diff --git a/Sources/AirTranslate/Models/FloatingCaptionTextAlignment.swift b/Sources/AirTranslate/Models/FloatingCaptionTextAlignment.swift new file mode 100644 index 0000000..029e6ae --- /dev/null +++ b/Sources/AirTranslate/Models/FloatingCaptionTextAlignment.swift @@ -0,0 +1,44 @@ +import SwiftUI + +enum FloatingCaptionTextAlignment: String, CaseIterable, Identifiable { + case leading + case center + + var id: String { rawValue } + + var title: String { + switch self { + case .leading: + AppText.textAlignmentLeading + case .center: + AppText.textAlignmentCenter + } + } + + var textAlignment: TextAlignment { + switch self { + case .leading: + .leading + case .center: + .center + } + } + + var frameAlignment: Alignment { + switch self { + case .leading: + .leading + case .center: + .center + } + } + + var horizontalAlignment: HorizontalAlignment { + switch self { + case .leading: + .leading + case .center: + .center + } + } +} diff --git a/Sources/AirTranslate/Models/OpenAIProvider.swift b/Sources/AirTranslate/Models/OpenAIProvider.swift new file mode 100644 index 0000000..759848f --- /dev/null +++ b/Sources/AirTranslate/Models/OpenAIProvider.swift @@ -0,0 +1,17 @@ +import Foundation + +enum OpenAIProvider: String, CaseIterable, Identifiable { + case openAI + case azure + + var id: String { rawValue } + + var title: String { + switch self { + case .openAI: + AppText.openAIProviderOpenAITitle + case .azure: + AppText.openAIProviderAzureTitle + } + } +} diff --git a/Sources/AirTranslate/Services/AzureOpenAIConfigStore.swift b/Sources/AirTranslate/Services/AzureOpenAIConfigStore.swift new file mode 100644 index 0000000..13cf6c1 --- /dev/null +++ b/Sources/AirTranslate/Services/AzureOpenAIConfigStore.swift @@ -0,0 +1,129 @@ +import Foundation +import Security + +enum AzureOpenAIConfigStore { + private static let service = "AirTranslate.AzureOpenAI" + private static let account = "AZURE_OPENAI_API_KEY" + static let endpointDefaultsKey = "azureOpenAIEndpoint" + private static let hasAPIKeyDefaultsKey = "azureOpenAIHasAPIKey" + + static func hasConfig() -> Bool { + guard let endpoint = readEndpoint(), !endpoint.isEmpty else { return false } + return UserDefaults.standard.bool(forKey: hasAPIKeyDefaultsKey) + } + + static func readEndpoint() -> String? { + UserDefaults.standard.string(forKey: endpointDefaultsKey) + } + + static func readAPIKey() throws -> String? { + var query = baseQuery() + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + if status == errSecItemNotFound { + return nil + } + guard status == errSecSuccess else { + throw OpenAIAPIKeyStoreError.keychainStatus(status) + } + guard let data = item as? Data, + let key = String(data: data, encoding: .utf8) else { + throw OpenAIAPIKeyStoreError.invalidStoredKey + } + if !key.isEmpty, !UserDefaults.standard.bool(forKey: hasAPIKeyDefaultsKey) { + UserDefaults.standard.set(true, forKey: hasAPIKeyDefaultsKey) + } + return key + } + + static func saveConfig(endpoint: String, apiKey: String) throws { + let trimmedKey = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedKey.isEmpty else { + throw OpenAIAPIKeyStoreError.emptyKey + } + guard let normalizedEndpoint = AzureOpenAIEndpoint.normalize(endpoint) else { + throw AzureOpenAIConfigStoreError.invalidEndpoint + } + guard let data = trimmedKey.data(using: .utf8) else { + throw OpenAIAPIKeyStoreError.invalidStoredKey + } + + SecItemDelete(baseQuery() as CFDictionary) + + var query = baseQuery() + query[kSecValueData as String] = data + query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + + var status = SecItemAdd(query as CFDictionary, nil) + if status == errSecDuplicateItem { + let attributes: [String: Any] = [ + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + ] + status = SecItemUpdate(baseQuery() as CFDictionary, attributes as CFDictionary) + } + guard status == errSecSuccess else { + throw OpenAIAPIKeyStoreError.keychainStatus(status) + } + UserDefaults.standard.set(true, forKey: hasAPIKeyDefaultsKey) + UserDefaults.standard.set(normalizedEndpoint, forKey: endpointDefaultsKey) + } + + static func deleteConfig() throws { + let status = SecItemDelete(baseQuery() as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw OpenAIAPIKeyStoreError.keychainStatus(status) + } + UserDefaults.standard.set(false, forKey: hasAPIKeyDefaultsKey) + UserDefaults.standard.removeObject(forKey: endpointDefaultsKey) + } + + private static func baseQuery() -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + } +} + +enum AzureOpenAIEndpoint { + static func normalize(_ input: String) -> String? { + parse(input)?.normalized + } + + static func host(from endpoint: String) -> String? { + parse(endpoint)?.host + } + + private static func parse(_ input: String) -> (normalized: String, host: String)? { + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + let candidate = trimmed.contains("://") ? trimmed : "https://\(trimmed)" + guard let url = URL(string: candidate), + let host = url.host, !host.isEmpty else { return nil } + + var components = URLComponents() + let scheme = (url.scheme ?? "https").lowercased() + components.scheme = scheme == "http" ? "http" : "https" + components.host = host + if let port = url.port { components.port = port } + guard let normalized = components.string else { return nil } + return (normalized, host) + } +} + +enum AzureOpenAIConfigStoreError: LocalizedError { + case invalidEndpoint + + var errorDescription: String? { + switch self { + case .invalidEndpoint: + AppText.azureOpenAIEndpointInvalid + } + } +} diff --git a/Sources/AirTranslate/Services/OpenAIRealtimeTranscriber.swift b/Sources/AirTranslate/Services/OpenAIRealtimeTranscriber.swift index a0939f1..60303cd 100644 --- a/Sources/AirTranslate/Services/OpenAIRealtimeTranscriber.swift +++ b/Sources/AirTranslate/Services/OpenAIRealtimeTranscriber.swift @@ -2,8 +2,73 @@ import AVFoundation import CoreMedia import Foundation +struct OpenAIRealtimeProviderConfig: Sendable { + enum Kind: Sendable { + case openAI + case azure + } + + let kind: Kind + let host: String + let apiKey: String + + static let openAIHost = "api.openai.com" + + static func openAI(apiKey: String) -> OpenAIRealtimeProviderConfig { + OpenAIRealtimeProviderConfig( + kind: .openAI, + host: openAIHost, + apiKey: apiKey + ) + } + + static func azure( + host: String, + apiKey: String + ) -> OpenAIRealtimeProviderConfig { + OpenAIRealtimeProviderConfig( + kind: .azure, + host: host, + apiKey: apiKey + ) + } + + func transcriptionURL() -> URL? { + switch kind { + case .openAI: + return URL(string: "wss://\(host)/v1/realtime?intent=transcription") + case .azure: + return URL(string: "wss://\(host)/openai/v1/realtime?intent=transcription") + } + } + + func translationURL(modelID: String) -> URL? { + let encodedModel = modelID + .addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? modelID + switch kind { + case .openAI: + return URL(string: "wss://\(host)/v1/realtime/translations?model=\(encodedModel)") + case .azure: + return URL(string: "wss://\(host)/openai/v1/realtime/translations?model=\(encodedModel)") + } + } + + func apply(to request: inout URLRequest) { + switch kind { + case .openAI: + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + case .azure: + request.setValue(apiKey, forHTTPHeaderField: "api-key") + } + } +} + +private extension String { + var nonEmpty: String? { isEmpty ? nil : self } +} + final class OpenAIRealtimeTranscriber: @unchecked Sendable { - private static let realtimeAudioSampleRate = 24_000 + static let realtimeAudioSampleRate = 24_000 private static let maxAudioChunkMilliseconds = 80 private static let bytesPerPCM16Sample = 2 private static let maxPCM16AudioChunkByteCount = realtimeAudioSampleRate @@ -27,21 +92,41 @@ final class OpenAIRealtimeTranscriber: @unchecked Sendable { private var isPaused = false private var realtimeTranscriptText = "" - func start(language: LanguageOption, model: OpenAIRealtimeTranscriptionModel) async throws { + func start( + language: LanguageOption, + model: OpenAIRealtimeTranscriptionModel, + modelIDOverride: String? = nil, + providerConfig: OpenAIRealtimeProviderConfig + ) async throws { + let trimmedOverride = modelIDOverride? + .trimmingCharacters(in: .whitespacesAndNewlines) + .nonEmpty + let resolvedID = trimmedOverride ?? model.rawValue try await start( language: language, - modelID: model.rawValue, + modelID: resolvedID, outputMode: .transcription, - isEnabled: model.isEnabled + isEnabled: model.isEnabled, + providerConfig: providerConfig ) } - func startRealtimeTranslationOnly(language: LanguageOption, model: OpenAIRealtimeTranslationModel) async throws { + func startRealtimeTranslationOnly( + language: LanguageOption, + model: OpenAIRealtimeTranslationModel, + modelIDOverride: String? = nil, + providerConfig: OpenAIRealtimeProviderConfig + ) async throws { + let trimmedOverride = modelIDOverride? + .trimmingCharacters(in: .whitespacesAndNewlines) + .nonEmpty + let resolvedID = trimmedOverride ?? model.apiModelID try await start( language: language, - modelID: model.apiModelID, + modelID: resolvedID, outputMode: .translationOnly, - isEnabled: model.usesRealtimeAudioTranslation + isEnabled: model.usesRealtimeAudioTranslation, + providerConfig: providerConfig ) } @@ -49,12 +134,15 @@ final class OpenAIRealtimeTranscriber: @unchecked Sendable { language: LanguageOption, modelID: String, outputMode: OutputMode, - isEnabled: Bool + isEnabled: Bool, + providerConfig: OpenAIRealtimeProviderConfig ) async throws { stop() - guard isEnabled else { return } - guard let apiKey = try OpenAIAPIKeyStore.readAPIKey(), !apiKey.isEmpty else { + guard isEnabled else { + return + } + guard !providerConfig.apiKey.isEmpty else { throw OpenAITranslationError.missingAPIKey } @@ -64,19 +152,29 @@ final class OpenAIRealtimeTranscriber: @unchecked Sendable { let url: URL switch outputMode { case .transcription: - url = URL(string: "wss://api.openai.com/v1/realtime?intent=transcription")! + guard let transcriptionURL = providerConfig.transcriptionURL() else { + throw OpenAITranslationError.transcriptionEndpointUnsupported + } + url = transcriptionURL case .translationOnly: - url = URL(string: "wss://api.openai.com/v1/realtime/translations?model=\(modelID)")! + guard let translationURL = providerConfig.translationURL(modelID: modelID) else { + throw OpenAITranslationError.invalidResponse + } + url = translationURL } var request = URLRequest(url: url) - request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + providerConfig.apply(to: &request) let webSocketTask = URLSession.shared.webSocketTask(with: request) self.webSocketTask = webSocketTask webSocketTask.resume() - try await sendSessionUpdate(language: language, modelID: modelID) + try await sendSessionUpdate( + language: language, + modelID: modelID, + providerKind: providerConfig.kind + ) receiveTask = Task { [weak self] in await self?.receiveLoop() } @@ -125,7 +223,26 @@ final class OpenAIRealtimeTranscriber: @unchecked Sendable { realtimeTranscriptText = "" } - private func sendSessionUpdate(language: LanguageOption, modelID: String) async throws { + private func sendSessionUpdate( + language: LanguageOption, + modelID: String, + providerKind: OpenAIRealtimeProviderConfig.Kind + ) async throws { + let text = try Self.sessionUpdatePayload( + language: language, + modelID: modelID, + outputMode: outputMode, + providerKind: providerKind + ) + try await send(text) + } + + static func sessionUpdatePayload( + language: LanguageOption, + modelID: String, + outputMode: OutputMode, + providerKind: OpenAIRealtimeProviderConfig.Kind = .openAI + ) throws -> String { let data: Data switch outputMode { case .transcription: @@ -147,9 +264,23 @@ final class OpenAIRealtimeTranscriber: @unchecked Sendable { ) data = try JSONEncoder().encode(event) case .translationOnly: + let input: OpenAIRealtimeTranslationAudioInput? + switch providerKind { + case .openAI: + input = nil + case .azure: + input = OpenAIRealtimeTranslationAudioInput( + transcription: OpenAIRealtimeTranscriptionConfig( + model: modelID + ), + noiseReduction: OpenAIRealtimeNoiseReduction(type: "near_field") + ) + } + let event = OpenAIRealtimeTranslationSessionUpdateEvent( session: OpenAIRealtimeTranslationSession( audio: OpenAIRealtimeTranslationAudio( + input: input, output: OpenAIRealtimeTranslationAudioOutput( language: language.openAILanguageCode ) @@ -158,8 +289,11 @@ final class OpenAIRealtimeTranscriber: @unchecked Sendable { ) data = try JSONEncoder().encode(event) } - guard let text = String(data: data, encoding: .utf8) else { return } - try await send(text) + + guard let text = String(data: data, encoding: .utf8) else { + throw OpenAITranslationError.invalidResponse + } + return text } private func send(_ text: String) async throws { @@ -193,7 +327,9 @@ final class OpenAIRealtimeTranscriber: @unchecked Sendable { private func handleEventText(_ text: String) { guard let data = text.data(using: .utf8), let event = try? JSONDecoder().decode(OpenAIRealtimeTranscriptionEvent.self, from: data) - else { return } + else { + return + } switch event.type { case "conversation.item.input_audio_transcription.delta": @@ -231,7 +367,12 @@ final class OpenAIRealtimeTranscriber: @unchecked Sendable { } private func appendRealtimeTranscriptDelta(_ delta: String) { - realtimeTranscriptText += delta + // OpenAI Realtime occasionally emits U+FFFD when a model-token boundary + // splits a multi-byte character. The full transcript arrives correctly + // on `*.done`, so strip FFFD from partials to avoid showing � in the UI. + let sanitized = delta.replacingOccurrences(of: "\u{FFFD}", with: "") + guard !sanitized.isEmpty else { return } + realtimeTranscriptText += sanitized publish(text: realtimeTranscriptText) } @@ -381,16 +522,32 @@ private struct OpenAIRealtimeTranslationSession: Encodable { } private struct OpenAIRealtimeTranslationAudio: Encodable { + let input: OpenAIRealtimeTranslationAudioInput? let output: OpenAIRealtimeTranslationAudioOutput } +private struct OpenAIRealtimeTranslationAudioInput: Encodable { + let transcription: OpenAIRealtimeTranscriptionConfig + let noiseReduction: OpenAIRealtimeNoiseReduction + + private enum CodingKeys: String, CodingKey { + case transcription + case noiseReduction = "noise_reduction" + } +} + private struct OpenAIRealtimeTranslationAudioOutput: Encodable { let language: String } private struct OpenAIRealtimeTranscriptionConfig: Encodable { let model: String - let language: String + let language: String? + + init(model: String, language: String? = nil) { + self.model = model + self.language = language + } } private struct OpenAIRealtimeTurnDetection: Encodable { diff --git a/Sources/AirTranslate/Services/OpenAITranslationService.swift b/Sources/AirTranslate/Services/OpenAITranslationService.swift index 882e698..9a20495 100644 --- a/Sources/AirTranslate/Services/OpenAITranslationService.swift +++ b/Sources/AirTranslate/Services/OpenAITranslationService.swift @@ -2,7 +2,6 @@ import Foundation actor OpenAITranslationService { private let endpoint = URL(string: "https://api.openai.com/v1/responses")! - private let model = "gpt-realtime-translate" func translate( _ text: String, @@ -98,6 +97,9 @@ private struct OpenAIErrorBody: Decodable { enum OpenAITranslationError: LocalizedError { case missingAPIKey + case missingAzureEndpoint + case missingAzureAPIKey + case transcriptionEndpointUnsupported case invalidResponse case emptyOutput case requestFailed(statusCode: Int, message: String?) @@ -106,6 +108,12 @@ enum OpenAITranslationError: LocalizedError { switch self { case .missingAPIKey: AppText.openAIAPIKeyMissing + case .missingAzureEndpoint: + AppText.azureOpenAIEndpointMissing + case .missingAzureAPIKey: + AppText.azureOpenAIAPIKeyMissing + case .transcriptionEndpointUnsupported: + AppText.azureOpenAITranscriptionUnsupported case .invalidResponse: AppText.openAIInvalidResponse case .emptyOutput: diff --git a/Sources/AirTranslate/Services/TranslationSessionStore.swift b/Sources/AirTranslate/Services/TranslationSessionStore.swift index d18787d..858761c 100644 --- a/Sources/AirTranslate/Services/TranslationSessionStore.swift +++ b/Sources/AirTranslate/Services/TranslationSessionStore.swift @@ -15,12 +15,17 @@ private enum SettingsKey { static let floatingCaptionDisplayMode = "floatingCaptionDisplayMode" static let floatingCaptionTextSize = "floatingCaptionTextSize" static let floatingCaptionLineCount = "floatingCaptionLineCount" + static let floatingCaptionTextAlignment = "floatingCaptionTextAlignment" + static let isFloatingCaptionImmediateDisplayEnabled = "isFloatingCaptionImmediateDisplayEnabled" static let paragraphBreakSilenceInterval = "paragraphBreakSilenceInterval" static let savedTranscriptContentMode = "savedTranscriptContentMode" static let sessionDurationMode = "sessionDurationMode" static let audioInputSource = "audioInputSource" static let selectedMicrophoneInputDeviceID = "selectedMicrophoneInputDeviceID" static let isAppleSourceAutoDetectionEnabled = "isAppleSourceAutoDetectionEnabled" + static let openAIProvider = "openAIProvider" + static let customAzureTranscriptionModelName = "customAzureTranscriptionModelName" + static let customAzureTranslationModelName = "customAzureTranslationModelName" } private struct TranslationRequest { @@ -79,10 +84,6 @@ final class TranslationSessionStore { private static let largeTranscriptPresentationInterval: TimeInterval = 0.35 private static let largeTranscriptTranslationCharacterLimit = 4_000 private static let veryLargeTranscriptTranslationCharacterLimit = 10_000 - private static let floatingCaptionEarlyRevisionWindow = 0.45 - private static let floatingCaptionImmediateExtensionCharacterLimit = 28 - private static let minimumFloatingCaptionDwell = 1.4 - private static let maximumFloatingCaptionDwell = 3.6 private static let appleAutoDetectionMinimumConfidence = 0.35 private static let appleAutoDetectionLanguageSwitchMinimumConfidence = 0.72 private static let isAppleSourceAutoDetectionTemporarilyDisabled = true @@ -130,6 +131,20 @@ final class TranslationSessionStore { } } var hasOpenAIAPIKey = OpenAIAPIKeyStore.hasAPIKey() + var hasAzureOpenAIConfig = AzureOpenAIConfigStore.hasConfig() + var azureOpenAIEndpoint: String = AzureOpenAIConfigStore.readEndpoint() ?? "" + var openAIProvider: OpenAIProvider = .openAI { + didSet { + persistSelectedSettings() + refreshModelAvailability() + } + } + var customAzureTranscriptionModelName: String = "" { + didSet { persistSelectedSettings() } + } + var customAzureTranslationModelName: String = "" { + didSet { persistSelectedSettings() } + } var openAITranscriptionModel = OpenAIRealtimeTranscriptionModel.off { didSet { if openAITranscriptionModel.isEnabled { @@ -163,6 +178,17 @@ final class TranslationSessionStore { var floatingCaptionLineCount = FloatingCaptionLineCount.three { didSet { persistSelectedSettings() } } + var floatingCaptionTextAlignment = FloatingCaptionTextAlignment.center { + didSet { persistSelectedSettings() } + } + var isFloatingCaptionImmediateDisplayEnabled = false { + didSet { + persistSelectedSettings() + if isFloatingCaptionImmediateDisplayEnabled { + promoteQueuedFloatingPresentationIfReady() + } + } + } var paragraphBreakSilenceInterval = 5.0 { didSet { persistSelectedSettings() } } @@ -308,6 +334,7 @@ final class TranslationSessionStore { let now = Date() hasOpenAIAPIKey = false + hasAzureOpenAIConfig = false lines = [ CaptionLine( sourceText: "The speaker is explaining how the product roadmap changes when customers need live translation during meetings.", @@ -509,14 +536,17 @@ final class TranslationSessionStore { } } - func saveOpenAIAPIKey(_ key: String) { + @discardableResult + func saveOpenAIAPIKey(_ key: String) -> Bool { do { try OpenAIAPIKeyStore.saveAPIKey(key) hasOpenAIAPIKey = true statusMessage = AppText.openAIAPIKeySaved refreshModelAvailability() + return true } catch { statusMessage = error.localizedDescription + return false } } @@ -531,6 +561,73 @@ final class TranslationSessionStore { } } + @discardableResult + func saveAzureOpenAIConfig(endpoint: String, apiKey: String) -> Bool { + do { + try AzureOpenAIConfigStore.saveConfig(endpoint: endpoint, apiKey: apiKey) + hasAzureOpenAIConfig = AzureOpenAIConfigStore.hasConfig() + azureOpenAIEndpoint = AzureOpenAIConfigStore.readEndpoint() ?? "" + statusMessage = AppText.azureOpenAIConfigSaved + refreshModelAvailability() + return true + } catch { + statusMessage = error.localizedDescription + return false + } + } + + func removeAzureOpenAIConfig() { + do { + try AzureOpenAIConfigStore.deleteConfig() + hasAzureOpenAIConfig = false + azureOpenAIEndpoint = "" + statusMessage = AppText.azureOpenAIConfigRemoved + refreshModelAvailability() + } catch { + statusMessage = error.localizedDescription + } + } + + var hasOpenAIRealtimeCredentials: Bool { + switch openAIProvider { + case .openAI: hasOpenAIAPIKey + case .azure: hasAzureOpenAIConfig + } + } + + private func resolveOpenAIRealtimeProviderConfig() throws -> OpenAIRealtimeProviderConfig { + switch openAIProvider { + case .openAI: + guard let key = try OpenAIAPIKeyStore.readAPIKey(), !key.isEmpty else { + throw OpenAITranslationError.missingAPIKey + } + return .openAI(apiKey: key) + case .azure: + guard let endpoint = AzureOpenAIConfigStore.readEndpoint(), + let host = AzureOpenAIEndpoint.host(from: endpoint) else { + throw OpenAITranslationError.missingAzureEndpoint + } + guard let key = try AzureOpenAIConfigStore.readAPIKey(), !key.isEmpty else { + throw OpenAITranslationError.missingAzureAPIKey + } + return .azure(host: host, apiKey: key) + } + } + + private func resolvedCustomTranscriptionModelName() -> String? { + guard openAIProvider == .azure else { return nil } + let value = customAzureTranscriptionModelName + .trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } + + private func resolvedCustomTranslationModelName() -> String? { + guard openAIProvider == .azure else { return nil } + let value = customAzureTranslationModelName + .trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } + func openTranscriptsFolder() { do { try FileManager.default.createDirectory( @@ -797,13 +894,25 @@ final class TranslationSessionStore { openAITranscriber = OpenAIRealtimeTranscriber() openAITranscriber.delegate = self + let customTranscription = resolvedCustomTranscriptionModelName() + let customTranslation = resolvedCustomTranslationModelName() + if openAITranslationModel.usesRealtimeAudioTranslation { + let providerConfig = try resolveOpenAIRealtimeProviderConfig() try await openAITranscriber.startRealtimeTranslationOnly( language: targetLanguage, - model: openAITranslationModel + model: openAITranslationModel, + modelIDOverride: customTranslation, + providerConfig: providerConfig ) } else if openAITranscriptionModel.isEnabled { - try await openAITranscriber.start(language: sourceLanguage, model: openAITranscriptionModel) + let providerConfig = try resolveOpenAIRealtimeProviderConfig() + try await openAITranscriber.start( + language: sourceLanguage, + model: openAITranscriptionModel, + modelIDOverride: customTranscription, + providerConfig: providerConfig + ) } else { try await transcriber.start(languages: await appleSpeechLanguagesForCurrentMode()) } @@ -981,6 +1090,15 @@ final class TranslationSessionStore { let lineCount = FloatingCaptionLineCount(rawValue: rawValue) { floatingCaptionLineCount = lineCount } + if let alignmentID = defaults.string(forKey: SettingsKey.floatingCaptionTextAlignment), + let alignment = FloatingCaptionTextAlignment(rawValue: alignmentID) { + floatingCaptionTextAlignment = alignment + } + if defaults.object(forKey: SettingsKey.isFloatingCaptionImmediateDisplayEnabled) != nil { + isFloatingCaptionImmediateDisplayEnabled = defaults.bool( + forKey: SettingsKey.isFloatingCaptionImmediateDisplayEnabled + ) + } if defaults.object(forKey: SettingsKey.paragraphBreakSilenceInterval) != nil { paragraphBreakSilenceInterval = min( max(defaults.double(forKey: SettingsKey.paragraphBreakSilenceInterval), 1), @@ -1004,6 +1122,16 @@ final class TranslationSessionStore { } isAppleSourceAutoDetectionEnabled = isAppleSourceAutoDetectionAvailable && defaults.bool(forKey: SettingsKey.isAppleSourceAutoDetectionEnabled) + if let providerID = defaults.string(forKey: SettingsKey.openAIProvider), + let provider = OpenAIProvider(rawValue: providerID) { + openAIProvider = provider + } + if let value = defaults.string(forKey: SettingsKey.customAzureTranscriptionModelName) { + customAzureTranscriptionModelName = value + } + if let value = defaults.string(forKey: SettingsKey.customAzureTranslationModelName) { + customAzureTranslationModelName = value + } refreshMicrophoneInputDevices() } @@ -1021,12 +1149,20 @@ final class TranslationSessionStore { defaults.set(floatingCaptionDisplayMode.id, forKey: SettingsKey.floatingCaptionDisplayMode) defaults.set(floatingCaptionTextSize.id, forKey: SettingsKey.floatingCaptionTextSize) defaults.set(floatingCaptionLineCount.id, forKey: SettingsKey.floatingCaptionLineCount) + defaults.set(floatingCaptionTextAlignment.id, forKey: SettingsKey.floatingCaptionTextAlignment) + defaults.set( + isFloatingCaptionImmediateDisplayEnabled, + forKey: SettingsKey.isFloatingCaptionImmediateDisplayEnabled + ) defaults.set(paragraphBreakSilenceInterval, forKey: SettingsKey.paragraphBreakSilenceInterval) defaults.set(savedTranscriptContentMode.id, forKey: SettingsKey.savedTranscriptContentMode) defaults.set(sessionDurationMode.id, forKey: SettingsKey.sessionDurationMode) defaults.set(audioInputSource.id, forKey: SettingsKey.audioInputSource) defaults.set(selectedMicrophoneInputDeviceID, forKey: SettingsKey.selectedMicrophoneInputDeviceID) defaults.set(isAppleSourceAutoDetectionEnabled, forKey: SettingsKey.isAppleSourceAutoDetectionEnabled) + defaults.set(openAIProvider.rawValue, forKey: SettingsKey.openAIProvider) + defaults.set(customAzureTranscriptionModelName, forKey: SettingsKey.customAzureTranscriptionModelName) + defaults.set(customAzureTranslationModelName, forKey: SettingsKey.customAzureTranslationModelName) } private func stopCapture() async { @@ -1855,13 +1991,13 @@ final class TranslationSessionStore { return } - let normalizedCandidate = normalizedTranscriptForComparison(candidate) - let normalizedPresented = normalizedTranscriptForComparison(floatingPresentedSourceText) - guard normalizedCandidate != normalizedPresented else { return } - - let now = Date() - if canUpdateFloatingPresentationImmediately(to: candidate, now: now) - || canAdvanceFloatingPresentation(now: now) { + if FloatingCaptionPresentationPolicy.canPresentUpdate( + isImmediateDisplayEnabled: isFloatingCaptionImmediateDisplayEnabled, + presentedText: floatingPresentedSourceText, + translatedText: floatingDisplayTranslationText, + candidateText: candidate, + presentedAt: floatingPresentedAt + ) { presentFloatingSourceText(candidate) return } @@ -1870,31 +2006,20 @@ final class TranslationSessionStore { scheduleFloatingPresentationAdvance() } - private func canUpdateFloatingPresentationImmediately(to candidate: String, now: Date) -> Bool { - let elapsed = now.timeIntervalSince(floatingPresentedAt) - if elapsed <= Self.floatingCaptionEarlyRevisionWindow { - return true - } - - let normalizedPresented = normalizedTranscriptForComparison(floatingPresentedSourceText) - let normalizedCandidate = normalizedTranscriptForComparison(candidate) - return normalizedPresented.count < Self.floatingCaptionImmediateExtensionCharacterLimit - && isWholeTextPrefix(normalizedPresented, of: normalizedCandidate) - } - private func canAdvanceFloatingPresentation(now: Date = Date()) -> Bool { - guard !floatingPresentedSourceText.isEmpty else { return true } - return now.timeIntervalSince(floatingPresentedAt) >= floatingCaptionDwellDuration() + FloatingCaptionPresentationPolicy.canAdvance( + isImmediateDisplayEnabled: isFloatingCaptionImmediateDisplayEnabled, + presentedText: floatingPresentedSourceText, + translatedText: floatingDisplayTranslationText, + presentedAt: floatingPresentedAt, + now: now + ) } private func floatingCaptionDwellDuration() -> TimeInterval { - let sourceLength = normalizedTranscriptForComparison(floatingPresentedSourceText).count - let translationLength = normalizedTranscriptForComparison(floatingDisplayTranslationText).count - let readableLength = max(sourceLength, translationLength) - let dwell = 1.1 + Double(readableLength) / 32.0 - return min( - max(Self.minimumFloatingCaptionDwell, dwell), - Self.maximumFloatingCaptionDwell + FloatingCaptionPresentationPolicy.dwellDuration( + presentedText: floatingPresentedSourceText, + translatedText: floatingDisplayTranslationText ) } diff --git a/Sources/AirTranslate/Support/FloatingCaptionPresentationPolicy.swift b/Sources/AirTranslate/Support/FloatingCaptionPresentationPolicy.swift new file mode 100644 index 0000000..bcad8eb --- /dev/null +++ b/Sources/AirTranslate/Support/FloatingCaptionPresentationPolicy.swift @@ -0,0 +1,79 @@ +import AirTranslateCore +import Foundation + +enum FloatingCaptionPresentationPolicy { + private static let earlyRevisionWindow: TimeInterval = 0.45 + private static let immediateExtensionCharacterLimit = 28 + private static let minimumDwell: TimeInterval = 1.4 + private static let maximumDwell: TimeInterval = 3.6 + + static func canPresentUpdate( + isImmediateDisplayEnabled: Bool, + presentedText: String, + translatedText: String, + candidateText: String, + presentedAt: Date, + now: Date = Date() + ) -> Bool { + guard !presentedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return true } + guard normalized(candidateText) != normalized(presentedText) else { return false } + guard !isImmediateDisplayEnabled else { return true } + + return canReviseImmediately( + presentedText: presentedText, + candidateText: candidateText, + presentedAt: presentedAt, + now: now + ) || canAdvance( + isImmediateDisplayEnabled: false, + presentedText: presentedText, + translatedText: translatedText, + presentedAt: presentedAt, + now: now + ) + } + + static func canAdvance( + isImmediateDisplayEnabled: Bool, + presentedText: String, + translatedText: String, + presentedAt: Date, + now: Date = Date() + ) -> Bool { + guard !presentedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return true } + guard !isImmediateDisplayEnabled else { return true } + + return now.timeIntervalSince(presentedAt) >= dwellDuration( + presentedText: presentedText, + translatedText: translatedText + ) + } + + static func dwellDuration(presentedText: String, translatedText: String) -> TimeInterval { + let sourceLength = normalized(presentedText).count + let translationLength = normalized(translatedText).count + let readableLength = max(sourceLength, translationLength) + let dwell = 1.1 + Double(readableLength) / 32.0 + return min(max(minimumDwell, dwell), maximumDwell) + } + + private static func canReviseImmediately( + presentedText: String, + candidateText: String, + presentedAt: Date, + now: Date + ) -> Bool { + if now.timeIntervalSince(presentedAt) <= earlyRevisionWindow { + return true + } + + let normalizedPresented = normalized(presentedText) + let normalizedCandidate = normalized(candidateText) + return normalizedPresented.count < immediateExtensionCharacterLimit + && TranscriptTextProcessor.isWholeTextPrefix(normalizedPresented, of: normalizedCandidate) + } + + private static func normalized(_ text: String) -> String { + TranscriptTextProcessor.normalizedForComparison(text) + } +} diff --git a/Sources/AirTranslate/Views/FloatingCaptionWindowView.swift b/Sources/AirTranslate/Views/FloatingCaptionWindowView.swift index 43706f4..6c41e07 100644 --- a/Sources/AirTranslate/Views/FloatingCaptionWindowView.swift +++ b/Sources/AirTranslate/Views/FloatingCaptionWindowView.swift @@ -7,7 +7,7 @@ struct FloatingCaptionWindowView: View { ZStack { Color.clear - VStack(spacing: 8) { + VStack(alignment: session.floatingCaptionTextAlignment.horizontalAlignment, spacing: 8) { content } .padding(.horizontal, 28) @@ -85,12 +85,12 @@ struct FloatingCaptionWindowView: View { foregroundColor: .white, isTextSelectionEnabled: false, lineLimit: lineLimit, - textAlignment: .center, - frameAlignment: .center, + textAlignment: session.floatingCaptionTextAlignment.textAlignment, + frameAlignment: session.floatingCaptionTextAlignment.frameAlignment, truncationMode: .tail ) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity, alignment: .center) + .multilineTextAlignment(session.floatingCaptionTextAlignment.textAlignment) + .frame(maxWidth: .infinity, alignment: session.floatingCaptionTextAlignment.frameAlignment) .lineSpacing(5) .shadow(color: .black.opacity(0.95), radius: 3, x: 0, y: 1) .shadow(color: .black.opacity(0.65), radius: 8, x: 0, y: 2) diff --git a/Sources/AirTranslate/Views/OpenAIRealtimeModelPickers.swift b/Sources/AirTranslate/Views/OpenAIRealtimeModelPickers.swift new file mode 100644 index 0000000..d18e65d --- /dev/null +++ b/Sources/AirTranslate/Views/OpenAIRealtimeModelPickers.swift @@ -0,0 +1,69 @@ +import SwiftUI + +extension OpenAIRealtimeTranslationModel { + /// Single source of truth for which translation models are user-selectable per provider. + /// Azure routes both `.gptRealtimeTranslate` and `.gptRealtimeTranslateOnly` through identical + /// API calls, so the "translation only" duplicate is hidden there. + static func availableCases(for provider: OpenAIProvider) -> [OpenAIRealtimeTranslationModel] { + switch provider { + case .azure: + return allCases.filter { $0 != .gptRealtimeTranslateOnly } + case .openAI: + return allCases + } + } +} + +/// Reusable trio of pickers (Provider, Transcription model, Translation model) shared by +/// SettingsView and the in-session ConfigurationSheetView so the two stay in lockstep. +struct OpenAIRealtimeModelPickers: View { + @Bindable var session: TranslationSessionStore + var onProviderChange: ((OpenAIProvider) -> Void)? = nil + + var body: some View { + VStack(spacing: 6) { + SettingsProviderPickerRow( + title: AppText.openAIProvider, + selection: Binding( + get: { session.openAIProvider }, + set: { newValue in + session.openAIProvider = newValue + onProviderChange?(newValue) + } + ) + ) + + if session.openAITranslationModel.usesRealtimeAudioTranslation { + SettingsCompactInfoRow( + title: AppText.gptTranscriptionLockedByRealtimeTitle, + detail: AppText.gptTranscriptionLockedByRealtimeDetail, + systemImage: "waveform.circle.fill" + ) + } else { + SettingsCompactMenuRow( + title: AppText.gptTranscriptionModel, + systemImage: "waveform.circle.fill", + value: session.openAITranscriptionModel.title + ) { + ForEach(OpenAIRealtimeTranscriptionModel.allCases) { model in + Button(model.title) { + session.openAITranscriptionModel = model + } + } + } + } + + SettingsCompactMenuRow( + title: AppText.gptTranslationModel, + systemImage: "globe", + value: session.openAITranslationModel.title + ) { + ForEach(OpenAIRealtimeTranslationModel.availableCases(for: session.openAIProvider)) { model in + Button(model.title) { + session.openAITranslationModel = model + } + } + } + } + } +} diff --git a/Sources/AirTranslate/Views/SettingsComponents.swift b/Sources/AirTranslate/Views/SettingsComponents.swift new file mode 100644 index 0000000..97c91fa --- /dev/null +++ b/Sources/AirTranslate/Views/SettingsComponents.swift @@ -0,0 +1,592 @@ +import SwiftUI + +struct SettingsInlineGroup: View { + let systemImage: String + let title: String + @ViewBuilder let content: Content + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 9) { + Image(systemName: systemImage) + .font(.caption.weight(.bold)) + .foregroundStyle(.secondary) + .frame(width: 16, height: 16) + + Text(title) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + + Spacer(minLength: 0) + } + + content + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.primary.opacity(0.035), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(Color.primary.opacity(0.055), lineWidth: 1) + } + } +} + +struct SettingsCompactToggleRow: View { + let title: String + let systemImage: String + @Binding var isOn: Bool + + var body: some View { + HStack(spacing: 10) { + Image(systemName: systemImage) + .font(.caption.weight(.bold)) + .foregroundStyle(isOn ? Color.accentColor : Color.secondary) + .frame(width: 18, height: 18) + + Text(title) + .font(.caption.weight(.semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + + Spacer(minLength: 12) + + Toggle(title, isOn: $isOn) + .labelsHidden() + .toggleStyle(.switch) + } + .accessibilityLabel(title) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .frame(maxWidth: .infinity, minHeight: 42, alignment: .leading) + .background(Color.primary.opacity(0.04), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } +} + +struct SettingsCompactInfoRow: View { + let title: String + let detail: String + let systemImage: String + + var body: some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: systemImage) + .font(.caption.weight(.bold)) + .foregroundStyle(Color.accentColor) + .frame(width: 18, height: 18) + + VStack(alignment: .leading, spacing: 3) { + Text(title) + .font(.caption.weight(.semibold)) + .foregroundStyle(.primary) + + Text(detail) + .font(.caption2) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer(minLength: 0) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, minHeight: 42, alignment: .leading) + .background(Color.accentColor.opacity(0.07), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } +} + +struct SettingsCompactMenuRow: View { + let title: String + let systemImage: String + let value: String + @ViewBuilder let menuContent: MenuContent + + var body: some View { + Menu { + menuContent + } label: { + HStack(spacing: 8) { + Image(systemName: systemImage) + .font(.caption.weight(.bold)) + .foregroundStyle(Color.accentColor) + .frame(width: 16) + + Text(title) + .font(.caption.weight(.semibold)) + .foregroundStyle(.primary) + + Spacer(minLength: 6) + + Text(value) + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + .lineLimit(1) + .minimumScaleFactor(0.76) + + Image(systemName: "chevron.up.chevron.down") + .font(.caption2.weight(.bold)) + .foregroundStyle(.tertiary) + } + .padding(.horizontal, 8) + .padding(.vertical, 7) + .background(Color.primary.opacity(0.04), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + .buttonStyle(.plain) + .help(title) + } +} + +struct SettingsProviderPickerRow: View { + let title: String + @Binding var selection: OpenAIProvider + + var body: some View { + HStack(spacing: 8) { + Image(systemName: "cloud.fill") + .font(.caption.weight(.bold)) + .foregroundStyle(Color.accentColor) + .frame(width: 16) + + Text(title) + .font(.caption.weight(.semibold)) + .foregroundStyle(.primary) + + Spacer(minLength: 6) + + Picker("", selection: $selection) { + ForEach(OpenAIProvider.allCases) { provider in + Text(provider.title).tag(provider) + } + } + .labelsHidden() + .pickerStyle(.segmented) + .controlSize(.small) + .fixedSize() + } + .padding(.horizontal, 8) + .padding(.vertical, 7) + .background(Color.primary.opacity(0.04), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } +} + +struct SettingsSegmentedPickerRow: View { + let title: String + let systemImage: String + @Binding var selection: Selection + @ViewBuilder let pickerContent: PickerContent + + var body: some View { + HStack(spacing: 8) { + Image(systemName: systemImage) + .font(.caption.weight(.bold)) + .foregroundStyle(.secondary) + .frame(width: 16) + + Text(title) + .font(.caption.weight(.semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + + Spacer(minLength: 8) + + Picker(title, selection: $selection) { + pickerContent + } + .pickerStyle(.segmented) + .labelsHidden() + .controlSize(.small) + .fixedSize() + .accessibilityLabel(title) + } + .padding(.horizontal, 8) + .padding(.vertical, 7) + .background(Color.primary.opacity(0.04), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } +} + +struct SettingsStepperRow: View { + let title: String + let systemImage: String + let valueText: String + @Binding var value: Double + + var body: some View { + Stepper(value: $value, in: 1...15, step: 0.5) { + HStack(spacing: 8) { + Image(systemName: systemImage) + .font(.caption.weight(.bold)) + .foregroundStyle(Color.accentColor) + .frame(width: 16) + + Text(title) + .font(.caption.weight(.semibold)) + .foregroundStyle(.primary) + + Spacer(minLength: 6) + + Text(valueText) + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 7) + .frame(maxWidth: .infinity, minHeight: 42, alignment: .leading) + .background(Color.primary.opacity(0.04), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } +} + +struct SettingsAPIKeyRow: View { + @Binding var apiKey: String + @Binding var shouldFocusAPIKey: Bool + @FocusState private var isAPIKeyFocused: Bool + let hasAPIKey: Bool + let notice: String? + let save: () -> Void + let remove: () -> Void + + private var trimmedAPIKey: String { + apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + } + + var body: some View { + VStack(alignment: .leading, spacing: 7) { + HStack(spacing: 8) { + Image(systemName: "key.fill") + .font(.caption.weight(.bold)) + .foregroundStyle(hasAPIKey ? Color.green : Color.secondary) + .frame(width: 16) + + SecureField(AppText.openAIAPIKeyPlaceholder, text: $apiKey) + .textFieldStyle(.roundedBorder) + .font(.caption) + .focused($isAPIKeyFocused) + .frame(minWidth: 0, maxWidth: .infinity) + + Button { save() } label: { + Image(systemName: "checkmark.circle.fill") + } + .buttonStyle(.borderless) + .disabled(trimmedAPIKey.isEmpty) + .help(AppText.saveOpenAIAPIKey) + .accessibilityLabel(AppText.saveOpenAIAPIKey) + + Button { remove() } label: { + Image(systemName: "trash") + } + .buttonStyle(.borderless) + .disabled(!hasAPIKey) + .help(AppText.removeOpenAIAPIKey) + .accessibilityLabel(AppText.removeOpenAIAPIKey) + } + + if let notice, !hasAPIKey { + SettingsNoticeText(notice) + } + + Text(hasAPIKey ? AppText.openAIAPIKeyConfigured : AppText.openAIAPIKeyNotConfigured) + .font(.caption2.weight(.semibold)) + .foregroundStyle(hasAPIKey ? Color.green : Color.secondary) + .padding(.leading, 24) + } + .padding(.horizontal, 8) + .padding(.vertical, 7) + .background(Color.primary.opacity(0.04), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + .onAppear { focusAPIKeyIfNeeded() } + .onChange(of: shouldFocusAPIKey) { _, _ in focusAPIKeyIfNeeded() } + } + + private func focusAPIKeyIfNeeded() { + guard shouldFocusAPIKey else { return } + Task { @MainActor in + isAPIKeyFocused = true + shouldFocusAPIKey = false + } + } +} + +struct SettingsAzureConfigRow: View { + @Binding var endpoint: String + @Binding var apiKey: String + @Binding var shouldFocusAPIKey: Bool + @FocusState private var isAPIKeyFocused: Bool + let hasConfig: Bool + let notice: String? + let save: () -> Void + let remove: () -> Void + + private var trimmedEndpoint: String { + endpoint.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var trimmedAPIKey: String { + apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + } + + var body: some View { + VStack(alignment: .leading, spacing: 7) { + HStack(spacing: 8) { + Image(systemName: "link") + .font(.caption.weight(.bold)) + .foregroundStyle(hasConfig ? Color.green : Color.secondary) + .frame(width: 16) + + TextField(AppText.azureOpenAIEndpointPlaceholder, text: $endpoint) + .textFieldStyle(.roundedBorder) + .font(.caption) + .textContentType(.URL) + .autocorrectionDisabled(true) + .frame(minWidth: 0, maxWidth: .infinity) + } + + Text(AppText.azureOpenAIEndpointFormatHint) + .font(.caption2) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .padding(.leading, 24) + + HStack(spacing: 8) { + Image(systemName: "key.fill") + .font(.caption.weight(.bold)) + .foregroundStyle(hasConfig ? Color.green : Color.secondary) + .frame(width: 16) + + SecureField(AppText.azureOpenAIAPIKeyPlaceholder, text: $apiKey) + .textFieldStyle(.roundedBorder) + .font(.caption) + .focused($isAPIKeyFocused) + .frame(minWidth: 0, maxWidth: .infinity) + + Button { save() } label: { + Image(systemName: "checkmark.circle.fill") + } + .buttonStyle(.borderless) + .disabled(trimmedAPIKey.isEmpty || trimmedEndpoint.isEmpty) + .help(AppText.saveAzureOpenAIConfig) + .accessibilityLabel(AppText.saveAzureOpenAIConfig) + + Button { remove() } label: { + Image(systemName: "trash") + } + .buttonStyle(.borderless) + .disabled(!hasConfig) + .help(AppText.removeAzureOpenAIConfig) + .accessibilityLabel(AppText.removeAzureOpenAIConfig) + } + + if let notice, !hasConfig { + SettingsNoticeText(notice) + } + + Text(hasConfig ? AppText.azureOpenAIConfigConfigured : AppText.azureOpenAIConfigNotConfigured) + .font(.caption2.weight(.semibold)) + .foregroundStyle(hasConfig ? Color.green : Color.secondary) + .padding(.leading, 24) + } + .padding(.horizontal, 8) + .padding(.vertical, 7) + .background(Color.primary.opacity(0.04), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + .onAppear { focusAPIKeyIfNeeded() } + .onChange(of: shouldFocusAPIKey) { _, _ in focusAPIKeyIfNeeded() } + } + + private func focusAPIKeyIfNeeded() { + guard shouldFocusAPIKey else { return } + Task { @MainActor in + isAPIKeyFocused = true + shouldFocusAPIKey = false + } + } +} + +struct SettingsAssetAvailabilityRow: View { + let title: String + let availability: ModelAvailability + let download: () -> Void + + var body: some View { + HStack(alignment: .center, spacing: 10) { + Image(systemName: symbolName) + .font(.caption.weight(.bold)) + .foregroundStyle(color) + .frame(width: 18, height: 18) + + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.caption.weight(.semibold)) + .foregroundStyle(.primary) + + Text(availability.detail) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(2) + } + + Spacer(minLength: 8) + + if availability.state == .checking || availability.state == .downloading { + ProgressView() + .controlSize(.small) + } else if availability.state.canDownload { + Button(AppText.download) { download() } + .controlSize(.small) + } else { + Text(availability.state.title) + .font(.caption2.weight(.semibold)) + .foregroundStyle(color) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, minHeight: 42, alignment: .leading) + .background(Color.primary.opacity(0.04), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + .help(availability.detail) + } + + private var symbolName: String { + switch availability.state { + case .checking: + "clock" + case .installed: + "checkmark.seal.fill" + case .downloadRequired, .downloading: + "arrow.down.circle.fill" + case .unsupported, .unavailable, .failed: + "exclamationmark.triangle.fill" + } + } + + private var color: Color { + switch availability.state { + case .checking: + .secondary + case .installed: + .green + case .downloadRequired, .downloading: + .orange + case .unsupported, .unavailable, .failed: + .red + } + } +} + +struct SettingsAdvancedDisclosure: View { + let title: String + @ViewBuilder let content: Content + @State private var isExpanded = false + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Button { + withAnimation(.easeInOut(duration: 0.18)) { isExpanded.toggle() } + } label: { + HStack(spacing: 6) { + Image(systemName: "chevron.right") + .font(.caption2.weight(.bold)) + .foregroundStyle(.secondary) + .rotationEffect(.degrees(isExpanded ? 90 : 0)) + + Text(title) + .font(.caption.weight(.semibold)) + .foregroundStyle(.primary) + + Spacer(minLength: 0) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + if isExpanded { + content + } + } + .padding(.horizontal, 8) + .padding(.vertical, 7) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.primary.opacity(0.04), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } +} + +struct ModelNameOverrideField: View { + let title: String + let systemImage: String + let placeholder: String + @Binding var text: String + + var body: some View { + HStack(alignment: .center, spacing: 8) { + Image(systemName: systemImage) + .font(.caption.weight(.bold)) + .foregroundStyle(.secondary) + .frame(width: 16) + .help(title) + .accessibilityLabel(title) + + TextField(placeholder, text: $text) + .textFieldStyle(.roundedBorder) + .font(.caption) + .autocorrectionDisabled(true) + .frame(minWidth: 0, maxWidth: .infinity) + } + } +} + +/// Azure-only advanced overrides for the OpenAI Realtime section. +/// Renders nothing when the active provider is OpenAI. +struct OpenAIAdvancedOverridesView: View { + @Bindable var session: TranslationSessionStore + + var body: some View { + if session.openAIProvider == .azure { + let transcriptionPlaceholder = session.openAITranscriptionModel.isEnabled + ? session.openAITranscriptionModel.rawValue + : OpenAIRealtimeTranscriptionModel.gptRealtimeWhisper.rawValue + let translationPlaceholder = session.openAITranslationModel.isEnabled + ? session.openAITranslationModel.apiModelID + : OpenAIRealtimeTranslationModel.gptRealtimeTranslate.apiModelID + + SettingsAdvancedDisclosure(title: AppText.advancedSection) { + VStack(alignment: .leading, spacing: 6) { + ModelNameOverrideField( + title: AppText.customTranscriptionModelName, + systemImage: "waveform.circle.fill", + placeholder: transcriptionPlaceholder, + text: $session.customAzureTranscriptionModelName + ) + ModelNameOverrideField( + title: AppText.customTranslationModelName, + systemImage: "globe", + placeholder: translationPlaceholder, + text: $session.customAzureTranslationModelName + ) + + Text(AppText.customModelNameFootnote) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + } +} + +private struct SettingsNoticeText: View { + let notice: String + + init(_ notice: String) { + self.notice = notice + } + + var body: some View { + HStack(alignment: .top, spacing: 6) { + Image(systemName: "exclamationmark.circle.fill") + .font(.caption2.weight(.bold)) + .foregroundStyle(Color.orange) + + Text(notice) + .font(.caption2.weight(.semibold)) + .foregroundStyle(Color.orange) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.leading, 24) + } +} diff --git a/Sources/AirTranslate/Views/SettingsView.swift b/Sources/AirTranslate/Views/SettingsView.swift index c2d2a2b..d6cf7e7 100644 --- a/Sources/AirTranslate/Views/SettingsView.swift +++ b/Sources/AirTranslate/Views/SettingsView.swift @@ -3,94 +3,190 @@ import SwiftUI struct SettingsView: View { @Bindable var session: TranslationSessionStore @State private var openAIAPIKey = "" + @State private var azureEndpoint = "" + @State private var azureAPIKey = "" + @State private var configurationNotice: String? + @State private var shouldFocusAPIKey = false + @State private var saveAlert: SaveAlert? - var body: some View { - Form { - Section(AppText.openAIAPIKey) { - SecureField(AppText.openAIAPIKeyPlaceholder, text: $openAIAPIKey) - .textFieldStyle(.roundedBorder) - - HStack { - Text(session.hasOpenAIAPIKey ? AppText.openAIAPIKeyConfigured : AppText.openAIAPIKeyNotConfigured) - .font(.caption) - .foregroundStyle(session.hasOpenAIAPIKey ? .green : .secondary) + private struct SaveAlert: Identifiable { + let id = UUID() + let title: String + let message: String + } - Spacer() + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + openAIRealtimeSection + transcriptSection + floatingCaptionsSection + requiredAssetsSection + permissionsSection + } + .padding(16) + } + .frame(minWidth: 460, idealWidth: 520, maxWidth: .infinity) + .frame(minHeight: 560) + .background(.regularMaterial) + .onAppear { + azureEndpoint = session.azureOpenAIEndpoint + session.refreshModelAvailability() + } + .onChange(of: session.openAIProvider) { _, _ in + configurationNotice = nil + shouldFocusAPIKey = false + azureEndpoint = session.azureOpenAIEndpoint + } + .alert(item: $saveAlert) { alert in + Alert( + title: Text(alert.title), + message: Text(alert.message), + dismissButton: .default(Text("OK")) + ) + } + } - Button(AppText.saveOpenAIAPIKey) { - session.saveOpenAIAPIKey(openAIAPIKey) - openAIAPIKey = "" - } - .disabled(openAIAPIKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + private var openAIRealtimeSection: some View { + SettingsInlineGroup( + systemImage: "bolt.horizontal.circle.fill", + title: AppText.gptModels + ) { + VStack(spacing: 6) { + OpenAIRealtimeModelPickers(session: session) { _ in + configurationNotice = nil + shouldFocusAPIKey = false + } - Button(AppText.removeOpenAIAPIKey) { - session.removeOpenAIAPIKey() - openAIAPIKey = "" - } - .disabled(!session.hasOpenAIAPIKey) + switch session.openAIProvider { + case .openAI: + SettingsAPIKeyRow( + apiKey: $openAIAPIKey, + shouldFocusAPIKey: $shouldFocusAPIKey, + hasAPIKey: session.hasOpenAIAPIKey, + notice: configurationNotice, + save: saveOpenAIAPIKey, + remove: removeOpenAIAPIKey + ) + case .azure: + SettingsAzureConfigRow( + endpoint: $azureEndpoint, + apiKey: $azureAPIKey, + shouldFocusAPIKey: $shouldFocusAPIKey, + hasConfig: session.hasAzureOpenAIConfig, + notice: configurationNotice, + save: saveAzureOpenAIConfig, + remove: removeAzureOpenAIConfig + ) } - Text(AppText.openAIAPIKeyDescription) - .font(.caption) + advancedModelOverridesGroup + + Text(AppText.gptModelsDescription) + .font(.caption2) .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 8) + + providerAccessLink } + } + } - Section(AppText.transcript) { - Picker(AppText.sessionLength, selection: $session.sessionDurationMode) { - ForEach(SessionDurationMode.allCases) { mode in - Text(mode.title).tag(mode) - } - } - .pickerStyle(.radioGroup) - .disabled(session.isRunning) + @ViewBuilder + private var advancedModelOverridesGroup: some View { + OpenAIAdvancedOverridesView(session: session) + } - Text(session.sessionDurationMode.detail) - .font(.caption) - .foregroundStyle(.secondary) + private var transcriptSection: some View { + SettingsInlineGroup(systemImage: "text.alignleft", title: AppText.transcript) { + VStack(spacing: 6) { + SettingsSessionDurationRadioGroup( + selection: $session.sessionDurationMode, + isDisabled: session.isRunning + ) - Stepper( - value: $session.paragraphBreakSilenceInterval, - in: 1...15, - step: 0.5 - ) { - HStack { - Text(AppText.paragraphBreakSilenceInterval) - Spacer() - Text(AppText.seconds(session.paragraphBreakSilenceInterval)) - .foregroundStyle(.secondary) - } - } + SettingsStepperRow( + title: AppText.paragraphBreakSilenceInterval, + systemImage: "timer", + valueText: AppText.seconds(session.paragraphBreakSilenceInterval), + value: $session.paragraphBreakSilenceInterval + ) Text(AppText.paragraphBreakSilenceDescription) - .font(.caption) + .font(.caption2) .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 8) } + } + } - Section(AppText.floatingCaptions) { - Picker(AppText.floatingDisplay, selection: $session.floatingCaptionDisplayMode) { + private var floatingCaptionsSection: some View { + SettingsInlineGroup(systemImage: "captions.bubble.fill", title: AppText.floatingCaptions) { + VStack(spacing: 6) { + SettingsSegmentedPickerRow( + title: AppText.floatingDisplay, + systemImage: "rectangle.split.2x1", + selection: $session.floatingCaptionDisplayMode + ) { ForEach(FloatingCaptionDisplayMode.allCases) { mode in Text(mode.title).tag(mode) } } - Picker(AppText.floatingTextSize, selection: $session.floatingCaptionTextSize) { + SettingsSegmentedPickerRow( + title: AppText.floatingTextAlignment, + systemImage: "text.alignleft", + selection: $session.floatingCaptionTextAlignment + ) { + ForEach(FloatingCaptionTextAlignment.allCases) { alignment in + Text(alignment.title).tag(alignment) + } + } + + SettingsCompactMenuRow( + title: AppText.floatingTextSize, + systemImage: "textformat.size", + value: session.floatingCaptionTextSize.title + ) { ForEach(FloatingCaptionTextSize.allCases) { size in - Text(size.title).tag(size) + Button(size.title) { + session.floatingCaptionTextSize = size + } } } - Picker(AppText.floatingLineCount, selection: $session.floatingCaptionLineCount) { + SettingsCompactMenuRow( + title: AppText.floatingLineCount, + systemImage: "line.3.horizontal", + value: session.floatingCaptionLineCount.title + ) { ForEach(FloatingCaptionLineCount.allCases) { lineCount in - Text(lineCount.title).tag(lineCount) + Button(lineCount.title) { + session.floatingCaptionLineCount = lineCount + } } } - Text(AppText.floatingDisplayDescription) - .font(.caption) + SettingsCompactToggleRow( + title: AppText.floatingImmediateDisplay, + systemImage: "bolt.fill", + isOn: $session.isFloatingCaptionImmediateDisplayEnabled + ) + + Text(AppText.floatingImmediateDisplayDescription) + .font(.caption2) .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 8) } + } + } - Section(AppText.requiredAssets) { + private var requiredAssetsSection: some View { + SettingsInlineGroup(systemImage: "arrow.down.circle.fill", title: AppText.requiredAssets) { + VStack(spacing: 6) { SettingsAssetAvailabilityRow( title: AppText.speechLanguagePack, availability: session.modelAvailability(for: .appleSpeechOnly) @@ -105,80 +201,128 @@ struct SettingsView: View { session.downloadModelAssets(for: .appleOnDevice) } } + } + } + + private var permissionsSection: some View { + SettingsInlineGroup(systemImage: "lock.shield.fill", title: AppText.permissions) { + SettingsCompactInfoRow( + title: AppText.permissions, + detail: AppText.permissionsHelp, + systemImage: "hand.raised.fill" + ) + } + } - Section(AppText.permissions) { - Text(AppText.permissionsHelp) + private var providerAccessLink: some View { + HStack(spacing: 5) { + switch session.openAIProvider { + case .openAI: + Text(AppText.openAIAPIKeyPlatformPrompt) + .font(.caption2) .foregroundStyle(.secondary) + + Link( + AppText.openAIAPIKeyPlatformLink, + destination: URL(string: "https://platform.openai.com/api-keys")! + ) + .font(.caption2.weight(.semibold)) + case .azure: + Text(AppText.azureOpenAIPlatformPrompt) + .font(.caption2) + .foregroundStyle(.secondary) + + Link( + AppText.azureOpenAIPlatformLink, + destination: URL(string: "https://ai.azure.com/")! + ) + .font(.caption2.weight(.semibold)) } } - .formStyle(.grouped) - .frame(width: 420) - .padding() + .padding(.horizontal, 8) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func saveOpenAIAPIKey() { + if session.saveOpenAIAPIKey(openAIAPIKey) { + openAIAPIKey = "" + configurationNotice = nil + shouldFocusAPIKey = false + } else { + configurationNotice = session.statusMessage + shouldFocusAPIKey = true + } + } + + private func removeOpenAIAPIKey() { + session.removeOpenAIAPIKey() + openAIAPIKey = "" + configurationNotice = nil + } + + private func saveAzureOpenAIConfig() { + if session.saveAzureOpenAIConfig(endpoint: azureEndpoint, apiKey: azureAPIKey) { + azureAPIKey = "" + azureEndpoint = session.azureOpenAIEndpoint + configurationNotice = nil + shouldFocusAPIKey = false + saveAlert = SaveAlert( + title: AppText.azureOpenAIConfigSavedTitle, + message: session.statusMessage + ) + } else { + configurationNotice = session.statusMessage + shouldFocusAPIKey = true + saveAlert = SaveAlert( + title: AppText.azureOpenAIConfigSaveFailedTitle, + message: session.statusMessage + ) + } + } + + private func removeAzureOpenAIConfig() { + session.removeAzureOpenAIConfig() + azureAPIKey = "" + azureEndpoint = "" + configurationNotice = nil } } -private struct SettingsAssetAvailabilityRow: View { - let title: String - let availability: ModelAvailability - let download: () -> Void +private struct SettingsSessionDurationRadioGroup: View { + @Binding var selection: SessionDurationMode + let isDisabled: Bool var body: some View { - HStack(alignment: .center, spacing: 10) { - Image(systemName: symbolName) - .font(.body.weight(.semibold)) - .foregroundStyle(color) - .frame(width: 22) - - VStack(alignment: .leading, spacing: 2) { - Text(title) - .font(.body) - - Text(availability.detail) - .font(.caption) + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 7) { + Image(systemName: "timer") + .font(.caption.weight(.bold)) .foregroundStyle(.secondary) - } + .frame(width: 14) - Spacer() - - if availability.state == .checking || availability.state == .downloading { - ProgressView() - .controlSize(.small) - } else if availability.state.canDownload { - Button(AppText.download) { - download() - } - } else { - Text(availability.state.title) + Text(AppText.sessionLength) .font(.caption.weight(.semibold)) - .foregroundStyle(color) + .foregroundStyle(.secondary) + + Spacer(minLength: 0) } - } - .help(availability.detail) - } - private var symbolName: String { - switch availability.state { - case .checking: - "clock" - case .installed: - "checkmark.seal.fill" - case .downloadRequired, .downloading: - "arrow.down.circle.fill" - case .unsupported, .unavailable, .failed: - "exclamationmark.triangle.fill" - } - } + Picker(AppText.sessionLength, selection: $selection) { + ForEach(SessionDurationMode.allCases) { mode in + Text(mode.title).tag(mode) + } + } + .pickerStyle(.radioGroup) + .labelsHidden() + .disabled(isDisabled) + .accessibilityLabel(AppText.sessionLength) - private var color: Color { - switch availability.state { - case .checking: - .secondary - case .installed: - .green - case .downloadRequired, .downloading: - .orange - case .unsupported, .unavailable, .failed: - .red + Text(selection.detail) + .font(.caption2) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) } + .padding(.horizontal, 8) + .padding(.vertical, 6) } } diff --git a/Sources/AirTranslate/Views/SidebarView.swift b/Sources/AirTranslate/Views/SidebarView.swift index 7c0f35c..5f07dee 100644 --- a/Sources/AirTranslate/Views/SidebarView.swift +++ b/Sources/AirTranslate/Views/SidebarView.swift @@ -203,10 +203,16 @@ struct SidebarView: View { ) } + private var gptCredentialsRequiredNotice: String { + session.openAIProvider == .azure + ? AppText.azureOpenAIConfigRequiredForGPTMode + : AppText.openAIAPIKeyRequiredForGPTMode + } + private var openConfigurationButton: some View { Button { - if ProcessingEngine.current(for: session) == .gpt && !session.hasOpenAIAPIKey { - configurationNotice = AppText.openAIAPIKeyRequiredForGPTMode + if ProcessingEngine.current(for: session) == .gpt && !session.hasOpenAIRealtimeCredentials { + configurationNotice = gptCredentialsRequiredNotice shouldFocusOpenAIAPIKey = true } isConfigurationPresented = true @@ -237,8 +243,8 @@ struct SidebarView: View { session.useAppleDefaultMode() case .gpt: session.useGPTRealtimeMode() - if !session.hasOpenAIAPIKey { - configurationNotice = AppText.openAIAPIKeyRequiredForGPTMode + if !session.hasOpenAIRealtimeCredentials { + configurationNotice = gptCredentialsRequiredNotice shouldFocusOpenAIAPIKey = true isConfigurationPresented = true } @@ -339,6 +345,16 @@ private struct ConfigurationSheetView: View { @Binding var shouldFocusOpenAIAPIKey: Bool let dismiss: () -> Void + @State private var azureEndpoint: String = "" + @State private var azureAPIKey: String = "" + @State private var saveAlert: SaveAlert? + + private struct SaveAlert: Identifiable { + let id = UUID() + let title: String + let message: String + } + var body: some View { VStack(spacing: 0) { HStack(alignment: .center, spacing: 12) { @@ -407,6 +423,14 @@ private struct ConfigurationSheetView: View { .frame(minHeight: 560) .onAppear { session.refreshModelAvailability() + azureEndpoint = session.azureOpenAIEndpoint + } + .alert(item: $saveAlert) { alert in + Alert( + title: Text(alert.title), + message: Text(alert.message), + dismissButton: .default(Text("OK")) + ) } } @@ -444,50 +468,73 @@ private struct ConfigurationSheetView: View { title: AppText.gptModels ) { VStack(spacing: 6) { - GPTModelMenuRow( - title: AppText.gptTranscriptionModel, - systemImage: "waveform.circle.fill", - value: session.openAITranscriptionModel.title - ) { - ForEach(OpenAIRealtimeTranscriptionModel.allCases) { model in - Button(model.title) { - session.openAITranscriptionModel = model - } - } + OpenAIRealtimeModelPickers(session: session) { _ in + configurationNotice = nil + shouldFocusOpenAIAPIKey = false } - GPTModelMenuRow( - title: AppText.gptTranslationModel, - systemImage: "globe", - value: session.openAITranslationModel.title - ) { - ForEach(OpenAIRealtimeTranslationModel.allCases) { model in - Button(model.title) { - session.openAITranslationModel = model + switch session.openAIProvider { + case .openAI: + GPTAPIKeyRow( + apiKey: $openAIAPIKey, + shouldFocusAPIKey: $shouldFocusOpenAIAPIKey, + hasAPIKey: session.hasOpenAIAPIKey, + notice: configurationNotice, + save: { + session.saveOpenAIAPIKey(openAIAPIKey) + openAIAPIKey = "" + configurationNotice = nil + shouldFocusOpenAIAPIKey = false + }, + remove: { + session.removeOpenAIAPIKey() + openAIAPIKey = "" + if ProcessingEngine.current(for: session) == .gpt { + configurationNotice = AppText.openAIAPIKeyRequiredForGPTMode + shouldFocusOpenAIAPIKey = true + } } - } + ) + case .azure: + GPTAzureConfigRow( + endpoint: $azureEndpoint, + apiKey: $azureAPIKey, + shouldFocusAPIKey: $shouldFocusOpenAIAPIKey, + hasConfig: session.hasAzureOpenAIConfig, + notice: configurationNotice, + save: { + let ok = session.saveAzureOpenAIConfig(endpoint: azureEndpoint, apiKey: azureAPIKey) + azureAPIKey = "" + if ok { + azureEndpoint = session.azureOpenAIEndpoint + configurationNotice = nil + shouldFocusOpenAIAPIKey = false + saveAlert = SaveAlert( + title: AppText.azureOpenAIConfigSavedTitle, + message: session.statusMessage + ) + } else { + configurationNotice = session.statusMessage + shouldFocusOpenAIAPIKey = true + saveAlert = SaveAlert( + title: AppText.azureOpenAIConfigSaveFailedTitle, + message: session.statusMessage + ) + } + }, + remove: { + session.removeAzureOpenAIConfig() + azureAPIKey = "" + azureEndpoint = "" + if ProcessingEngine.current(for: session) == .gpt { + configurationNotice = AppText.azureOpenAIConfigRequiredForGPTMode + shouldFocusOpenAIAPIKey = true + } + } + ) } - GPTAPIKeyRow( - apiKey: $openAIAPIKey, - shouldFocusAPIKey: $shouldFocusOpenAIAPIKey, - hasAPIKey: session.hasOpenAIAPIKey, - notice: configurationNotice, - save: { - session.saveOpenAIAPIKey(openAIAPIKey) - openAIAPIKey = "" - configurationNotice = nil - shouldFocusOpenAIAPIKey = false - }, - remove: { - session.removeOpenAIAPIKey() - openAIAPIKey = "" - if ProcessingEngine.current(for: session) == .gpt { - configurationNotice = AppText.openAIAPIKeyRequiredForGPTMode - shouldFocusOpenAIAPIKey = true - } - } - ) + OpenAIAdvancedOverridesView(session: session) Text(AppText.gptModelsDescription) .font(.caption2) @@ -496,15 +543,28 @@ private struct ConfigurationSheetView: View { .padding(.horizontal, 8) HStack(spacing: 5) { - Text(AppText.openAIAPIKeyPlatformPrompt) - .font(.caption2) - .foregroundStyle(.secondary) - - Link( - AppText.openAIAPIKeyPlatformLink, - destination: URL(string: "https://platform.openai.com/api-keys")! - ) - .font(.caption2.weight(.semibold)) + switch session.openAIProvider { + case .openAI: + Text(AppText.openAIAPIKeyPlatformPrompt) + .font(.caption2) + .foregroundStyle(.secondary) + + Link( + AppText.openAIAPIKeyPlatformLink, + destination: URL(string: "https://platform.openai.com/api-keys")! + ) + .font(.caption2.weight(.semibold)) + case .azure: + Text(AppText.azureOpenAIPlatformPrompt) + .font(.caption2) + .foregroundStyle(.secondary) + + Link( + AppText.azureOpenAIPlatformLink, + destination: URL(string: "https://ai.azure.com/")! + ) + .font(.caption2.weight(.semibold)) + } } .padding(.horizontal, 8) .frame(maxWidth: .infinity, alignment: .leading) @@ -534,7 +594,7 @@ private struct ConfigurationSheetView: View { } CompactToggleRow( - title: ProcessingEngine.current(for: session) == .gpt + title: session.isUsingOpenAIRealtimeTranslation ? AppText.translatedVoiceOutput : AppText.voiceOutput, systemImage: "speaker.wave.2.fill", @@ -1013,6 +1073,116 @@ private struct GPTAPIKeyRow: View { } } +private struct GPTAzureConfigRow: View { + @Binding var endpoint: String + @Binding var apiKey: String + @Binding var shouldFocusAPIKey: Bool + @FocusState private var isAPIKeyFocused: Bool + let hasConfig: Bool + let notice: String? + let save: () -> Void + let remove: () -> Void + + private var trimmedEndpoint: String { + endpoint.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var trimmedAPIKey: String { + apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + } + + var body: some View { + VStack(alignment: .leading, spacing: 7) { + HStack(spacing: 8) { + Image(systemName: "link") + .font(.caption.weight(.bold)) + .foregroundStyle(hasConfig ? Color.green : Color.secondary) + .frame(width: 16) + + TextField(AppText.azureOpenAIEndpointPlaceholder, text: $endpoint) + .textFieldStyle(.roundedBorder) + .font(.caption) + .textContentType(.URL) + .autocorrectionDisabled(true) + } + + Text(AppText.azureOpenAIEndpointFormatHint) + .font(.caption2) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .padding(.leading, 24) + + HStack(spacing: 8) { + Image(systemName: "key.fill") + .font(.caption.weight(.bold)) + .foregroundStyle(hasConfig ? Color.green : Color.secondary) + .frame(width: 16) + + SecureField(AppText.azureOpenAIAPIKeyPlaceholder, text: $apiKey) + .textFieldStyle(.roundedBorder) + .font(.caption) + .focused($isAPIKeyFocused) + + Button { + save() + } label: { + Image(systemName: "checkmark.circle.fill") + } + .buttonStyle(.borderless) + .disabled(trimmedAPIKey.isEmpty || trimmedEndpoint.isEmpty) + .help(AppText.saveAzureOpenAIConfig) + .accessibilityLabel(AppText.saveAzureOpenAIConfig) + + Button { + remove() + } label: { + Image(systemName: "trash") + } + .buttonStyle(.borderless) + .disabled(!hasConfig) + .help(AppText.removeAzureOpenAIConfig) + .accessibilityLabel(AppText.removeAzureOpenAIConfig) + } + + if let notice, !hasConfig { + HStack(alignment: .top, spacing: 6) { + Image(systemName: "exclamationmark.circle.fill") + .font(.caption2.weight(.bold)) + .foregroundStyle(Color.orange) + + Text(notice) + .font(.caption2.weight(.semibold)) + .foregroundStyle(Color.orange) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.leading, 24) + } + + Text(hasConfig ? AppText.azureOpenAIConfigConfigured : AppText.azureOpenAIConfigNotConfigured) + .font(.caption2.weight(.semibold)) + .foregroundStyle(hasConfig ? Color.green : Color.secondary) + .padding(.leading, 24) + } + .padding(.horizontal, 8) + .padding(.vertical, 7) + .background(Color.primary.opacity(0.04), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + .onAppear { + focusAPIKeyIfNeeded() + } + .onChange(of: shouldFocusAPIKey) { _, _ in + focusAPIKeyIfNeeded() + } + } + + private func focusAPIKeyIfNeeded() { + guard shouldFocusAPIKey else { return } + Task { @MainActor in + isAPIKeyFocused = true + shouldFocusAPIKey = false + } + } +} + private struct SessionDurationRadioGroup: View { @Binding var selection: SessionDurationMode let isDisabled: Bool