From 8c694d9cf3701e97014fbb9f956dad03a79159a5 Mon Sep 17 00:00:00 2001 From: Marco Hillger Date: Fri, 3 Apr 2026 18:33:52 +0200 Subject: [PATCH 1/2] Add Soniox cloud transcription plugin Cloud transcription via Soniox API with real-time WebSocket streaming (wss://stt-rt.soniox.com), REST batch fallback, and translation support. 60+ languages, models stt-rt-v4 and stt-rt-preview. --- Plugins/SonioxPlugin/Localizable.xcstrings | 86 +++ Plugins/SonioxPlugin/SonioxPlugin.swift | 698 +++++++++++++++++++++ Plugins/SonioxPlugin/manifest.json | 16 + TypeWhisper.xcodeproj/project.pbxproj | 128 ++++ 4 files changed, 928 insertions(+) create mode 100644 Plugins/SonioxPlugin/Localizable.xcstrings create mode 100644 Plugins/SonioxPlugin/SonioxPlugin.swift create mode 100644 Plugins/SonioxPlugin/manifest.json diff --git a/Plugins/SonioxPlugin/Localizable.xcstrings b/Plugins/SonioxPlugin/Localizable.xcstrings new file mode 100644 index 0000000..3503697 --- /dev/null +++ b/Plugins/SonioxPlugin/Localizable.xcstrings @@ -0,0 +1,86 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "API Key" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "API-Schl\u00FCssel" + } + } + } + }, + "API keys are stored securely in the Keychain" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "API-Schl\u00FCssel werden sicher im Schl\u00FCsselbund gespeichert" + } + } + } + }, + "Invalid API Key" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ung\u00FCltiger API-Schl\u00FCssel" + } + } + } + }, + "Model" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modell" + } + } + } + }, + "Remove" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Entfernen" + } + } + } + }, + "Save" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Speichern" + } + } + } + }, + "Valid API Key" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "G\u00FCltiger API-Schl\u00FCssel" + } + } + } + }, + "Validating..." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wird \u00FCberpr\u00FCft..." + } + } + } + } + }, + "version" : "1.0" +} diff --git a/Plugins/SonioxPlugin/SonioxPlugin.swift b/Plugins/SonioxPlugin/SonioxPlugin.swift new file mode 100644 index 0000000..9121d55 --- /dev/null +++ b/Plugins/SonioxPlugin/SonioxPlugin.swift @@ -0,0 +1,698 @@ +import Foundation +import SwiftUI +import os +import TypeWhisperPluginSDK + +// MARK: - Supported Languages + +private let sonioxSupportedLanguages = [ + "af", "am", "ar", "az", "be", "bg", "bn", "bs", "ca", "cs", + "cy", "da", "de", "el", "en", "es", "et", "fa", "fi", "fr", + "gl", "gu", "ha", "he", "hi", "hr", "hu", "hy", "id", "is", + "it", "ja", "ka", "kk", "km", "kn", "ko", "lo", "lt", "lv", + "mk", "ml", "mn", "mr", "ms", "my", "ne", "nl", "no", "pa", + "pl", "pt", "ro", "ru", "sk", "sl", "so", "sq", "sr", "sv", + "sw", "ta", "te", "th", "tr", "uk", "ur", "uz", "vi", "zh", +] + +// MARK: - Transcript Collector + +private actor TranscriptCollector { + private var finals: [String] = [] + private var interim: String = "" + private var _detectedLanguage: String? + private var _error: String? + + func addFinal(_ text: String, language: String? = nil) { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + finals.append(trimmed) + } + interim = "" + if let language, !language.isEmpty { + _detectedLanguage = language + } + } + + func setInterim(_ text: String, language: String? = nil) { + interim = text.trimmingCharacters(in: .whitespacesAndNewlines) + if let language, !language.isEmpty { + _detectedLanguage = language + } + } + + func setError(_ message: String) { + _error = message + } + + var error: String? { _error } + + func currentText() -> String { + var parts = finals + if !interim.isEmpty { + parts.append(interim) + } + return parts.joined(separator: " ") + } + + func finalResult() -> String { + finals.joined(separator: " ") + } + + func detectedLanguage(fallback: String?) -> String? { + _detectedLanguage ?? fallback + } +} + +// MARK: - Plugin Entry Point + +@objc(SonioxPlugin) +final class SonioxPlugin: NSObject, TranscriptionEnginePlugin, @unchecked Sendable { + static let pluginId = "com.typewhisper.soniox" + static let pluginName = "Soniox" + + fileprivate var host: HostServices? + fileprivate var _apiKey: String? + fileprivate var _selectedModelId: String? + + private let logger = Logger(subsystem: "com.typewhisper.soniox", category: "Plugin") + + required override init() { + super.init() + } + + func activate(host: HostServices) { + self.host = host + _apiKey = host.loadSecret(key: "api-key") + _selectedModelId = host.userDefault(forKey: "selectedModel") as? String + ?? transcriptionModels.first?.id + } + + func deactivate() { + host = nil + } + + // MARK: - TranscriptionEnginePlugin + + var providerId: String { "soniox" } + var providerDisplayName: String { "Soniox" } + + var isConfigured: Bool { + guard let key = _apiKey else { return false } + return !key.isEmpty + } + + var transcriptionModels: [PluginModelInfo] { + [ + PluginModelInfo(id: "stt-rt-v4", displayName: "STT RT v4"), + PluginModelInfo(id: "stt-rt-preview", displayName: "STT RT Preview"), + ] + } + + var selectedModelId: String? { _selectedModelId } + + func selectModel(_ modelId: String) { + _selectedModelId = modelId + host?.setUserDefault(modelId, forKey: "selectedModel") + } + + var supportsTranslation: Bool { true } + var supportsStreaming: Bool { true } + + var supportedLanguages: [String] { sonioxSupportedLanguages } + + // MARK: - Transcription (REST Fallback) + + func transcribe(audio: AudioData, language: String?, translate: Bool, prompt: String?) async throws -> PluginTranscriptionResult { + guard let apiKey = _apiKey, !apiKey.isEmpty else { + throw PluginTranscriptionError.notConfigured + } + + return try await transcribeREST(audio: audio, language: language, translate: translate, apiKey: apiKey) + } + + // MARK: - Transcription (WebSocket Streaming) + + func transcribe( + audio: AudioData, + language: String?, + translate: Bool, + prompt: String?, + onProgress: @Sendable @escaping (String) -> Bool + ) async throws -> PluginTranscriptionResult { + guard let apiKey = _apiKey, !apiKey.isEmpty else { + throw PluginTranscriptionError.notConfigured + } + guard let modelId = _selectedModelId else { + throw PluginTranscriptionError.noModelSelected + } + + do { + return try await transcribeWebSocket( + audio: audio, language: language, translate: translate, + modelId: modelId, apiKey: apiKey, onProgress: onProgress + ) + } catch { + logger.warning("WebSocket streaming failed, falling back to REST: \(error.localizedDescription)") + return try await transcribeREST(audio: audio, language: language, translate: translate, apiKey: apiKey) + } + } + + // MARK: - WebSocket Implementation + + private func transcribeWebSocket( + audio: AudioData, + language: String?, + translate: Bool, + modelId: String, + apiKey: String, + onProgress: @Sendable @escaping (String) -> Bool + ) async throws -> PluginTranscriptionResult { + guard let url = URL(string: "wss://stt-rt.soniox.com/transcribe-websocket") else { + throw PluginTranscriptionError.apiError("Invalid Soniox WebSocket URL") + } + + let wsTask = URLSession.shared.webSocketTask(with: url) + wsTask.resume() + + // Send config message with API key (Soniox auth is in the first message) + var config: [String: Any] = [ + "api_key": apiKey, + "model": modelId, + "audio_format": "s16le", + "sample_rate": 16000, + "num_channels": 1, + "enable_endpoint_detection": true, + ] + + if let language, !language.isEmpty { + config["language_hints"] = [language] + } + + if translate { + config["translation"] = [ + "type": "one_way", + "target_language": "en", + ] + } + + let configData = try JSONSerialization.data(withJSONObject: config) + guard let configString = String(data: configData, encoding: .utf8) else { + throw PluginTranscriptionError.apiError("Failed to encode config") + } + try await wsTask.send(.string(configString)) + + // Receive loop + let collector = TranscriptCollector() + let loggerRef = self.logger + let isTranslating = translate + + let receiveTask = Task { + do { + while !Task.isCancelled { + let message = try await wsTask.receive() + + guard case .string(let text) = message else { continue } + + guard let data = text.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + continue + } + + // Check for finished signal + if json["finished"] as? Bool == true { break } + + // Check for error + if let errorObj = json["error"] as? [String: Any] { + let msg = errorObj["message"] as? String ?? "Unknown Soniox error" + loggerRef.error("Soniox error: \(msg)") + await collector.setError(msg) + break + } + + // Parse tokens + guard let tokens = json["tokens"] as? [[String: Any]] else { continue } + + var finalText: [String] = [] + var interimText: [String] = [] + var tokenLanguage: String? + + for token in tokens { + guard let tokenStr = token["text"] as? String else { continue } + + // When translating, skip original tokens + if isTranslating { + let status = token["translation_status"] as? String + if status == "original" { continue } + } + + let isFinal = token["is_final"] as? Bool ?? false + if let lang = token["language"] as? String, !lang.isEmpty { + tokenLanguage = lang + } + + if isFinal { + finalText.append(tokenStr) + } else { + interimText.append(tokenStr) + } + } + + let joinedFinal = finalText.joined() + let joinedInterim = interimText.joined() + + if !joinedFinal.isEmpty { + await collector.addFinal(joinedFinal, language: tokenLanguage) + } + if !joinedInterim.isEmpty { + await collector.setInterim(joinedInterim, language: tokenLanguage) + } + + let currentText = await collector.currentText() + if !currentText.isEmpty { + _ = onProgress(currentText) + } + } + } catch { + loggerRef.error("WebSocket receive error: \(error.localizedDescription)") + } + } + + // Send audio as binary frames + let pcmData = Self.floatToPCM16(audio.samples) + let chunkSize = 8192 + var offset = 0 + + defer { receiveTask.cancel() } + + while offset < pcmData.count { + let end = min(offset + chunkSize, pcmData.count) + let chunk = pcmData.subdata(in: offset.. PluginTranscriptionResult { + let fileId = try await uploadFile(wavData: audio.wavData, apiKey: apiKey) + let transcriptionId = try await createTranscription( + fileId: fileId, language: language, translate: translate, apiKey: apiKey + ) + try await pollUntilCompleted(id: transcriptionId, apiKey: apiKey) + return try await fetchTranscript(id: transcriptionId, apiKey: apiKey, language: language) + } + + private func uploadFile(wavData: Data, apiKey: String) async throws -> String { + guard let url = URL(string: "https://api.soniox.com/v1/files") else { + throw PluginTranscriptionError.apiError("Invalid upload URL") + } + + let boundary = UUID().uuidString + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + request.timeoutInterval = 120 + + var body = Data() + body.append("--\(boundary)\r\n".data(using: .utf8)!) + body.append("Content-Disposition: form-data; name=\"file\"; filename=\"audio.wav\"\r\n".data(using: .utf8)!) + body.append("Content-Type: audio/wav\r\n\r\n".data(using: .utf8)!) + body.append(wavData) + body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!) + request.httpBody = body + + let (data, response) = try await PluginHTTPClient.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw PluginTranscriptionError.apiError("No HTTP response") + } + + switch httpResponse.statusCode { + case 200, 201: break + case 401: throw PluginTranscriptionError.invalidApiKey + case 413: throw PluginTranscriptionError.fileTooLarge + case 429: throw PluginTranscriptionError.rateLimited + default: + let body = String(data: data, encoding: .utf8) ?? "" + throw PluginTranscriptionError.apiError("Upload failed HTTP \(httpResponse.statusCode): \(body)") + } + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let fileId = json["id"] as? String else { + throw PluginTranscriptionError.apiError("Invalid upload response") + } + + return fileId + } + + private func createTranscription( + fileId: String, + language: String?, + translate: Bool, + apiKey: String + ) async throws -> String { + guard let url = URL(string: "https://api.soniox.com/v1/transcriptions") else { + throw PluginTranscriptionError.apiError("Invalid transcriptions URL") + } + + var body: [String: Any] = [ + "file_id": fileId, + "model": "stt-async-v4", + ] + + if let language, !language.isEmpty { + body["language_hints"] = [language] + } + + if translate { + body["translation"] = [ + "type": "one_way", + "target_language": "en", + ] + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: body) + request.timeoutInterval = 30 + + let (data, response) = try await PluginHTTPClient.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw PluginTranscriptionError.apiError("No HTTP response") + } + + switch httpResponse.statusCode { + case 200, 201: break + case 401: throw PluginTranscriptionError.invalidApiKey + case 429: throw PluginTranscriptionError.rateLimited + default: + let body = String(data: data, encoding: .utf8) ?? "" + throw PluginTranscriptionError.apiError("Create transcription failed HTTP \(httpResponse.statusCode): \(body)") + } + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let id = json["id"] as? String else { + throw PluginTranscriptionError.apiError("Invalid transcription creation response") + } + + return id + } + + private func pollUntilCompleted(id: String, apiKey: String) async throws { + guard let url = URL(string: "https://api.soniox.com/v1/transcriptions/\(id)") else { + throw PluginTranscriptionError.apiError("Invalid poll URL") + } + + var request = URLRequest(url: url) + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.timeoutInterval = 15 + + for _ in 0..<300 { + try await Task.sleep(for: .seconds(1)) + + let (data, response) = try await PluginHTTPClient.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + continue + } + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let status = json["status"] as? String else { + continue + } + + switch status { + case "completed": + return + case "error", "failed": + // Try multiple error field formats + let errorMsg: String + if let errStr = json["error"] as? String { + errorMsg = errStr + } else if let errObj = json["error"] as? [String: Any], let msg = errObj["message"] as? String { + errorMsg = msg + } else if let errMsg = json["error_message"] as? String { + errorMsg = errMsg + } else { + // Log full response for debugging + let responseStr = String(data: data, encoding: .utf8) ?? "" + logger.error("Soniox transcription failed, full response: \(responseStr)") + errorMsg = "Transcription failed (status: \(status))" + } + throw PluginTranscriptionError.apiError(errorMsg) + default: + continue + } + } + + throw PluginTranscriptionError.apiError("Transcription timed out after 5 minutes") + } + + private func fetchTranscript(id: String, apiKey: String, language: String?) async throws -> PluginTranscriptionResult { + guard let url = URL(string: "https://api.soniox.com/v1/transcriptions/\(id)/transcript") else { + throw PluginTranscriptionError.apiError("Invalid transcript URL") + } + + var request = URLRequest(url: url) + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.timeoutInterval = 30 + + let (data, response) = try await PluginHTTPClient.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw PluginTranscriptionError.apiError("No HTTP response") + } + + guard httpResponse.statusCode == 200 else { + let body = String(data: data, encoding: .utf8) ?? "" + throw PluginTranscriptionError.apiError("Fetch transcript failed HTTP \(httpResponse.statusCode): \(body)") + } + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw PluginTranscriptionError.apiError("Invalid transcript response") + } + + // Extract full text from tokens or top-level text field + let text: String + if let tokens = json["tokens"] as? [[String: Any]] { + text = tokens.compactMap { $0["text"] as? String }.joined() + } else { + text = json["text"] as? String ?? "" + } + + return PluginTranscriptionResult(text: text, detectedLanguage: language) + } + + // MARK: - Audio Conversion + + private static func floatToPCM16(_ samples: [Float]) -> Data { + var data = Data(capacity: samples.count * 2) + for sample in samples { + let clamped = max(-1.0, min(1.0, sample)) + var int16 = Int16(clamped * 32767.0) + withUnsafeBytes(of: &int16) { data.append(contentsOf: $0) } + } + return data + } + + // MARK: - API Key Validation + + fileprivate func validateApiKey(_ key: String) async -> Bool { + guard let url = URL(string: "https://api.soniox.com/v1/files") else { return false } + var request = URLRequest(url: url) + request.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization") + request.timeoutInterval = 10 + + do { + let (_, response) = try await PluginHTTPClient.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { return false } + return httpResponse.statusCode == 200 + } catch { + return false + } + } + + // MARK: - Settings View + + var settingsView: AnyView? { + AnyView(SonioxSettingsView(plugin: self)) + } + + // MARK: - Internal Methods for Settings + + fileprivate func setApiKey(_ key: String) { + _apiKey = key + if let host { + do { + try host.storeSecret(key: "api-key", value: key) + } catch { + print("[SonioxPlugin] Failed to store API key: \(error)") + } + host.notifyCapabilitiesChanged() + } + } + + fileprivate func removeApiKey() { + _apiKey = nil + if let host { + do { + try host.storeSecret(key: "api-key", value: "") + } catch { + print("[SonioxPlugin] Failed to delete API key: \(error)") + } + host.notifyCapabilitiesChanged() + } + } +} + +// MARK: - Settings View + +private struct SonioxSettingsView: View { + let plugin: SonioxPlugin + @State private var apiKeyInput = "" + @State private var isValidating = false + @State private var validationResult: Bool? + @State private var showApiKey = false + @State private var selectedModel: String = "" + private let bundle = Bundle(for: SonioxPlugin.self) + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + // API Key Section + VStack(alignment: .leading, spacing: 8) { + Text("API Key", bundle: bundle) + .font(.headline) + + HStack(spacing: 8) { + if showApiKey { + TextField("API Key", text: $apiKeyInput) + .textFieldStyle(.roundedBorder) + .font(.system(.body, design: .monospaced)) + } else { + SecureField("API Key", text: $apiKeyInput) + .textFieldStyle(.roundedBorder) + } + + Button { + showApiKey.toggle() + } label: { + Image(systemName: showApiKey ? "eye.slash" : "eye") + } + .buttonStyle(.borderless) + + if plugin.isConfigured { + Button(String(localized: "Remove", bundle: bundle)) { + apiKeyInput = "" + validationResult = nil + plugin.removeApiKey() + } + .buttonStyle(.bordered) + .controlSize(.small) + .foregroundStyle(.red) + } else { + Button(String(localized: "Save", bundle: bundle)) { + saveApiKey() + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .disabled(apiKeyInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + + if isValidating { + HStack(spacing: 4) { + ProgressView().controlSize(.small) + Text("Validating...", bundle: bundle) + .font(.caption) + .foregroundStyle(.secondary) + } + } else if let result = validationResult { + HStack(spacing: 4) { + Image(systemName: result ? "checkmark.circle.fill" : "xmark.circle.fill") + .foregroundStyle(result ? .green : .red) + Text(result ? String(localized: "Valid API Key", bundle: bundle) : String(localized: "Invalid API Key", bundle: bundle)) + .font(.caption) + .foregroundStyle(result ? .green : .red) + } + } + } + + if plugin.isConfigured { + Divider() + + // Model Selection + VStack(alignment: .leading, spacing: 8) { + Text("Model", bundle: bundle) + .font(.headline) + + Picker("Model", selection: $selectedModel) { + ForEach(plugin.transcriptionModels, id: \.id) { model in + Text(model.displayName).tag(model.id) + } + } + .labelsHidden() + .onChange(of: selectedModel) { + plugin.selectModel(selectedModel) + } + } + } + + Text("API keys are stored securely in the Keychain", bundle: bundle) + .font(.caption) + .foregroundStyle(.secondary) + } + .padding() + .onAppear { + if let key = plugin._apiKey, !key.isEmpty { + apiKeyInput = key + } + selectedModel = plugin.selectedModelId ?? plugin.transcriptionModels.first?.id ?? "" + } + } + + private func saveApiKey() { + let trimmedKey = apiKeyInput.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedKey.isEmpty else { return } + + plugin.setApiKey(trimmedKey) + + isValidating = true + validationResult = nil + Task { + let isValid = await plugin.validateApiKey(trimmedKey) + await MainActor.run { + isValidating = false + validationResult = isValid + } + } + } +} diff --git a/Plugins/SonioxPlugin/manifest.json b/Plugins/SonioxPlugin/manifest.json new file mode 100644 index 0000000..e43eae4 --- /dev/null +++ b/Plugins/SonioxPlugin/manifest.json @@ -0,0 +1,16 @@ +{ + "id": "com.typewhisper.soniox", + "name": "Soniox", + "version": "1.0.0", + "minHostVersion": "0.12.0", + "minOSVersion": "14.0", + "author": "TypeWhisper", + "description": "Cloud transcription via Soniox API with real-time WebSocket streaming and translation support. Requires API key.", + "descriptions": { + "de": "Cloud-Transkription ueber die Soniox-API mit Echtzeit-WebSocket-Streaming und Uebersetzungsunterstuetzung. Erfordert API-Key." + }, + "category": "transcription", + "iconSystemName": "waveform.badge.mic", + "requiresAPIKey": true, + "principalClass": "SonioxPlugin" +} diff --git a/TypeWhisper.xcodeproj/project.pbxproj b/TypeWhisper.xcodeproj/project.pbxproj index b620370..819fe9c 100644 --- a/TypeWhisper.xcodeproj/project.pbxproj +++ b/TypeWhisper.xcodeproj/project.pbxproj @@ -254,6 +254,10 @@ AA00000000000000000286 /* MediaRemoteAdapter in Frameworks */ = {isa = PBXBuildFile; productRef = PP00000000000000000040 /* MediaRemoteAdapter */; }; AA00000000000000000287 /* MediaRemoteAdapter in Embed Frameworks */ = {isa = PBXBuildFile; productRef = PP00000000000000000040 /* MediaRemoteAdapter */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; C7A3E9F1D5B2084A6E9C3D71 /* DictionaryExporterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8D4F2B6E1C3097B5F8A4E62 /* DictionaryExporterTests.swift */; }; + AA00000000000000000288 /* SonioxPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB00000000000000000274 /* SonioxPlugin.swift */; }; + AA00000000000000000289 /* manifest.json in Resources */ = {isa = PBXBuildFile; fileRef = BB00000000000000000275 /* manifest.json */; }; + AA00000000000000000290 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = BB00000000000000000276 /* Localizable.xcstrings */; }; + AA00000000000000000291 /* TypeWhisperPluginSDK in Frameworks */ = {isa = PBXBuildFile; productRef = PP00000000000000000041 /* TypeWhisperPluginSDK */; }; AA00000000000000000275 /* ElevenLabsPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB00000000000000000263 /* ElevenLabsPlugin.swift */; }; AA00000000000000000276 /* manifest.json in Resources */ = {isa = PBXBuildFile; fileRef = BB00000000000000000264 /* manifest.json */; }; AA00000000000000000277 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = BB00000000000000000265 /* Localizable.xcstrings */; }; @@ -565,6 +569,10 @@ BB00000000000000000272 /* TermPackRegistryService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TermPackRegistryService.swift; sourceTree = ""; }; BB00000000000000000273 /* MediaPlaybackService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlaybackService.swift; sourceTree = ""; }; A8D4F2B6E1C3097B5F8A4E62 /* DictionaryExporterTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DictionaryExporterTests.swift; sourceTree = ""; }; + BB00000000000000000274 /* SonioxPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SonioxPlugin.swift; sourceTree = ""; }; + BB00000000000000000275 /* manifest.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = manifest.json; sourceTree = ""; }; + BB00000000000000000276 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; + BB00000000000000000277 /* SonioxPlugin.bundle */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SonioxPlugin.bundle; sourceTree = BUILT_PRODUCTS_DIR; }; 01D368E23E2A0AB2390B34EA /* GoogleCloudSTTPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoogleCloudSTTPlugin.swift; sourceTree = ""; }; 1D584661B6B55F6329CB5FC4 /* manifest.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = manifest.json; sourceTree = ""; }; 5CA4DB761B06383B5B8CF082 /* GoogleCloudSTTPlugin.bundle */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = GoogleCloudSTTPlugin.bundle; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -812,6 +820,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + FF00000000000000000144 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + AA00000000000000000291 /* TypeWhisperPluginSDK in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; B44AC50D02E0B348403BEE41 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -879,6 +895,7 @@ CC00000000000000000039 /* ClaudePlugin */, CC00000000000000000041 /* GladiaPlugin */, CC00000000000000000040 /* ElevenLabsPlugin */, + CC00000000000000000042 /* SonioxPlugin */, CC00000000000000000025 /* TypeWhisperWidgetExtension */, CC00000000000000000026 /* TypeWhisperWidgetShared */, CC00000000000000000090 /* Products */, @@ -1392,6 +1409,17 @@ path = Plugins/ElevenLabsPlugin; sourceTree = ""; }; + CC00000000000000000042 /* SonioxPlugin */ = { + isa = PBXGroup; + children = ( + BB00000000000000000274 /* SonioxPlugin.swift */, + BB00000000000000000275 /* manifest.json */, + BB00000000000000000276 /* Localizable.xcstrings */, + ); + name = SonioxPlugin; + path = Plugins/SonioxPlugin; + sourceTree = ""; + }; 0C3F892FBBE11FA7B5924B24 /* GoogleCloudSTTPlugin */ = { isa = PBXGroup; children = ( @@ -1432,6 +1460,7 @@ BB00000000000000000262 /* ClaudePlugin.bundle */, BB00000000000000000270 /* GladiaPlugin.bundle */, BB00000000000000000266 /* ElevenLabsPlugin.bundle */, + BB00000000000000000277 /* SonioxPlugin.bundle */, BB00000000000000000188 /* TypeWhisperWidgetExtension.appex */, 5CA4DB761B06383B5B8CF082 /* GoogleCloudSTTPlugin.bundle */, E6F8E5940EF4832A7B8D735D /* TypeWhisperTests.xctest */, @@ -2053,6 +2082,26 @@ productReference = BB00000000000000000266 /* ElevenLabsPlugin.bundle */; productType = "com.apple.product-type.bundle"; }; + DD00000000000000000030 /* SonioxPlugin */ = { + isa = PBXNativeTarget; + buildConfigurationList = FF00000000000000000146 /* Build configuration list for PBXNativeTarget "SonioxPlugin" */; + buildPhases = ( + FF00000000000000000143 /* Sources */, + FF00000000000000000144 /* Frameworks */, + FF00000000000000000145 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = SonioxPlugin; + packageProductDependencies = ( + PP00000000000000000041 /* TypeWhisperPluginSDK */, + ); + productName = SonioxPlugin; + productReference = BB00000000000000000277 /* SonioxPlugin.bundle */; + productType = "com.apple.product-type.bundle"; + }; F3E17BDD5CED7E75F4153E95 /* GoogleCloudSTTPlugin */ = { isa = PBXNativeTarget; buildConfigurationList = 7C80F8B2CDA6AFA512E7E2E3 /* Build configuration list for PBXNativeTarget "GoogleCloudSTTPlugin" */; @@ -2140,6 +2189,7 @@ DD00000000000000000027 /* ClaudePlugin */, DD00000000000000000029 /* GladiaPlugin */, DD00000000000000000028 /* ElevenLabsPlugin */, + DD00000000000000000030 /* SonioxPlugin */, DD00000000000000000014 /* TypeWhisperWidgetExtension */, 40F22D3F350BA09ADEFBD2CB /* TypeWhisperTests */, F3E17BDD5CED7E75F4153E95 /* GoogleCloudSTTPlugin */, @@ -2398,6 +2448,15 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + FF00000000000000000145 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + AA00000000000000000289 /* manifest.json in Resources */, + AA00000000000000000290 /* Localizable.xcstrings in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 5FEDEFEE5F8724EBC9AB3898 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -2770,6 +2829,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + FF00000000000000000143 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + AA00000000000000000288 /* SonioxPlugin.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 6BB8C9A39AC6D396AE808535 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -4275,6 +4342,54 @@ }; name = Release; }; + XX00000000000000000119 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = Soniox; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSPrincipalClass = SonioxPlugin; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Bundles"; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.typewhisper.soniox; + PRODUCT_NAME = SonioxPlugin; + SDKROOT = macosx; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 6.0; + WRAPPER_EXTENSION = bundle; + }; + name = Debug; + }; + XX00000000000000000120 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = Soniox; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSPrincipalClass = SonioxPlugin; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Bundles"; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.typewhisper.soniox; + PRODUCT_NAME = SonioxPlugin; + SDKROOT = macosx; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 6.0; + WRAPPER_EXTENSION = bundle; + }; + name = Release; + }; 31C90797268650EB47DEEB4B /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -4596,6 +4711,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + FF00000000000000000146 /* Build configuration list for PBXNativeTarget "SonioxPlugin" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + XX00000000000000000119 /* Debug */, + XX00000000000000000120 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 7C80F8B2CDA6AFA512E7E2E3 /* Build configuration list for PBXNativeTarget "GoogleCloudSTTPlugin" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -4812,6 +4936,10 @@ isa = XCSwiftPackageProductDependency; productName = TypeWhisperPluginSDK; }; + PP00000000000000000041 /* TypeWhisperPluginSDK */ = { + isa = XCSwiftPackageProductDependency; + productName = TypeWhisperPluginSDK; + }; PP00000000000000000040 /* MediaRemoteAdapter */ = { isa = XCSwiftPackageProductDependency; package = RR00000000000000000007 /* XCRemoteSwiftPackageReference "mediaremote-adapter" */; From c19287ef5d62778d93b3f1ca0fc4403d8f5b4033 Mon Sep 17 00:00:00 2001 From: Marco Hillger Date: Fri, 3 Apr 2026 22:53:31 +0200 Subject: [PATCH 2/2] Merge main: renumber SonioxPlugin pbxproj IDs to avoid OpenRouterPlugin conflicts OpenRouterPlugin on main used the same ID ranges (AA0288-0291, BB0274-0277, DD0030, FF0143-0146, XX0119-0120, PP0041). Renumbered SonioxPlugin to AA0292-0295, BB0278-0281, DD0031, FF0147-0150, XX0121-0122, PP0042. --- TypeWhisper.xcodeproj/project.pbxproj | 61 +++++++++++++++------------ 1 file changed, 34 insertions(+), 27 deletions(-) diff --git a/TypeWhisper.xcodeproj/project.pbxproj b/TypeWhisper.xcodeproj/project.pbxproj index 4176cc7..1d9bdeb 100644 --- a/TypeWhisper.xcodeproj/project.pbxproj +++ b/TypeWhisper.xcodeproj/project.pbxproj @@ -258,6 +258,10 @@ AA00000000000000000289 /* manifest.json in Resources */ = {isa = PBXBuildFile; fileRef = BB00000000000000000275 /* manifest.json */; }; AA00000000000000000290 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = BB00000000000000000276 /* Localizable.xcstrings */; }; AA00000000000000000291 /* TypeWhisperPluginSDK in Frameworks */ = {isa = PBXBuildFile; productRef = PP00000000000000000041 /* TypeWhisperPluginSDK */; }; + AA00000000000000000292 /* SonioxPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB00000000000000000278 /* SonioxPlugin.swift */; }; + AA00000000000000000293 /* manifest.json in Resources */ = {isa = PBXBuildFile; fileRef = BB00000000000000000279 /* manifest.json */; }; + AA00000000000000000294 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = BB00000000000000000280 /* Localizable.xcstrings */; }; + AA00000000000000000295 /* TypeWhisperPluginSDK in Frameworks */ = {isa = PBXBuildFile; productRef = PP00000000000000000042 /* TypeWhisperPluginSDK */; }; AA00000000000000000275 /* ElevenLabsPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB00000000000000000263 /* ElevenLabsPlugin.swift */; }; AA00000000000000000276 /* manifest.json in Resources */ = {isa = PBXBuildFile; fileRef = BB00000000000000000264 /* manifest.json */; }; AA00000000000000000277 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = BB00000000000000000265 /* Localizable.xcstrings */; }; @@ -569,10 +573,10 @@ BB00000000000000000272 /* TermPackRegistryService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TermPackRegistryService.swift; sourceTree = ""; }; BB00000000000000000273 /* MediaPlaybackService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlaybackService.swift; sourceTree = ""; }; A8D4F2B6E1C3097B5F8A4E62 /* DictionaryExporterTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DictionaryExporterTests.swift; sourceTree = ""; }; - BB00000000000000000274 /* SonioxPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SonioxPlugin.swift; sourceTree = ""; }; - BB00000000000000000275 /* manifest.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = manifest.json; sourceTree = ""; }; - BB00000000000000000276 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; - BB00000000000000000277 /* SonioxPlugin.bundle */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SonioxPlugin.bundle; sourceTree = BUILT_PRODUCTS_DIR; }; + BB00000000000000000278 /* SonioxPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SonioxPlugin.swift; sourceTree = ""; }; + BB00000000000000000279 /* manifest.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = manifest.json; sourceTree = ""; }; + BB00000000000000000280 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; + BB00000000000000000281 /* SonioxPlugin.bundle */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SonioxPlugin.bundle; sourceTree = BUILT_PRODUCTS_DIR; }; 01D368E23E2A0AB2390B34EA /* GoogleCloudSTTPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoogleCloudSTTPlugin.swift; sourceTree = ""; }; 1D584661B6B55F6329CB5FC4 /* manifest.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = manifest.json; sourceTree = ""; }; 5CA4DB761B06383B5B8CF082 /* GoogleCloudSTTPlugin.bundle */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = GoogleCloudSTTPlugin.bundle; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -840,11 +844,11 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - FF00000000000000000144 /* Frameworks */ = { + FF00000000000000000148 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - AA00000000000000000291 /* TypeWhisperPluginSDK in Frameworks */, + AA00000000000000000295 /* TypeWhisperPluginSDK in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -908,6 +912,7 @@ CC00000000000000000041 /* GladiaPlugin */, CC00000000000000000040 /* ElevenLabsPlugin */, CC00000000000000000091 /* OpenRouterPlugin */, + CC00000000000000000042 /* SonioxPlugin */, CC00000000000000000025 /* TypeWhisperWidgetExtension */, CC00000000000000000026 /* TypeWhisperWidgetShared */, CC00000000000000000090 /* Products */, @@ -1435,9 +1440,9 @@ CC00000000000000000042 /* SonioxPlugin */ = { isa = PBXGroup; children = ( - BB00000000000000000274 /* SonioxPlugin.swift */, - BB00000000000000000275 /* manifest.json */, - BB00000000000000000276 /* Localizable.xcstrings */, + BB00000000000000000278 /* SonioxPlugin.swift */, + BB00000000000000000279 /* manifest.json */, + BB00000000000000000280 /* Localizable.xcstrings */, ); name = SonioxPlugin; path = Plugins/SonioxPlugin; @@ -1484,6 +1489,7 @@ BB00000000000000000270 /* GladiaPlugin.bundle */, BB00000000000000000266 /* ElevenLabsPlugin.bundle */, BB00000000000000000277 /* OpenRouterPlugin.bundle */, + BB00000000000000000281 /* SonioxPlugin.bundle */, BB00000000000000000188 /* TypeWhisperWidgetExtension.appex */, 5CA4DB761B06383B5B8CF082 /* GoogleCloudSTTPlugin.bundle */, E6F8E5940EF4832A7B8D735D /* TypeWhisperTests.xctest */, @@ -2125,13 +2131,13 @@ productReference = BB00000000000000000266 /* ElevenLabsPlugin.bundle */; productType = "com.apple.product-type.bundle"; }; - DD00000000000000000030 /* SonioxPlugin */ = { + DD00000000000000000031 /* SonioxPlugin */ = { isa = PBXNativeTarget; - buildConfigurationList = FF00000000000000000146 /* Build configuration list for PBXNativeTarget "SonioxPlugin" */; + buildConfigurationList = FF00000000000000000150 /* Build configuration list for PBXNativeTarget "SonioxPlugin" */; buildPhases = ( - FF00000000000000000143 /* Sources */, - FF00000000000000000144 /* Frameworks */, - FF00000000000000000145 /* Resources */, + FF00000000000000000147 /* Sources */, + FF00000000000000000148 /* Frameworks */, + FF00000000000000000149 /* Resources */, ); buildRules = ( ); @@ -2139,10 +2145,10 @@ ); name = SonioxPlugin; packageProductDependencies = ( - PP00000000000000000041 /* TypeWhisperPluginSDK */, + PP00000000000000000042 /* TypeWhisperPluginSDK */, ); productName = SonioxPlugin; - productReference = BB00000000000000000277 /* SonioxPlugin.bundle */; + productReference = BB00000000000000000281 /* SonioxPlugin.bundle */; productType = "com.apple.product-type.bundle"; }; F3E17BDD5CED7E75F4153E95 /* GoogleCloudSTTPlugin */ = { @@ -2233,6 +2239,7 @@ DD00000000000000000029 /* GladiaPlugin */, DD00000000000000000028 /* ElevenLabsPlugin */, DD00000000000000000030 /* OpenRouterPlugin */, + DD00000000000000000031 /* SonioxPlugin */, DD00000000000000000014 /* TypeWhisperWidgetExtension */, 40F22D3F350BA09ADEFBD2CB /* TypeWhisperTests */, F3E17BDD5CED7E75F4153E95 /* GoogleCloudSTTPlugin */, @@ -2508,12 +2515,12 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - FF00000000000000000145 /* Resources */ = { + FF00000000000000000149 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - AA00000000000000000289 /* manifest.json in Resources */, - AA00000000000000000290 /* Localizable.xcstrings in Resources */, + AA00000000000000000293 /* manifest.json in Resources */, + AA00000000000000000294 /* Localizable.xcstrings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -2881,11 +2888,11 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - FF00000000000000000143 /* Sources */ = { + FF00000000000000000147 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - AA00000000000000000288 /* SonioxPlugin.swift in Sources */, + AA00000000000000000292 /* SonioxPlugin.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4402,7 +4409,7 @@ }; name = Release; }; - XX00000000000000000119 /* Debug */ = { + XX00000000000000000121 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; @@ -4426,7 +4433,7 @@ }; name = Debug; }; - XX00000000000000000120 /* Release */ = { + XX00000000000000000122 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; @@ -4819,11 +4826,11 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - FF00000000000000000146 /* Build configuration list for PBXNativeTarget "SonioxPlugin" */ = { + FF00000000000000000150 /* Build configuration list for PBXNativeTarget "SonioxPlugin" */ = { isa = XCConfigurationList; buildConfigurations = ( - XX00000000000000000119 /* Debug */, - XX00000000000000000120 /* Release */, + XX00000000000000000121 /* Debug */, + XX00000000000000000122 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; @@ -5062,7 +5069,7 @@ package = RR00000000000000000007 /* XCRemoteSwiftPackageReference "mediaremote-adapter" */; productName = MediaRemoteAdapter; }; - PP00000000000000000041 /* TypeWhisperPluginSDK */ = { + PP00000000000000000042 /* TypeWhisperPluginSDK */ = { isa = XCSwiftPackageProductDependency; productName = TypeWhisperPluginSDK; };