From 7c734501c171d604102594257b605d8daa29c2c4 Mon Sep 17 00:00:00 2001 From: Zander Wang Date: Thu, 21 May 2026 10:51:39 +0800 Subject: [PATCH 01/11] [Added] Azure OpenAI realtime provider --- Sources/AirTranslate/Models/AppText.swift | 116 +++++++ .../AirTranslate/Models/OpenAIProvider.swift | 17 + .../Services/AzureOpenAIConfigStore.swift | 122 +++++++ .../Services/LiveSpeechTranscriber.swift | 22 ++ .../Services/OpenAIRealtimeTranscriber.swift | 323 ++++++++++++++++-- .../Services/OpenAITranslationService.swift | 9 + .../Services/TranslationSessionStore.swift | 162 ++++++++- Sources/AirTranslate/Views/SettingsView.swift | 96 ++++-- Sources/AirTranslate/Views/SidebarView.swift | 271 +++++++++++++-- ...onSessionStoreLanguageCandidateTests.swift | 78 +++++ 10 files changed, 1128 insertions(+), 88 deletions(-) create mode 100644 Sources/AirTranslate/Models/OpenAIProvider.swift create mode 100644 Sources/AirTranslate/Services/AzureOpenAIConfigStore.swift diff --git a/Sources/AirTranslate/Models/AppText.swift b/Sources/AirTranslate/Models/AppText.swift index d61ec24..bd96ce0 100644 --- a/Sources/AirTranslate/Models/AppText.swift +++ b/Sources/AirTranslate/Models/AppText.swift @@ -210,6 +210,122 @@ 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 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.", 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..bb11d5e --- /dev/null +++ b/Sources/AirTranslate/Services/AzureOpenAIConfigStore.swift @@ -0,0 +1,122 @@ +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 + + let status = SecItemAdd(query as CFDictionary, nil) + 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/LiveSpeechTranscriber.swift b/Sources/AirTranslate/Services/LiveSpeechTranscriber.swift index 0b922a8..7c96aef 100644 --- a/Sources/AirTranslate/Services/LiveSpeechTranscriber.swift +++ b/Sources/AirTranslate/Services/LiveSpeechTranscriber.swift @@ -15,6 +15,13 @@ protocol LiveSpeechTranscriberDelegate: AnyObject { language: LanguageOption, confidence: Double ) + func liveSpeechTranscriber( + _ transcriber: LiveSpeechTranscriber, + didTranslate text: String, + language: LanguageOption, + confidence: Double, + isFinal: Bool + ) func liveSpeechTranscriber( _ transcriber: LiveSpeechTranscriber, didOutputAudioPCM16Base64 audio: String, @@ -31,6 +38,21 @@ extension LiveSpeechTranscriberDelegate { confidence: Double ) {} + func liveSpeechTranscriber( + _ transcriber: LiveSpeechTranscriber, + didTranslate text: String, + language: LanguageOption, + confidence: Double, + isFinal: Bool + ) { + liveSpeechTranscriber( + transcriber, + didTranslate: text, + language: language, + confidence: confidence + ) + } + func liveSpeechTranscriber( _ transcriber: LiveSpeechTranscriber, didOutputAudioPCM16Base64 audio: String, diff --git a/Sources/AirTranslate/Services/OpenAIRealtimeTranscriber.swift b/Sources/AirTranslate/Services/OpenAIRealtimeTranscriber.swift index a0939f1..8eb1fee 100644 --- a/Sources/AirTranslate/Services/OpenAIRealtimeTranscriber.swift +++ b/Sources/AirTranslate/Services/OpenAIRealtimeTranscriber.swift @@ -1,9 +1,68 @@ import AVFoundation import CoreMedia import Foundation +import os + +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 let azureRealtimeTranscriptionSessionDeployment = "gpt-realtime-1.5" + + 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(modelID: String) -> URL? { + switch kind { + case .openAI: + return URL(string: "wss://\(host)/v1/realtime?intent=transcription") + case .azure: + return URL(string: "wss://\(host)/openai/v1/realtime?model=\(Self.azureRealtimeTranscriptionSessionDeployment)") + } + } + + 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") + } + } + + var kindLogDescription: String { + switch kind { + case .openAI: "openAI" + case .azure: "azure" + } + } +} 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 @@ -11,6 +70,11 @@ final class OpenAIRealtimeTranscriber: @unchecked Sendable { * maxAudioChunkMilliseconds / 1_000 + private static let logger = Logger( + subsystem: "dev.appcaster.AirTranslate", + category: "OpenAIRealtime" + ) + enum OutputMode { case transcription case translationOnly @@ -20,6 +84,8 @@ final class OpenAIRealtimeTranscriber: @unchecked Sendable { private let stateLock = NSLock() private let conversionLock = NSLock() + private let urlSessionDelegate = OpenAIRealtimeURLSessionDelegate() + private var urlSession: URLSession? private var webSocketTask: URLSessionWebSocketTask? private var receiveTask: Task? private var language = LanguageOption.supported[0] @@ -27,21 +93,31 @@ 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, + providerConfig: OpenAIRealtimeProviderConfig + ) async throws { try await start( language: language, modelID: model.rawValue, outputMode: .transcription, - isEnabled: model.isEnabled + isEnabled: model.isEnabled, + providerConfig: providerConfig ) } - func startRealtimeTranslationOnly(language: LanguageOption, model: OpenAIRealtimeTranslationModel) async throws { + func startRealtimeTranslationOnly( + language: LanguageOption, + model: OpenAIRealtimeTranslationModel, + providerConfig: OpenAIRealtimeProviderConfig + ) async throws { try await start( language: language, modelID: model.apiModelID, outputMode: .translationOnly, - isEnabled: model.usesRealtimeAudioTranslation + isEnabled: model.usesRealtimeAudioTranslation, + providerConfig: providerConfig ) } @@ -49,12 +125,21 @@ 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 { + Self.logger.notice( + "OpenAIRealtimeTranscriber.start skipped (disabled). mode=\(String(describing: outputMode), privacy: .public) model=\(modelID, privacy: .public)" + ) + return + } + guard !providerConfig.apiKey.isEmpty else { + Self.logger.error( + "OpenAIRealtimeTranscriber.start aborted: missing api key. provider=\(providerConfig.kindLogDescription, privacy: .public)" + ) throw OpenAITranslationError.missingAPIKey } @@ -64,19 +149,41 @@ 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(modelID: modelID) else { + Self.logger.error( + "OpenAIRealtimeTranscriber.start aborted: provider \(providerConfig.kindLogDescription, privacy: .public) returned no transcription URL" + ) + 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 { + Self.logger.error( + "OpenAIRealtimeTranscriber.start aborted: provider \(providerConfig.kindLogDescription, privacy: .public) returned no translation URL for model=\(modelID, privacy: .public)" + ) + throw OpenAITranslationError.invalidResponse + } + url = translationURL } + Self.logger.notice( + "OpenAIRealtimeTranscriber.start mode=\(String(describing: outputMode), privacy: .public) provider=\(providerConfig.kindLogDescription, privacy: .public) host=\(providerConfig.host, privacy: .public) model=\(modelID, privacy: .public) language=\(language.id, privacy: .public) url=\(url.absoluteString, privacy: .public)" + ) + var request = URLRequest(url: url) - request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + providerConfig.apply(to: &request) - let webSocketTask = URLSession.shared.webSocketTask(with: request) + let urlSession = URLSession(configuration: .default, delegate: urlSessionDelegate, delegateQueue: nil) + self.urlSession = urlSession + let webSocketTask = urlSession.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() } @@ -105,6 +212,9 @@ final class OpenAIRealtimeTranscriber: @unchecked Sendable { webSocketTask.send(.string(text)) { [weak self] error in guard let error, let self else { return } + Self.logger.error( + "OpenAIRealtimeTranscriber audio append failed: \(error.localizedDescription, privacy: .public)" + ) self.delegate?.liveSpeechTranscriber(self.proxyTranscriber, didFail: error) } } @@ -117,26 +227,73 @@ final class OpenAIRealtimeTranscriber: @unchecked Sendable { } func stop() { + let hadTask = webSocketTask != nil setPaused(false) receiveTask?.cancel() receiveTask = nil webSocketTask?.cancel(with: .goingAway, reason: nil) webSocketTask = nil + urlSession?.invalidateAndCancel() + urlSession = nil realtimeTranscriptText = "" + if hadTask { + Self.logger.notice("OpenAIRealtimeTranscriber.stop closed websocket") + } + } + + 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 + ) + Self.logger.debug( + "OpenAIRealtimeTranscriber sending session.update mode=\(String(describing: self.outputMode), privacy: .public) bytes=\(text.utf8.count, privacy: .public)" + ) + do { + try await send(text) + Self.logger.notice("OpenAIRealtimeTranscriber session.update accepted by socket") + } catch { + Self.logger.error( + "OpenAIRealtimeTranscriber session.update failed: \(error.localizedDescription, privacy: .public)" + ) + throw error + } } - private func sendSessionUpdate(language: LanguageOption, modelID: String) async throws { + static func sessionUpdatePayload( + language: LanguageOption, + modelID: String, + outputMode: OutputMode, + providerKind: OpenAIRealtimeProviderConfig.Kind = .openAI + ) throws -> String { let data: Data switch outputMode { case .transcription: + let sessionType: String + let transcriptionModelID: String + switch providerKind { + case .openAI: + sessionType = "transcription" + transcriptionModelID = modelID + case .azure: + sessionType = "realtime" + transcriptionModelID = OpenAIRealtimeTranscriptionModel.gptRealtimeWhisper.rawValue + } + let event = OpenAIRealtimeTranscriptionSessionUpdateEvent( session: OpenAIRealtimeTranscriptionSession( - type: "transcription", + type: sessionType, audio: OpenAIRealtimeTranscriptionAudio( input: OpenAIRealtimeTranscriptionAudioInput( format: OpenAIRealtimeAudioFormat(type: "audio/pcm", rate: Self.realtimeAudioSampleRate), transcription: OpenAIRealtimeTranscriptionConfig( - model: modelID, + model: transcriptionModelID, language: language.openAILanguageCode ), turnDetection: .lowLatencyServerVAD, @@ -147,9 +304,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: OpenAIRealtimeTranscriptionModel.gptRealtimeWhisper.rawValue + ), + noiseReduction: OpenAIRealtimeNoiseReduction(type: "near_field") + ) + } + let event = OpenAIRealtimeTranslationSessionUpdateEvent( session: OpenAIRealtimeTranslationSession( audio: OpenAIRealtimeTranslationAudio( + input: input, output: OpenAIRealtimeTranslationAudioOutput( language: language.openAILanguageCode ) @@ -158,8 +329,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 { @@ -184,6 +358,9 @@ final class OpenAIRealtimeTranscriber: @unchecked Sendable { handleEventText(text) } catch { guard !Task.isCancelled else { return } + Self.logger.error( + "OpenAIRealtimeTranscriber receive loop failed: \(error.localizedDescription, privacy: .public)" + ) delegate?.liveSpeechTranscriber(proxyTranscriber, didFail: error) return } @@ -193,7 +370,23 @@ 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 { + Self.logger.debug( + "OpenAIRealtimeTranscriber received undecodable event (bytes=\(text.utf8.count, privacy: .public))" + ) + return + } + + switch event.type { + case "session.created", "session.updated": + Self.logger.notice("OpenAIRealtimeTranscriber event \(event.type, privacy: .public)") + case "error": + Self.logger.error( + "OpenAIRealtimeTranscriber server error: \(event.error?.message ?? "", privacy: .public) raw=\(text, privacy: .public)" + ) + default: + Self.logger.debug("OpenAIRealtimeTranscriber event \(event.type, privacy: .public)") + } switch event.type { case "conversation.item.input_audio_transcription.delta": @@ -212,7 +405,7 @@ final class OpenAIRealtimeTranscriber: @unchecked Sendable { guard outputMode == .translationOnly, let transcript = event.transcript, !transcript.isEmpty else { return } - publish(text: transcript) + publishTranslation(text: transcript, isFinal: true) realtimeTranscriptText = "" case "session.output_audio.delta": guard outputMode == .translationOnly, @@ -245,15 +438,20 @@ final class OpenAIRealtimeTranscriber: @unchecked Sendable { confidence: 0.5 ) case .translationOnly: - delegate?.liveSpeechTranscriber( - proxyTranscriber, - didTranslate: text, - language: language, - confidence: 0.5 - ) + publishTranslation(text: text, isFinal: false) } } + private func publishTranslation(text: String, isFinal: Bool) { + delegate?.liveSpeechTranscriber( + proxyTranscriber, + didTranslate: text, + language: language, + confidence: 0.5, + isFinal: isFinal + ) + } + private var proxyTranscriber: LiveSpeechTranscriber { LiveSpeechTranscriber() } @@ -381,16 +579,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 { @@ -401,7 +615,7 @@ private struct OpenAIRealtimeTurnDetection: Encodable { static let lowLatencyServerVAD = OpenAIRealtimeTurnDetection( type: "server_vad", - threshold: 0.42, + threshold: 0.5, prefixPaddingMilliseconds: 120, silenceDurationMilliseconds: 220 ) @@ -446,6 +660,59 @@ private struct OpenAIRealtimeErrorBody: Decodable { let message: String? } +private final class OpenAIRealtimeURLSessionDelegate: NSObject, URLSessionWebSocketDelegate, URLSessionTaskDelegate, @unchecked Sendable { + private let logger = Logger( + subsystem: "dev.appcaster.AirTranslate", + category: "OpenAIRealtime" + ) + + func urlSession( + _ session: URLSession, + webSocketTask: URLSessionWebSocketTask, + didOpenWithProtocol protocol: String? + ) { + logger.notice( + "OpenAIRealtimeTranscriber websocket opened url=\(webSocketTask.currentRequest?.url?.absoluteString ?? "", privacy: .public) protocol=\(`protocol` ?? "", privacy: .public)" + ) + } + + func urlSession( + _ session: URLSession, + webSocketTask: URLSessionWebSocketTask, + didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, + reason: Data? + ) { + let reasonText = reason.flatMap { String(data: $0, encoding: .utf8) } ?? "" + logger.notice( + "OpenAIRealtimeTranscriber websocket closed code=\(closeCode.rawValue, privacy: .public) reason=\(reasonText, privacy: .public)" + ) + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + didFinishCollecting metrics: URLSessionTaskMetrics + ) { + guard let transaction = metrics.transactionMetrics.last else { return } + let statusCode = (transaction.response as? HTTPURLResponse)?.statusCode ?? -1 + logger.notice( + "OpenAIRealtimeTranscriber task metrics url=\(task.currentRequest?.url?.absoluteString ?? "", privacy: .public) status=\(statusCode, privacy: .public) networkProtocol=\(transaction.networkProtocolName ?? "", privacy: .public) reusedConnection=\(transaction.isReusedConnection, privacy: .public)" + ) + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + didCompleteWithError error: Error? + ) { + guard let error else { return } + let statusCode = (task.response as? HTTPURLResponse)?.statusCode ?? -1 + logger.error( + "OpenAIRealtimeTranscriber task completed with error status=\(statusCode, privacy: .public) url=\(task.currentRequest?.url?.absoluteString ?? "", privacy: .public) error=\(error.localizedDescription, privacy: .public) details=\(String(describing: error), privacy: .public)" + ) + } +} + private enum OpenAIRealtimeTranscriberError: LocalizedError { case server(String?) diff --git a/Sources/AirTranslate/Services/OpenAITranslationService.swift b/Sources/AirTranslate/Services/OpenAITranslationService.swift index 882e698..5d07eb4 100644 --- a/Sources/AirTranslate/Services/OpenAITranslationService.swift +++ b/Sources/AirTranslate/Services/OpenAITranslationService.swift @@ -98,6 +98,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 +109,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..47afa9b 100644 --- a/Sources/AirTranslate/Services/TranslationSessionStore.swift +++ b/Sources/AirTranslate/Services/TranslationSessionStore.swift @@ -3,6 +3,7 @@ import AppKit import AirTranslateCore import Foundation import Observation +import os private enum SettingsKey { static let sourceLanguageID = "sourceLanguageID" @@ -21,6 +22,7 @@ private enum SettingsKey { static let audioInputSource = "audioInputSource" static let selectedMicrophoneInputDeviceID = "selectedMicrophoneInputDeviceID" static let isAppleSourceAutoDetectionEnabled = "isAppleSourceAutoDetectionEnabled" + static let openAIProvider = "openAIProvider" } private struct TranslationRequest { @@ -86,6 +88,10 @@ final class TranslationSessionStore { private static let appleAutoDetectionMinimumConfidence = 0.35 private static let appleAutoDetectionLanguageSwitchMinimumConfidence = 0.72 private static let isAppleSourceAutoDetectionTemporarilyDisabled = true + private static let logger = Logger( + subsystem: "dev.appcaster.AirTranslate", + category: "Session" + ) var isRunning = false var isPaused = false @@ -130,6 +136,14 @@ final class TranslationSessionStore { } } var hasOpenAIAPIKey = OpenAIAPIKeyStore.hasAPIKey() + var hasAzureOpenAIConfig = AzureOpenAIConfigStore.hasConfig() + var azureOpenAIEndpoint: String = AzureOpenAIConfigStore.readEndpoint() ?? "" + var openAIProvider: OpenAIProvider = .openAI { + didSet { + persistSelectedSettings() + refreshModelAvailability() + } + } var openAITranscriptionModel = OpenAIRealtimeTranscriptionModel.off { didSet { if openAITranscriptionModel.isEnabled { @@ -248,6 +262,7 @@ final class TranslationSessionStore { private var translatedSegmentsBySource: [String: String] = [:] private var translationCacheKeyOrder: [String] = [] private var realtimeTranslationOnlyText = "" + private var realtimeTranslationOnlyCommittedText = "" private var activeAutosaveSourceText = "" private var activeAutosaveTranslatedText = "" private var isRestoringSelectedSettings = false @@ -308,6 +323,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.", @@ -385,6 +401,9 @@ final class TranslationSessionStore { warmTranslationSession() } catch { guard !Task.isCancelled else { return } + Self.logger.error( + "session start failed: \(error.localizedDescription, privacy: .public) details=\(String(describing: error), privacy: .public)" + ) isRunning = false stopCaptioners() await stopCapture() @@ -531,6 +550,63 @@ final class TranslationSessionStore { } } + func saveAzureOpenAIConfig(endpoint: String, apiKey: String) { + do { + try AzureOpenAIConfigStore.saveConfig(endpoint: endpoint, apiKey: apiKey) + hasAzureOpenAIConfig = true + azureOpenAIEndpoint = AzureOpenAIConfigStore.readEndpoint() ?? "" + statusMessage = AppText.azureOpenAIConfigSaved + refreshModelAvailability() + } catch { + statusMessage = error.localizedDescription + } + } + + 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 { + Self.logger.error("resolveOpenAIRealtimeProviderConfig: missing OpenAI API key") + throw OpenAITranslationError.missingAPIKey + } + Self.logger.notice("resolveOpenAIRealtimeProviderConfig: using OpenAI provider") + return .openAI(apiKey: key) + case .azure: + guard let endpoint = AzureOpenAIConfigStore.readEndpoint(), + let host = AzureOpenAIEndpoint.host(from: endpoint) else { + Self.logger.error("resolveOpenAIRealtimeProviderConfig: missing Azure endpoint") + throw OpenAITranslationError.missingAzureEndpoint + } + guard let key = try AzureOpenAIConfigStore.readAPIKey(), !key.isEmpty else { + Self.logger.error("resolveOpenAIRealtimeProviderConfig: missing Azure API key") + throw OpenAITranslationError.missingAzureAPIKey + } + Self.logger.notice( + "resolveOpenAIRealtimeProviderConfig: using Azure provider host=\(host, privacy: .public)" + ) + return .azure(host: host, apiKey: key) + } + } + func openTranscriptsFolder() { do { try FileManager.default.createDirectory( @@ -797,14 +873,26 @@ final class TranslationSessionStore { openAITranscriber = OpenAIRealtimeTranscriber() openAITranscriber.delegate = self + Self.logger.notice( + "startCaptioners provider=\(self.openAIProvider.rawValue, privacy: .public) transcriptionModel=\(self.openAITranscriptionModel.rawValue, privacy: .public) translationModel=\(self.openAITranslationModel.rawValue, privacy: .public) source=\(self.sourceLanguage.id, privacy: .public) target=\(self.targetLanguage.id, privacy: .public)" + ) + if openAITranslationModel.usesRealtimeAudioTranslation { + let providerConfig = try resolveOpenAIRealtimeProviderConfig() try await openAITranscriber.startRealtimeTranslationOnly( language: targetLanguage, - model: openAITranslationModel + model: openAITranslationModel, + 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, + providerConfig: providerConfig + ) } else { + Self.logger.notice("startCaptioners using Apple SpeechTranscriber path") try await transcriber.start(languages: await appleSpeechLanguagesForCurrentMode()) } } @@ -878,6 +966,7 @@ final class TranslationSessionStore { translationBurstStartedAt = Date.distantPast resetTranslationCache() realtimeTranslationOnlyText = "" + realtimeTranslationOnlyCommittedText = "" activeAutosaveSourceText = "" activeAutosaveTranslatedText = "" stopSpeaking() @@ -1004,6 +1093,10 @@ 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 + } refreshMicrophoneInputDevices() } @@ -1027,6 +1120,7 @@ final class TranslationSessionStore { 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) } private func stopCapture() async { @@ -2374,19 +2468,21 @@ final class TranslationSessionStore { translationCacheKeyOrder.removeAll() } - private func appendRealtimeTranslationOnly(_ text: String) { + private func appendRealtimeTranslationOnly(_ text: String, isFinal: Bool) { guard isRunning, !isPaused else { return } guard text.rangeOfCharacter(from: .whitespacesAndNewlines.inverted) != nil || !realtimeTranslationOnlyText.isEmpty else { return } - if text.hasPrefix(realtimeTranslationOnlyText) { + if isFinal { + commitRealtimeTranslationOnlyText(text) + } else if text.hasPrefix(realtimeTranslationOnlyText) { realtimeTranslationOnlyText = text } else if !realtimeTranslationOnlyText.hasSuffix(text) { realtimeTranslationOnlyText += text } - let translatedText = realtimeTranslationOnlyText.trimmingCharacters(in: .whitespacesAndNewlines) + let translatedText = visibleRealtimeTranslationOnlyText().trimmingCharacters(in: .whitespacesAndNewlines) guard !translatedText.isEmpty else { return } lastRecognizedText = translatedText @@ -2404,7 +2500,7 @@ final class TranslationSessionStore { translatedText: translatedText, translatedSourceText: sourceText, createdAt: existingLine.createdAt, - isFinal: false, + isFinal: isFinal, revision: existingLine.revision + 1, usesLongSessionDisplay: usesLongSessionMode ) @@ -2414,7 +2510,7 @@ final class TranslationSessionStore { translatedText: translatedText, translatedSourceText: sourceText, createdAt: Date(), - isFinal: false, + isFinal: isFinal, revision: 1, usesLongSessionDisplay: usesLongSessionMode ) @@ -2430,6 +2526,44 @@ final class TranslationSessionStore { speakTranslatedDeltaIfNeeded(translatedText) } + private func commitRealtimeTranslationOnlyText(_ text: String) { + let finalText = text.trimmingCharacters(in: .whitespacesAndNewlines) + realtimeTranslationOnlyText = "" + guard !finalText.isEmpty else { return } + + let committedText = realtimeTranslationOnlyCommittedText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !committedText.isEmpty else { + realtimeTranslationOnlyCommittedText = finalText + return + } + + let normalizedCommitted = normalizedTranscriptForComparison(committedText) + let normalizedFinal = normalizedTranscriptForComparison(finalText) + if isWholeTextPrefix(normalizedCommitted, of: normalizedFinal) { + realtimeTranslationOnlyCommittedText = finalText + return + } + guard !normalizedCommitted.hasSuffix(normalizedFinal) else { return } + + realtimeTranslationOnlyCommittedText = committedText + "\n" + finalText + } + + private func visibleRealtimeTranslationOnlyText() -> String { + let committedText = realtimeTranslationOnlyCommittedText.trimmingCharacters(in: .whitespacesAndNewlines) + let partialText = realtimeTranslationOnlyText.trimmingCharacters(in: .whitespacesAndNewlines) + + guard !committedText.isEmpty else { return partialText } + guard !partialText.isEmpty else { return committedText } + + let normalizedCommitted = normalizedTranscriptForComparison(committedText) + let normalizedPartial = normalizedTranscriptForComparison(partialText) + if isWholeTextPrefix(normalizedCommitted, of: normalizedPartial) { + return partialText + } + + return committedText + "\n" + partialText + } + private func requestTranslation(for line: CaptionLine, source: LanguageOption, target: LanguageOption) { guard !openAITranslationModel.usesRealtimeAudioTranslation else { return } @@ -2943,7 +3077,19 @@ extension TranslationSessionStore: LiveSpeechTranscriberDelegate { confidence: Double ) { Task { @MainActor in - appendRealtimeTranslationOnly(text) + appendRealtimeTranslationOnly(text, isFinal: false) + } + } + + nonisolated func liveSpeechTranscriber( + _ transcriber: LiveSpeechTranscriber, + didTranslate text: String, + language: LanguageOption, + confidence: Double, + isFinal: Bool + ) { + Task { @MainActor in + appendRealtimeTranslationOnly(text, isFinal: isFinal) } } diff --git a/Sources/AirTranslate/Views/SettingsView.swift b/Sources/AirTranslate/Views/SettingsView.swift index c2d2a2b..1a9ee4d 100644 --- a/Sources/AirTranslate/Views/SettingsView.swift +++ b/Sources/AirTranslate/Views/SettingsView.swift @@ -3,36 +3,89 @@ import SwiftUI struct SettingsView: View { @Bindable var session: TranslationSessionStore @State private var openAIAPIKey = "" + @State private var azureEndpoint = "" + @State private var azureAPIKey = "" var body: some View { Form { - Section(AppText.openAIAPIKey) { - SecureField(AppText.openAIAPIKeyPlaceholder, text: $openAIAPIKey) - .textFieldStyle(.roundedBorder) + Section(AppText.gptModels) { + Picker(AppText.openAIProvider, selection: $session.openAIProvider) { + ForEach(OpenAIProvider.allCases) { provider in + Text(provider.title).tag(provider) + } + } + .pickerStyle(.segmented) + } - HStack { - Text(session.hasOpenAIAPIKey ? AppText.openAIAPIKeyConfigured : AppText.openAIAPIKeyNotConfigured) - .font(.caption) - .foregroundStyle(session.hasOpenAIAPIKey ? .green : .secondary) + switch session.openAIProvider { + case .openAI: + 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) - Spacer() + Spacer() - Button(AppText.saveOpenAIAPIKey) { - session.saveOpenAIAPIKey(openAIAPIKey) - openAIAPIKey = "" + Button(AppText.saveOpenAIAPIKey) { + session.saveOpenAIAPIKey(openAIAPIKey) + openAIAPIKey = "" + } + .disabled(openAIAPIKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + + Button(AppText.removeOpenAIAPIKey) { + session.removeOpenAIAPIKey() + openAIAPIKey = "" + } + .disabled(!session.hasOpenAIAPIKey) } - .disabled(openAIAPIKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - Button(AppText.removeOpenAIAPIKey) { - session.removeOpenAIAPIKey() - openAIAPIKey = "" - } - .disabled(!session.hasOpenAIAPIKey) + Text(AppText.openAIAPIKeyDescription) + .font(.caption) + .foregroundStyle(.secondary) } + case .azure: + Section(AppText.openAIProviderAzureTitle) { + TextField(AppText.azureOpenAIEndpointPlaceholder, text: $azureEndpoint) + .textFieldStyle(.roundedBorder) + .textContentType(.URL) + .autocorrectionDisabled(true) + + Text(AppText.azureOpenAIEndpointFormatHint) + .font(.caption) + .foregroundStyle(.secondary) - Text(AppText.openAIAPIKeyDescription) - .font(.caption) - .foregroundStyle(.secondary) + SecureField(AppText.azureOpenAIAPIKeyPlaceholder, text: $azureAPIKey) + .textFieldStyle(.roundedBorder) + + HStack { + Text(session.hasAzureOpenAIConfig ? AppText.azureOpenAIConfigConfigured : AppText.azureOpenAIConfigNotConfigured) + .font(.caption) + .foregroundStyle(session.hasAzureOpenAIConfig ? .green : .secondary) + + Spacer() + + Button(AppText.saveAzureOpenAIConfig) { + session.saveAzureOpenAIConfig(endpoint: azureEndpoint, apiKey: azureAPIKey) + azureAPIKey = "" + azureEndpoint = session.azureOpenAIEndpoint + } + .disabled( + azureAPIKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || azureEndpoint.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ) + + Button(AppText.removeAzureOpenAIConfig) { + session.removeAzureOpenAIConfig() + azureAPIKey = "" + azureEndpoint = "" + } + .disabled(!session.hasAzureOpenAIConfig) + } + } } Section(AppText.transcript) { @@ -114,6 +167,9 @@ struct SettingsView: View { .formStyle(.grouped) .frame(width: 420) .padding() + .onAppear { + azureEndpoint = session.azureOpenAIEndpoint + } } } diff --git a/Sources/AirTranslate/Views/SidebarView.swift b/Sources/AirTranslate/Views/SidebarView.swift index 7c0f35c..4591ad8 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,9 @@ private struct ConfigurationSheetView: View { @Binding var shouldFocusOpenAIAPIKey: Bool let dismiss: () -> Void + @State private var azureEndpoint: String = "" + @State private var azureAPIKey: String = "" + var body: some View { VStack(spacing: 0) { HStack(alignment: .center, spacing: 12) { @@ -407,6 +416,7 @@ private struct ConfigurationSheetView: View { .frame(minHeight: 560) .onAppear { session.refreshModelAvailability() + azureEndpoint = session.azureOpenAIEndpoint } } @@ -444,6 +454,18 @@ private struct ConfigurationSheetView: View { title: AppText.gptModels ) { VStack(spacing: 6) { + GPTProviderPickerRow( + title: AppText.openAIProvider, + selection: Binding( + get: { session.openAIProvider }, + set: { newValue in + session.openAIProvider = newValue + configurationNotice = nil + shouldFocusOpenAIAPIKey = false + } + ) + ) + GPTModelMenuRow( title: AppText.gptTranscriptionModel, systemImage: "waveform.circle.fill", @@ -468,26 +490,55 @@ private struct ConfigurationSheetView: View { } } - 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 + 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: { + session.saveAzureOpenAIConfig(endpoint: azureEndpoint, apiKey: azureAPIKey) + azureAPIKey = "" + if session.hasAzureOpenAIConfig { + azureEndpoint = session.azureOpenAIEndpoint + configurationNotice = nil + shouldFocusOpenAIAPIKey = false + } + }, + remove: { + session.removeAzureOpenAIConfig() + azureAPIKey = "" + azureEndpoint = "" + if ProcessingEngine.current(for: session) == .gpt { + configurationNotice = AppText.azureOpenAIConfigRequiredForGPTMode + shouldFocusOpenAIAPIKey = true + } + } + ) + } Text(AppText.gptModelsDescription) .font(.caption2) @@ -496,15 +547,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) @@ -1013,6 +1077,149 @@ private struct GPTAPIKeyRow: View { } } +private struct GPTProviderPickerRow: 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)) + } +} + +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 diff --git a/Tests/AirTranslateCoreTests/TranslationSessionStoreLanguageCandidateTests.swift b/Tests/AirTranslateCoreTests/TranslationSessionStoreLanguageCandidateTests.swift index ab400de..bc1cb20 100644 --- a/Tests/AirTranslateCoreTests/TranslationSessionStoreLanguageCandidateTests.swift +++ b/Tests/AirTranslateCoreTests/TranslationSessionStoreLanguageCandidateTests.swift @@ -140,4 +140,82 @@ struct TranslationSessionStoreLanguageCandidateTests { #expect(line.sourceDisplayText.hasPrefix("...")) #expect(line.translatedDisplayText.hasPrefix("...")) } + + @Test + func azureRealtimeURLUsesWebSocketSessionEndpoint() { + let config = OpenAIRealtimeProviderConfig.azure( + host: "example.openai.azure.com", + apiKey: "test-key" + ) + + #expect( + config.transcriptionURL(modelID: "gpt-realtime-whisper")?.absoluteString + == "wss://example.openai.azure.com/openai/v1/realtime?model=gpt-realtime-1.5" + ) + #expect( + config.translationURL(modelID: "gpt-realtime-translate")?.absoluteString + == "wss://example.openai.azure.com/openai/v1/realtime/translations?model=gpt-realtime-translate" + ) + } + + @Test + func azureTranscriptionSessionUpdateUsesRealtimeSessionWithWhisperInput() throws { + let payload = try OpenAIRealtimeTranscriber.sessionUpdatePayload( + language: LanguageOption.supported[0], + modelID: "gpt-realtime-whisper", + outputMode: .transcription, + providerKind: .azure + ) + let json = try #require(try JSONSerialization.jsonObject(with: Data(payload.utf8)) as? [String: Any]) + let session = try #require(json["session"] as? [String: Any]) + let audio = try #require(session["audio"] as? [String: Any]) + let input = try #require(audio["input"] as? [String: Any]) + let transcription = try #require(input["transcription"] as? [String: Any]) + let turnDetection = try #require(input["turn_detection"] as? [String: Any]) + + #expect(session["type"] as? String == "realtime") + #expect(transcription["model"] as? String == "gpt-realtime-whisper") + #expect(transcription["language"] as? String == "en") + #expect(turnDetection["type"] as? String == "server_vad") + #expect(turnDetection["threshold"] as? Double == 0.5) + } + + @Test + func azureTranslationSessionUpdateDeclaresWhisperInputConfig() throws { + let payload = try OpenAIRealtimeTranscriber.sessionUpdatePayload( + language: LanguageOption.supported[3], + modelID: "gpt-realtime-translate", + outputMode: .translationOnly, + providerKind: .azure + ) + let json = try #require(try JSONSerialization.jsonObject(with: Data(payload.utf8)) as? [String: Any]) + let session = try #require(json["session"] as? [String: Any]) + let audio = try #require(session["audio"] as? [String: Any]) + let input = try #require(audio["input"] as? [String: Any]) + let transcription = try #require(input["transcription"] as? [String: Any]) + let noiseReduction = try #require(input["noise_reduction"] as? [String: Any]) + let output = try #require(audio["output"] as? [String: Any]) + + #expect(input["format"] == nil) + #expect(transcription["model"] as? String == "gpt-realtime-whisper") + #expect(noiseReduction["type"] as? String == "near_field") + #expect(output["language"] as? String == "zh") + } + + @Test + func openAITranslationSessionUpdateKeepsMinimalOutputConfig() throws { + let payload = try OpenAIRealtimeTranscriber.sessionUpdatePayload( + language: LanguageOption.supported[3], + modelID: "gpt-realtime-translate", + outputMode: .translationOnly, + providerKind: .openAI + ) + let json = try #require(try JSONSerialization.jsonObject(with: Data(payload.utf8)) as? [String: Any]) + let session = try #require(json["session"] as? [String: Any]) + let audio = try #require(session["audio"] as? [String: Any]) + let output = try #require(audio["output"] as? [String: Any]) + + #expect(audio["input"] == nil) + #expect(output["language"] as? String == "zh") + } } From 2e8959ed239932a6557477ce2efc111a96d295cb Mon Sep 17 00:00:00 2001 From: Zander Wang Date: Thu, 21 May 2026 13:56:33 +0800 Subject: [PATCH 02/11] [Improved] Cover OpenAI realtime handling --- .../Services/OpenAITranslationService.swift | 37 ++++- .../OpenAIRealtimeProviderConfigTests.swift | 127 +++++++++++++++ .../OpenAITranslationServiceTests.swift | 149 ++++++++++++++++++ ...onSessionStoreLanguageCandidateTests.swift | 2 +- 4 files changed, 309 insertions(+), 6 deletions(-) create mode 100644 Tests/AirTranslateCoreTests/OpenAIRealtimeProviderConfigTests.swift create mode 100644 Tests/AirTranslateCoreTests/OpenAITranslationServiceTests.swift diff --git a/Sources/AirTranslate/Services/OpenAITranslationService.swift b/Sources/AirTranslate/Services/OpenAITranslationService.swift index 5d07eb4..be539e4 100644 --- a/Sources/AirTranslate/Services/OpenAITranslationService.swift +++ b/Sources/AirTranslate/Services/OpenAITranslationService.swift @@ -1,8 +1,35 @@ import Foundation +protocol OpenAITranslationAPIKeyProviding: Sendable { + func readAPIKey() throws -> String? +} + +struct KeychainOpenAITranslationAPIKeyProvider: OpenAITranslationAPIKeyProviding { + func readAPIKey() throws -> String? { + try OpenAIAPIKeyStore.readAPIKey() + } +} + +protocol OpenAITranslationHTTPClient: Sendable { + func data(for request: URLRequest) async throws -> (Data, URLResponse) +} + +extension URLSession: OpenAITranslationHTTPClient {} + actor OpenAITranslationService { - private let endpoint = URL(string: "https://api.openai.com/v1/responses")! - private let model = "gpt-realtime-translate" + private let endpoint: URL + private let apiKeyProvider: OpenAITranslationAPIKeyProviding + private let httpClient: OpenAITranslationHTTPClient + + init( + endpoint: URL = URL(string: "https://api.openai.com/v1/responses")!, + apiKeyProvider: OpenAITranslationAPIKeyProviding = KeychainOpenAITranslationAPIKeyProvider(), + httpClient: OpenAITranslationHTTPClient = URLSession.shared + ) { + self.endpoint = endpoint + self.apiKeyProvider = apiKeyProvider + self.httpClient = httpClient + } func translate( _ text: String, @@ -12,7 +39,7 @@ actor OpenAITranslationService { ) async throws -> String { guard !text.isEmpty else { return text } guard selectedModel.isEnabled else { return text } - guard let apiKey = try OpenAIAPIKeyStore.readAPIKey(), !apiKey.isEmpty else { + guard let apiKey = try apiKeyProvider.readAPIKey(), !apiKey.isEmpty else { throw OpenAITranslationError.missingAPIKey } @@ -32,7 +59,7 @@ actor OpenAITranslationService { ) ) - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await httpClient.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw OpenAITranslationError.invalidResponse } @@ -96,7 +123,7 @@ private struct OpenAIErrorBody: Decodable { let message: String } -enum OpenAITranslationError: LocalizedError { +enum OpenAITranslationError: LocalizedError, Equatable { case missingAPIKey case missingAzureEndpoint case missingAzureAPIKey diff --git a/Tests/AirTranslateCoreTests/OpenAIRealtimeProviderConfigTests.swift b/Tests/AirTranslateCoreTests/OpenAIRealtimeProviderConfigTests.swift new file mode 100644 index 0000000..00d219f --- /dev/null +++ b/Tests/AirTranslateCoreTests/OpenAIRealtimeProviderConfigTests.swift @@ -0,0 +1,127 @@ +import Foundation +import Testing +@testable import AirTranslate + +@Suite +struct OpenAIRealtimeProviderConfigTests { + @Test + func openAIProviderUsesBearerAuthorizationOnly() { + let config = OpenAIRealtimeProviderConfig.openAI(apiKey: "openai-test-key") + var request = URLRequest(url: URL(string: "wss://api.openai.com/v1/realtime")!) + + config.apply(to: &request) + + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer openai-test-key") + #expect(request.value(forHTTPHeaderField: "api-key") == nil) + } + + @Test + func azureProviderUsesAPIKeyHeaderOnly() { + let config = OpenAIRealtimeProviderConfig.azure( + host: "example.openai.azure.com", + apiKey: "azure-test-key" + ) + var request = URLRequest(url: URL(string: "wss://example.openai.azure.com/openai/v1/realtime")!) + + config.apply(to: &request) + + #expect(request.value(forHTTPHeaderField: "api-key") == "azure-test-key") + #expect(request.value(forHTTPHeaderField: "Authorization") == nil) + } + + @Test + func openAIRealtimeURLsUsePublicRealtimeEndpoints() { + let config = OpenAIRealtimeProviderConfig.openAI(apiKey: "test-key") + + #expect( + config.transcriptionURL(modelID: "gpt-realtime-whisper")?.absoluteString + == "wss://api.openai.com/v1/realtime?intent=transcription" + ) + #expect( + config.translationURL(modelID: "gpt-realtime-translate")?.absoluteString + == "wss://api.openai.com/v1/realtime/translations?model=gpt-realtime-translate" + ) + } + + @Test + func azureEndpointNormalizationKeepsOnlySchemeHostAndPort() { + let endpoint = " https://example.openai.azure.com:444/openai/deployments/demo?api-version=preview " + + #expect(AzureOpenAIEndpoint.normalize(endpoint) == "https://example.openai.azure.com:444") + #expect(AzureOpenAIEndpoint.host(from: endpoint) == "example.openai.azure.com") + } + + @Test + func bareAzureEndpointDefaultsToHTTPS() { + #expect( + AzureOpenAIEndpoint.normalize("example.openai.azure.com") + == "https://example.openai.azure.com" + ) + #expect(AzureOpenAIEndpoint.host(from: "example.openai.azure.com") == "example.openai.azure.com") + } + + @Test + func azureTranscriptionSessionAlwaysUsesWhisperInputModel() throws { + let payload = try OpenAIRealtimeTranscriber.sessionUpdatePayload( + language: LanguageOption.english, + modelID: "unexpected-session-model", + outputMode: .transcription, + providerKind: .azure + ) + let json = try #require(try JSONSerialization.jsonObject(with: Data(payload.utf8)) as? [String: Any]) + let session = try #require(json["session"] as? [String: Any]) + let audio = try #require(session["audio"] as? [String: Any]) + let input = try #require(audio["input"] as? [String: Any]) + let format = try #require(input["format"] as? [String: Any]) + let transcription = try #require(input["transcription"] as? [String: Any]) + + #expect(json["type"] as? String == "session.update") + #expect(session["type"] as? String == "realtime") + #expect(format["type"] as? String == "audio/pcm") + #expect(format["rate"] as? Int == 24_000) + #expect(transcription["model"] as? String == "gpt-realtime-whisper") + #expect(transcription["language"] as? String == "en") + } + + @Test + func openAITranscriptionSessionUsesSelectedWhisperModel() throws { + let payload = try OpenAIRealtimeTranscriber.sessionUpdatePayload( + language: LanguageOption.korean, + modelID: "gpt-realtime-whisper", + outputMode: .transcription, + providerKind: .openAI + ) + let json = try #require(try JSONSerialization.jsonObject(with: Data(payload.utf8)) as? [String: Any]) + let session = try #require(json["session"] as? [String: Any]) + let audio = try #require(session["audio"] as? [String: Any]) + let input = try #require(audio["input"] as? [String: Any]) + let transcription = try #require(input["transcription"] as? [String: Any]) + let noiseReduction = try #require(input["noise_reduction"] as? [String: Any]) + + #expect(json["type"] as? String == "session.update") + #expect(session["type"] as? String == "transcription") + #expect(transcription["model"] as? String == "gpt-realtime-whisper") + #expect(transcription["language"] as? String == "ko") + #expect(noiseReduction["type"] as? String == "near_field") + } + + @Test + func realtimeTranslationSessionTargetsOutputLanguage() throws { + let payload = try OpenAIRealtimeTranscriber.sessionUpdatePayload( + language: LanguageOption.supported[3], + modelID: "gpt-realtime-translate", + outputMode: .translationOnly, + providerKind: .azure + ) + let json = try #require(try JSONSerialization.jsonObject(with: Data(payload.utf8)) as? [String: Any]) + let session = try #require(json["session"] as? [String: Any]) + let audio = try #require(session["audio"] as? [String: Any]) + let input = try #require(audio["input"] as? [String: Any]) + let transcription = try #require(input["transcription"] as? [String: Any]) + let output = try #require(audio["output"] as? [String: Any]) + + #expect(json["type"] as? String == "session.update") + #expect(transcription["model"] as? String == "gpt-realtime-whisper") + #expect(output["language"] as? String == "zh") + } +} diff --git a/Tests/AirTranslateCoreTests/OpenAITranslationServiceTests.swift b/Tests/AirTranslateCoreTests/OpenAITranslationServiceTests.swift new file mode 100644 index 0000000..62309f0 --- /dev/null +++ b/Tests/AirTranslateCoreTests/OpenAITranslationServiceTests.swift @@ -0,0 +1,149 @@ +import Foundation +import Testing +@testable import AirTranslate + +@Suite +struct OpenAITranslationServiceTests { + @Test + func translatePostsResponsesRequestWithRealtimeTranslateModel() async throws { + let httpClient = CapturingOpenAITranslationHTTPClient( + data: Data(#"{"output_text":" Translated hello "}"#.utf8), + statusCode: 200 + ) + let service = OpenAITranslationService( + apiKeyProvider: StaticOpenAIAPIKeyProvider(apiKey: "test-api-key"), + httpClient: httpClient + ) + + let translation = try await service.translate( + "Hello", + source: .english, + target: .korean, + model: .gptRealtimeTranslate + ) + let request = try #require(await httpClient.lastRequest) + let body = try #require(request.httpBody) + let json = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any]) + + #expect(translation == "Translated hello") + #expect(request.url?.absoluteString == "https://api.openai.com/v1/responses") + #expect(request.httpMethod == "POST") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer test-api-key") + #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json") + #expect(json["model"] as? String == "gpt-realtime-translate") + #expect(json["input"] as? String == "Hello") + #expect(json["store"] as? Bool == false) + #expect((json["instructions"] as? String)?.contains("English") == true) + #expect((json["instructions"] as? String)?.contains("Korean") == true) + } + + @Test + func translateReadsFirstNestedOutputTextWhenOutputTextIsMissing() async throws { + let response = Data( + #"{"output":[{"content":[{"text":"Bonjour"}]}]}"#.utf8 + ) + let service = OpenAITranslationService( + apiKeyProvider: StaticOpenAIAPIKeyProvider(apiKey: "test-api-key"), + httpClient: CapturingOpenAITranslationHTTPClient(data: response, statusCode: 200) + ) + + let translation = try await service.translate( + "Hello", + source: .english, + target: LanguageOption.supported[5], + model: .gptRealtimeTranslate + ) + + #expect(translation == "Bonjour") + } + + @Test + func translateThrowsRequestFailedWithServerMessage() async throws { + let response = Data(#"{"error":{"message":"bad deployment"}}"#.utf8) + let service = OpenAITranslationService( + apiKeyProvider: StaticOpenAIAPIKeyProvider(apiKey: "test-api-key"), + httpClient: CapturingOpenAITranslationHTTPClient(data: response, statusCode: 404) + ) + + await #expect(throws: OpenAITranslationError.requestFailed(statusCode: 404, message: "bad deployment")) { + _ = try await service.translate( + "Hello", + source: .english, + target: .korean, + model: .gptRealtimeTranslate + ) + } + } + + @Test + func translateDoesNotCallNetworkWhenModelIsOff() async throws { + let httpClient = CapturingOpenAITranslationHTTPClient( + data: Data(#"{"output_text":"ignored"}"#.utf8), + statusCode: 200 + ) + let service = OpenAITranslationService( + apiKeyProvider: StaticOpenAIAPIKeyProvider(apiKey: nil), + httpClient: httpClient + ) + + let translation = try await service.translate( + "Hello", + source: .english, + target: .korean, + model: .off + ) + + #expect(translation == "Hello") + #expect(await httpClient.lastRequest == nil) + } + + @Test + func translateRequiresAPIKeyOnlyForEnabledModel() async throws { + let service = OpenAITranslationService( + apiKeyProvider: StaticOpenAIAPIKeyProvider(apiKey: nil), + httpClient: CapturingOpenAITranslationHTTPClient( + data: Data(#"{"output_text":"ignored"}"#.utf8), + statusCode: 200 + ) + ) + + await #expect(throws: OpenAITranslationError.missingAPIKey) { + _ = try await service.translate( + "Hello", + source: .english, + target: .korean, + model: .gptRealtimeTranslate + ) + } + } +} + +private struct StaticOpenAIAPIKeyProvider: OpenAITranslationAPIKeyProviding { + let apiKey: String? + + func readAPIKey() throws -> String? { + apiKey + } +} + +private actor CapturingOpenAITranslationHTTPClient: OpenAITranslationHTTPClient { + private let data: Data + private let statusCode: Int + private(set) var lastRequest: URLRequest? + + init(data: Data, statusCode: Int) { + self.data = data + self.statusCode = statusCode + } + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + lastRequest = request + let response = HTTPURLResponse( + url: request.url!, + statusCode: statusCode, + httpVersion: nil, + headerFields: nil + )! + return (data, response) + } +} diff --git a/Tests/AirTranslateCoreTests/TranslationSessionStoreLanguageCandidateTests.swift b/Tests/AirTranslateCoreTests/TranslationSessionStoreLanguageCandidateTests.swift index bc1cb20..08d7206 100644 --- a/Tests/AirTranslateCoreTests/TranslationSessionStoreLanguageCandidateTests.swift +++ b/Tests/AirTranslateCoreTests/TranslationSessionStoreLanguageCandidateTests.swift @@ -203,7 +203,7 @@ struct TranslationSessionStoreLanguageCandidateTests { } @Test - func openAITranslationSessionUpdateKeepsMinimalOutputConfig() throws { + func openAITranslationSessionUpdateOmitsInputTranscription() throws { let payload = try OpenAIRealtimeTranscriber.sessionUpdatePayload( language: LanguageOption.supported[3], modelID: "gpt-realtime-translate", From a398b5a93d650bca1cb7acbe3e053bc81d57c228 Mon Sep 17 00:00:00 2001 From: Zander Wang Date: Thu, 21 May 2026 13:57:45 +0800 Subject: [PATCH 03/11] [Added] Add floating caption controls --- Sources/AirTranslate/Models/AppText.swift | 30 +++++++ .../Models/FloatingCaptionTextAlignment.swift | 44 +++++++++++ .../Services/TranslationSessionStore.swift | 76 ++++++++++-------- .../FloatingCaptionPresentationPolicy.swift | 79 +++++++++++++++++++ .../Views/FloatingCaptionWindowView.swift | 10 +-- ...onSessionStoreLanguageCandidateTests.swift | 38 +++++++++ 6 files changed, 240 insertions(+), 37 deletions(-) create mode 100644 Sources/AirTranslate/Models/FloatingCaptionTextAlignment.swift create mode 100644 Sources/AirTranslate/Support/FloatingCaptionPresentationPolicy.swift diff --git a/Sources/AirTranslate/Models/AppText.swift b/Sources/AirTranslate/Models/AppText.swift index bd96ce0..fca816b 100644 --- a/Sources/AirTranslate/Models/AppText.swift +++ b/Sources/AirTranslate/Models/AppText.swift @@ -438,8 +438,38 @@ enum AppText { 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/Services/TranslationSessionStore.swift b/Sources/AirTranslate/Services/TranslationSessionStore.swift index 47afa9b..1f155ef 100644 --- a/Sources/AirTranslate/Services/TranslationSessionStore.swift +++ b/Sources/AirTranslate/Services/TranslationSessionStore.swift @@ -16,6 +16,8 @@ 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" @@ -81,10 +83,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 @@ -177,6 +175,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() } } @@ -1070,6 +1079,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), @@ -1114,6 +1132,11 @@ 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) @@ -1949,13 +1972,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 } @@ -1964,31 +1987,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/Tests/AirTranslateCoreTests/TranslationSessionStoreLanguageCandidateTests.swift b/Tests/AirTranslateCoreTests/TranslationSessionStoreLanguageCandidateTests.swift index 08d7206..2fc3877 100644 --- a/Tests/AirTranslateCoreTests/TranslationSessionStoreLanguageCandidateTests.swift +++ b/Tests/AirTranslateCoreTests/TranslationSessionStoreLanguageCandidateTests.swift @@ -141,6 +141,44 @@ struct TranslationSessionStoreLanguageCandidateTests { #expect(line.translatedDisplayText.hasPrefix("...")) } + @Test + func immediateFloatingCaptionPresentationBypassesDwell() { + let presentedAt = Date() + + #expect( + FloatingCaptionPresentationPolicy.canPresentUpdate( + isImmediateDisplayEnabled: true, + presentedText: "Hello", + translatedText: "", + candidateText: "Hello there", + presentedAt: presentedAt, + now: presentedAt.addingTimeInterval(0.1) + ) + ) + } + + @Test + func stableFloatingCaptionPresentationKeepsDwell() { + let presentedAt = Date() + + #expect( + !FloatingCaptionPresentationPolicy.canPresentUpdate( + isImmediateDisplayEnabled: false, + presentedText: "Hello, this subtitle should stay readable.", + translatedText: "", + candidateText: "A completely revised subtitle arrives quickly.", + presentedAt: presentedAt, + now: presentedAt.addingTimeInterval(0.5) + ) + ) + } + + @Test + func floatingCaptionTextAlignmentMapsToSwiftUIAlignment() { + #expect(FloatingCaptionTextAlignment.leading.textAlignment == .leading) + #expect(FloatingCaptionTextAlignment.center.textAlignment == .center) + } + @Test func azureRealtimeURLUsesWebSocketSessionEndpoint() { let config = OpenAIRealtimeProviderConfig.azure( From 32d0483826fe92e8031113964898bf0fff4a411e Mon Sep 17 00:00:00 2001 From: Zander Wang Date: Thu, 21 May 2026 13:58:12 +0800 Subject: [PATCH 04/11] [Improved] Unify settings UI style --- .../Services/TranslationSessionStore.swift | 12 +- .../Views/SettingsComponents.swift | 493 ++++++++++++++++++ Sources/AirTranslate/Views/SettingsView.swift | 418 +++++++++------ 3 files changed, 759 insertions(+), 164 deletions(-) create mode 100644 Sources/AirTranslate/Views/SettingsComponents.swift diff --git a/Sources/AirTranslate/Services/TranslationSessionStore.swift b/Sources/AirTranslate/Services/TranslationSessionStore.swift index 1f155ef..f650fca 100644 --- a/Sources/AirTranslate/Services/TranslationSessionStore.swift +++ b/Sources/AirTranslate/Services/TranslationSessionStore.swift @@ -537,14 +537,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 } } @@ -559,15 +562,18 @@ final class TranslationSessionStore { } } - func saveAzureOpenAIConfig(endpoint: String, apiKey: String) { + @discardableResult + func saveAzureOpenAIConfig(endpoint: String, apiKey: String) -> Bool { do { try AzureOpenAIConfigStore.saveConfig(endpoint: endpoint, apiKey: apiKey) - hasAzureOpenAIConfig = true + hasAzureOpenAIConfig = AzureOpenAIConfigStore.hasConfig() azureOpenAIEndpoint = AzureOpenAIConfigStore.readEndpoint() ?? "" statusMessage = AppText.azureOpenAIConfigSaved refreshModelAvailability() + return true } catch { statusMessage = error.localizedDescription + return false } } diff --git a/Sources/AirTranslate/Views/SettingsComponents.swift b/Sources/AirTranslate/Views/SettingsComponents.swift new file mode 100644 index 0000000..5000fa7 --- /dev/null +++ b/Sources/AirTranslate/Views/SettingsComponents.swift @@ -0,0 +1,493 @@ +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 + } + } +} + +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 1a9ee4d..091828a 100644 --- a/Sources/AirTranslate/Views/SettingsView.swift +++ b/Sources/AirTranslate/Views/SettingsView.swift @@ -5,145 +5,204 @@ struct SettingsView: View { @State private var openAIAPIKey = "" @State private var azureEndpoint = "" @State private var azureAPIKey = "" + @State private var configurationNotice: String? + @State private var shouldFocusAPIKey = false var body: some View { - Form { - Section(AppText.gptModels) { - Picker(AppText.openAIProvider, selection: $session.openAIProvider) { - ForEach(OpenAIProvider.allCases) { provider in - Text(provider.title).tag(provider) - } - } - .pickerStyle(.segmented) + ScrollView { + VStack(alignment: .leading, spacing: 12) { + openAIRealtimeSection + transcriptSection + floatingCaptionsSection + requiredAssetsSection + permissionsSection } + .padding(16) + } + .frame(width: 460) + .frame(minHeight: 560) + .background(.regularMaterial) + .onAppear { + azureEndpoint = session.azureOpenAIEndpoint + session.refreshModelAvailability() + } + .onChange(of: session.openAIProvider) { _, _ in + configurationNotice = nil + shouldFocusAPIKey = false + azureEndpoint = session.azureOpenAIEndpoint + } + } - switch session.openAIProvider { - case .openAI: - 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) - - Spacer() - - Button(AppText.saveOpenAIAPIKey) { - session.saveOpenAIAPIKey(openAIAPIKey) - openAIAPIKey = "" + private var openAIRealtimeSection: some View { + SettingsInlineGroup( + systemImage: "bolt.horizontal.circle.fill", + title: AppText.gptModels + ) { + VStack(spacing: 6) { + SettingsProviderPickerRow( + title: AppText.openAIProvider, + selection: Binding( + get: { session.openAIProvider }, + set: { provider in + session.openAIProvider = provider + configurationNotice = nil + shouldFocusAPIKey = false } - .disabled(openAIAPIKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + ) + ) - Button(AppText.removeOpenAIAPIKey) { - session.removeOpenAIAPIKey() - openAIAPIKey = "" + SettingsCompactMenuRow( + title: AppText.gptTranscriptionModel, + systemImage: "waveform.circle.fill", + value: session.openAITranscriptionModel.title + ) { + ForEach(OpenAIRealtimeTranscriptionModel.allCases) { model in + Button(model.title) { + session.openAITranscriptionModel = model } - .disabled(!session.hasOpenAIAPIKey) } - - Text(AppText.openAIAPIKeyDescription) - .font(.caption) - .foregroundStyle(.secondary) } - case .azure: - Section(AppText.openAIProviderAzureTitle) { - TextField(AppText.azureOpenAIEndpointPlaceholder, text: $azureEndpoint) - .textFieldStyle(.roundedBorder) - .textContentType(.URL) - .autocorrectionDisabled(true) - - Text(AppText.azureOpenAIEndpointFormatHint) - .font(.caption) - .foregroundStyle(.secondary) - - SecureField(AppText.azureOpenAIAPIKeyPlaceholder, text: $azureAPIKey) - .textFieldStyle(.roundedBorder) - - HStack { - Text(session.hasAzureOpenAIConfig ? AppText.azureOpenAIConfigConfigured : AppText.azureOpenAIConfigNotConfigured) - .font(.caption) - .foregroundStyle(session.hasAzureOpenAIConfig ? .green : .secondary) - - Spacer() - - Button(AppText.saveAzureOpenAIConfig) { - session.saveAzureOpenAIConfig(endpoint: azureEndpoint, apiKey: azureAPIKey) - azureAPIKey = "" - azureEndpoint = session.azureOpenAIEndpoint - } - .disabled( - azureAPIKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - || azureEndpoint.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - ) - - Button(AppText.removeAzureOpenAIConfig) { - session.removeAzureOpenAIConfig() - azureAPIKey = "" - azureEndpoint = "" + + SettingsCompactMenuRow( + title: AppText.gptTranslationModel, + systemImage: "globe", + value: session.openAITranslationModel.title + ) { + ForEach(OpenAIRealtimeTranslationModel.allCases) { model in + Button(model.title) { + session.openAITranslationModel = model } - .disabled(!session.hasAzureOpenAIConfig) } } - } - Section(AppText.transcript) { - Picker(AppText.sessionLength, selection: $session.sessionDurationMode) { - ForEach(SessionDurationMode.allCases) { mode in - Text(mode.title).tag(mode) - } + 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 + ) } - .pickerStyle(.radioGroup) - .disabled(session.isRunning) - Text(session.sessionDurationMode.detail) - .font(.caption) + Text(AppText.gptModelsDescription) + .font(.caption2) .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 8) - Stepper( - value: $session.paragraphBreakSilenceInterval, - in: 1...15, - step: 0.5 - ) { - HStack { - Text(AppText.paragraphBreakSilenceInterval) - Spacer() - Text(AppText.seconds(session.paragraphBreakSilenceInterval)) - .foregroundStyle(.secondary) - } - } + providerAccessLink + } + } + } + + private var transcriptSection: some View { + SettingsInlineGroup(systemImage: "text.alignleft", title: AppText.transcript) { + VStack(spacing: 6) { + SettingsSessionDurationRadioGroup( + selection: $session.sessionDurationMode, + isDisabled: session.isRunning + ) + + 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 + } } } + SettingsCompactToggleRow( + title: AppText.floatingImmediateDisplay, + systemImage: "bolt.fill", + isOn: $session.isFloatingCaptionImmediateDisplayEnabled + ) + Text(AppText.floatingDisplayDescription) - .font(.caption) + .font(.caption2) .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 8) + + 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) @@ -158,83 +217,120 @@ 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" + ) + } + } + + private var providerAccessLink: some View { + HStack(spacing: 5) { + switch session.openAIProvider { + case .openAI: + Text(AppText.openAIAPIKeyPlatformPrompt) + .font(.caption2) + .foregroundStyle(.secondary) - Section(AppText.permissions) { - Text(AppText.permissionsHelp) + 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() - .onAppear { + .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 + } else { + configurationNotice = session.statusMessage + shouldFocusAPIKey = true } } + + 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) - } - } - .help(availability.detail) - } + .foregroundStyle(.secondary) - 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" - } - } + Spacer(minLength: 0) + } - private var color: Color { - switch availability.state { - case .checking: - .secondary - case .installed: - .green - case .downloadRequired, .downloading: - .orange - case .unsupported, .unavailable, .failed: - .red + Picker(AppText.sessionLength, selection: $selection) { + ForEach(SessionDurationMode.allCases) { mode in + Text(mode.title).tag(mode) + } + } + .pickerStyle(.radioGroup) + .labelsHidden() + .disabled(isDisabled) + .accessibilityLabel(AppText.sessionLength) + + Text(selection.detail) + .font(.caption2) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) } + .padding(.horizontal, 8) + .padding(.vertical, 6) } } From 68325ed6d85901a343f2f91b60ad29401244436c Mon Sep 17 00:00:00 2001 From: Zander Wang Date: Thu, 21 May 2026 20:25:52 +0800 Subject: [PATCH 05/11] Support endpoint --- Sources/AirTranslate/Models/AppText.swift | 34 +++++- .../Services/OpenAIRealtimeTranscriber.swift | 60 ++++++++-- .../Services/TranslationSessionStore.swift | 50 ++++++++- .../Views/OpenAIRealtimeModelPickers.swift | 61 ++++++++++ .../Views/SettingsComponents.swift | 106 ++++++++++++++++++ Sources/AirTranslate/Views/SettingsView.swift | 52 ++------- Sources/AirTranslate/Views/SidebarView.swift | 39 +------ .../OpenAIRealtimeProviderConfigTests.swift | 2 +- ...onSessionStoreLanguageCandidateTests.swift | 2 +- 9 files changed, 311 insertions(+), 95 deletions(-) create mode 100644 Sources/AirTranslate/Views/OpenAIRealtimeModelPickers.swift diff --git a/Sources/AirTranslate/Models/AppText.swift b/Sources/AirTranslate/Models/AppText.swift index fca816b..2a94d9e 100644 --- a/Sources/AirTranslate/Models/AppText.swift +++ b/Sources/AirTranslate/Models/AppText.swift @@ -348,6 +348,36 @@ 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 customAzureDeploymentName = localized( + english: "Azure transcription deployment", + korean: "Azure 전사 배포 이름", + japanese: "Azure 文字起こしデプロイ名", + chineseSimplified: "Azure 转写部署名称" + ) + static let customModelNameFootnote = localized( + english: "Leave empty to use the default.", + korean: "기본값을 사용하려면 비워두세요.", + japanese: "デフォルトを使う場合は空のままにします。", + chineseSimplified: "留空则使用默认值。" + ) static let openAINativeOutput = localized( english: "OpenAI native output", korean: "OpenAI 본연의 출력", @@ -434,10 +464,6 @@ 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: "플로팅 텍스트 정렬", diff --git a/Sources/AirTranslate/Services/OpenAIRealtimeTranscriber.swift b/Sources/AirTranslate/Services/OpenAIRealtimeTranscriber.swift index 8eb1fee..4df40cb 100644 --- a/Sources/AirTranslate/Services/OpenAIRealtimeTranscriber.swift +++ b/Sources/AirTranslate/Services/OpenAIRealtimeTranscriber.swift @@ -12,24 +12,48 @@ struct OpenAIRealtimeProviderConfig: Sendable { let kind: Kind let host: String let apiKey: String + /// Azure-only override for the transcription deployment name. + /// `nil` falls back to `azureRealtimeTranscriptionSessionDeployment`. + let azureTranscriptionDeployment: String? static let openAIHost = "api.openai.com" static let azureRealtimeTranscriptionSessionDeployment = "gpt-realtime-1.5" static func openAI(apiKey: String) -> OpenAIRealtimeProviderConfig { - OpenAIRealtimeProviderConfig(kind: .openAI, host: openAIHost, apiKey: apiKey) + OpenAIRealtimeProviderConfig( + kind: .openAI, + host: openAIHost, + apiKey: apiKey, + azureTranscriptionDeployment: nil + ) } - static func azure(host: String, apiKey: String) -> OpenAIRealtimeProviderConfig { - OpenAIRealtimeProviderConfig(kind: .azure, host: host, apiKey: apiKey) + static func azure( + host: String, + apiKey: String, + transcriptionDeployment: String? = nil + ) -> OpenAIRealtimeProviderConfig { + OpenAIRealtimeProviderConfig( + kind: .azure, + host: host, + apiKey: apiKey, + azureTranscriptionDeployment: transcriptionDeployment + ) } - func transcriptionURL(modelID: String) -> URL? { + 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?model=\(Self.azureRealtimeTranscriptionSessionDeployment)") + let trimmed = azureTranscriptionDeployment? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let deployment = trimmed.isEmpty + ? Self.azureRealtimeTranscriptionSessionDeployment + : trimmed + let encoded = deployment + .addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? deployment + return URL(string: "wss://\(host)/openai/v1/realtime?model=\(encoded)") } } @@ -61,6 +85,10 @@ struct OpenAIRealtimeProviderConfig: Sendable { } } +private extension String { + var nonEmpty: String? { isEmpty ? nil : self } +} + final class OpenAIRealtimeTranscriber: @unchecked Sendable { static let realtimeAudioSampleRate = 24_000 private static let maxAudioChunkMilliseconds = 80 @@ -96,11 +124,15 @@ final class OpenAIRealtimeTranscriber: @unchecked Sendable { func start( language: LanguageOption, model: OpenAIRealtimeTranscriptionModel, + modelIDOverride: String? = nil, providerConfig: OpenAIRealtimeProviderConfig ) async throws { + let resolvedID = modelIDOverride? + .trimmingCharacters(in: .whitespacesAndNewlines) + .nonEmpty ?? model.rawValue try await start( language: language, - modelID: model.rawValue, + modelID: resolvedID, outputMode: .transcription, isEnabled: model.isEnabled, providerConfig: providerConfig @@ -110,11 +142,15 @@ final class OpenAIRealtimeTranscriber: @unchecked Sendable { func startRealtimeTranslationOnly( language: LanguageOption, model: OpenAIRealtimeTranslationModel, + modelIDOverride: String? = nil, providerConfig: OpenAIRealtimeProviderConfig ) async throws { + let resolvedID = modelIDOverride? + .trimmingCharacters(in: .whitespacesAndNewlines) + .nonEmpty ?? model.apiModelID try await start( language: language, - modelID: model.apiModelID, + modelID: resolvedID, outputMode: .translationOnly, isEnabled: model.usesRealtimeAudioTranslation, providerConfig: providerConfig @@ -149,7 +185,7 @@ final class OpenAIRealtimeTranscriber: @unchecked Sendable { let url: URL switch outputMode { case .transcription: - guard let transcriptionURL = providerConfig.transcriptionURL(modelID: modelID) else { + guard let transcriptionURL = providerConfig.transcriptionURL() else { Self.logger.error( "OpenAIRealtimeTranscriber.start aborted: provider \(providerConfig.kindLogDescription, privacy: .public) returned no transcription URL" ) @@ -167,7 +203,7 @@ final class OpenAIRealtimeTranscriber: @unchecked Sendable { } Self.logger.notice( - "OpenAIRealtimeTranscriber.start mode=\(String(describing: outputMode), privacy: .public) provider=\(providerConfig.kindLogDescription, privacy: .public) host=\(providerConfig.host, privacy: .public) model=\(modelID, privacy: .public) language=\(language.id, privacy: .public) url=\(url.absoluteString, privacy: .public)" + "OpenAIRealtimeTranscriber.start mode=\(String(describing: outputMode), privacy: .public) provider=\(providerConfig.kindLogDescription, privacy: .public) host=\(providerConfig.host, privacy: .private(mask: .hash)) model=\(modelID, privacy: .public) language=\(language.id, privacy: .public) url=\(url.absoluteString, privacy: .private(mask: .hash))" ) var request = URLRequest(url: url) @@ -672,7 +708,7 @@ private final class OpenAIRealtimeURLSessionDelegate: NSObject, URLSessionWebSoc didOpenWithProtocol protocol: String? ) { logger.notice( - "OpenAIRealtimeTranscriber websocket opened url=\(webSocketTask.currentRequest?.url?.absoluteString ?? "", privacy: .public) protocol=\(`protocol` ?? "", privacy: .public)" + "OpenAIRealtimeTranscriber websocket opened url=\(webSocketTask.currentRequest?.url?.absoluteString ?? "", privacy: .private(mask: .hash)) protocol=\(`protocol` ?? "", privacy: .public)" ) } @@ -696,7 +732,7 @@ private final class OpenAIRealtimeURLSessionDelegate: NSObject, URLSessionWebSoc guard let transaction = metrics.transactionMetrics.last else { return } let statusCode = (transaction.response as? HTTPURLResponse)?.statusCode ?? -1 logger.notice( - "OpenAIRealtimeTranscriber task metrics url=\(task.currentRequest?.url?.absoluteString ?? "", privacy: .public) status=\(statusCode, privacy: .public) networkProtocol=\(transaction.networkProtocolName ?? "", privacy: .public) reusedConnection=\(transaction.isReusedConnection, privacy: .public)" + "OpenAIRealtimeTranscriber task metrics url=\(task.currentRequest?.url?.absoluteString ?? "", privacy: .private(mask: .hash)) status=\(statusCode, privacy: .public) networkProtocol=\(transaction.networkProtocolName ?? "", privacy: .public) reusedConnection=\(transaction.isReusedConnection, privacy: .public)" ) } @@ -708,7 +744,7 @@ private final class OpenAIRealtimeURLSessionDelegate: NSObject, URLSessionWebSoc guard let error else { return } let statusCode = (task.response as? HTTPURLResponse)?.statusCode ?? -1 logger.error( - "OpenAIRealtimeTranscriber task completed with error status=\(statusCode, privacy: .public) url=\(task.currentRequest?.url?.absoluteString ?? "", privacy: .public) error=\(error.localizedDescription, privacy: .public) details=\(String(describing: error), privacy: .public)" + "OpenAIRealtimeTranscriber task completed with error status=\(statusCode, privacy: .public) url=\(task.currentRequest?.url?.absoluteString ?? "", privacy: .private(mask: .hash)) error=\(error.localizedDescription, privacy: .public) details=\(String(describing: error), privacy: .public)" ) } } diff --git a/Sources/AirTranslate/Services/TranslationSessionStore.swift b/Sources/AirTranslate/Services/TranslationSessionStore.swift index f650fca..a3cddc7 100644 --- a/Sources/AirTranslate/Services/TranslationSessionStore.swift +++ b/Sources/AirTranslate/Services/TranslationSessionStore.swift @@ -25,6 +25,9 @@ private enum SettingsKey { static let selectedMicrophoneInputDeviceID = "selectedMicrophoneInputDeviceID" static let isAppleSourceAutoDetectionEnabled = "isAppleSourceAutoDetectionEnabled" static let openAIProvider = "openAIProvider" + static let customAzureTranscriptionModelName = "customAzureTranscriptionModelName" + static let customAzureTranslationModelName = "customAzureTranslationModelName" + static let customAzureTranscriptionDeployment = "customAzureTranscriptionDeployment" } private struct TranslationRequest { @@ -142,6 +145,15 @@ final class TranslationSessionStore { refreshModelAvailability() } } + var customAzureTranscriptionModelName: String = "" { + didSet { persistSelectedSettings() } + } + var customAzureTranslationModelName: String = "" { + didSet { persistSelectedSettings() } + } + var customAzureTranscriptionDeployment: String = "" { + didSet { persistSelectedSettings() } + } var openAITranscriptionModel = OpenAIRealtimeTranscriptionModel.off { didSet { if openAITranscriptionModel.isEnabled { @@ -616,12 +628,32 @@ final class TranslationSessionStore { throw OpenAITranslationError.missingAzureAPIKey } Self.logger.notice( - "resolveOpenAIRealtimeProviderConfig: using Azure provider host=\(host, privacy: .public)" + "resolveOpenAIRealtimeProviderConfig: using Azure provider host=\(host, privacy: .private(mask: .hash))" + ) + let deployment = customAzureTranscriptionDeployment + .trimmingCharacters(in: .whitespacesAndNewlines) + return .azure( + host: host, + apiKey: key, + transcriptionDeployment: deployment.isEmpty ? nil : deployment ) - 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( @@ -897,6 +929,7 @@ final class TranslationSessionStore { try await openAITranscriber.startRealtimeTranslationOnly( language: targetLanguage, model: openAITranslationModel, + modelIDOverride: resolvedCustomTranslationModelName(), providerConfig: providerConfig ) } else if openAITranscriptionModel.isEnabled { @@ -904,6 +937,7 @@ final class TranslationSessionStore { try await openAITranscriber.start( language: sourceLanguage, model: openAITranscriptionModel, + modelIDOverride: resolvedCustomTranscriptionModelName(), providerConfig: providerConfig ) } else { @@ -1121,6 +1155,15 @@ final class TranslationSessionStore { 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 + } + if let value = defaults.string(forKey: SettingsKey.customAzureTranscriptionDeployment) { + customAzureTranscriptionDeployment = value + } refreshMicrophoneInputDevices() } @@ -1150,6 +1193,9 @@ final class TranslationSessionStore { 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) + defaults.set(customAzureTranscriptionDeployment, forKey: SettingsKey.customAzureTranscriptionDeployment) } private func stopCapture() async { diff --git a/Sources/AirTranslate/Views/OpenAIRealtimeModelPickers.swift b/Sources/AirTranslate/Views/OpenAIRealtimeModelPickers.swift new file mode 100644 index 0000000..b9bb68a --- /dev/null +++ b/Sources/AirTranslate/Views/OpenAIRealtimeModelPickers.swift @@ -0,0 +1,61 @@ +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) + } + ) + ) + + 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 index 5000fa7..3270ed2 100644 --- a/Sources/AirTranslate/Views/SettingsComponents.swift +++ b/Sources/AirTranslate/Views/SettingsComponents.swift @@ -470,6 +470,112 @@ struct SettingsAssetAvailabilityRow: View { } } +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 + ) + ModelNameOverrideField( + title: AppText.customAzureDeploymentName, + systemImage: "server.rack", + placeholder: OpenAIRealtimeProviderConfig + .azureRealtimeTranscriptionSessionDeployment, + text: $session.customAzureTranscriptionDeployment + ) + + Text(AppText.customModelNameFootnote) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + } +} + private struct SettingsNoticeText: View { let notice: String diff --git a/Sources/AirTranslate/Views/SettingsView.swift b/Sources/AirTranslate/Views/SettingsView.swift index 091828a..6993a2b 100644 --- a/Sources/AirTranslate/Views/SettingsView.swift +++ b/Sources/AirTranslate/Views/SettingsView.swift @@ -19,7 +19,7 @@ struct SettingsView: View { } .padding(16) } - .frame(width: 460) + .frame(minWidth: 460, idealWidth: 520, maxWidth: .infinity) .frame(minHeight: 560) .background(.regularMaterial) .onAppear { @@ -39,40 +39,9 @@ struct SettingsView: View { title: AppText.gptModels ) { VStack(spacing: 6) { - SettingsProviderPickerRow( - title: AppText.openAIProvider, - selection: Binding( - get: { session.openAIProvider }, - set: { provider in - session.openAIProvider = provider - configurationNotice = nil - shouldFocusAPIKey = false - } - ) - ) - - 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.allCases) { model in - Button(model.title) { - session.openAITranslationModel = model - } - } + OpenAIRealtimeModelPickers(session: session) { _ in + configurationNotice = nil + shouldFocusAPIKey = false } switch session.openAIProvider { @@ -97,6 +66,8 @@ struct SettingsView: View { ) } + advancedModelOverridesGroup + Text(AppText.gptModelsDescription) .font(.caption2) .foregroundStyle(.secondary) @@ -108,6 +79,11 @@ struct SettingsView: View { } } + @ViewBuilder + private var advancedModelOverridesGroup: some View { + OpenAIAdvancedOverridesView(session: session) + } + private var transcriptSection: some View { SettingsInlineGroup(systemImage: "text.alignleft", title: AppText.transcript) { VStack(spacing: 6) { @@ -185,12 +161,6 @@ struct SettingsView: View { isOn: $session.isFloatingCaptionImmediateDisplayEnabled ) - Text(AppText.floatingDisplayDescription) - .font(.caption2) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - .padding(.horizontal, 8) - Text(AppText.floatingImmediateDisplayDescription) .font(.caption2) .foregroundStyle(.secondary) diff --git a/Sources/AirTranslate/Views/SidebarView.swift b/Sources/AirTranslate/Views/SidebarView.swift index 4591ad8..5d7c8bd 100644 --- a/Sources/AirTranslate/Views/SidebarView.swift +++ b/Sources/AirTranslate/Views/SidebarView.swift @@ -454,40 +454,9 @@ private struct ConfigurationSheetView: View { title: AppText.gptModels ) { VStack(spacing: 6) { - GPTProviderPickerRow( - title: AppText.openAIProvider, - selection: Binding( - get: { session.openAIProvider }, - set: { newValue in - session.openAIProvider = newValue - configurationNotice = nil - shouldFocusOpenAIAPIKey = false - } - ) - ) - - GPTModelMenuRow( - title: AppText.gptTranscriptionModel, - systemImage: "waveform.circle.fill", - value: session.openAITranscriptionModel.title - ) { - ForEach(OpenAIRealtimeTranscriptionModel.allCases) { model in - Button(model.title) { - session.openAITranscriptionModel = model - } - } - } - - GPTModelMenuRow( - title: AppText.gptTranslationModel, - systemImage: "globe", - value: session.openAITranslationModel.title - ) { - ForEach(OpenAIRealtimeTranslationModel.allCases) { model in - Button(model.title) { - session.openAITranslationModel = model - } - } + OpenAIRealtimeModelPickers(session: session) { _ in + configurationNotice = nil + shouldFocusOpenAIAPIKey = false } switch session.openAIProvider { @@ -540,6 +509,8 @@ private struct ConfigurationSheetView: View { ) } + OpenAIAdvancedOverridesView(session: session) + Text(AppText.gptModelsDescription) .font(.caption2) .foregroundStyle(.secondary) diff --git a/Tests/AirTranslateCoreTests/OpenAIRealtimeProviderConfigTests.swift b/Tests/AirTranslateCoreTests/OpenAIRealtimeProviderConfigTests.swift index 00d219f..ae05144 100644 --- a/Tests/AirTranslateCoreTests/OpenAIRealtimeProviderConfigTests.swift +++ b/Tests/AirTranslateCoreTests/OpenAIRealtimeProviderConfigTests.swift @@ -34,7 +34,7 @@ struct OpenAIRealtimeProviderConfigTests { let config = OpenAIRealtimeProviderConfig.openAI(apiKey: "test-key") #expect( - config.transcriptionURL(modelID: "gpt-realtime-whisper")?.absoluteString + config.transcriptionURL()?.absoluteString == "wss://api.openai.com/v1/realtime?intent=transcription" ) #expect( diff --git a/Tests/AirTranslateCoreTests/TranslationSessionStoreLanguageCandidateTests.swift b/Tests/AirTranslateCoreTests/TranslationSessionStoreLanguageCandidateTests.swift index 2fc3877..3ed096c 100644 --- a/Tests/AirTranslateCoreTests/TranslationSessionStoreLanguageCandidateTests.swift +++ b/Tests/AirTranslateCoreTests/TranslationSessionStoreLanguageCandidateTests.swift @@ -187,7 +187,7 @@ struct TranslationSessionStoreLanguageCandidateTests { ) #expect( - config.transcriptionURL(modelID: "gpt-realtime-whisper")?.absoluteString + config.transcriptionURL()?.absoluteString == "wss://example.openai.azure.com/openai/v1/realtime?model=gpt-realtime-1.5" ) #expect( From e30619c10462b390af5ab8e3d6264202fe368b8f Mon Sep 17 00:00:00 2001 From: Zander Wang Date: Thu, 21 May 2026 20:39:01 +0800 Subject: [PATCH 06/11] lint code --- .../Services/OpenAIRealtimeTranscriber.swift | 29 +++++++++------- .../Services/TranslationSessionStore.swift | 34 ++++++++----------- Sources/AirTranslate/Views/SidebarView.swift | 33 ------------------ 3 files changed, 31 insertions(+), 65 deletions(-) diff --git a/Sources/AirTranslate/Services/OpenAIRealtimeTranscriber.swift b/Sources/AirTranslate/Services/OpenAIRealtimeTranscriber.swift index 4df40cb..a09b898 100644 --- a/Sources/AirTranslate/Services/OpenAIRealtimeTranscriber.swift +++ b/Sources/AirTranslate/Services/OpenAIRealtimeTranscriber.swift @@ -127,9 +127,13 @@ final class OpenAIRealtimeTranscriber: @unchecked Sendable { modelIDOverride: String? = nil, providerConfig: OpenAIRealtimeProviderConfig ) async throws { - let resolvedID = modelIDOverride? + let trimmedOverride = modelIDOverride? .trimmingCharacters(in: .whitespacesAndNewlines) - .nonEmpty ?? model.rawValue + .nonEmpty + let resolvedID = trimmedOverride ?? model.rawValue + Self.logger.notice( + "OpenAIRealtimeTranscriber.start(transcription) defaultModel=\(model.rawValue, privacy: .public) override=\(trimmedOverride ?? "", privacy: .public) resolvedModelID=\(resolvedID, privacy: .public)" + ) try await start( language: language, modelID: resolvedID, @@ -145,9 +149,13 @@ final class OpenAIRealtimeTranscriber: @unchecked Sendable { modelIDOverride: String? = nil, providerConfig: OpenAIRealtimeProviderConfig ) async throws { - let resolvedID = modelIDOverride? + let trimmedOverride = modelIDOverride? .trimmingCharacters(in: .whitespacesAndNewlines) - .nonEmpty ?? model.apiModelID + .nonEmpty + let resolvedID = trimmedOverride ?? model.apiModelID + Self.logger.notice( + "OpenAIRealtimeTranscriber.start(translationOnly) defaultModel=\(model.apiModelID, privacy: .public) override=\(trimmedOverride ?? "", privacy: .public) resolvedModelID=\(resolvedID, privacy: .public)" + ) try await start( language: language, modelID: resolvedID, @@ -203,7 +211,7 @@ final class OpenAIRealtimeTranscriber: @unchecked Sendable { } Self.logger.notice( - "OpenAIRealtimeTranscriber.start mode=\(String(describing: outputMode), privacy: .public) provider=\(providerConfig.kindLogDescription, privacy: .public) host=\(providerConfig.host, privacy: .private(mask: .hash)) model=\(modelID, privacy: .public) language=\(language.id, privacy: .public) url=\(url.absoluteString, privacy: .private(mask: .hash))" + "OpenAIRealtimeTranscriber.start mode=\(String(describing: outputMode), privacy: .public) provider=\(providerConfig.kindLogDescription, privacy: .public) host=\(providerConfig.host, privacy: .public) model=\(modelID, privacy: .public) language=\(language.id, privacy: .public) url=\(url.absoluteString, privacy: .public)" ) var request = URLRequest(url: url) @@ -288,8 +296,8 @@ final class OpenAIRealtimeTranscriber: @unchecked Sendable { outputMode: outputMode, providerKind: providerKind ) - Self.logger.debug( - "OpenAIRealtimeTranscriber sending session.update mode=\(String(describing: self.outputMode), privacy: .public) bytes=\(text.utf8.count, privacy: .public)" + Self.logger.notice( + "OpenAIRealtimeTranscriber session.update mode=\(String(describing: self.outputMode), privacy: .public) modelID=\(modelID, privacy: .public) payload=\(text, privacy: .public)" ) do { try await send(text) @@ -312,14 +320,11 @@ final class OpenAIRealtimeTranscriber: @unchecked Sendable { switch outputMode { case .transcription: let sessionType: String - let transcriptionModelID: String switch providerKind { case .openAI: sessionType = "transcription" - transcriptionModelID = modelID case .azure: sessionType = "realtime" - transcriptionModelID = OpenAIRealtimeTranscriptionModel.gptRealtimeWhisper.rawValue } let event = OpenAIRealtimeTranscriptionSessionUpdateEvent( @@ -329,7 +334,7 @@ final class OpenAIRealtimeTranscriber: @unchecked Sendable { input: OpenAIRealtimeTranscriptionAudioInput( format: OpenAIRealtimeAudioFormat(type: "audio/pcm", rate: Self.realtimeAudioSampleRate), transcription: OpenAIRealtimeTranscriptionConfig( - model: transcriptionModelID, + model: modelID, language: language.openAILanguageCode ), turnDetection: .lowLatencyServerVAD, @@ -347,7 +352,7 @@ final class OpenAIRealtimeTranscriber: @unchecked Sendable { case .azure: input = OpenAIRealtimeTranslationAudioInput( transcription: OpenAIRealtimeTranscriptionConfig( - model: OpenAIRealtimeTranscriptionModel.gptRealtimeWhisper.rawValue + model: modelID ), noiseReduction: OpenAIRealtimeNoiseReduction(type: "near_field") ) diff --git a/Sources/AirTranslate/Services/TranslationSessionStore.swift b/Sources/AirTranslate/Services/TranslationSessionStore.swift index a3cddc7..fdac5a3 100644 --- a/Sources/AirTranslate/Services/TranslationSessionStore.swift +++ b/Sources/AirTranslate/Services/TranslationSessionStore.swift @@ -3,7 +3,6 @@ import AppKit import AirTranslateCore import Foundation import Observation -import os private enum SettingsKey { static let sourceLanguageID = "sourceLanguageID" @@ -89,10 +88,6 @@ final class TranslationSessionStore { private static let appleAutoDetectionMinimumConfidence = 0.35 private static let appleAutoDetectionLanguageSwitchMinimumConfidence = 0.72 private static let isAppleSourceAutoDetectionTemporarilyDisabled = true - private static let logger = Logger( - subsystem: "dev.appcaster.AirTranslate", - category: "Session" - ) var isRunning = false var isPaused = false @@ -422,9 +417,6 @@ final class TranslationSessionStore { warmTranslationSession() } catch { guard !Task.isCancelled else { return } - Self.logger.error( - "session start failed: \(error.localizedDescription, privacy: .public) details=\(String(describing: error), privacy: .public)" - ) isRunning = false stopCaptioners() await stopCapture() @@ -612,24 +604,17 @@ final class TranslationSessionStore { switch openAIProvider { case .openAI: guard let key = try OpenAIAPIKeyStore.readAPIKey(), !key.isEmpty else { - Self.logger.error("resolveOpenAIRealtimeProviderConfig: missing OpenAI API key") throw OpenAITranslationError.missingAPIKey } - Self.logger.notice("resolveOpenAIRealtimeProviderConfig: using OpenAI provider") return .openAI(apiKey: key) case .azure: guard let endpoint = AzureOpenAIConfigStore.readEndpoint(), let host = AzureOpenAIEndpoint.host(from: endpoint) else { - Self.logger.error("resolveOpenAIRealtimeProviderConfig: missing Azure endpoint") throw OpenAITranslationError.missingAzureEndpoint } guard let key = try AzureOpenAIConfigStore.readAPIKey(), !key.isEmpty else { - Self.logger.error("resolveOpenAIRealtimeProviderConfig: missing Azure API key") throw OpenAITranslationError.missingAzureAPIKey } - Self.logger.notice( - "resolveOpenAIRealtimeProviderConfig: using Azure provider host=\(host, privacy: .private(mask: .hash))" - ) let deployment = customAzureTranscriptionDeployment .trimmingCharacters(in: .whitespacesAndNewlines) return .azure( @@ -920,8 +905,18 @@ final class TranslationSessionStore { openAITranscriber = OpenAIRealtimeTranscriber() openAITranscriber.delegate = self - Self.logger.notice( - "startCaptioners provider=\(self.openAIProvider.rawValue, privacy: .public) transcriptionModel=\(self.openAITranscriptionModel.rawValue, privacy: .public) translationModel=\(self.openAITranslationModel.rawValue, privacy: .public) source=\(self.sourceLanguage.id, privacy: .public) target=\(self.targetLanguage.id, privacy: .public)" + let customTranscription = resolvedCustomTranscriptionModelName() + let customTranslation = resolvedCustomTranslationModelName() + let customAzureDeployment = customAzureTranscriptionDeployment + .trimmingCharacters(in: .whitespacesAndNewlines) + NSLog( + "[AirTranslate] startCaptioners provider=%@ transcriptionModel=%@ translationModel=%@ overrideTranscription=%@ overrideTranslation=%@ azureDeployment=%@", + self.openAIProvider.rawValue, + self.openAITranscriptionModel.rawValue, + self.openAITranslationModel.rawValue, + customTranscription ?? "", + customTranslation ?? "", + customAzureDeployment.isEmpty ? "" : customAzureDeployment ) if openAITranslationModel.usesRealtimeAudioTranslation { @@ -929,7 +924,7 @@ final class TranslationSessionStore { try await openAITranscriber.startRealtimeTranslationOnly( language: targetLanguage, model: openAITranslationModel, - modelIDOverride: resolvedCustomTranslationModelName(), + modelIDOverride: customTranslation, providerConfig: providerConfig ) } else if openAITranscriptionModel.isEnabled { @@ -937,11 +932,10 @@ final class TranslationSessionStore { try await openAITranscriber.start( language: sourceLanguage, model: openAITranscriptionModel, - modelIDOverride: resolvedCustomTranscriptionModelName(), + modelIDOverride: customTranscription, providerConfig: providerConfig ) } else { - Self.logger.notice("startCaptioners using Apple SpeechTranscriber path") try await transcriber.start(languages: await appleSpeechLanguagesForCurrentMode()) } } diff --git a/Sources/AirTranslate/Views/SidebarView.swift b/Sources/AirTranslate/Views/SidebarView.swift index 5d7c8bd..603faea 100644 --- a/Sources/AirTranslate/Views/SidebarView.swift +++ b/Sources/AirTranslate/Views/SidebarView.swift @@ -1048,39 +1048,6 @@ private struct GPTAPIKeyRow: View { } } -private struct GPTProviderPickerRow: 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)) - } -} - private struct GPTAzureConfigRow: View { @Binding var endpoint: String @Binding var apiKey: String From 516bca08be6144c49e19f9167780d4388836c909 Mon Sep 17 00:00:00 2001 From: Zander Wang Date: Thu, 21 May 2026 20:58:51 +0800 Subject: [PATCH 07/11] lint code --- .../Services/OpenAIRealtimeTranscriber.swift | 133 +--------------- .../Services/OpenAITranslationService.swift | 36 +---- .../Services/TranslationSessionStore.swift | 11 -- .../OpenAIRealtimeProviderConfigTests.swift | 127 --------------- .../OpenAITranslationServiceTests.swift | 149 ------------------ 5 files changed, 6 insertions(+), 450 deletions(-) delete mode 100644 Tests/AirTranslateCoreTests/OpenAIRealtimeProviderConfigTests.swift delete mode 100644 Tests/AirTranslateCoreTests/OpenAITranslationServiceTests.swift diff --git a/Sources/AirTranslate/Services/OpenAIRealtimeTranscriber.swift b/Sources/AirTranslate/Services/OpenAIRealtimeTranscriber.swift index a09b898..e991012 100644 --- a/Sources/AirTranslate/Services/OpenAIRealtimeTranscriber.swift +++ b/Sources/AirTranslate/Services/OpenAIRealtimeTranscriber.swift @@ -1,7 +1,6 @@ import AVFoundation import CoreMedia import Foundation -import os struct OpenAIRealtimeProviderConfig: Sendable { enum Kind: Sendable { @@ -76,13 +75,6 @@ struct OpenAIRealtimeProviderConfig: Sendable { request.setValue(apiKey, forHTTPHeaderField: "api-key") } } - - var kindLogDescription: String { - switch kind { - case .openAI: "openAI" - case .azure: "azure" - } - } } private extension String { @@ -98,11 +90,6 @@ final class OpenAIRealtimeTranscriber: @unchecked Sendable { * maxAudioChunkMilliseconds / 1_000 - private static let logger = Logger( - subsystem: "dev.appcaster.AirTranslate", - category: "OpenAIRealtime" - ) - enum OutputMode { case transcription case translationOnly @@ -112,8 +99,6 @@ final class OpenAIRealtimeTranscriber: @unchecked Sendable { private let stateLock = NSLock() private let conversionLock = NSLock() - private let urlSessionDelegate = OpenAIRealtimeURLSessionDelegate() - private var urlSession: URLSession? private var webSocketTask: URLSessionWebSocketTask? private var receiveTask: Task? private var language = LanguageOption.supported[0] @@ -131,9 +116,6 @@ final class OpenAIRealtimeTranscriber: @unchecked Sendable { .trimmingCharacters(in: .whitespacesAndNewlines) .nonEmpty let resolvedID = trimmedOverride ?? model.rawValue - Self.logger.notice( - "OpenAIRealtimeTranscriber.start(transcription) defaultModel=\(model.rawValue, privacy: .public) override=\(trimmedOverride ?? "", privacy: .public) resolvedModelID=\(resolvedID, privacy: .public)" - ) try await start( language: language, modelID: resolvedID, @@ -153,9 +135,6 @@ final class OpenAIRealtimeTranscriber: @unchecked Sendable { .trimmingCharacters(in: .whitespacesAndNewlines) .nonEmpty let resolvedID = trimmedOverride ?? model.apiModelID - Self.logger.notice( - "OpenAIRealtimeTranscriber.start(translationOnly) defaultModel=\(model.apiModelID, privacy: .public) override=\(trimmedOverride ?? "", privacy: .public) resolvedModelID=\(resolvedID, privacy: .public)" - ) try await start( language: language, modelID: resolvedID, @@ -175,15 +154,9 @@ final class OpenAIRealtimeTranscriber: @unchecked Sendable { stop() guard isEnabled else { - Self.logger.notice( - "OpenAIRealtimeTranscriber.start skipped (disabled). mode=\(String(describing: outputMode), privacy: .public) model=\(modelID, privacy: .public)" - ) return } guard !providerConfig.apiKey.isEmpty else { - Self.logger.error( - "OpenAIRealtimeTranscriber.start aborted: missing api key. provider=\(providerConfig.kindLogDescription, privacy: .public)" - ) throw OpenAITranslationError.missingAPIKey } @@ -194,32 +167,20 @@ final class OpenAIRealtimeTranscriber: @unchecked Sendable { switch outputMode { case .transcription: guard let transcriptionURL = providerConfig.transcriptionURL() else { - Self.logger.error( - "OpenAIRealtimeTranscriber.start aborted: provider \(providerConfig.kindLogDescription, privacy: .public) returned no transcription URL" - ) throw OpenAITranslationError.transcriptionEndpointUnsupported } url = transcriptionURL case .translationOnly: guard let translationURL = providerConfig.translationURL(modelID: modelID) else { - Self.logger.error( - "OpenAIRealtimeTranscriber.start aborted: provider \(providerConfig.kindLogDescription, privacy: .public) returned no translation URL for model=\(modelID, privacy: .public)" - ) throw OpenAITranslationError.invalidResponse } url = translationURL } - Self.logger.notice( - "OpenAIRealtimeTranscriber.start mode=\(String(describing: outputMode), privacy: .public) provider=\(providerConfig.kindLogDescription, privacy: .public) host=\(providerConfig.host, privacy: .public) model=\(modelID, privacy: .public) language=\(language.id, privacy: .public) url=\(url.absoluteString, privacy: .public)" - ) - var request = URLRequest(url: url) providerConfig.apply(to: &request) - let urlSession = URLSession(configuration: .default, delegate: urlSessionDelegate, delegateQueue: nil) - self.urlSession = urlSession - let webSocketTask = urlSession.webSocketTask(with: request) + let webSocketTask = URLSession.shared.webSocketTask(with: request) self.webSocketTask = webSocketTask webSocketTask.resume() @@ -256,9 +217,6 @@ final class OpenAIRealtimeTranscriber: @unchecked Sendable { webSocketTask.send(.string(text)) { [weak self] error in guard let error, let self else { return } - Self.logger.error( - "OpenAIRealtimeTranscriber audio append failed: \(error.localizedDescription, privacy: .public)" - ) self.delegate?.liveSpeechTranscriber(self.proxyTranscriber, didFail: error) } } @@ -271,18 +229,12 @@ final class OpenAIRealtimeTranscriber: @unchecked Sendable { } func stop() { - let hadTask = webSocketTask != nil setPaused(false) receiveTask?.cancel() receiveTask = nil webSocketTask?.cancel(with: .goingAway, reason: nil) webSocketTask = nil - urlSession?.invalidateAndCancel() - urlSession = nil realtimeTranscriptText = "" - if hadTask { - Self.logger.notice("OpenAIRealtimeTranscriber.stop closed websocket") - } } private func sendSessionUpdate( @@ -296,18 +248,7 @@ final class OpenAIRealtimeTranscriber: @unchecked Sendable { outputMode: outputMode, providerKind: providerKind ) - Self.logger.notice( - "OpenAIRealtimeTranscriber session.update mode=\(String(describing: self.outputMode), privacy: .public) modelID=\(modelID, privacy: .public) payload=\(text, privacy: .public)" - ) - do { - try await send(text) - Self.logger.notice("OpenAIRealtimeTranscriber session.update accepted by socket") - } catch { - Self.logger.error( - "OpenAIRealtimeTranscriber session.update failed: \(error.localizedDescription, privacy: .public)" - ) - throw error - } + try await send(text) } static func sessionUpdatePayload( @@ -399,9 +340,6 @@ final class OpenAIRealtimeTranscriber: @unchecked Sendable { handleEventText(text) } catch { guard !Task.isCancelled else { return } - Self.logger.error( - "OpenAIRealtimeTranscriber receive loop failed: \(error.localizedDescription, privacy: .public)" - ) delegate?.liveSpeechTranscriber(proxyTranscriber, didFail: error) return } @@ -412,23 +350,9 @@ final class OpenAIRealtimeTranscriber: @unchecked Sendable { guard let data = text.data(using: .utf8), let event = try? JSONDecoder().decode(OpenAIRealtimeTranscriptionEvent.self, from: data) else { - Self.logger.debug( - "OpenAIRealtimeTranscriber received undecodable event (bytes=\(text.utf8.count, privacy: .public))" - ) return } - switch event.type { - case "session.created", "session.updated": - Self.logger.notice("OpenAIRealtimeTranscriber event \(event.type, privacy: .public)") - case "error": - Self.logger.error( - "OpenAIRealtimeTranscriber server error: \(event.error?.message ?? "", privacy: .public) raw=\(text, privacy: .public)" - ) - default: - Self.logger.debug("OpenAIRealtimeTranscriber event \(event.type, privacy: .public)") - } - switch event.type { case "conversation.item.input_audio_transcription.delta": guard let delta = event.delta, !delta.isEmpty else { return } @@ -701,59 +625,6 @@ private struct OpenAIRealtimeErrorBody: Decodable { let message: String? } -private final class OpenAIRealtimeURLSessionDelegate: NSObject, URLSessionWebSocketDelegate, URLSessionTaskDelegate, @unchecked Sendable { - private let logger = Logger( - subsystem: "dev.appcaster.AirTranslate", - category: "OpenAIRealtime" - ) - - func urlSession( - _ session: URLSession, - webSocketTask: URLSessionWebSocketTask, - didOpenWithProtocol protocol: String? - ) { - logger.notice( - "OpenAIRealtimeTranscriber websocket opened url=\(webSocketTask.currentRequest?.url?.absoluteString ?? "", privacy: .private(mask: .hash)) protocol=\(`protocol` ?? "", privacy: .public)" - ) - } - - func urlSession( - _ session: URLSession, - webSocketTask: URLSessionWebSocketTask, - didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, - reason: Data? - ) { - let reasonText = reason.flatMap { String(data: $0, encoding: .utf8) } ?? "" - logger.notice( - "OpenAIRealtimeTranscriber websocket closed code=\(closeCode.rawValue, privacy: .public) reason=\(reasonText, privacy: .public)" - ) - } - - func urlSession( - _ session: URLSession, - task: URLSessionTask, - didFinishCollecting metrics: URLSessionTaskMetrics - ) { - guard let transaction = metrics.transactionMetrics.last else { return } - let statusCode = (transaction.response as? HTTPURLResponse)?.statusCode ?? -1 - logger.notice( - "OpenAIRealtimeTranscriber task metrics url=\(task.currentRequest?.url?.absoluteString ?? "", privacy: .private(mask: .hash)) status=\(statusCode, privacy: .public) networkProtocol=\(transaction.networkProtocolName ?? "", privacy: .public) reusedConnection=\(transaction.isReusedConnection, privacy: .public)" - ) - } - - func urlSession( - _ session: URLSession, - task: URLSessionTask, - didCompleteWithError error: Error? - ) { - guard let error else { return } - let statusCode = (task.response as? HTTPURLResponse)?.statusCode ?? -1 - logger.error( - "OpenAIRealtimeTranscriber task completed with error status=\(statusCode, privacy: .public) url=\(task.currentRequest?.url?.absoluteString ?? "", privacy: .private(mask: .hash)) error=\(error.localizedDescription, privacy: .public) details=\(String(describing: error), privacy: .public)" - ) - } -} - private enum OpenAIRealtimeTranscriberError: LocalizedError { case server(String?) diff --git a/Sources/AirTranslate/Services/OpenAITranslationService.swift b/Sources/AirTranslate/Services/OpenAITranslationService.swift index be539e4..9a20495 100644 --- a/Sources/AirTranslate/Services/OpenAITranslationService.swift +++ b/Sources/AirTranslate/Services/OpenAITranslationService.swift @@ -1,35 +1,7 @@ import Foundation -protocol OpenAITranslationAPIKeyProviding: Sendable { - func readAPIKey() throws -> String? -} - -struct KeychainOpenAITranslationAPIKeyProvider: OpenAITranslationAPIKeyProviding { - func readAPIKey() throws -> String? { - try OpenAIAPIKeyStore.readAPIKey() - } -} - -protocol OpenAITranslationHTTPClient: Sendable { - func data(for request: URLRequest) async throws -> (Data, URLResponse) -} - -extension URLSession: OpenAITranslationHTTPClient {} - actor OpenAITranslationService { - private let endpoint: URL - private let apiKeyProvider: OpenAITranslationAPIKeyProviding - private let httpClient: OpenAITranslationHTTPClient - - init( - endpoint: URL = URL(string: "https://api.openai.com/v1/responses")!, - apiKeyProvider: OpenAITranslationAPIKeyProviding = KeychainOpenAITranslationAPIKeyProvider(), - httpClient: OpenAITranslationHTTPClient = URLSession.shared - ) { - self.endpoint = endpoint - self.apiKeyProvider = apiKeyProvider - self.httpClient = httpClient - } + private let endpoint = URL(string: "https://api.openai.com/v1/responses")! func translate( _ text: String, @@ -39,7 +11,7 @@ actor OpenAITranslationService { ) async throws -> String { guard !text.isEmpty else { return text } guard selectedModel.isEnabled else { return text } - guard let apiKey = try apiKeyProvider.readAPIKey(), !apiKey.isEmpty else { + guard let apiKey = try OpenAIAPIKeyStore.readAPIKey(), !apiKey.isEmpty else { throw OpenAITranslationError.missingAPIKey } @@ -59,7 +31,7 @@ actor OpenAITranslationService { ) ) - let (data, response) = try await httpClient.data(for: request) + let (data, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw OpenAITranslationError.invalidResponse } @@ -123,7 +95,7 @@ private struct OpenAIErrorBody: Decodable { let message: String } -enum OpenAITranslationError: LocalizedError, Equatable { +enum OpenAITranslationError: LocalizedError { case missingAPIKey case missingAzureEndpoint case missingAzureAPIKey diff --git a/Sources/AirTranslate/Services/TranslationSessionStore.swift b/Sources/AirTranslate/Services/TranslationSessionStore.swift index fdac5a3..d04cd08 100644 --- a/Sources/AirTranslate/Services/TranslationSessionStore.swift +++ b/Sources/AirTranslate/Services/TranslationSessionStore.swift @@ -907,17 +907,6 @@ final class TranslationSessionStore { let customTranscription = resolvedCustomTranscriptionModelName() let customTranslation = resolvedCustomTranslationModelName() - let customAzureDeployment = customAzureTranscriptionDeployment - .trimmingCharacters(in: .whitespacesAndNewlines) - NSLog( - "[AirTranslate] startCaptioners provider=%@ transcriptionModel=%@ translationModel=%@ overrideTranscription=%@ overrideTranslation=%@ azureDeployment=%@", - self.openAIProvider.rawValue, - self.openAITranscriptionModel.rawValue, - self.openAITranslationModel.rawValue, - customTranscription ?? "", - customTranslation ?? "", - customAzureDeployment.isEmpty ? "" : customAzureDeployment - ) if openAITranslationModel.usesRealtimeAudioTranslation { let providerConfig = try resolveOpenAIRealtimeProviderConfig() diff --git a/Tests/AirTranslateCoreTests/OpenAIRealtimeProviderConfigTests.swift b/Tests/AirTranslateCoreTests/OpenAIRealtimeProviderConfigTests.swift deleted file mode 100644 index ae05144..0000000 --- a/Tests/AirTranslateCoreTests/OpenAIRealtimeProviderConfigTests.swift +++ /dev/null @@ -1,127 +0,0 @@ -import Foundation -import Testing -@testable import AirTranslate - -@Suite -struct OpenAIRealtimeProviderConfigTests { - @Test - func openAIProviderUsesBearerAuthorizationOnly() { - let config = OpenAIRealtimeProviderConfig.openAI(apiKey: "openai-test-key") - var request = URLRequest(url: URL(string: "wss://api.openai.com/v1/realtime")!) - - config.apply(to: &request) - - #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer openai-test-key") - #expect(request.value(forHTTPHeaderField: "api-key") == nil) - } - - @Test - func azureProviderUsesAPIKeyHeaderOnly() { - let config = OpenAIRealtimeProviderConfig.azure( - host: "example.openai.azure.com", - apiKey: "azure-test-key" - ) - var request = URLRequest(url: URL(string: "wss://example.openai.azure.com/openai/v1/realtime")!) - - config.apply(to: &request) - - #expect(request.value(forHTTPHeaderField: "api-key") == "azure-test-key") - #expect(request.value(forHTTPHeaderField: "Authorization") == nil) - } - - @Test - func openAIRealtimeURLsUsePublicRealtimeEndpoints() { - let config = OpenAIRealtimeProviderConfig.openAI(apiKey: "test-key") - - #expect( - config.transcriptionURL()?.absoluteString - == "wss://api.openai.com/v1/realtime?intent=transcription" - ) - #expect( - config.translationURL(modelID: "gpt-realtime-translate")?.absoluteString - == "wss://api.openai.com/v1/realtime/translations?model=gpt-realtime-translate" - ) - } - - @Test - func azureEndpointNormalizationKeepsOnlySchemeHostAndPort() { - let endpoint = " https://example.openai.azure.com:444/openai/deployments/demo?api-version=preview " - - #expect(AzureOpenAIEndpoint.normalize(endpoint) == "https://example.openai.azure.com:444") - #expect(AzureOpenAIEndpoint.host(from: endpoint) == "example.openai.azure.com") - } - - @Test - func bareAzureEndpointDefaultsToHTTPS() { - #expect( - AzureOpenAIEndpoint.normalize("example.openai.azure.com") - == "https://example.openai.azure.com" - ) - #expect(AzureOpenAIEndpoint.host(from: "example.openai.azure.com") == "example.openai.azure.com") - } - - @Test - func azureTranscriptionSessionAlwaysUsesWhisperInputModel() throws { - let payload = try OpenAIRealtimeTranscriber.sessionUpdatePayload( - language: LanguageOption.english, - modelID: "unexpected-session-model", - outputMode: .transcription, - providerKind: .azure - ) - let json = try #require(try JSONSerialization.jsonObject(with: Data(payload.utf8)) as? [String: Any]) - let session = try #require(json["session"] as? [String: Any]) - let audio = try #require(session["audio"] as? [String: Any]) - let input = try #require(audio["input"] as? [String: Any]) - let format = try #require(input["format"] as? [String: Any]) - let transcription = try #require(input["transcription"] as? [String: Any]) - - #expect(json["type"] as? String == "session.update") - #expect(session["type"] as? String == "realtime") - #expect(format["type"] as? String == "audio/pcm") - #expect(format["rate"] as? Int == 24_000) - #expect(transcription["model"] as? String == "gpt-realtime-whisper") - #expect(transcription["language"] as? String == "en") - } - - @Test - func openAITranscriptionSessionUsesSelectedWhisperModel() throws { - let payload = try OpenAIRealtimeTranscriber.sessionUpdatePayload( - language: LanguageOption.korean, - modelID: "gpt-realtime-whisper", - outputMode: .transcription, - providerKind: .openAI - ) - let json = try #require(try JSONSerialization.jsonObject(with: Data(payload.utf8)) as? [String: Any]) - let session = try #require(json["session"] as? [String: Any]) - let audio = try #require(session["audio"] as? [String: Any]) - let input = try #require(audio["input"] as? [String: Any]) - let transcription = try #require(input["transcription"] as? [String: Any]) - let noiseReduction = try #require(input["noise_reduction"] as? [String: Any]) - - #expect(json["type"] as? String == "session.update") - #expect(session["type"] as? String == "transcription") - #expect(transcription["model"] as? String == "gpt-realtime-whisper") - #expect(transcription["language"] as? String == "ko") - #expect(noiseReduction["type"] as? String == "near_field") - } - - @Test - func realtimeTranslationSessionTargetsOutputLanguage() throws { - let payload = try OpenAIRealtimeTranscriber.sessionUpdatePayload( - language: LanguageOption.supported[3], - modelID: "gpt-realtime-translate", - outputMode: .translationOnly, - providerKind: .azure - ) - let json = try #require(try JSONSerialization.jsonObject(with: Data(payload.utf8)) as? [String: Any]) - let session = try #require(json["session"] as? [String: Any]) - let audio = try #require(session["audio"] as? [String: Any]) - let input = try #require(audio["input"] as? [String: Any]) - let transcription = try #require(input["transcription"] as? [String: Any]) - let output = try #require(audio["output"] as? [String: Any]) - - #expect(json["type"] as? String == "session.update") - #expect(transcription["model"] as? String == "gpt-realtime-whisper") - #expect(output["language"] as? String == "zh") - } -} diff --git a/Tests/AirTranslateCoreTests/OpenAITranslationServiceTests.swift b/Tests/AirTranslateCoreTests/OpenAITranslationServiceTests.swift deleted file mode 100644 index 62309f0..0000000 --- a/Tests/AirTranslateCoreTests/OpenAITranslationServiceTests.swift +++ /dev/null @@ -1,149 +0,0 @@ -import Foundation -import Testing -@testable import AirTranslate - -@Suite -struct OpenAITranslationServiceTests { - @Test - func translatePostsResponsesRequestWithRealtimeTranslateModel() async throws { - let httpClient = CapturingOpenAITranslationHTTPClient( - data: Data(#"{"output_text":" Translated hello "}"#.utf8), - statusCode: 200 - ) - let service = OpenAITranslationService( - apiKeyProvider: StaticOpenAIAPIKeyProvider(apiKey: "test-api-key"), - httpClient: httpClient - ) - - let translation = try await service.translate( - "Hello", - source: .english, - target: .korean, - model: .gptRealtimeTranslate - ) - let request = try #require(await httpClient.lastRequest) - let body = try #require(request.httpBody) - let json = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any]) - - #expect(translation == "Translated hello") - #expect(request.url?.absoluteString == "https://api.openai.com/v1/responses") - #expect(request.httpMethod == "POST") - #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer test-api-key") - #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json") - #expect(json["model"] as? String == "gpt-realtime-translate") - #expect(json["input"] as? String == "Hello") - #expect(json["store"] as? Bool == false) - #expect((json["instructions"] as? String)?.contains("English") == true) - #expect((json["instructions"] as? String)?.contains("Korean") == true) - } - - @Test - func translateReadsFirstNestedOutputTextWhenOutputTextIsMissing() async throws { - let response = Data( - #"{"output":[{"content":[{"text":"Bonjour"}]}]}"#.utf8 - ) - let service = OpenAITranslationService( - apiKeyProvider: StaticOpenAIAPIKeyProvider(apiKey: "test-api-key"), - httpClient: CapturingOpenAITranslationHTTPClient(data: response, statusCode: 200) - ) - - let translation = try await service.translate( - "Hello", - source: .english, - target: LanguageOption.supported[5], - model: .gptRealtimeTranslate - ) - - #expect(translation == "Bonjour") - } - - @Test - func translateThrowsRequestFailedWithServerMessage() async throws { - let response = Data(#"{"error":{"message":"bad deployment"}}"#.utf8) - let service = OpenAITranslationService( - apiKeyProvider: StaticOpenAIAPIKeyProvider(apiKey: "test-api-key"), - httpClient: CapturingOpenAITranslationHTTPClient(data: response, statusCode: 404) - ) - - await #expect(throws: OpenAITranslationError.requestFailed(statusCode: 404, message: "bad deployment")) { - _ = try await service.translate( - "Hello", - source: .english, - target: .korean, - model: .gptRealtimeTranslate - ) - } - } - - @Test - func translateDoesNotCallNetworkWhenModelIsOff() async throws { - let httpClient = CapturingOpenAITranslationHTTPClient( - data: Data(#"{"output_text":"ignored"}"#.utf8), - statusCode: 200 - ) - let service = OpenAITranslationService( - apiKeyProvider: StaticOpenAIAPIKeyProvider(apiKey: nil), - httpClient: httpClient - ) - - let translation = try await service.translate( - "Hello", - source: .english, - target: .korean, - model: .off - ) - - #expect(translation == "Hello") - #expect(await httpClient.lastRequest == nil) - } - - @Test - func translateRequiresAPIKeyOnlyForEnabledModel() async throws { - let service = OpenAITranslationService( - apiKeyProvider: StaticOpenAIAPIKeyProvider(apiKey: nil), - httpClient: CapturingOpenAITranslationHTTPClient( - data: Data(#"{"output_text":"ignored"}"#.utf8), - statusCode: 200 - ) - ) - - await #expect(throws: OpenAITranslationError.missingAPIKey) { - _ = try await service.translate( - "Hello", - source: .english, - target: .korean, - model: .gptRealtimeTranslate - ) - } - } -} - -private struct StaticOpenAIAPIKeyProvider: OpenAITranslationAPIKeyProviding { - let apiKey: String? - - func readAPIKey() throws -> String? { - apiKey - } -} - -private actor CapturingOpenAITranslationHTTPClient: OpenAITranslationHTTPClient { - private let data: Data - private let statusCode: Int - private(set) var lastRequest: URLRequest? - - init(data: Data, statusCode: Int) { - self.data = data - self.statusCode = statusCode - } - - func data(for request: URLRequest) async throws -> (Data, URLResponse) { - lastRequest = request - let response = HTTPURLResponse( - url: request.url!, - statusCode: statusCode, - httpVersion: nil, - headerFields: nil - )! - return (data, response) - } -} From 49114e3818f673257a53513156c075c064fbca20 Mon Sep 17 00:00:00 2001 From: Zander Wang Date: Thu, 21 May 2026 21:56:52 +0800 Subject: [PATCH 08/11] lint code --- .../Services/LiveSpeechTranscriber.swift | 22 ------- .../Services/OpenAIRealtimeTranscriber.swift | 21 +++--- .../Services/TranslationSessionStore.swift | 66 ++----------------- 3 files changed, 14 insertions(+), 95 deletions(-) diff --git a/Sources/AirTranslate/Services/LiveSpeechTranscriber.swift b/Sources/AirTranslate/Services/LiveSpeechTranscriber.swift index 7c96aef..0b922a8 100644 --- a/Sources/AirTranslate/Services/LiveSpeechTranscriber.swift +++ b/Sources/AirTranslate/Services/LiveSpeechTranscriber.swift @@ -15,13 +15,6 @@ protocol LiveSpeechTranscriberDelegate: AnyObject { language: LanguageOption, confidence: Double ) - func liveSpeechTranscriber( - _ transcriber: LiveSpeechTranscriber, - didTranslate text: String, - language: LanguageOption, - confidence: Double, - isFinal: Bool - ) func liveSpeechTranscriber( _ transcriber: LiveSpeechTranscriber, didOutputAudioPCM16Base64 audio: String, @@ -38,21 +31,6 @@ extension LiveSpeechTranscriberDelegate { confidence: Double ) {} - func liveSpeechTranscriber( - _ transcriber: LiveSpeechTranscriber, - didTranslate text: String, - language: LanguageOption, - confidence: Double, - isFinal: Bool - ) { - liveSpeechTranscriber( - transcriber, - didTranslate: text, - language: language, - confidence: confidence - ) - } - func liveSpeechTranscriber( _ transcriber: LiveSpeechTranscriber, didOutputAudioPCM16Base64 audio: String, diff --git a/Sources/AirTranslate/Services/OpenAIRealtimeTranscriber.swift b/Sources/AirTranslate/Services/OpenAIRealtimeTranscriber.swift index e991012..7ef5bb8 100644 --- a/Sources/AirTranslate/Services/OpenAIRealtimeTranscriber.swift +++ b/Sources/AirTranslate/Services/OpenAIRealtimeTranscriber.swift @@ -370,7 +370,7 @@ final class OpenAIRealtimeTranscriber: @unchecked Sendable { guard outputMode == .translationOnly, let transcript = event.transcript, !transcript.isEmpty else { return } - publishTranslation(text: transcript, isFinal: true) + publish(text: transcript) realtimeTranscriptText = "" case "session.output_audio.delta": guard outputMode == .translationOnly, @@ -403,20 +403,15 @@ final class OpenAIRealtimeTranscriber: @unchecked Sendable { confidence: 0.5 ) case .translationOnly: - publishTranslation(text: text, isFinal: false) + delegate?.liveSpeechTranscriber( + proxyTranscriber, + didTranslate: text, + language: language, + confidence: 0.5 + ) } } - private func publishTranslation(text: String, isFinal: Bool) { - delegate?.liveSpeechTranscriber( - proxyTranscriber, - didTranslate: text, - language: language, - confidence: 0.5, - isFinal: isFinal - ) - } - private var proxyTranscriber: LiveSpeechTranscriber { LiveSpeechTranscriber() } @@ -580,7 +575,7 @@ private struct OpenAIRealtimeTurnDetection: Encodable { static let lowLatencyServerVAD = OpenAIRealtimeTurnDetection( type: "server_vad", - threshold: 0.5, + threshold: 0.42, prefixPaddingMilliseconds: 120, silenceDurationMilliseconds: 220 ) diff --git a/Sources/AirTranslate/Services/TranslationSessionStore.swift b/Sources/AirTranslate/Services/TranslationSessionStore.swift index d04cd08..444cc70 100644 --- a/Sources/AirTranslate/Services/TranslationSessionStore.swift +++ b/Sources/AirTranslate/Services/TranslationSessionStore.swift @@ -278,7 +278,6 @@ final class TranslationSessionStore { private var translatedSegmentsBySource: [String: String] = [:] private var translationCacheKeyOrder: [String] = [] private var realtimeTranslationOnlyText = "" - private var realtimeTranslationOnlyCommittedText = "" private var activeAutosaveSourceText = "" private var activeAutosaveTranslatedText = "" private var isRestoringSelectedSettings = false @@ -998,7 +997,6 @@ final class TranslationSessionStore { translationBurstStartedAt = Date.distantPast resetTranslationCache() realtimeTranslationOnlyText = "" - realtimeTranslationOnlyCommittedText = "" activeAutosaveSourceText = "" activeAutosaveTranslatedText = "" stopSpeaking() @@ -2515,21 +2513,19 @@ final class TranslationSessionStore { translationCacheKeyOrder.removeAll() } - private func appendRealtimeTranslationOnly(_ text: String, isFinal: Bool) { + private func appendRealtimeTranslationOnly(_ text: String) { guard isRunning, !isPaused else { return } guard text.rangeOfCharacter(from: .whitespacesAndNewlines.inverted) != nil || !realtimeTranslationOnlyText.isEmpty else { return } - if isFinal { - commitRealtimeTranslationOnlyText(text) - } else if text.hasPrefix(realtimeTranslationOnlyText) { + if text.hasPrefix(realtimeTranslationOnlyText) { realtimeTranslationOnlyText = text } else if !realtimeTranslationOnlyText.hasSuffix(text) { realtimeTranslationOnlyText += text } - let translatedText = visibleRealtimeTranslationOnlyText().trimmingCharacters(in: .whitespacesAndNewlines) + let translatedText = realtimeTranslationOnlyText.trimmingCharacters(in: .whitespacesAndNewlines) guard !translatedText.isEmpty else { return } lastRecognizedText = translatedText @@ -2547,7 +2543,7 @@ final class TranslationSessionStore { translatedText: translatedText, translatedSourceText: sourceText, createdAt: existingLine.createdAt, - isFinal: isFinal, + isFinal: false, revision: existingLine.revision + 1, usesLongSessionDisplay: usesLongSessionMode ) @@ -2557,7 +2553,7 @@ final class TranslationSessionStore { translatedText: translatedText, translatedSourceText: sourceText, createdAt: Date(), - isFinal: isFinal, + isFinal: false, revision: 1, usesLongSessionDisplay: usesLongSessionMode ) @@ -2573,44 +2569,6 @@ final class TranslationSessionStore { speakTranslatedDeltaIfNeeded(translatedText) } - private func commitRealtimeTranslationOnlyText(_ text: String) { - let finalText = text.trimmingCharacters(in: .whitespacesAndNewlines) - realtimeTranslationOnlyText = "" - guard !finalText.isEmpty else { return } - - let committedText = realtimeTranslationOnlyCommittedText.trimmingCharacters(in: .whitespacesAndNewlines) - guard !committedText.isEmpty else { - realtimeTranslationOnlyCommittedText = finalText - return - } - - let normalizedCommitted = normalizedTranscriptForComparison(committedText) - let normalizedFinal = normalizedTranscriptForComparison(finalText) - if isWholeTextPrefix(normalizedCommitted, of: normalizedFinal) { - realtimeTranslationOnlyCommittedText = finalText - return - } - guard !normalizedCommitted.hasSuffix(normalizedFinal) else { return } - - realtimeTranslationOnlyCommittedText = committedText + "\n" + finalText - } - - private func visibleRealtimeTranslationOnlyText() -> String { - let committedText = realtimeTranslationOnlyCommittedText.trimmingCharacters(in: .whitespacesAndNewlines) - let partialText = realtimeTranslationOnlyText.trimmingCharacters(in: .whitespacesAndNewlines) - - guard !committedText.isEmpty else { return partialText } - guard !partialText.isEmpty else { return committedText } - - let normalizedCommitted = normalizedTranscriptForComparison(committedText) - let normalizedPartial = normalizedTranscriptForComparison(partialText) - if isWholeTextPrefix(normalizedCommitted, of: normalizedPartial) { - return partialText - } - - return committedText + "\n" + partialText - } - private func requestTranslation(for line: CaptionLine, source: LanguageOption, target: LanguageOption) { guard !openAITranslationModel.usesRealtimeAudioTranslation else { return } @@ -3124,19 +3082,7 @@ extension TranslationSessionStore: LiveSpeechTranscriberDelegate { confidence: Double ) { Task { @MainActor in - appendRealtimeTranslationOnly(text, isFinal: false) - } - } - - nonisolated func liveSpeechTranscriber( - _ transcriber: LiveSpeechTranscriber, - didTranslate text: String, - language: LanguageOption, - confidence: Double, - isFinal: Bool - ) { - Task { @MainActor in - appendRealtimeTranslationOnly(text, isFinal: isFinal) + appendRealtimeTranslationOnly(text) } } From e615dc100d4f15c1f2c67446dff8d011c7b8be65 Mon Sep 17 00:00:00 2001 From: Zander Wang Date: Thu, 21 May 2026 21:58:47 +0800 Subject: [PATCH 09/11] revert ut --- ...onSessionStoreLanguageCandidateTests.swift | 116 ------------------ 1 file changed, 116 deletions(-) diff --git a/Tests/AirTranslateCoreTests/TranslationSessionStoreLanguageCandidateTests.swift b/Tests/AirTranslateCoreTests/TranslationSessionStoreLanguageCandidateTests.swift index 3ed096c..ab400de 100644 --- a/Tests/AirTranslateCoreTests/TranslationSessionStoreLanguageCandidateTests.swift +++ b/Tests/AirTranslateCoreTests/TranslationSessionStoreLanguageCandidateTests.swift @@ -140,120 +140,4 @@ struct TranslationSessionStoreLanguageCandidateTests { #expect(line.sourceDisplayText.hasPrefix("...")) #expect(line.translatedDisplayText.hasPrefix("...")) } - - @Test - func immediateFloatingCaptionPresentationBypassesDwell() { - let presentedAt = Date() - - #expect( - FloatingCaptionPresentationPolicy.canPresentUpdate( - isImmediateDisplayEnabled: true, - presentedText: "Hello", - translatedText: "", - candidateText: "Hello there", - presentedAt: presentedAt, - now: presentedAt.addingTimeInterval(0.1) - ) - ) - } - - @Test - func stableFloatingCaptionPresentationKeepsDwell() { - let presentedAt = Date() - - #expect( - !FloatingCaptionPresentationPolicy.canPresentUpdate( - isImmediateDisplayEnabled: false, - presentedText: "Hello, this subtitle should stay readable.", - translatedText: "", - candidateText: "A completely revised subtitle arrives quickly.", - presentedAt: presentedAt, - now: presentedAt.addingTimeInterval(0.5) - ) - ) - } - - @Test - func floatingCaptionTextAlignmentMapsToSwiftUIAlignment() { - #expect(FloatingCaptionTextAlignment.leading.textAlignment == .leading) - #expect(FloatingCaptionTextAlignment.center.textAlignment == .center) - } - - @Test - func azureRealtimeURLUsesWebSocketSessionEndpoint() { - let config = OpenAIRealtimeProviderConfig.azure( - host: "example.openai.azure.com", - apiKey: "test-key" - ) - - #expect( - config.transcriptionURL()?.absoluteString - == "wss://example.openai.azure.com/openai/v1/realtime?model=gpt-realtime-1.5" - ) - #expect( - config.translationURL(modelID: "gpt-realtime-translate")?.absoluteString - == "wss://example.openai.azure.com/openai/v1/realtime/translations?model=gpt-realtime-translate" - ) - } - - @Test - func azureTranscriptionSessionUpdateUsesRealtimeSessionWithWhisperInput() throws { - let payload = try OpenAIRealtimeTranscriber.sessionUpdatePayload( - language: LanguageOption.supported[0], - modelID: "gpt-realtime-whisper", - outputMode: .transcription, - providerKind: .azure - ) - let json = try #require(try JSONSerialization.jsonObject(with: Data(payload.utf8)) as? [String: Any]) - let session = try #require(json["session"] as? [String: Any]) - let audio = try #require(session["audio"] as? [String: Any]) - let input = try #require(audio["input"] as? [String: Any]) - let transcription = try #require(input["transcription"] as? [String: Any]) - let turnDetection = try #require(input["turn_detection"] as? [String: Any]) - - #expect(session["type"] as? String == "realtime") - #expect(transcription["model"] as? String == "gpt-realtime-whisper") - #expect(transcription["language"] as? String == "en") - #expect(turnDetection["type"] as? String == "server_vad") - #expect(turnDetection["threshold"] as? Double == 0.5) - } - - @Test - func azureTranslationSessionUpdateDeclaresWhisperInputConfig() throws { - let payload = try OpenAIRealtimeTranscriber.sessionUpdatePayload( - language: LanguageOption.supported[3], - modelID: "gpt-realtime-translate", - outputMode: .translationOnly, - providerKind: .azure - ) - let json = try #require(try JSONSerialization.jsonObject(with: Data(payload.utf8)) as? [String: Any]) - let session = try #require(json["session"] as? [String: Any]) - let audio = try #require(session["audio"] as? [String: Any]) - let input = try #require(audio["input"] as? [String: Any]) - let transcription = try #require(input["transcription"] as? [String: Any]) - let noiseReduction = try #require(input["noise_reduction"] as? [String: Any]) - let output = try #require(audio["output"] as? [String: Any]) - - #expect(input["format"] == nil) - #expect(transcription["model"] as? String == "gpt-realtime-whisper") - #expect(noiseReduction["type"] as? String == "near_field") - #expect(output["language"] as? String == "zh") - } - - @Test - func openAITranslationSessionUpdateOmitsInputTranscription() throws { - let payload = try OpenAIRealtimeTranscriber.sessionUpdatePayload( - language: LanguageOption.supported[3], - modelID: "gpt-realtime-translate", - outputMode: .translationOnly, - providerKind: .openAI - ) - let json = try #require(try JSONSerialization.jsonObject(with: Data(payload.utf8)) as? [String: Any]) - let session = try #require(json["session"] as? [String: Any]) - let audio = try #require(session["audio"] as? [String: Any]) - let output = try #require(audio["output"] as? [String: Any]) - - #expect(audio["input"] == nil) - #expect(output["language"] as? String == "zh") - } } From d3511f903f8e44213f2ed5e4850c39bc2a92b354 Mon Sep 17 00:00:00 2001 From: Zander Wang Date: Thu, 21 May 2026 22:55:55 +0800 Subject: [PATCH 10/11] azure openai compatibility api --- Sources/AirTranslate/Models/AppText.swift | 6 ---- .../Services/OpenAIRealtimeTranscriber.swift | 32 +++---------------- .../Services/TranslationSessionStore.swift | 16 +--------- .../Views/SettingsComponents.swift | 7 ---- 4 files changed, 6 insertions(+), 55 deletions(-) diff --git a/Sources/AirTranslate/Models/AppText.swift b/Sources/AirTranslate/Models/AppText.swift index 2a94d9e..534bd73 100644 --- a/Sources/AirTranslate/Models/AppText.swift +++ b/Sources/AirTranslate/Models/AppText.swift @@ -366,12 +366,6 @@ enum AppText { japanese: "翻訳モデル名", chineseSimplified: "翻译模型名称" ) - static let customAzureDeploymentName = localized( - english: "Azure transcription deployment", - korean: "Azure 전사 배포 이름", - japanese: "Azure 文字起こしデプロイ名", - chineseSimplified: "Azure 转写部署名称" - ) static let customModelNameFootnote = localized( english: "Leave empty to use the default.", korean: "기본값을 사용하려면 비워두세요.", diff --git a/Sources/AirTranslate/Services/OpenAIRealtimeTranscriber.swift b/Sources/AirTranslate/Services/OpenAIRealtimeTranscriber.swift index 7ef5bb8..e3db067 100644 --- a/Sources/AirTranslate/Services/OpenAIRealtimeTranscriber.swift +++ b/Sources/AirTranslate/Services/OpenAIRealtimeTranscriber.swift @@ -11,32 +11,25 @@ struct OpenAIRealtimeProviderConfig: Sendable { let kind: Kind let host: String let apiKey: String - /// Azure-only override for the transcription deployment name. - /// `nil` falls back to `azureRealtimeTranscriptionSessionDeployment`. - let azureTranscriptionDeployment: String? static let openAIHost = "api.openai.com" - static let azureRealtimeTranscriptionSessionDeployment = "gpt-realtime-1.5" static func openAI(apiKey: String) -> OpenAIRealtimeProviderConfig { OpenAIRealtimeProviderConfig( kind: .openAI, host: openAIHost, - apiKey: apiKey, - azureTranscriptionDeployment: nil + apiKey: apiKey ) } static func azure( host: String, - apiKey: String, - transcriptionDeployment: String? = nil + apiKey: String ) -> OpenAIRealtimeProviderConfig { OpenAIRealtimeProviderConfig( kind: .azure, host: host, - apiKey: apiKey, - azureTranscriptionDeployment: transcriptionDeployment + apiKey: apiKey ) } @@ -45,14 +38,7 @@ struct OpenAIRealtimeProviderConfig: Sendable { case .openAI: return URL(string: "wss://\(host)/v1/realtime?intent=transcription") case .azure: - let trimmed = azureTranscriptionDeployment? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let deployment = trimmed.isEmpty - ? Self.azureRealtimeTranscriptionSessionDeployment - : trimmed - let encoded = deployment - .addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? deployment - return URL(string: "wss://\(host)/openai/v1/realtime?model=\(encoded)") + return URL(string: "wss://\(host)/openai/v1/realtime?intent=transcription") } } @@ -260,17 +246,9 @@ final class OpenAIRealtimeTranscriber: @unchecked Sendable { let data: Data switch outputMode { case .transcription: - let sessionType: String - switch providerKind { - case .openAI: - sessionType = "transcription" - case .azure: - sessionType = "realtime" - } - let event = OpenAIRealtimeTranscriptionSessionUpdateEvent( session: OpenAIRealtimeTranscriptionSession( - type: sessionType, + type: "transcription", audio: OpenAIRealtimeTranscriptionAudio( input: OpenAIRealtimeTranscriptionAudioInput( format: OpenAIRealtimeAudioFormat(type: "audio/pcm", rate: Self.realtimeAudioSampleRate), diff --git a/Sources/AirTranslate/Services/TranslationSessionStore.swift b/Sources/AirTranslate/Services/TranslationSessionStore.swift index 444cc70..858761c 100644 --- a/Sources/AirTranslate/Services/TranslationSessionStore.swift +++ b/Sources/AirTranslate/Services/TranslationSessionStore.swift @@ -26,7 +26,6 @@ private enum SettingsKey { static let openAIProvider = "openAIProvider" static let customAzureTranscriptionModelName = "customAzureTranscriptionModelName" static let customAzureTranslationModelName = "customAzureTranslationModelName" - static let customAzureTranscriptionDeployment = "customAzureTranscriptionDeployment" } private struct TranslationRequest { @@ -146,9 +145,6 @@ final class TranslationSessionStore { var customAzureTranslationModelName: String = "" { didSet { persistSelectedSettings() } } - var customAzureTranscriptionDeployment: String = "" { - didSet { persistSelectedSettings() } - } var openAITranscriptionModel = OpenAIRealtimeTranscriptionModel.off { didSet { if openAITranscriptionModel.isEnabled { @@ -614,13 +610,7 @@ final class TranslationSessionStore { guard let key = try AzureOpenAIConfigStore.readAPIKey(), !key.isEmpty else { throw OpenAITranslationError.missingAzureAPIKey } - let deployment = customAzureTranscriptionDeployment - .trimmingCharacters(in: .whitespacesAndNewlines) - return .azure( - host: host, - apiKey: key, - transcriptionDeployment: deployment.isEmpty ? nil : deployment - ) + return .azure(host: host, apiKey: key) } } @@ -1142,9 +1132,6 @@ final class TranslationSessionStore { if let value = defaults.string(forKey: SettingsKey.customAzureTranslationModelName) { customAzureTranslationModelName = value } - if let value = defaults.string(forKey: SettingsKey.customAzureTranscriptionDeployment) { - customAzureTranscriptionDeployment = value - } refreshMicrophoneInputDevices() } @@ -1176,7 +1163,6 @@ final class TranslationSessionStore { defaults.set(openAIProvider.rawValue, forKey: SettingsKey.openAIProvider) defaults.set(customAzureTranscriptionModelName, forKey: SettingsKey.customAzureTranscriptionModelName) defaults.set(customAzureTranslationModelName, forKey: SettingsKey.customAzureTranslationModelName) - defaults.set(customAzureTranscriptionDeployment, forKey: SettingsKey.customAzureTranscriptionDeployment) } private func stopCapture() async { diff --git a/Sources/AirTranslate/Views/SettingsComponents.swift b/Sources/AirTranslate/Views/SettingsComponents.swift index 3270ed2..97c91fa 100644 --- a/Sources/AirTranslate/Views/SettingsComponents.swift +++ b/Sources/AirTranslate/Views/SettingsComponents.swift @@ -559,13 +559,6 @@ struct OpenAIAdvancedOverridesView: View { placeholder: translationPlaceholder, text: $session.customAzureTranslationModelName ) - ModelNameOverrideField( - title: AppText.customAzureDeploymentName, - systemImage: "server.rack", - placeholder: OpenAIRealtimeProviderConfig - .azureRealtimeTranscriptionSessionDeployment, - text: $session.customAzureTranscriptionDeployment - ) Text(AppText.customModelNameFootnote) .font(.caption2) From c24a8e6ea554d2b17cf3f4f4d39453c5e828ee0d Mon Sep 17 00:00:00 2001 From: Zander Wang Date: Fri, 22 May 2026 00:05:31 +0800 Subject: [PATCH 11/11] lint code --- Sources/AirTranslate/Models/AppText.swift | 26 +++++++++++++++- .../Services/AzureOpenAIConfigStore.swift | 9 +++++- .../Services/OpenAIRealtimeTranscriber.swift | 7 ++++- .../Views/OpenAIRealtimeModelPickers.swift | 24 +++++++++----- Sources/AirTranslate/Views/SettingsView.swift | 22 +++++++++++++ Sources/AirTranslate/Views/SidebarView.swift | 31 +++++++++++++++++-- 6 files changed, 105 insertions(+), 14 deletions(-) diff --git a/Sources/AirTranslate/Models/AppText.swift b/Sources/AirTranslate/Models/AppText.swift index 534bd73..c964207 100644 --- a/Sources/AirTranslate/Models/AppText.swift +++ b/Sources/AirTranslate/Models/AppText.swift @@ -266,6 +266,18 @@ enum AppText { 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 설정이 삭제되었습니다.", @@ -338,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: "자동번역", @@ -446,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: "일시정지") diff --git a/Sources/AirTranslate/Services/AzureOpenAIConfigStore.swift b/Sources/AirTranslate/Services/AzureOpenAIConfigStore.swift index bb11d5e..13cf6c1 100644 --- a/Sources/AirTranslate/Services/AzureOpenAIConfigStore.swift +++ b/Sources/AirTranslate/Services/AzureOpenAIConfigStore.swift @@ -57,7 +57,14 @@ enum AzureOpenAIConfigStore { query[kSecValueData as String] = data query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly - let status = SecItemAdd(query as CFDictionary, nil) + 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) } diff --git a/Sources/AirTranslate/Services/OpenAIRealtimeTranscriber.swift b/Sources/AirTranslate/Services/OpenAIRealtimeTranscriber.swift index e3db067..60303cd 100644 --- a/Sources/AirTranslate/Services/OpenAIRealtimeTranscriber.swift +++ b/Sources/AirTranslate/Services/OpenAIRealtimeTranscriber.swift @@ -367,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) } diff --git a/Sources/AirTranslate/Views/OpenAIRealtimeModelPickers.swift b/Sources/AirTranslate/Views/OpenAIRealtimeModelPickers.swift index b9bb68a..d18e65d 100644 --- a/Sources/AirTranslate/Views/OpenAIRealtimeModelPickers.swift +++ b/Sources/AirTranslate/Views/OpenAIRealtimeModelPickers.swift @@ -33,14 +33,22 @@ struct OpenAIRealtimeModelPickers: View { ) ) - SettingsCompactMenuRow( - title: AppText.gptTranscriptionModel, - systemImage: "waveform.circle.fill", - value: session.openAITranscriptionModel.title - ) { - ForEach(OpenAIRealtimeTranscriptionModel.allCases) { model in - Button(model.title) { - session.openAITranscriptionModel = model + 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 + } } } } diff --git a/Sources/AirTranslate/Views/SettingsView.swift b/Sources/AirTranslate/Views/SettingsView.swift index 6993a2b..d6cf7e7 100644 --- a/Sources/AirTranslate/Views/SettingsView.swift +++ b/Sources/AirTranslate/Views/SettingsView.swift @@ -7,6 +7,13 @@ struct SettingsView: View { @State private var azureAPIKey = "" @State private var configurationNotice: String? @State private var shouldFocusAPIKey = false + @State private var saveAlert: SaveAlert? + + private struct SaveAlert: Identifiable { + let id = UUID() + let title: String + let message: String + } var body: some View { ScrollView { @@ -31,6 +38,13 @@ struct SettingsView: View { shouldFocusAPIKey = false azureEndpoint = session.azureOpenAIEndpoint } + .alert(item: $saveAlert) { alert in + Alert( + title: Text(alert.title), + message: Text(alert.message), + dismissButton: .default(Text("OK")) + ) + } } private var openAIRealtimeSection: some View { @@ -252,9 +266,17 @@ struct SettingsView: View { 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 + ) } } diff --git a/Sources/AirTranslate/Views/SidebarView.swift b/Sources/AirTranslate/Views/SidebarView.swift index 603faea..5f07dee 100644 --- a/Sources/AirTranslate/Views/SidebarView.swift +++ b/Sources/AirTranslate/Views/SidebarView.swift @@ -347,6 +347,13 @@ private struct ConfigurationSheetView: View { @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) { @@ -418,6 +425,13 @@ private struct ConfigurationSheetView: View { session.refreshModelAvailability() azureEndpoint = session.azureOpenAIEndpoint } + .alert(item: $saveAlert) { alert in + Alert( + title: Text(alert.title), + message: Text(alert.message), + dismissButton: .default(Text("OK")) + ) + } } private var appleProcessingSection: some View { @@ -489,12 +503,23 @@ private struct ConfigurationSheetView: View { hasConfig: session.hasAzureOpenAIConfig, notice: configurationNotice, save: { - session.saveAzureOpenAIConfig(endpoint: azureEndpoint, apiKey: azureAPIKey) + let ok = session.saveAzureOpenAIConfig(endpoint: azureEndpoint, apiKey: azureAPIKey) azureAPIKey = "" - if session.hasAzureOpenAIConfig { + 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: { @@ -569,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",