diff --git a/Tests/AppShellSupportTests/MacAppPreferencesStoreTests.swift b/Tests/AppShellSupportTests/MacAppPreferencesStoreTests.swift index 0745918..a163990 100644 --- a/Tests/AppShellSupportTests/MacAppPreferencesStoreTests.swift +++ b/Tests/AppShellSupportTests/MacAppPreferencesStoreTests.swift @@ -19,6 +19,7 @@ final class MacAppPreferencesStoreTests: XCTestCase { userDefaults.set(true, forKey: "wispr.settings.aiProcessingEnabled") userDefaults.set(false, forKey: "wispr.settings.launchOnLoginEnabled") userDefaults.set(false, forKey: "wispr.settings.automaticallyCheckForUpdates") + userDefaults.set(true, forKey: "wispr.settings.autoResolveVoiceModelConflicts") userDefaults.set("test-model", forKey: "wispr.settings.selectedAIModelID") userDefaults.set(85.0, forKey: "wispr.settings.soundEffectsVolume") userDefaults.set( @@ -38,6 +39,7 @@ final class MacAppPreferencesStoreTests: XCTestCase { XCTAssertTrue(snapshot.aiProcessingEnabled) XCTAssertFalse(snapshot.launchOnLoginEnabled) XCTAssertFalse(snapshot.automaticallyCheckForUpdates) + XCTAssertTrue(snapshot.autoResolveVoiceModelConflicts) XCTAssertEqual(snapshot.selectedAIModelID, "test-model") XCTAssertEqual(snapshot.soundEffectsVolume, 85) XCTAssertEqual(snapshot.remoteProviders.count, 1) @@ -57,6 +59,7 @@ final class MacAppPreferencesStoreTests: XCTestCase { XCTAssertTrue(snapshot.automaticallyCheckForUpdates) XCTAssertEqual(snapshot.selectedLanguage, .german) XCTAssertEqual(snapshot.translationOutputMode, .original) + XCTAssertFalse(snapshot.autoResolveVoiceModelConflicts) } } #endif diff --git a/Tests/AppShellSupportTests/SettingsSearchPresentationTests.swift b/Tests/AppShellSupportTests/SettingsSearchPresentationTests.swift index aa71511..c650bd5 100644 --- a/Tests/AppShellSupportTests/SettingsSearchPresentationTests.swift +++ b/Tests/AppShellSupportTests/SettingsSearchPresentationTests.swift @@ -23,6 +23,12 @@ final class SettingsSearchPresentationTests: XCTestCase { XCTAssertEqual(presentation.results.map(\.id), ["advanced.uninstall"]) } + func testSearchFindsVoiceModelCompatibilitySettings() { + let presentation = SettingsSearchPresentation(searchText: "kompatibilität", language: .german) + + XCTAssertEqual(presentation.results.map(\.id), ["speech.language"]) + } + func testSearchDoesNotExposePersonalTextOrUnknownTerms() { let presentation = SettingsSearchPresentation(searchText: "private dictation text", language: .german) diff --git a/Tests/AppShellSupportTests/VoiceModelCapabilityPolicyTests.swift b/Tests/AppShellSupportTests/VoiceModelCapabilityPolicyTests.swift index ccec3ec..58d921d 100644 --- a/Tests/AppShellSupportTests/VoiceModelCapabilityPolicyTests.swift +++ b/Tests/AppShellSupportTests/VoiceModelCapabilityPolicyTests.swift @@ -43,6 +43,24 @@ final class VoiceModelCapabilityPolicyTests: XCTestCase { catalog: models, installedFiles: files)?.id, standard.id) } + func testPreservingSuggestionsKeepEveryCurrentCapabilityAndPreferInstalledModels() { + let current = model("whisper.nano.en") + let standard = model("whisper.standard") + let choices: [VoiceModelCapabilityChoice] = [ + .translation(.english), .liveText, .quality(.accurate), + ] + + let suggestions = policy.suggestedModels( + preserving: choices, currentModel: current, currentLanguage: .english, + catalog: models, installedFiles: [standard.localFileName!] + ) + + XCTAssertEqual(suggestions.first?.id, standard.id) + XCTAssertTrue(suggestions.allSatisfy { candidate in + choices.allSatisfy { policy.supports($0, model: candidate, currentLanguage: .english) } + }) + } + func testLanguageSuggestionSupportsBothCurrentAndRequestedLanguage() { let englishNano = model("whisper.nano.en") let standard = model("whisper.standard") @@ -60,6 +78,58 @@ final class VoiceModelCapabilityPolicyTests: XCTestCase { installedFiles: ["german-only.bin"])?.id, standard.id) } + func testLanguageSuggestionsPrioritizeRequestedLanguageOverCurrentLanguage() { + let englishNano = model("whisper.nano.en") + let germanModel = VoiceModelDescriptor( + id: "german-only", providerID: VoiceProviderID.whisperCpp.rawValue, + displayName: "German only", languageCode: "de", languageScope: .multilingual, + supportsTranslationToEnglish: false, + supportsAutomaticLanguageDetection: false, + speedScore: 9, accuracyScore: 3, sizeLabel: "S", + installState: .downloadable, localFileName: "german-only.bin" + ) + + let suggestions = policy.suggestedModels( + for: .language(.german), currentModel: englishNano, + currentLanguage: .german, catalog: [englishNano, germanModel], + installedFiles: ["german-only.bin"] + ) + + XCTAssertEqual(suggestions.map(\.id), [germanModel.id]) + } + + func testAutoQualityProfileIsSupportedWithoutQualityProfiles() { + let parakeet = model("parakeet.multilingual") + + XCTAssertTrue(policy.supports(.quality(.auto), model: parakeet, currentLanguage: .auto)) + XCTAssertFalse(policy.supports(.quality(.accurate), model: parakeet, currentLanguage: .auto)) + } + + func testSupportedLanguagesFollowDescriptorCapabilities() { + let parakeet = model("parakeet.multilingual") + + XCTAssertEqual(policy.supportedLanguages(for: parakeet), [.auto]) + XCTAssertEqual( + Set(policy.supportedLanguages(for: model("whisper.standard"))), + Set(DictationLanguage.allCases) + ) + XCTAssertEqual(policy.suggestedLanguage(for: model("whisper.nano.en"), currentLanguage: .german), .english) + } + + func testAutomaticResolutionNeverSelectsAnUndownloadedModel() { + let parakeet = model("parakeet.multilingual") + let standard = model("whisper.standard") + + XCTAssertNil(policy.bestInstalledSuggestion( + for: .liveText, currentModel: parakeet, currentLanguage: .auto, + catalog: [parakeet, standard], installedFiles: [] + )) + XCTAssertEqual(policy.bestInstalledSuggestion( + for: .liveText, currentModel: parakeet, currentLanguage: .auto, + catalog: [parakeet, standard], installedFiles: [standard.localFileName!] + )?.id, standard.id) + } + func testEffectiveModelUsesInstalledLanguageOverrideLikeSessionBuilder() { let englishNano = model("whisper.nano.en") let standard = model("whisper.standard") diff --git a/apps/macos/AppShell/MacAppPreferencesStore.swift b/apps/macos/AppShell/MacAppPreferencesStore.swift index fc7d681..c9a81ee 100644 --- a/apps/macos/AppShell/MacAppPreferencesStore.swift +++ b/apps/macos/AppShell/MacAppPreferencesStore.swift @@ -9,6 +9,7 @@ struct MacAppPreferencesSnapshot { let translationOutputMode: TranslationOutputMode let visibleMenuBarLanguages: [String] let performanceProfile: DictationPerformance + let autoResolveVoiceModelConflicts: Bool let selectedVoiceProviderID: String let selectedVoiceModelID: String let voiceLanguageOverrides: [VoiceLanguageOverride] @@ -76,6 +77,7 @@ struct MacAppPreferencesStore { visibleMenuBarLanguages: userDefaults.stringArray(forKey: Keys.visibleMenuBarLanguages) ?? DictationLanguage.allCases.filter { $0 != .auto }.map(\.rawValue), performanceProfile: enumValue(Keys.performanceProfile, default: .auto), + autoResolveVoiceModelConflicts: bool(Keys.autoResolveVoiceModelConflicts, default: false), selectedVoiceProviderID: userDefaults.string(forKey: Keys.selectedVoiceProviderID) ?? LocalVoiceModelCatalog.defaultProviderID, selectedVoiceModelID: userDefaults.string(forKey: Keys.selectedVoiceModelID) @@ -248,6 +250,7 @@ struct MacAppPreferencesStore { static let translationOutputMode = "wispr.settings.translationOutputMode" static let visibleMenuBarLanguages = "wispr.settings.visibleMenuBarLanguages" static let performanceProfile = "wispr.settings.performanceProfile" + static let autoResolveVoiceModelConflicts = "wispr.settings.autoResolveVoiceModelConflicts" static let selectedVoiceProviderID = "wispr.settings.selectedVoiceProviderID" static let selectedVoiceModelID = "wispr.settings.selectedVoiceModelID" static let voiceLanguageOverrides = "wispr.settings.voiceLanguageOverrides" diff --git a/apps/macos/AppShell/MacAppState.swift b/apps/macos/AppShell/MacAppState.swift index 0e1724a..5f61741 100644 --- a/apps/macos/AppShell/MacAppState.swift +++ b/apps/macos/AppShell/MacAppState.swift @@ -320,6 +320,14 @@ final class MacAppState: ObservableObject { } } + @Published var autoResolveVoiceModelConflicts: Bool { + didSet { + userDefaults.set( + autoResolveVoiceModelConflicts, + forKey: UserDefaultsKeys.autoResolveVoiceModelConflicts) + } + } + @Published var selectedVoiceProviderID: String { didSet { if oldValue != selectedVoiceProviderID { voiceSelectionRevision &+= 1 } @@ -999,12 +1007,16 @@ final class MacAppState: ObservableObject { } var selectedVoiceModelLanguageOptions: [DictationLanguage] { - [.auto] + DictationLanguage.allCases.filter { $0 != .auto } + guard let model = activeVoiceModel ?? selectedVoiceModel else { + return [.auto] + DictationLanguage.allCases.filter { $0 != .auto } + } + let supported = VoiceModelCapabilityPolicy().supportedLanguages(for: model) + return supported.filter { $0 == .auto } + supported.filter { $0 != .auto } } var menuBarLanguageOptions: [DictationLanguage] { - [.auto] + DictationLanguage.allCases.filter { - $0 != .auto && (visibleMenuBarLanguages.contains($0.rawValue) || $0 == selectedLanguage) + selectedVoiceModelLanguageOptions.filter { + $0 == .auto || visibleMenuBarLanguages.contains($0.rawValue) || $0 == selectedLanguage } } @@ -1104,6 +1116,7 @@ final class MacAppState: ObservableObject { static let translationOutputMode = "wispr.settings.translationOutputMode" static let visibleMenuBarLanguages = "wispr.settings.visibleMenuBarLanguages" static let performanceProfile = "wispr.settings.performanceProfile" + static let autoResolveVoiceModelConflicts = "wispr.settings.autoResolveVoiceModelConflicts" static let selectedVoiceProviderID = "wispr.settings.selectedVoiceProviderID" static let selectedVoiceModelID = "wispr.settings.selectedVoiceModelID" static let voiceLanguageOverrides = "wispr.settings.voiceLanguageOverrides" @@ -1282,6 +1295,7 @@ final class MacAppState: ObservableObject { self.translationOutputMode = preferences.translationOutputMode self.visibleMenuBarLanguages = preferences.visibleMenuBarLanguages self.performanceProfile = preferences.performanceProfile + self.autoResolveVoiceModelConflicts = preferences.autoResolveVoiceModelConflicts self.selectedVoiceProviderID = preferences.selectedVoiceProviderID self.selectedVoiceModelID = preferences.selectedVoiceModelID self.voiceLanguageOverrides = preferences.voiceLanguageOverrides diff --git a/apps/macos/AppShell/MacAppStateFacadeActions.swift b/apps/macos/AppShell/MacAppStateFacadeActions.swift index 1ef141c..160fe9e 100644 --- a/apps/macos/AppShell/MacAppStateFacadeActions.swift +++ b/apps/macos/AppShell/MacAppStateFacadeActions.swift @@ -54,12 +54,13 @@ extension MacAppState { } func toggleDictationModeFromShortcut() { - streamingEnabled.toggle() + guard streamingEnabled else { + requestVoiceCapability(.liveText) + return + } + streamingEnabled = false appendAudit("mode.toggle streamingEnabled=\(streamingEnabled)") - appendDiagnostic( - streamingEnabled - ? "Live-Einfügen aktiviert. Der Wechsel gilt ab dem nächsten Diktat." - : "Live-Einfügen deaktiviert. Der Wechsel gilt ab dem nächsten Diktat.") + appendDiagnostic("Live-Einfügen deaktiviert. Der Wechsel gilt ab dem nächsten Diktat.") } func revealAppDataFolder() { diff --git a/apps/macos/AppShell/MacAppStateModelCapabilities.swift b/apps/macos/AppShell/MacAppStateModelCapabilities.swift index 0f3e045..fea9eb1 100644 --- a/apps/macos/AppShell/MacAppStateModelCapabilities.swift +++ b/apps/macos/AppShell/MacAppStateModelCapabilities.swift @@ -71,29 +71,136 @@ extension MacAppState { } func selectActiveVoiceModel(_ model: VoiceModelDescriptor) { - let language = selectedLanguage - let hadOverride = selectedLanguageVoiceOverride != nil - setSelectedVoiceModel(model) - if shouldUpdateVoiceModelLanguageOverride( - previousLanguage: language, - selectedLanguage: selectedLanguage, - hadOverride: hadOverride - ) { - voiceLanguageOverrides.removeAll { $0.languageCode == language.rawValue } - if VoiceModelCapabilityPolicy().supports(language: language, model: model) { - voiceLanguageOverrides.append(VoiceLanguageOverride( - languageCode: language.rawValue, modelID: model.id)) + requestVoiceModelSelection(model) + } + + func requestVoiceModelSelection(_ model: VoiceModelDescriptor) { + guard model.installState != .unavailable, + !isVoiceModelInstalled(model) + || voiceProviders.contains(where: { $0.id == model.providerID && $0.isAvailable }) + else { + showCapabilityModelManagementAlert() + return + } + let policy = VoiceModelCapabilityPolicy() + let languages = policy.supportedLanguages(for: model) + guard let suggestedLanguage = policy.suggestedLanguage( + for: model, currentLanguage: selectedLanguage + ) else { + showCapabilityModelManagementAlert() + return + } + + let conflicts = incompatibleActiveCapabilities(model: model, language: suggestedLanguage) + let isInstalled = isVoiceModelInstalled(model) + guard !conflicts.isEmpty || !isInstalled else { + applyVoiceModelSelection(model, language: suggestedLanguage) + return + } + + let revision = voiceSelectionRevision + if autoResolveVoiceModelConflicts, isInstalled { + applyVoiceModelSelection(model, language: suggestedLanguage) + return + } + + let alert = NSAlert() + alert.messageText = capabilityText("Modell und Einstellungen anpassen", "Adjust model settings") + alert.informativeText = modelSelectionMessage( + model: model, + originalLanguage: selectedLanguage, + language: suggestedLanguage + ) + let languagePopup: NSPopUpButton? + if languages.contains(selectedLanguage) { + languagePopup = nil + } else { + languagePopup = languageSelectionPopup( + languages: languages, + selected: suggestedLanguage + ) + } + let alternatives = languagePopup == nil + ? suggestedModelsPreservingCurrentCapabilities(from: model, language: suggestedLanguage) + : [] + let alternativePopup = alternatives.isEmpty ? nil : modelSelectionPopup(alternatives, for: nil) + if !conflicts.isEmpty || languagePopup != nil || alternativePopup != nil { + alert.accessoryView = modelSelectionAccessory( + conflicts: conflicts, + languagePopup: languagePopup, + alternativePopup: alternativePopup, + alternativeModelsAreInstalled: alternatives.first.map(isVoiceModelInstalled) ?? false + ) + } + configureAutoResolveSuppression(on: alert) + if alternativePopup != nil { + let alternativesInstalled = alternatives.first.map(isVoiceModelInstalled) ?? false + alert.addButton(withTitle: capabilityText( + isInstalled ? "Auswahl übernehmen" : "Herunterladen und übernehmen", + isInstalled ? "Use selected model" : "Download and use selected model" + )) + alert.addButton(withTitle: capabilityText( + alternativesInstalled ? "Kompatibles Modell übernehmen" : "Kompatibles Modell herunterladen", + alternativesInstalled ? "Use compatible model" : "Download compatible model" + )) + } else { + alert.addButton(withTitle: capabilityText( + isInstalled ? "Auswahl übernehmen" : "Herunterladen und übernehmen", + isInstalled ? "Use selected model" : "Download and use selected model" + )) + } + alert.addButton(withTitle: capabilityText("Abbrechen", "Cancel")) + NSApplication.shared.activate(ignoringOtherApps: true) + let response = alert.runModal() + guard voiceSelectionRevision == revision else { return } + let shouldAutoResolve = alert.suppressionButton?.state == .on + let chosenModel: VoiceModelDescriptor? + if let alternativePopup, response == .alertSecondButtonReturn { + chosenModel = selectedModel(from: alternativePopup, options: alternatives) + } else if response == .alertFirstButtonReturn { + chosenModel = model + } else { + chosenModel = nil + } + guard let chosenModel else { return } + let language: DictationLanguage + if chosenModel.id == model.id { + guard let selected = selectedLanguage( + from: languagePopup, options: languages, fallback: suggestedLanguage + ) else { return } + language = selected + } else { + language = suggestedLanguage + } + applyOrInstallVoiceModelSelection( + chosenModel, + language: language, + revision: revision, + shouldAutoResolve: shouldAutoResolve + ) + } + + private func applyOrInstallVoiceModelSelection( + _ model: VoiceModelDescriptor, + language: DictationLanguage, + revision: UInt64, + shouldAutoResolve: Bool + ) { + guard isVoiceModelInstalled(model) else { + speechModelController.installVoiceModelForCapability(model) { [weak self] in + guard let self, self.voiceSelectionRevision == revision else { return } + self.saveAutoResolveChoice(shouldAutoResolve) + self.applyVoiceModelSelection(model, language: language) } - speechModelController.sanitizeSpeechModelSelections() + return } + saveAutoResolveChoice(shouldAutoResolve) + applyVoiceModelSelection(model, language: language) } func requestVoiceCapability(_ choice: VoiceModelCapabilityChoice) { guard let source = activeVoiceModel ?? selectedVoiceModel else { - Task { @MainActor [weak self] in - await Task.yield() - self?.showCapabilityModelManagementAlert() - } + applyVoiceCapability(choice, model: nil) return } if supportsCapability(choice) { @@ -102,54 +209,316 @@ extension MacAppState { } let policy = VoiceModelCapabilityPolicy() - let selectionRevision = voiceSelectionRevision + let revision = voiceSelectionRevision let usableCatalog = voiceModels.filter { model in let installed = policy.isInstalled(model, files: installedVoiceModelFileNames) return !installed || voiceProviders.contains { $0.id == model.providerID && $0.isAvailable } } - let suggestion = policy.suggestedModel( + let suggestions = policy.suggestedModels( for: choice, currentModel: source, - currentLanguage: selectedLanguage, + currentLanguage: choice.requiredLanguage(current: selectedLanguage), catalog: usableCatalog, installedFiles: installedVoiceModelFileNames ) - Task { @MainActor [weak self] in - await Task.yield() - guard let self else { return } - guard let suggestion else { - self.showCapabilityModelManagementAlert() - return + guard !suggestions.isEmpty else { + showCapabilityModelManagementAlert() + return + } + if autoResolveVoiceModelConflicts, + let installedSuggestion = policy.bestInstalledSuggestion( + for: choice, + currentModel: source, + currentLanguage: choice.requiredLanguage(current: selectedLanguage), + catalog: usableCatalog, + installedFiles: installedVoiceModelFileNames + ) { + applyVoiceCapability(choice, model: installedSuggestion) + return + } + + let installedSuggestions = suggestions.filter(isVoiceModelInstalled) + let modelOptions = installedSuggestions.isEmpty ? suggestions : installedSuggestions + let alert = NSAlert() + alert.messageText = capabilityText("Modellwechsel erforderlich", "Change speech model") + alert.informativeText = capabilityText( + isVoiceModelInstalled(modelOptions[0]) + ? "Vorschlag: Wähle ein installiertes Modell, das diese Auswahl unterstützt." + : "Kein installiertes Modell passt. Die aufgeführten Modelle müssen nach deiner Bestätigung heruntergeladen werden.", + isVoiceModelInstalled(modelOptions[0]) + ? "Suggested: choose an installed model that supports this option." + : "No installed model matches. The listed models will download only after you confirm." + ) + let modelPopup = modelSelectionPopup(modelOptions, for: choice) + alert.accessoryView = accessoryStack( + label: capabilityText( + isVoiceModelInstalled(modelOptions[0]) ? "Installiertes Modell" : "Modell zum Herunterladen", + isVoiceModelInstalled(modelOptions[0]) ? "Installed model" : "Model to download" + ), + control: modelPopup + ) + configureAutoResolveSuppression(on: alert) + alert.addButton(withTitle: capabilityText( + isVoiceModelInstalled(modelOptions[0]) ? "Modell übernehmen" : "Herunterladen und übernehmen", + isVoiceModelInstalled(modelOptions[0]) ? "Use model" : "Download and use model" + )) + alert.addButton(withTitle: capabilityText("Abbrechen", "Cancel")) + NSApplication.shared.activate(ignoringOtherApps: true) + guard alert.runModal() == .alertFirstButtonReturn, + voiceSelectionRevision == revision, + let selectedModel = selectedModel(from: modelPopup, options: modelOptions) + else { return } + let shouldAutoResolve = alert.suppressionButton?.state == .on + applyOrInstallCapabilityChoice( + choice, + model: selectedModel, + revision: revision, + sourceID: source.id, + shouldAutoResolve: shouldAutoResolve + ) + } + + func resetVoiceModelCapabilityHints() { + autoResolveVoiceModelConflicts = false + } + + private func applyOrInstallCapabilityChoice( + _ choice: VoiceModelCapabilityChoice, + model: VoiceModelDescriptor, + revision: UInt64, + sourceID: String, + shouldAutoResolve: Bool + ) { + guard isVoiceModelInstalled(model) else { + speechModelController.installVoiceModelForCapability(model) { [weak self] in + guard let self, + self.voiceSelectionRevision == revision, + self.activeVoiceModel?.id == sourceID else { return } + self.saveAutoResolveChoice(shouldAutoResolve) + self.applyVoiceCapability(choice, model: model) } - let installed = self.isVoiceModelInstalled(suggestion) - let alert = NSAlert() - alert.messageText = self.capabilityText( - "Modellwechsel erforderlich", "Change speech model") - alert.informativeText = self.capabilityText( - "\(source.displayName) unterstützt diese Auswahl nicht. Zu \(suggestion.displayName) wechseln?", - "\(source.displayName) does not support this option. Switch to \(suggestion.displayName)?") - alert.addButton(withTitle: installed - ? self.capabilityText("Modell wechseln", "Switch model") - : self.capabilityText("Herunterladen und wechseln", "Download and switch")) - alert.addButton(withTitle: self.capabilityText("Abbrechen", "Cancel")) - NSApplication.shared.activate(ignoringOtherApps: true) - guard alert.runModal() == .alertFirstButtonReturn, - self.voiceSelectionRevision == selectionRevision, - (self.activeVoiceModel ?? self.selectedVoiceModel)?.id == source.id else { return } - - if installed { - self.applyVoiceCapability(choice, model: suggestion) - } else { - self.speechModelController.installVoiceModelForCapability(suggestion) { [weak self] in - guard let self, - self.voiceSelectionRevision == selectionRevision, - (self.activeVoiceModel ?? self.selectedVoiceModel)?.id == source.id else { return } - self.applyVoiceCapability(choice, model: suggestion) - } + return + } + saveAutoResolveChoice(shouldAutoResolve) + applyVoiceCapability(choice, model: model) + } + + private func applyVoiceModelSelection( + _ model: VoiceModelDescriptor, + language: DictationLanguage + ) { + let previousLanguage = selectedLanguage + let hadOverride = selectedLanguageVoiceOverride != nil + setSelectedVoiceModel(model) + guard selectedVoiceModelID == model.id else { return } + if selectedLanguage != language { selectedLanguage = language } + if shouldUpdateVoiceModelLanguageOverride( + previousLanguage: previousLanguage, + selectedLanguage: selectedLanguage, + hadOverride: hadOverride + ) { + voiceLanguageOverrides.removeAll { $0.languageCode == previousLanguage.rawValue } + if VoiceModelCapabilityPolicy().supports(language: selectedLanguage, model: model) { + voiceLanguageOverrides.append(VoiceLanguageOverride( + languageCode: selectedLanguage.rawValue, modelID: model.id)) } + speechModelController.sanitizeSpeechModelSelections() + } + } + + private func incompatibleActiveCapabilities( + model: VoiceModelDescriptor, + language: DictationLanguage + ) -> [String] { + let policy = VoiceModelCapabilityPolicy() + var conflicts: [String] = [] + if !policy.supports(language: language, model: model) { + conflicts.append(capabilityText("Eingabesprache", "Input language")) + } + if translationOutputMode == .english, + !policy.supports(.translation(.english), model: model, currentLanguage: language) { + conflicts.append(capabilityText("Übersetzung", "Translation")) + } + if streamingEnabled && !policy.supports(.liveText, model: model, currentLanguage: language) { + conflicts.append(capabilityText("Live-Text", "Live text")) + } + if performanceProfile != .auto, + !policy.supports(.quality(performanceProfile), model: model, currentLanguage: language) { + conflicts.append(capabilityText("Qualitätsprofil", "Quality profile")) + } + return conflicts + } + + private func suggestedModelsPreservingCurrentCapabilities( + from selectedModel: VoiceModelDescriptor, + language: DictationLanguage + ) -> [VoiceModelDescriptor] { + var choices: [VoiceModelCapabilityChoice] = [] + if translationOutputMode == .english { choices.append(.translation(.english)) } + if streamingEnabled { choices.append(.liveText) } + if performanceProfile != .auto { choices.append(.quality(performanceProfile)) } + + let policy = VoiceModelCapabilityPolicy() + let catalog = voiceModels.filter { model in + !isVoiceModelInstalled(model) + || voiceProviders.contains { $0.id == model.providerID && $0.isAvailable } + } + let suggestions = policy.suggestedModels( + preserving: choices, + currentModel: selectedModel, + currentLanguage: language, + catalog: catalog, + installedFiles: installedVoiceModelFileNames + ) + let installed = suggestions.filter(isVoiceModelInstalled) + return installed.isEmpty ? suggestions : installed + } + + private func modelSelectionMessage( + model: VoiceModelDescriptor, + originalLanguage: DictationLanguage, + language: DictationLanguage + ) -> String { + let languageName = language.localizedDisplayName( + interfaceLanguageCode: capabilityAppLanguage.embeddedInterfaceCode + ) + let languageChange = originalLanguage == language + ? "" + : capabilityText( + "\nDie Eingabesprache wechselt von \(originalLanguage.localizedDisplayName(interfaceLanguageCode: capabilityAppLanguage.embeddedInterfaceCode)) zu \(languageName).", + "\nThe input language changes from \(originalLanguage.localizedDisplayName(interfaceLanguageCode: capabilityAppLanguage.embeddedInterfaceCode)) to \(languageName)." + ) + return capabilityText( + "\(model.displayName) unterstützt \(languageName).\(languageChange)", + "\(model.displayName) supports \(languageName).\(languageChange)" + ) + } + + private func modelSelectionPopup( + _ models: [VoiceModelDescriptor], + for choice: VoiceModelCapabilityChoice? + ) -> NSPopUpButton { + let popup = NSPopUpButton(frame: NSRect(x: 0, y: 0, width: 360, height: 26)) + popup.setAccessibilityLabel(capabilityText("Kompatibles Sprachmodell", "Compatible speech model")) + for model in models { + let state = isVoiceModelInstalled(model) + ? capabilityText("installiert", "installed") + : capabilityText("Download", "download") + let language = choice?.requiredLanguage(current: selectedLanguage) ?? selectedLanguage + let paused = incompatibleActiveCapabilities(model: model, language: language) + let pauseDescription = paused.isEmpty + ? "" + : capabilityText(" · pausiert: \(paused.joined(separator: ", "))", " · pauses: \(paused.joined(separator: ", "))") + popup.addItem(withTitle: "\(model.displayName) · \(state)\(pauseDescription)") + popup.lastItem?.representedObject = model.id + } + return popup + } + + private func modelSelectionAccessory( + conflicts: [String], + languagePopup: NSPopUpButton?, + alternativePopup: NSPopUpButton?, + alternativeModelsAreInstalled: Bool + ) -> NSView { + var views: [NSView] = [] + if !conflicts.isEmpty { + let title = NSTextField(labelWithString: capabilityText( + "Wird pausiert (Einstellung bleibt gespeichert)", + "Paused (preference stays saved)" + )) + title.font = .systemFont(ofSize: NSFont.smallSystemFontSize, weight: .semibold) + views.append(title) + views.append(contentsOf: conflicts.map { + NSTextField(wrappingLabelWithString: "• \($0)") + }) + } + if let languagePopup { + views.append(accessoryStack( + label: capabilityText("Diktatsprache", "Dictation language"), + control: languagePopup + )) + } + if let alternativePopup { + views.append(accessoryStack( + label: capabilityText( + alternativeModelsAreInstalled + ? "Vorschlag: kompatibles installiertes Modell" + : "Vorschlag: kompatibles Modell zum Download", + alternativeModelsAreInstalled + ? "Suggestion: compatible installed model" + : "Suggestion: compatible model to download" + ), + control: alternativePopup + )) + } + let stack = NSStackView(views: views) + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 6 + let contentHeight = views.reduce(CGFloat(0)) { $0 + max($1.frame.height, 20) } + let spacingHeight = CGFloat(max(views.count - 1, 0)) * stack.spacing + stack.frame = NSRect(x: 0, y: 0, width: 380, height: contentHeight + spacingHeight) + return stack + } + + private func languageSelectionPopup( + languages: [DictationLanguage], + selected: DictationLanguage + ) -> NSPopUpButton { + let popup = NSPopUpButton(frame: NSRect(x: 0, y: 0, width: 360, height: 26)) + popup.setAccessibilityLabel(capabilityText("Diktatsprache", "Dictation language")) + for language in languages { + popup.addItem(withTitle: language.localizedDisplayName( + interfaceLanguageCode: capabilityAppLanguage.embeddedInterfaceCode + )) + popup.lastItem?.representedObject = language.rawValue } + if let index = languages.firstIndex(of: selected) { popup.selectItem(at: index) } + return popup + } + + private func accessoryStack(label: String, control: NSView) -> NSView { + let title = NSTextField(labelWithString: label) + title.font = .systemFont(ofSize: NSFont.smallSystemFontSize) + let stack = NSStackView(views: [title, control]) + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 6 + stack.frame = NSRect(x: 0, y: 0, width: 380, height: 54) + return stack + } + + private func configureAutoResolveSuppression(on alert: NSAlert) { + alert.showsSuppressionButton = true + alert.suppressionButton?.title = capabilityText( + "Nicht mehr fragen (Auswahl automatisch anpassen)", + "Don't ask again (automatically adjust selection)" + ) + } + + private func saveAutoResolveChoice(_ shouldAutoResolve: Bool) { + if shouldAutoResolve { autoResolveVoiceModelConflicts = true } + } + + private func selectedModel( + from popup: NSPopUpButton, + options: [VoiceModelDescriptor] + ) -> VoiceModelDescriptor? { + guard let id = popup.selectedItem?.representedObject as? String else { return nil } + return options.first { $0.id == id } + } + + private func selectedLanguage( + from popup: NSPopUpButton?, + options: [DictationLanguage], + fallback: DictationLanguage + ) -> DictationLanguage? { + guard let popup else { return fallback } + guard let rawValue = popup.selectedItem?.representedObject as? String else { return nil } + return options.first { $0.rawValue == rawValue } } private func applyVoiceCapability( @@ -184,6 +553,8 @@ extension MacAppState { translationOutputMode = mode case .liveText: streamingEnabled = true + appendAudit("mode.toggle streamingEnabled=true") + appendDiagnostic("Live-Einfügen aktiviert. Der Wechsel gilt ab dem nächsten Diktat.") case .quality(let profile): performanceProfile = profile } @@ -205,8 +576,12 @@ extension MacAppState { } private func capabilityText(_ german: String, _ english: String) -> String { + capabilityAppLanguage.text(german, english) + } + + private var capabilityAppLanguage: AppLanguage { let raw = UserDefaults.standard.string(forKey: "wispr.uiLanguage") ?? AppLanguage.system.rawValue - return (AppLanguage(rawValue: raw) ?? .system).text(german, english) + return AppLanguage(rawValue: raw) ?? .system } } diff --git a/apps/macos/AppShell/MenuBarContentView.swift b/apps/macos/AppShell/MenuBarContentView.swift index 4d5aeab..b3720a6 100644 --- a/apps/macos/AppShell/MenuBarContentView.swift +++ b/apps/macos/AppShell/MenuBarContentView.swift @@ -381,23 +381,27 @@ struct MenuBarContentView: View { } } } label: { - HStack(alignment: .firstTextBaseline, spacing: 12) { - Text(text("Sprachmodell", "Voice model")) - Spacer(minLength: 8) - Text(appState.activeVoiceModel?.displayName ?? text("Auswählen…", "Choose…")) - .foregroundStyle(.secondary) - .lineLimit(1) + if appState.compactMenuBarDesign { + MenuActionLabel( + title: text("Sprachmodell", "Voice model"), + shortcutGlyph: nil, + shortcutText: nil + ) + } else { + HStack(alignment: .firstTextBaseline, spacing: 12) { + Text(text("Sprachmodell", "Voice model")) + Spacer(minLength: 8) + Text(appState.activeVoiceModel?.displayName ?? text("Auswählen…", "Choose…")) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) } - .frame(maxWidth: .infinity, alignment: .leading) } } private func selectVoiceModelFromMenu(_ model: VoiceModelDescriptor) { - if appState.isVoiceModelInstalled(model) { - appState.selectActiveVoiceModel(model) - } else if model.installState != .unavailable { - appState.installVoiceModel(model) - } + appState.requestVoiceModelSelection(model) } private func voiceModelMenuRowTitle(for model: VoiceModelDescriptor) -> String { @@ -593,42 +597,40 @@ struct MenuBarContentView: View { Divider() } - Toggle(text("Live-Text einfügen", "Insert live text"), isOn: appState.capabilityLiveTextBinding) - .disabled(appState.finalResultDeliveryMode == .clipboardOnly) + if appState.supportsCapability(.liveText) { + Toggle(text("Live-Text einfügen", "Insert live text"), isOn: appState.capabilityLiveTextBinding) + .disabled(appState.finalResultDeliveryMode == .clipboardOnly) + } Picker(text("Sprache", "Language"), selection: appState.capabilityLanguageBinding) { ForEach(visibleMenuBarLanguages) { language in Text( language.localizedDisplayName( interfaceLanguageCode: effectiveLanguage.embeddedInterfaceCode) - + (appState.supportsCapability(.language(language)) - ? "" : text(" · Modellwechsel nötig", " · Change model")) ) .tag(language) } } - Picker(text("Übersetzung", "Translation"), selection: appState.capabilityTranslationBinding) { - ForEach(TranslationOutputMode.allCases) { mode in - Text( - mode.localizedDisplayName( - interfaceLanguageCode: effectiveLanguage.embeddedInterfaceCode) - + (appState.supportsCapability(.translation(mode)) - ? "" : text(" · Modellwechsel nötig", " · Change model")) - ) - .tag(mode) + if appState.speechTranslationAvailable { + Picker(text("Übersetzung", "Translation"), selection: appState.capabilityTranslationBinding) { + ForEach(TranslationOutputMode.allCases) { mode in + Text(mode.localizedDisplayName( + interfaceLanguageCode: effectiveLanguage.embeddedInterfaceCode + )).tag(mode) + } } } - if !appState.compactMenuBarDesign { - if selectableVoiceModels.isEmpty { - Text(text("Keine Modelle erkannt", "No models detected")) - .font(.footnote) - .foregroundStyle(.secondary) - } else { - menuBarVoiceModelMenu - } + if selectableVoiceModels.isEmpty { + Text(text("Keine Modelle erkannt", "No models detected")) + .font(.footnote) + .foregroundStyle(.secondary) + } else { + menuBarVoiceModelMenu + } + if !appState.compactMenuBarDesign { if appState.activeVoiceModel?.supportsQualityProfile == true { Picker(text("Qualität", "Quality"), selection: appState.capabilityQualityBinding) { ForEach(DictationPerformance.allCases) { profile in diff --git a/apps/macos/AppShell/Onboarding/OnboardingView.swift b/apps/macos/AppShell/Onboarding/OnboardingView.swift index 80df46e..8767937 100644 --- a/apps/macos/AppShell/Onboarding/OnboardingView.swift +++ b/apps/macos/AppShell/Onboarding/OnboardingView.swift @@ -246,7 +246,7 @@ struct OnboardingView: View { .font(.title3.weight(.semibold)) LabeledContent(text("Sprache", "Language")) { - Picker(text("Sprache", "Language"), selection: $appState.selectedLanguage) { + Picker(text("Sprache", "Language"), selection: appState.capabilityLanguageBinding) { ForEach(DictationLanguage.allCases.filter { $0 != .auto }) { language in Text( language.localizedDisplayName( diff --git a/apps/macos/AppShell/SettingsFormPages.swift b/apps/macos/AppShell/SettingsFormPages.swift index 92c8572..457143c 100644 --- a/apps/macos/AppShell/SettingsFormPages.swift +++ b/apps/macos/AppShell/SettingsFormPages.swift @@ -35,6 +35,7 @@ struct SpeechSettingsPage: View { let qualitySectionTitle: String let translationSectionTitle: String let installedModelsSectionTitle: String + let showsTranslationSection: Bool let modelSelectionContent: AnyView let languageContent: AnyView let qualityContent: AnyView @@ -43,33 +44,42 @@ struct SpeechSettingsPage: View { var body: some View { SettingsFormPage( - sections: [ - SettingsPageSection( - id: "model", - title: modelSectionTitle, - content: modelSelectionContent - ), - SettingsPageSection( - id: "language", - title: languageSectionTitle, - content: languageContent - ), - SettingsPageSection( - id: "quality", - title: qualitySectionTitle, - content: qualityContent - ), - SettingsPageSection( - id: "translation", - title: translationSectionTitle, - content: translationContent - ), - SettingsPageSection( - id: "installed-models", - title: installedModelsSectionTitle, - content: installedModelsContent - ), - ] + sections: { + var sections = [ + SettingsPageSection( + id: "model", + title: modelSectionTitle, + content: modelSelectionContent + ), + SettingsPageSection( + id: "language", + title: languageSectionTitle, + content: languageContent + ), + SettingsPageSection( + id: "quality", + title: qualitySectionTitle, + content: qualityContent + ), + ] + if showsTranslationSection { + sections.append( + SettingsPageSection( + id: "translation", + title: translationSectionTitle, + content: translationContent + ) + ) + } + sections.append( + SettingsPageSection( + id: "installed-models", + title: installedModelsSectionTitle, + content: installedModelsContent + ) + ) + return sections + }() ) } } diff --git a/apps/macos/AppShell/SettingsSearchPresentation.swift b/apps/macos/AppShell/SettingsSearchPresentation.swift index 44de39c..f15f196 100644 --- a/apps/macos/AppShell/SettingsSearchPresentation.swift +++ b/apps/macos/AppShell/SettingsSearchPresentation.swift @@ -42,7 +42,7 @@ struct SettingsSearchPresentation { destination(.general, "app", "App", "Sprache und Startverhalten", ["language", "sprache", "menüleiste", "menu bar", "dock"]), destination(.general, "access", "Berechtigungen", "Mikrofon und Bedienungshilfen", ["permissions", "zugriff", "mikrofon", "accessibility", "bedienungshilfen"]), destination(.speech, "model", "Modell", "Lokale Spracherkennung", ["speech", "whisper", "parakeet", "download", "installieren"]), - destination(.speech, "language", "Sprache", "Erkennungssprache", ["language", "speech", "voice"]), + destination(.speech, "language", "Sprache", "Erkennungssprache", ["language", "speech", "voice", "kompatibilität", "kompatibilitaet", "compatibility", "modellwechsel", "hinweise", "model change"]), destination(.speech, "translation", "Übersetzung", "Sprachübersetzung", ["translation", "translate", "übersetzen"]), destination(.dictation, "live-rewrite", "Live-Anpassung", "Verarbeitung während des Diktats", ["streaming", "rewrite", "anpassung", "kontext"]), destination(.dictation, "text-input", "Texteingabe", "Einfügen und Zwischenablage", ["clipboard", "paste", "insert", "delivery", "auto-send"]), diff --git a/apps/macos/AppShell/SettingsView.swift b/apps/macos/AppShell/SettingsView.swift index 36e90de..ce0531a 100644 --- a/apps/macos/AppShell/SettingsView.swift +++ b/apps/macos/AppShell/SettingsView.swift @@ -31,7 +31,6 @@ struct SettingsView: View { @State var editingDictionaryTerm: String = "" @State var editingDictionaryLanguageCode: String = "" @State var editingDictionaryCategory: DictionaryTermCategory = .personalTerm - @State var pendingVoiceModelForInstallation: VoiceModelDescriptor? @Environment(\.accessibilityReduceMotion) private var accessibilityReduceMotion let personalWebsiteURL = URL(string: "https://leon-stadler.com")! @@ -356,6 +355,7 @@ struct SettingsView: View { qualitySectionTitle: text("Qualität", "Quality"), translationSectionTitle: text("Übersetzung", "Translation"), installedModelsSectionTitle: text("Modellverwaltung", "Model management"), + showsTranslationSection: appState.speechTranslationAvailable, modelSelectionContent: erasedView { speechModelSelectionContent }, languageContent: erasedView { speechLanguageContent }, qualityContent: erasedView { speechQualityContent }, diff --git a/apps/macos/AppShell/SettingsViewSpeechDictationSections.swift b/apps/macos/AppShell/SettingsViewSpeechDictationSections.swift index 522ddf0..bc4733a 100644 --- a/apps/macos/AppShell/SettingsViewSpeechDictationSections.swift +++ b/apps/macos/AppShell/SettingsViewSpeechDictationSections.swift @@ -148,23 +148,6 @@ extension SettingsView { .labelsHidden() .pickerStyle(.menu) .settingsFormMenuPickerSlot(minWidth: 240) - .confirmationDialog( - text("Modell herunterladen?", "Download model?"), - isPresented: pendingVoiceModelInstallDialogBinding - ) { - Button(text("Download und auswählen", "Download and choose")) { - if let model = pendingVoiceModelForInstallation { - appState.installVoiceModel(model) - pendingVoiceModelForInstallation = nil - } - } - Button(text("Abbrechen", "Cancel"), role: .cancel) {} - } message: { - Text( - pendingVoiceModelForInstallation.map(voiceModelInstallationMessage) - ?? "" - ) - } } } label: { SettingsFieldLabel( @@ -189,9 +172,8 @@ extension SettingsView { Text( language.localizedDisplayName( interfaceLanguageCode: effectiveLanguage.embeddedInterfaceCode) - + (appState.supportsCapability(.language(language)) - ? "" : text(" · Modellwechsel nötig", " · Change model")) - ).tag(language) + ) + .tag(language) } } .labelsHidden() @@ -224,6 +206,24 @@ extension SettingsView { .foregroundStyle(.secondary) } } + + if appState.autoResolveVoiceModelConflicts { + Text(text( + "Inkompatible Auswahlen werden automatisch mit einem installierten Modell abgeglichen. Downloads benötigen weiterhin deine Bestätigung.", + "Incompatible selections are automatically resolved with an installed model. Downloads still require your confirmation." + )) + .font(.footnote) + .foregroundStyle(.secondary) + Button(text("Kompatibilitätshinweise wieder anzeigen", "Show compatibility notices again")) { + appState.resetVoiceModelCapabilityHints() + } + .buttonStyle(.bordered) + } else { + Button(text("Kompatibilitätshinweise automatisch lösen", "Automatically resolve compatibility notices")) { + appState.autoResolveVoiceModelConflicts = true + } + .buttonStyle(.bordered) + } } } @@ -237,11 +237,9 @@ extension SettingsView { selection: appState.capabilityQualityBinding ) { ForEach(DictationPerformance.allCases) { mode in - Text( - mode.localizedDisplayName( - interfaceLanguageCode: effectiveLanguage.embeddedInterfaceCode) - ) - .tag(mode) + Text(mode.localizedDisplayName( + interfaceLanguageCode: effectiveLanguage.embeddedInterfaceCode + )).tag(mode) } } .labelsHidden() @@ -256,17 +254,9 @@ extension SettingsView { ) ) } - Text(qualityProfileExplanation) .font(.footnote) .foregroundStyle(.secondary) - } else { - LabeledContent(text("Qualitätsprofil", "Quality profile")) { - Text(qualityProfileExplanation) - .font(.footnote) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - } } if let selectedModel = appState.activeVoiceModel { @@ -336,13 +326,9 @@ extension SettingsView { selection: appState.capabilityTranslationBinding ) { ForEach(TranslationOutputMode.allCases) { mode in - Text( - mode.localizedDisplayName( - interfaceLanguageCode: effectiveLanguage.embeddedInterfaceCode) - + (appState.supportsCapability(.translation(mode)) - ? "" : text(" · Modellwechsel nötig", " · Change model")) - ) - .tag(mode) + Text(mode.localizedDisplayName( + interfaceLanguageCode: effectiveLanguage.embeddedInterfaceCode + )).tag(mode) } } .labelsHidden() @@ -580,22 +566,7 @@ extension SettingsView { set: { newValue in guard let model = appState.allVoiceModels.first(where: { $0.id == newValue }) else { return } - if appState.isVoiceModelInstalled(model) { - appState.selectActiveVoiceModel(model) - } else if model.installState != .unavailable { - pendingVoiceModelForInstallation = model - } - } - ) - } - - private var pendingVoiceModelInstallDialogBinding: Binding { - Binding( - get: { pendingVoiceModelForInstallation != nil }, - set: { isPresented in - if !isPresented { - pendingVoiceModelForInstallation = nil - } + appState.requestVoiceModelSelection(model) } ) } @@ -614,22 +585,6 @@ extension SettingsView { return "\(model.displayName) · \(status)" } - private func voiceModelInstallationMessage(for model: VoiceModelDescriptor) -> String { - let language = model.languageCode?.uppercased() ?? text("mehrsprachig", "multilingual") - let translation = model.supportsTranslationToEnglish ? text("ja", "yes") : text("nein", "no") - if model.providerID == VoiceProviderID.nvidiaParakeet.rawValue, - VoiceModelInstaller.resolveNemoSpeechCLI() == nil { - return text( - "\(model.displayName)\nSprache: \(language)\nGröße: \(model.sizeLabel)\nÜbersetzung: \(translation)\n\nCortexa richtet zuerst die passende NeMo-Speech-Runtime ein und lädt danach das Modell herunter.", - "\(model.displayName)\nLanguage: \(language)\nSize: \(model.sizeLabel)\nTranslation: \(translation)\n\nCortexa will first install the compatible NeMo-Speech runtime, then download the model." - ) - } - return text( - "\(model.displayName)\nSprache: \(language)\nGröße: \(model.sizeLabel)\nÜbersetzung: \(translation)\n\nDas Modell wird heruntergeladen und danach automatisch ausgewählt.", - "\(model.displayName)\nLanguage: \(language)\nSize: \(model.sizeLabel)\nTranslation: \(translation)\n\nThe model will download and then be selected automatically." - ) - } - @ViewBuilder private func voiceModelStatusLabel(for model: VoiceModelDescriptor) -> some View { if let operation = voiceModelOperationStore.states[model.id] { @@ -672,7 +627,7 @@ extension SettingsView { .controlSize(.small) } Button(text("Als Standard verwenden", "Use as default")) { - appState.setSelectedVoiceModel(model) + appState.requestVoiceModelSelection(model) } .buttonStyle(.bordered) .controlSize(.small) @@ -692,7 +647,7 @@ extension SettingsView { } } else { Button(text("Installieren", "Install")) { - appState.installVoiceModel(model) + appState.requestVoiceModelSelection(model) } .buttonStyle(.borderedProminent) .controlSize(.small) @@ -776,26 +731,19 @@ extension SettingsView { ) } - Toggle(isOn: appState.capabilityLiveTextBinding) { - SettingsFieldLabel( - title: text("Live-Text einfügen", "Insert live text"), - helpText: text( - "Zeigt laufende Zwischenergebnisse direkt im Zieltextfeld an, solange gestreamt wird.", - "Shows live intermediate results directly in the target text field while streaming is active." + if appState.selectedVoiceModelSupportsLiveTranscription { + Toggle(isOn: appState.capabilityLiveTextBinding) { + SettingsFieldLabel( + title: text("Live-Text einfügen", "Insert live text"), + helpText: text( + "Zeigt laufende Zwischenergebnisse direkt im Zieltextfeld an, solange gestreamt wird.", + "Shows live intermediate results directly in the target text field while streaming is active." + ) ) - ) - } - .disabled( - appState.finalResultDeliveryMode == .clipboardOnly - || !appState.dictationCapability.allowsDirectInsertion) - - if !appState.selectedVoiceModelSupportsLiveTranscription { - Text(text( - "Das aktive Modell unterstützt keinen Live-Text. Wähle die Option für einen Modellwechsel.", - "The active model does not support live text. Select the option to change models." - )) - .font(.footnote) - .foregroundStyle(.secondary) + } + .disabled( + appState.finalResultDeliveryMode == .clipboardOnly + || !appState.dictationCapability.allowsDirectInsertion) } Text( diff --git a/apps/macos/AppShell/SpeechModelController.swift b/apps/macos/AppShell/SpeechModelController.swift index 8234ad8..96c9eea 100644 --- a/apps/macos/AppShell/SpeechModelController.swift +++ b/apps/macos/AppShell/SpeechModelController.swift @@ -136,28 +136,7 @@ final class SpeechModelController { func installVoiceModel(_ descriptor: VoiceModelDescriptor) { startVoiceModelInstallation(descriptor) { [weak self] in - guard let self else { return } - let language = self.currentSelectedLanguage() - let hadOverride = language != .auto && self.currentVoiceLanguageOverrides().contains { - $0.languageCode == language.rawValue - } - if self.isVoiceProviderAvailable(descriptor.providerID) { - if self.currentSelectedVoiceProviderID() != descriptor.providerID { - self.setSelectedVoiceProviderID(descriptor.providerID) - } - if self.currentSelectedVoiceModelID() != descriptor.id { - self.setSelectedVoiceModelID(descriptor.id) - } - if hadOverride, VoiceModelCapabilityPolicy().supports( - language: language, model: descriptor) { - var overrides = self.currentVoiceLanguageOverrides() - overrides.removeAll { $0.languageCode == language.rawValue } - overrides.append(VoiceLanguageOverride( - languageCode: language.rawValue, modelID: descriptor.id)) - self.setVoiceLanguageOverrides(overrides) - } - } - self.sanitizeSpeechModelSelections() + self?.sanitizeSpeechModelSelections() } } diff --git a/apps/macos/AppShell/VoiceModelCapabilityPolicy.swift b/apps/macos/AppShell/VoiceModelCapabilityPolicy.swift index 6472f3e..281d9fe 100644 --- a/apps/macos/AppShell/VoiceModelCapabilityPolicy.swift +++ b/apps/macos/AppShell/VoiceModelCapabilityPolicy.swift @@ -61,8 +61,8 @@ struct VoiceModelCapabilityPolicy { return mode == .original || model.supportsTranslationToEnglish case .liveText: return model.supportsLiveTranscription - case .quality: - return model.supportsQualityProfile + case .quality(let profile): + return profile == .auto || model.supportsQualityProfile } } @@ -80,21 +80,82 @@ struct VoiceModelCapabilityPolicy { catalog: [VoiceModelDescriptor], installedFiles: Set ) -> VoiceModelDescriptor? { + suggestedModels( + for: choice, + currentModel: currentModel, + currentLanguage: currentLanguage, + catalog: catalog, + installedFiles: installedFiles + ).first + } + + func suggestedModels( + for choice: VoiceModelCapabilityChoice, + currentModel: VoiceModelDescriptor, + currentLanguage: DictationLanguage, + catalog: [VoiceModelDescriptor], + installedFiles: Set + ) -> [VoiceModelDescriptor] { + suggestedModels( + preserving: [choice], currentModel: currentModel, + currentLanguage: currentLanguage, catalog: catalog, installedFiles: installedFiles + ) + } + + func suggestedModels( + preserving choices: [VoiceModelCapabilityChoice], + currentModel: VoiceModelDescriptor, + currentLanguage: DictationLanguage, + catalog: [VoiceModelDescriptor], + installedFiles: Set + ) -> [VoiceModelDescriptor] { let eligible = catalog.enumerated().filter { _, model in model.id != currentModel.id && model.installState != .unavailable && supports(language: currentLanguage, model: model) - && supports(choice, model: model, currentLanguage: currentLanguage) - } - if let preferredID = currentModel.preferredCompatibleModelID, - let preferred = eligible.first(where: { $0.element.id == preferredID }) { - return preferred.element + && choices.allSatisfy { + supports($0, model: model, currentLanguage: currentLanguage) + } } - return eligible.min { left, right in + return eligible.sorted { left, right in let lhs = rank(left.element, index: left.offset, current: currentModel, installed: installedFiles) let rhs = rank(right.element, index: right.offset, current: currentModel, installed: installedFiles) return lhs.lexicographicallyPrecedes(rhs) - }?.element + }.map(\.element) + } + + func bestInstalledSuggestion( + for choice: VoiceModelCapabilityChoice, + currentModel: VoiceModelDescriptor, + currentLanguage: DictationLanguage, + catalog: [VoiceModelDescriptor], + installedFiles: Set + ) -> VoiceModelDescriptor? { + suggestedModels( + for: choice, + currentModel: currentModel, + currentLanguage: currentLanguage, + catalog: catalog, + installedFiles: installedFiles + ).first { isInstalled($0, files: installedFiles) } + } + + func supportedLanguages(for model: VoiceModelDescriptor) -> [DictationLanguage] { + DictationLanguage.allCases.filter { supports(language: $0, model: model) } + } + + func suggestedLanguage( + for model: VoiceModelDescriptor, + currentLanguage: DictationLanguage + ) -> DictationLanguage? { + let languages = supportedLanguages(for: model) + if languages.contains(currentLanguage) { return currentLanguage } + if let fixedLanguage = model.languageCode.flatMap(DictationLanguage.init(rawValue:)), + languages.contains(fixedLanguage) { + return fixedLanguage + } + if languages.contains(.auto) { return .auto } + return languages.first } private func rank( @@ -104,9 +165,10 @@ struct VoiceModelCapabilityPolicy { installed: Set ) -> [Int] { let installedRank = model.localFileName.map { installed.contains($0) } == true ? 0 : 1 + let preferredRank = model.id == current.preferredCompatibleModelID ? 0 : 1 let providerRank = model.providerID == current.providerID ? 0 : 1 let distance = abs(model.speedScore - current.speedScore) + abs(model.accuracyScore - current.accuracyScore) - return [installedRank, providerRank, distance, index] + return [installedRank, preferredRank, providerRank, distance, index] } } diff --git a/apps/macos/README.md b/apps/macos/README.md index 53d5d07..451480f 100644 --- a/apps/macos/README.md +++ b/apps/macos/README.md @@ -30,7 +30,7 @@ Phonon-2 and Parakeet Redux are optional, local Core ML models provided through The `Advanced` settings page provides a confirmed full uninstall. A short-lived second app process waits for Cortexa to exit, then removes all contents of its `~/Library/Application Support/WisprLocal` and `~/Library/Caches/WisprLocal` directories (including Whisper models and app logs), the catalogued Parakeet model and its partial download, Cortexa-managed NeMo runtime versions and interrupted install/upgrade transaction directories, the Cortexa-owned `~/Library/Application Support/Cortexa/NativeVoiceModels` bundles, and Cortexa-prefixed model-download temporary folders. It also removes preferences and remote-provider Keychain items, resets only Cortexa's Microphone and Accessibility privacy decisions, and moves the app bundle to the Trash after cleanup succeeds. Unrecognized files in the shared NeMo model cache, unrelated temporary files, and external NeMo runtimes are retained. If a cleanup or Trash operation fails, the user sees the failed steps and the app stays available for another attempt or manual removal. -Parakeet supports 25 European languages with automatic language detection, but does not accept a fixed language prompt. Transcription runs over the full recording after stop, with a 24-minute maximum supported recording; exceeding a model's duration limit stops the session with an explicit error instead of silently dropping earlier audio. Live partial text and translation are unavailable. The model selector includes catalog models from all providers independently of the active transcription provider. Model management can be filtered by provider, installation status, language scope, specific recognition language, or automatic detection. Its search accepts combined terms for provider, model name or ID, localized language names and codes, size, runtime, file name, and supported capabilities; unsupported languages and capabilities are excluded from a model's search terms. In-progress model downloads can be canceled; temporary download files are removed, and a NeMo runtime installed only for that canceled Parakeet install is cleaned up. The quality profile changes Whisper engine presets; Parakeet does not consume those parameters. The quality selector is shown in Settings and the menu bar only when the effective model supports profiles. For models with fixed recognition settings, Settings explains the limitation and the saved profile is retained for a later compatible model. Unsupported language, translation, and live-text choices offer a confirmed switch to a compatible model and a download when needed. Compatible languages and Auto can be selected directly. The preferred counterpart of an English-only Whisper model is its multilingual variant. Saved live-text, translation, and quality preferences are paused for an incompatible model and resume when a compatible model is active. A canceled or failed download leaves the model and choice unchanged, and a manual model change during download cancels the pending automatic switch. Parakeet weights are attributed to NVIDIA under CC BY 4.0; runtime source code is Apache-2.0 with separate third-party notices. +Parakeet supports 25 European languages with automatic language detection, but does not accept a fixed language prompt. Transcription runs over the full recording after stop, with a 24-minute maximum supported recording; exceeding a model's duration limit stops the session with an explicit error instead of silently dropping earlier audio. Live partial text and translation are unavailable. The model selector includes catalog models from all providers independently of the active transcription provider. Model management can be filtered by provider, installation status, language scope, specific recognition language, or automatic detection. Its search accepts combined terms for provider, model name or ID, localized language names and codes, size, runtime, file name, and supported capabilities; unsupported languages and capabilities are excluded from a model's search terms. In-progress model downloads can be canceled; temporary download files are removed, and a NeMo runtime installed only for that canceled Parakeet install is cleaned up. The quality profile changes Whisper engine presets; Parakeet does not consume those parameters. Quality choices stay visible in Settings and the menu bar; unsupported profiles are marked and offer a compatible model. Language, translation, live-text, and quality choices use a shared compatibility flow across Settings, the menu bar, onboarding, and the mode-switch shortcut. A selected language or feature offers a compatible model chooser; selecting a model offers a supported language and lists features that will pause. Confirmation is required before changes or downloads. Saved live-text, translation, and quality preferences resume with a compatible model. Users can choose automatic conflict resolution for installed models and reset that choice in Settings; it never downloads a model without confirmation. A canceled or failed download leaves the model and choice unchanged, and a manual model change during download cancels the pending automatic switch. Parakeet weights are attributed to NVIDIA under CC BY 4.0; runtime source code is Apache-2.0 with separate third-party notices. The `scripts/smoke_test_parakeet.sh` smoke test synthesizes speech, installs missing runtime/model assets through `VoiceModelInstaller`, and performs a real Parakeet transcription through `ASRCore`. It requires macOS Apple Silicon, `say`, and `afconvert`; the first run downloads the pinned runtime and the 714 MB model into their managed locations. The settings now also include a dedicated `AI` area for post-processing with provider/model selection, style/tone, salutation, and separate toggles for live insertion and the final result. diff --git a/changelog.md b/changelog.md index fbc2c36..184cf21 100644 --- a/changelog.md +++ b/changelog.md @@ -34,6 +34,10 @@ ## Features +- 2026-10-29: Modellkonflikte einheitlich auflösen und Hinweise in Settings zurücksetzen. + - Dateien: `apps/macos/AppShell/VoiceModelCapabilityPolicy.swift`, `apps/macos/AppShell/MacAppStateModelCapabilities.swift`, `apps/macos/AppShell/MacAppState.swift`, `apps/macos/AppShell/MacAppPreferencesStore.swift`, `apps/macos/AppShell/MacAppStateFacadeActions.swift`, `apps/macos/AppShell/SpeechModelController.swift`, `apps/macos/AppShell/MenuBarContentView.swift`, `apps/macos/AppShell/Onboarding/OnboardingView.swift`, `apps/macos/AppShell/SettingsView.swift`, `apps/macos/AppShell/SettingsFormPages.swift`, `apps/macos/AppShell/SettingsViewSpeechDictationSections.swift`, `apps/macos/AppShell/SettingsSearchPresentation.swift`, `Tests/AppShellSupportTests/VoiceModelCapabilityPolicyTests.swift`, `Tests/AppShellSupportTests/MacAppPreferencesStoreTests.swift`, `Tests/AppShellSupportTests/SettingsSearchPresentationTests.swift`, `apps/macos/README.md`, `docs/system-design.md`, `VERSION`, `changelog.md`. + - Funktionalität: Sprache, Übersetzung, Live-Text, Qualitätsprofil und direkte Modellwechsel folgen einer gemeinsamen Fähigkeitsprüfung. Sprach- und Funktionsauswahl bieten kompatible Modelle; installierte Vorschläge haben Vorrang vor Downloads. Bei einem Modellwechsel zeigt der Dialog eine Alternative, die gespeicherte Funktionen erhält, und listet pausierte Funktionen übersichtlich auf. Abbruch lässt die Auswahl unverändert, Downloads erfordern eine ausdrückliche Bestätigung, und gespeicherte Funktionen kehren bei einem kompatiblen Modell zurück. Ein gespeicherter Modus kann Konflikte automatisch mit installierten Modellen auflösen und lässt sich in den Spracheinstellungen zurücksetzen. Nicht unterstützte Sprachen und Funktionen werden in den Auswahllisten ausgeblendet; das Sprachmodell lässt sich auch im kompakten Menüleistenmodus wechseln. Installationen ohne explizite Modellauswahl ändern das aktive Modell nicht automatisch. Feature-Version `0.49.0`. + - 2026-09-29: Vollständige Deinstallation über die erweiterten Einstellungen ergänzt. - Dateien: `apps/macos/AppShell/AppUninstaller.swift`, `apps/macos/AppShell/WisprLocalMacApp.swift`, `apps/macos/AppShell/SettingsView.swift`, `apps/macos/AppShell/SettingsViewGeneralSections.swift`, `apps/macos/AppShell/SettingsFormPages.swift`, `apps/macos/AppShell/SettingsSearchPresentation.swift`, `Tests/AppShellSupportTests/AppUninstallerTests.swift`, `Tests/AppShellSupportTests/SettingsSearchPresentationTests.swift`, `apps/macos/README.md`, `docs/system-design.md`, `VERSION`, `changelog.md`. - Funktionalität: Die bestätigte Aktion beendet Cortexa, entfernt App-Daten, Cache, bekannte lokale Modelle, validierte Cortexa-Runtimes, Einstellungen und Remote-AI-Schlüssel, setzt Mikrofon- und Bedienungshilfenfreigaben zurück und verschiebt das App-Bundle zuletzt in den Papierkorb. Bei laufendem Diktat oder Modellvorgängen bleibt die Aktion deaktiviert; fehlgeschlagene Schritte werden gemeldet und lassen die App zur Wiederholung installiert. Externe NeMo-Runtimes und nicht zuordenbare Modellcache-Dateien bleiben erhalten. Feature-Version `0.48.0`. diff --git a/docs/system-design.md b/docs/system-design.md index 96982c6..85ccc77 100644 --- a/docs/system-design.md +++ b/docs/system-design.md @@ -22,6 +22,7 @@ Cortexa is an offline-first dictation/transcription product for Apple platforms. - optional NVIDIA NeMo-Speech.cpp Parakeet TDT v3 backend; model descriptors declare runtime dependencies, and the pinned 0.1.0 Apple Silicon Metal runtime is SHA-256 verified and installed atomically in Cortexa Application Support when no compatible external runtime is found. External runtimes are read-only and never removed. GGUF model files stay in the separate NeMo model cache. A runtime dependency scan removes the Cortexa-owned runtime after the final dependent model is removed, synchronized with active NeMo subprocesses. Model capabilities drive language, live-mode, translation, and maximum-recording-duration compatibility; the engine fails explicitly at model duration limits instead of truncating the oldest captured audio. Parakeet uses automatic language detection and full-utterance offline transcription. - model capability selection is shared by Settings, the menu bar, and session configuration. It resolves a valid installed language override before the global model, then checks the descriptor's language, automatic detection, translation, live-text, and quality flags. An unsupported user choice proposes the declared counterpart or a compatible installed/downloadable model. The requested model and option are applied only after confirmation and successful installation; a changed selection revision invalidates pending switches. Incompatible live-text, translation, and quality preferences remain stored while the session configuration uses safe effective values. - Explicitly selecting the English-only FluidAudio Phonon-2 model also selects English; startup still preserves a previously stored language conflict for the user to resolve. +- The same capability resolution is used by onboarding and hotkeys. The most recent choice takes priority: language or feature choices can offer compatible installed/downloadable models, while a model choice offers a supported language and pauses unsupported translation, live-text, or quality settings. Dialog choices are applied only after confirmation and successful installation; a changed selection revision invalidates pending changes. Incompatible feature preferences remain stored and resume with a compatible model. A persisted auto-resolution preference applies the best installed compatible model or adjusts secondary settings for an explicitly selected model; it never starts downloads. Settings can reset this preference. Language and feature controls only list options supported by the selected model. 2. `AudioCore`