diff --git a/clean-local.sh b/clean-local.sh index 4af8ca7..90fdd3a 100755 --- a/clean-local.sh +++ b/clean-local.sh @@ -8,13 +8,11 @@ set -e # Exit on error # Colors for output (only if stdout is a terminal) if [[ -t 1 ]]; then - RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' # No Color else - RED='' GREEN='' YELLOW='' BLUE='' @@ -56,12 +54,6 @@ if [[ $REPLY =~ ^[Yy]$ ]]; then # Remove data files - # Remove desktop files - if [[ -f "$HOME/.local/share/applications/cthulhu-autostart.desktop" ]]; then - rm -f "$HOME/.local/share/applications/cthulhu-autostart.desktop" - echo " Removed: ~/.local/share/applications/cthulhu-autostart.desktop" - fi - # Remove icons for size in 16x16 22x22 24x24 32x32 48x48 256x256 scalable symbolic; do icon_path="$HOME/.local/share/icons/hicolor/$size/apps" diff --git a/cthulhu-autostart.desktop b/contrib/cthulhu-autostart.desktop similarity index 100% rename from cthulhu-autostart.desktop rename to contrib/cthulhu-autostart.desktop diff --git a/distro-packages/Arch-Linux/PKGBUILD b/distro-packages/Arch-Linux/PKGBUILD index 02a99de..19f3f8d 100644 --- a/distro-packages/Arch-Linux/PKGBUILD +++ b/distro-packages/Arch-Linux/PKGBUILD @@ -1,7 +1,7 @@ # Maintainer: Storm Dragon pkgname=cthulhu -pkgver=2026.02.18 +pkgver=2026.02.22 pkgrel=1 pkgdesc="Desktop-agnostic screen reader with plugin system, forked from Orca" url="https://git.stormux.org/storm/cthulhu" diff --git a/meson.build b/meson.build index b1fc474..f97b590 100644 --- a/meson.build +++ b/meson.build @@ -1,5 +1,5 @@ project('cthulhu', - version: '2026.02.18-master', + version: '2026.02.22-master', meson_version: '>= 1.0.0', ) @@ -93,16 +93,6 @@ else summary += {'sound support': 'no (missing gstreamer)'} endif -# Integration with session startup -i18n.merge_file( - input: 'cthulhu-autostart.desktop.in', - output: '@BASENAME@', - type: 'desktop', - po_dir: meson.project_source_root() / 'po', - install: true, - install_dir: get_option('sysconfdir') / 'xdg' / 'autostart', -) - # Update icon cache manually (desktop-neutral) - optional, ignore failures gtk_update_icon_cache = find_program('gtk4-update-icon-cache', required: false) if gtk_update_icon_cache.found() diff --git a/po/POTFILES.in b/po/POTFILES.in index 02c94bc..9ba28c5 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -1,6 +1,5 @@ # List of source files containing translatable strings. # Please keep this file sorted alphabetically. -cthulhu-autostart.desktop.in src/cthulhu/braille_rolenames.py src/cthulhu/brltablenames.py src/cthulhu/chnames.py diff --git a/src/cthulhu/backends/toml_backend.py b/src/cthulhu/backends/toml_backend.py index 400c6dd..d8ca97e 100644 --- a/src/cthulhu/backends/toml_backend.py +++ b/src/cthulhu/backends/toml_backend.py @@ -85,12 +85,16 @@ class Backend: with open(fileName, 'w', encoding='utf-8') as settingsFile: settingsFile.write(dumps(prefsDoc)) - def _updateTable(self, targetTable, newValues): + def _updateTable(self, targetTable, newValues, preserveMissingKeys=None): if not isinstance(newValues, dict): return + preserveMissingKeys = set(preserveMissingKeys or []) + for key in list(targetTable.keys()): if key not in newValues: + if key in preserveMissingKeys: + continue del targetTable[key] continue newValue = newValues[key] @@ -173,7 +177,12 @@ class Backend: profiles[profile] = {} profileTable = profiles[profile] - self._updateTable(profileTable, general) + # Keep plugin persistence keys when callers provide partial updates. + self._updateTable( + profileTable, + general, + preserveMissingKeys={"activePlugins", "pluginSources"}, + ) self._writeDocument(self.settingsFile, prefsDoc) def _getSettings(self): @@ -213,12 +222,33 @@ class Backend: return settingsDict + def _hasLikelyCustomEchoVoice(self, echoVoice, defaultVoice): + """Return True when echoVoice appears intentionally customized.""" + + if not echoVoice: + return False + + echoEstablished = bool(echoVoice.get('established', False)) + if not defaultVoice: + return echoEstablished or bool(echoVoice.get(acss.ACSS.FAMILY)) + + for key in [acss.ACSS.RATE, acss.ACSS.AVERAGE_PITCH, acss.ACSS.GAIN, acss.ACSS.FAMILY]: + echoValue = echoVoice.get(key) + defaultValue = defaultVoice.get(key) + if echoValue is not None and echoValue != defaultValue: + return True + + return echoEstablished and bool(echoVoice.get(acss.ACSS.FAMILY)) + def getGeneral(self, profile=None): """ Get general settings from default settings and override with profile values. """ self._getSettings() generalSettings = self.general.copy() generalSettings = self._migrateSettings(generalSettings) + # Plugin state is profile-scoped; ignore legacy/global values. + generalSettings.pop('activePlugins', None) + generalSettings.pop('pluginSources', None) defaultProfile = generalSettings.get('startingProfile', ['Default', 'default']) if profile is None: @@ -229,8 +259,24 @@ class Backend: if key == 'voices': for voiceType, voiceDef in value.items(): value[voiceType] = acss.ACSS(voiceDef) + if key == 'echoVoice' and isinstance(value, dict): + value = acss.ACSS(value) if key not in ['startingProfile', 'activeProfile']: generalSettings[key] = value + + # Backward compatibility: recover custom echo behavior when legacy + # profiles have echoVoice but are missing custom-echo toggle keys. + if 'useCustomEchoVoice' not in profileSettings: + voices = generalSettings.get('voices') or {} + defaultVoice = voices.get(settings.DEFAULT_VOICE) + echoVoice = generalSettings.get('echoVoice') + if self._hasLikelyCustomEchoVoice(echoVoice, defaultVoice): + generalSettings['useCustomEchoVoice'] = True + if 'useCustomEchoForKey' not in profileSettings: + generalSettings['useCustomEchoForKey'] = True + if 'useCustomEchoForCharacter' not in profileSettings: + generalSettings['useCustomEchoForCharacter'] = True + try: generalSettings['activeProfile'] = profileSettings['profile'] except KeyError: diff --git a/src/cthulhu/cthulhu-setup.ui b/src/cthulhu/cthulhu-setup.ui index b6aa5b8..86ffe6d 100644 --- a/src/cthulhu/cthulhu-setup.ui +++ b/src/cthulhu/cthulhu-setup.ui @@ -136,6 +136,24 @@ 0.10000000149 1 + + 10 + 5 + 0.10000000149 + 1 + + + 100 + 50 + 1 + 10 + + + 10 + 10 + 0.10000000149 + 1 + False Cthulhu Preferences @@ -3037,6 +3055,295 @@ 1 + + + True + False + 0 + none + + + True + False + 12 + + + True + False + 10 + 8 + + + Use c_ustom echo voice settings + True + True + False + True + True + + + + 0 + 0 + 2 + + + + + Use custom _speech-dispatcher module for echo + True + True + False + True + True + + + + 0 + 1 + 2 + + + + + True + False + 1 + Echo _module: + True + echoSpeechServers + + + + + + 0 + 2 + + + + + True + False + + + + + + + 1 + 2 + + + + + True + False + 1 + Echo _voice: + True + echoSpeechFamilies + + + + + + 0 + 3 + + + + + True + False + + + + + + + 1 + 3 + + + + + True + False + 1 + Echo ra_te: + True + echoRateScale + + + + + + 0 + 4 + + + + + True + True + echoRateAdjustment + 0 + 0 + right + + + + 1 + 4 + + + + + True + False + 1 + Echo pi_tch: + True + echoPitchScale + + + + + + 0 + 5 + + + + + True + True + echoPitchAdjustment + 1 + right + + + + 1 + 5 + + + + + True + False + 1 + Echo vo_lume: + True + echoVolumeScale + + + + + + 0 + 6 + + + + + True + True + echoVolumeAdjustment + 1 + right + + + + 1 + 6 + + + + + Use custom voice for _key echo + True + True + False + True + True + + + + 0 + 7 + 2 + + + + + Use custom voice for _character echo + True + True + False + True + True + + + + 0 + 8 + 2 + + + + + Use custom voice for _word echo + True + True + False + True + True + + + + 0 + 9 + 2 + + + + + Use custom voice for _sentence echo + True + True + False + True + True + + + + 0 + 10 + 2 + + + + + + + + + True + False + Echo Voice + + + + + + + + 0 + 2 + + Enable echo by cha_racter @@ -3049,7 +3356,7 @@ 0 - 2 + 3 @@ -3064,7 +3371,7 @@ 0 - 3 + 4 @@ -3079,7 +3386,7 @@ 0 - 4 + 5 diff --git a/src/cthulhu/cthulhuVersion.py b/src/cthulhu/cthulhuVersion.py index d50296b..beaeb13 100644 --- a/src/cthulhu/cthulhuVersion.py +++ b/src/cthulhu/cthulhuVersion.py @@ -23,5 +23,5 @@ # Forked from Orca screen reader. # Cthulhu project: https://git.stormux.org/storm/cthulhu -version = "2026.02.18" +version = "2026.02.22" codeName = "master" diff --git a/src/cthulhu/cthulhu_gui_prefs.py b/src/cthulhu/cthulhu_gui_prefs.py index 2771d10..55b90fb 100644 --- a/src/cthulhu/cthulhu_gui_prefs.py +++ b/src/cthulhu/cthulhu_gui_prefs.py @@ -38,6 +38,9 @@ gi.require_version("Gtk", "3.0") from gi.repository import Atspi import os +import json +import subprocess +import sys import threading from gi.repository import Gdk from gi.repository import GLib @@ -173,6 +176,16 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): self.speechSystemsChoice = None self.speechSystemsChoices = None self.speechSystemsModel = None + self.echoVoice = None + self.echoSpeechFamiliesChoice = None + self.echoSpeechFamiliesChoices = None + self.echoSpeechFamiliesModel = None + self.echoSpeechServersChoice = None + self.echoSpeechServersChoices = None + self.echoSpeechServersModel = None + self.initializingEchoSpeech = False + self._echoVoiceFetchToken = 0 + self._updatingEchoSpeechFamilies = False self.systemVoice = None self.uppercaseVoice = None self.window = None @@ -386,7 +399,12 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): self._initComboBox(self.get_widget("speechLanguages")) self.speechFamiliesModel = \ self._initComboBox(self.get_widget("speechFamilies")) + self.echoSpeechServersModel = \ + self._initComboBox(self.get_widget("echoSpeechServers")) + self.echoSpeechFamiliesModel = \ + self._initComboBox(self.get_widget("echoSpeechFamilies")) self._initSpeechState() + self._initEchoSpeechState() # TODO - JD: Will this ever be the case?? self._isInitialSetup = \ @@ -1671,6 +1689,261 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): self._setupSpeechSystems(factories) self.initializingSpeech = False + def _getSpeechDispatcherFactory(self): + """Returns the Speech Dispatcher factory if available.""" + + factories = cthulhu.cthulhuApp.settingsManager.getSpeechServerFactories() + for factory in factories: + try: + if factory.SpeechServer.getFactoryName() == guilabels.SPEECH_DISPATCHER: + return factory + except Exception: + debug.printException(debug.LEVEL_FINEST) + + return None + + def _setEchoVoiceFamily(self, family): + """Sets echo voice family from the selected server family object.""" + + if not family: + return + + name = family.get(speechserver.VoiceFamily.NAME) + language = family.get(speechserver.VoiceFamily.LANG) + dialect = family.get(speechserver.VoiceFamily.DIALECT) + variant = family.get(speechserver.VoiceFamily.VARIANT) + self.echoVoice[acss.ACSS.FAMILY] = { + speechserver.VoiceFamily.NAME: name, + speechserver.VoiceFamily.LANG: language, + speechserver.VoiceFamily.DIALECT: dialect, + speechserver.VoiceFamily.VARIANT: variant, + } + self.echoVoice['established'] = True + + def _populateEchoSpeechFamilies(self, families): + """Populate the echo family combobox from the provided families list.""" + + combobox = self.get_widget("echoSpeechFamilies") + combobox.set_model(None) + self.echoSpeechFamiliesModel.clear() + self.echoSpeechFamiliesChoices = list(families or []) + self.echoSpeechFamiliesChoice = None + + selectedIndex = 0 + selectedMatchFound = False + selectedFamily = self.echoVoice.get(acss.ACSS.FAMILY) + selectedName = selectedLanguage = selectedDialect = selectedVariant = None + if selectedFamily: + selectedName = selectedFamily.get(speechserver.VoiceFamily.NAME) + selectedLanguage = selectedFamily.get(speechserver.VoiceFamily.LANG) + selectedDialect = selectedFamily.get(speechserver.VoiceFamily.DIALECT) + selectedVariant = selectedFamily.get(speechserver.VoiceFamily.VARIANT) + + for i, family in enumerate(self.echoSpeechFamiliesChoices): + name = family.get(speechserver.VoiceFamily.NAME) or "" + language = family.get(speechserver.VoiceFamily.LANG) or "" + dialect = family.get(speechserver.VoiceFamily.DIALECT) or "" + variant = family.get(speechserver.VoiceFamily.VARIANT) + display = name + locale = language + if dialect: + locale = f"{language}-{dialect}" if language else dialect + if locale: + display = f"{name} ({locale})" + self.echoSpeechFamiliesModel.append((i, display)) + if selectedName == name and selectedLanguage == language \ + and selectedDialect == dialect and selectedVariant == variant: + selectedIndex = i + selectedMatchFound = True + + combobox.set_model(self.echoSpeechFamiliesModel) + if self.echoSpeechFamiliesChoices: + self._updatingEchoSpeechFamilies = True + try: + combobox.set_active(selectedIndex) + finally: + self._updatingEchoSpeechFamilies = False + self.echoSpeechFamiliesChoice = self.echoSpeechFamiliesChoices[selectedIndex] + + def _fetchEchoSpeechFamiliesWorker(self, serverInfo, requestToken): + """Fetch module-specific voices in a subprocess and apply asynchronously.""" + + queryScript = """ +import json +import sys +from cthulhu import speechdispatcherfactory +from cthulhu import speechserver + +serverInfo = json.loads(sys.argv[1]) +if isinstance(serverInfo, list): + serverInfo = tuple(serverInfo) + +server = speechdispatcherfactory.SpeechServer.getSpeechServer(serverInfo) +families = server.getVoiceFamilies() if server else [] + +result = [] +for family in families: + result.append({ + "name": family.get(speechserver.VoiceFamily.NAME), + "lang": family.get(speechserver.VoiceFamily.LANG), + "dialect": family.get(speechserver.VoiceFamily.DIALECT), + "variant": family.get(speechserver.VoiceFamily.VARIANT), + }) + +print(json.dumps(result)) +""" + + familyDefs = None + try: + completed = subprocess.run( + [sys.executable, "-c", queryScript, json.dumps(serverInfo)], + capture_output=True, + text=True, + timeout=4.0, + check=False, + ) + if completed.returncode == 0 and completed.stdout.strip(): + familyDefs = json.loads(completed.stdout.strip()) + else: + tokens = [ + "PREFERENCES DIALOG: Echo voice fetch failed for", + serverInfo, + "rc=", + completed.returncode, + ] + debug.printTokens(debug.LEVEL_WARNING, tokens, True) + except subprocess.TimeoutExpired: + tokens = ["PREFERENCES DIALOG: Echo voice fetch timed out for", serverInfo] + debug.printTokens(debug.LEVEL_WARNING, tokens, True) + except Exception: + debug.printException(debug.LEVEL_WARNING) + + GLib.idle_add(self._applyFetchedEchoSpeechFamilies, requestToken, familyDefs) + + def _applyFetchedEchoSpeechFamilies(self, requestToken, familyDefs): + """Apply fetched echo families if this is still the active request.""" + + if requestToken != self._echoVoiceFetchToken: + return False + + if not isinstance(familyDefs, list): + return False + + families = [] + for familyDef in familyDefs: + if not isinstance(familyDef, dict): + continue + families.append( + speechserver.VoiceFamily({ + speechserver.VoiceFamily.NAME: familyDef.get("name"), + speechserver.VoiceFamily.LANG: familyDef.get("lang"), + speechserver.VoiceFamily.DIALECT: familyDef.get("dialect"), + speechserver.VoiceFamily.VARIANT: familyDef.get("variant"), + }) + ) + + if not families: + return False + + self._populateEchoSpeechFamilies(families) + return False + + def _setupEchoSpeechFamilies(self): + """Populate echo voice family list for the current echo server.""" + self._echoVoiceFetchToken += 1 + requestToken = self._echoVoiceFetchToken + useCustomModule = self.get_widget("useCustomEchoSpeechServerCheckButton").get_active() + fallbackFamilies = list(self.speechFamiliesChoices or []) + self._populateEchoSpeechFamilies(fallbackFamilies) + + if useCustomModule and self.echoSpeechServersChoice: + serverInfo = self.echoSpeechServersChoice.getInfo() + thread = threading.Thread( + target=self._fetchEchoSpeechFamiliesWorker, + args=(serverInfo, requestToken), + daemon=True, + ) + thread.start() + + def _setEchoSpeechServersChoice(self, serverInfo): + """Set the active echo module based on stored server info.""" + + if not self.echoSpeechServersChoices: + self.echoSpeechServersChoice = None + self._setupEchoSpeechFamilies() + return + + valueSet = False + for i, server in enumerate(self.echoSpeechServersChoices): + info = server.getInfo() + if serverInfo and info == serverInfo: + self.get_widget("echoSpeechServers").set_active(i) + self.echoSpeechServersChoice = server + valueSet = True + break + + if not valueSet: + self.get_widget("echoSpeechServers").set_active(0) + self.echoSpeechServersChoice = self.echoSpeechServersChoices[0] + + self._setupEchoSpeechFamilies() + + def _setupEchoSpeechServers(self): + """Populate available speech-dispatcher modules for echo.""" + + combobox = self.get_widget("echoSpeechServers") + combobox.set_model(None) + self.echoSpeechServersModel.clear() + self.echoSpeechServersChoices = [] + self.echoSpeechServersChoice = None + + if not self.prefsDict.get('enableSpeech', True): + combobox.set_model(self.echoSpeechServersModel) + self._setupEchoSpeechFamilies() + return + + factory = self._getSpeechDispatcherFactory() + if factory is None: + combobox.set_model(self.echoSpeechServersModel) + self._setupEchoSpeechFamilies() + return + + try: + self.echoSpeechServersChoices = factory.SpeechServer.getSpeechServers() + except Exception: + debug.printException(debug.LEVEL_WARNING) + self.echoSpeechServersChoices = [] + + for i, server in enumerate(self.echoSpeechServersChoices): + name = server.getInfo()[0] + self.echoSpeechServersModel.append((i, name)) + + combobox.set_model(self.echoSpeechServersModel) + self._setEchoSpeechServersChoice(self.prefsDict.get("echoSpeechServerInfo")) + + def _initEchoSpeechState(self): + """Initialize echo voice controls and choices.""" + + self.echoVoice = acss.ACSS( + self.prefsDict.get("echoVoice", settings.echoVoice) or {}) + + baseVoice = self.defaultVoice or acss.ACSS( + self.prefsDict.get("voices", {}).get(settings.DEFAULT_VOICE, {})) + + rate = self.echoVoice.get(acss.ACSS.RATE, baseVoice.get(acss.ACSS.RATE, 50.0)) + pitch = self.echoVoice.get(acss.ACSS.AVERAGE_PITCH, baseVoice.get(acss.ACSS.AVERAGE_PITCH, 5.0)) + volume = self.echoVoice.get(acss.ACSS.GAIN, baseVoice.get(acss.ACSS.GAIN, 10.0)) + + self.get_widget("echoRateScale").set_value(rate) + self.get_widget("echoPitchScale").set_value(pitch) + self.get_widget("echoVolumeScale").set_value(volume) + + self.initializingEchoSpeech = True + self._setupEchoSpeechServers() + self.initializingEchoSpeech = False + + self._setEchoVoiceItems() + def _setSpokenTextAttributes(self, view, setAttributes, state, moveToTop=False): """Given a set of spoken text attributes, update the model used by the @@ -2404,6 +2677,19 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): prefs["enableEchoByWord"]) self.get_widget("enableEchoBySentenceCheckButton").set_active( \ prefs["enableEchoBySentence"]) + self.get_widget("useCustomEchoVoiceCheckButton").set_active( + prefs.get("useCustomEchoVoice", settings.useCustomEchoVoice)) + self.get_widget("useCustomEchoSpeechServerCheckButton").set_active( + prefs.get("useCustomEchoSpeechServer", settings.useCustomEchoSpeechServer)) + self.get_widget("useCustomEchoForKeyCheckButton").set_active( + prefs.get("useCustomEchoForKey", settings.useCustomEchoForKey)) + self.get_widget("useCustomEchoForCharacterCheckButton").set_active( + prefs.get("useCustomEchoForCharacter", settings.useCustomEchoForCharacter)) + self.get_widget("useCustomEchoForWordCheckButton").set_active( + prefs.get("useCustomEchoForWord", settings.useCustomEchoForWord)) + self.get_widget("useCustomEchoForSentenceCheckButton").set_active( + prefs.get("useCustomEchoForSentence", settings.useCustomEchoForSentence)) + self._setEchoVoiceItems() # Text attributes pane. # @@ -2563,22 +2849,20 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): self._updateAIControlsState(enabled) def _updateAIControlsState(self, enabled): - """Enable or disable AI controls based on AI enabled state.""" - self.aiProviderCombo.set_sensitive(enabled) - self.aiApiKeyEntry.set_sensitive(enabled) - self.aiOllamaModelEntry.set_sensitive(enabled) - self.aiOllamaEndpointEntry.set_sensitive(enabled) - self.aiConfirmationCheckButton.set_sensitive(enabled) - self.aiScreenshotQualityCombo.set_sensitive(enabled) + """Refresh AI controls while keeping configuration fields editable.""" + _ = enabled # kept for signal/call compatibility + # Keep settings editable even when AI assistant is disabled so users + # can configure providers/keys before enabling it. + self.aiProviderCombo.set_sensitive(True) + self.aiConfirmationCheckButton.set_sensitive(True) + self.aiScreenshotQualityCombo.set_sensitive(True) try: - self.get_widget("aiGetClaudeKeyButton").set_sensitive(enabled) + self.get_widget("aiGetClaudeKeyButton").set_sensitive(True) except: pass # Button might not exist in older UI files - - # Update provider-specific controls if AI is enabled - if enabled: - current_provider = self.prefsDict.get("aiProvider", settings.aiProvider) - self._updateProviderControls(current_provider) + + current_provider = self.prefsDict.get("aiProvider", settings.aiProvider) + self._updateProviderControls(current_provider) def _initIndentationState(self): """Initialize Indentation widgets with current settings.""" @@ -2908,6 +3192,45 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): self.get_widget("enableNavigationKeysCheckButton").set_sensitive(enable) self.get_widget("enableDiacriticalKeysCheckButton").set_sensitive( \ enable) + self._setEchoVoiceItems() + + def _setEchoVoiceItems(self): + """[In]sensitize echo voice controls based on current state.""" + + useCustomVoice = self.get_widget("useCustomEchoVoiceCheckButton").get_active() + useCustomModule = self.get_widget("useCustomEchoSpeechServerCheckButton").get_active() + keyEchoEnabled = self.get_widget("keyEchoCheckButton").get_active() + charEchoEnabled = self.get_widget("enableEchoByCharacterCheckButton").get_active() + wordEchoEnabled = self.get_widget("enableEchoByWordCheckButton").get_active() + sentenceEchoEnabled = self.get_widget("enableEchoBySentenceCheckButton").get_active() + speechEnabled = self.get_widget("speechSupportCheckButton").get_active() + + speechSystemIsDispatcher = False + if self.speechSystemsChoice: + try: + speechSystemIsDispatcher = \ + self.speechSystemsChoice.SpeechServer.getFactoryName() == guilabels.SPEECH_DISPATCHER + except Exception: + speechSystemIsDispatcher = False + + voiceControlsEnabled = useCustomVoice and speechEnabled + moduleOverrideAvailable = voiceControlsEnabled and speechSystemIsDispatcher + moduleControlsEnabled = moduleOverrideAvailable and useCustomModule + + self.get_widget("useCustomEchoSpeechServerCheckButton").set_sensitive(moduleOverrideAvailable) + self.get_widget("echoSpeechServers").set_sensitive(moduleControlsEnabled) + self.get_widget("echoSpeechFamilies").set_sensitive(voiceControlsEnabled) + + self.get_widget("echoRateScale").set_sensitive(useCustomVoice) + self.get_widget("echoPitchScale").set_sensitive(useCustomVoice) + self.get_widget("echoVolumeScale").set_sensitive(useCustomVoice) + + self.get_widget("useCustomEchoForKeyCheckButton").set_sensitive(useCustomVoice and keyEchoEnabled) + self.get_widget("useCustomEchoForCharacterCheckButton").set_sensitive( + useCustomVoice and charEchoEnabled) + self.get_widget("useCustomEchoForWordCheckButton").set_sensitive(useCustomVoice and wordEchoEnabled) + self.get_widget("useCustomEchoForSentenceCheckButton").set_sensitive( + useCustomVoice and sentenceEchoEnabled) def _presentMessage(self, text, interrupt=False, voice=None): """If the text field is not None, presents the given text, optionally @@ -3234,6 +3557,8 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): enable = widget.get_active() self.prefsDict["enableSpeech"] = enable self.get_widget("speechOptionsGrid").set_sensitive(enable) + self._setupEchoSpeechServers() + self._setEchoVoiceItems() def onlySpeakDisplayedTextToggled(self, widget): """Signal handler for the "toggled" signal for the GtkCheckButton @@ -3265,6 +3590,8 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): selectedIndex = widget.get_active() self.speechSystemsChoice = self.speechSystemsChoices[selectedIndex] self._setupSpeechServers() + self._setupEchoSpeechServers() + self._setEchoVoiceItems() def speechServersChanged(self, widget): """Signal handler for the "changed" signal for the speechServers @@ -3297,6 +3624,7 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): pass self._setupVoices() + self._setEchoVoiceItems() def speechLanguagesChanged(self, widget): """Signal handler for the "value_changed" signal for the languages @@ -3435,6 +3763,75 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): voices.get(settings.DEFAULT_VOICE, {})[acss.ACSS.GAIN] = volume cthulhu.cthulhuApp.settingsManager.setSetting('voices', voices) + def useCustomEchoVoiceToggled(self, widget): + """Signal handler for enabling/disabling custom echo voice settings.""" + + self.prefsDict["useCustomEchoVoice"] = widget.get_active() + self._setEchoVoiceItems() + self._setupEchoSpeechFamilies() + + def useCustomEchoSpeechServerToggled(self, widget): + """Signal handler for enabling/disabling custom echo module override.""" + + self.prefsDict["useCustomEchoSpeechServer"] = widget.get_active() + self._setEchoVoiceItems() + self._setupEchoSpeechFamilies() + + def echoSpeechServersChanged(self, widget): + """Signal handler for selecting the echo speech-dispatcher module.""" + + if self.initializingEchoSpeech: + return + + selectedIndex = widget.get_active() + if selectedIndex < 0: + self.echoSpeechServersChoice = None + self.prefsDict["echoSpeechServerInfo"] = None + self._setupEchoSpeechFamilies() + return + + self.echoSpeechServersChoice = self.echoSpeechServersChoices[selectedIndex] + self.prefsDict["echoSpeechServerInfo"] = self.echoSpeechServersChoice.getInfo() + self._setupEchoSpeechFamilies() + + def echoSpeechFamiliesChanged(self, widget): + """Signal handler for selecting the echo voice family.""" + + if self.initializingEchoSpeech or self._updatingEchoSpeechFamilies: + return + + selectedIndex = widget.get_active() + if selectedIndex < 0: + self.echoSpeechFamiliesChoice = None + return + + self.echoSpeechFamiliesChoice = self.echoSpeechFamiliesChoices[selectedIndex] + self._setEchoVoiceFamily(self.echoSpeechFamiliesChoice) + + def echoRateValueChanged(self, widget): + """Signal handler for changing custom echo rate.""" + + if self.echoVoice is None: + self.echoVoice = acss.ACSS({}) + self.echoVoice[acss.ACSS.RATE] = widget.get_value() + self.echoVoice['established'] = True + + def echoPitchValueChanged(self, widget): + """Signal handler for changing custom echo pitch.""" + + if self.echoVoice is None: + self.echoVoice = acss.ACSS({}) + self.echoVoice[acss.ACSS.AVERAGE_PITCH] = widget.get_value() + self.echoVoice['established'] = True + + def echoVolumeValueChanged(self, widget): + """Signal handler for changing custom echo volume.""" + + if self.echoVoice is None: + self.echoVoice = acss.ACSS({}) + self.echoVoice[acss.ACSS.GAIN] = widget.get_value() + self.echoVoice['established'] = True + def checkButtonToggled(self, widget): """Signal handler for "toggled" signal for basic GtkCheckButton widgets. The user has altered the state of the checkbox. @@ -3452,6 +3849,15 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): # strip "CheckButton" from the end. settingName = settingName[:-11] self.prefsDict[settingName] = widget.get_active() + if settingName in [ + "enableEchoByCharacter", + "enableEchoByWord", + "enableEchoBySentence", + "useCustomEchoForKey", + "useCustomEchoForCharacter", + "useCustomEchoForWord", + "useCustomEchoForSentence"]: + self._setEchoVoiceItems() def keyEchoChecked(self, widget): """Signal handler for the "toggled" signal for the @@ -4254,6 +4660,35 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): settings.SYSTEM_VOICE: acss.ACSS(self.systemVoice), } + self.prefsDict["useCustomEchoVoice"] = \ + self.get_widget("useCustomEchoVoiceCheckButton").get_active() + self.prefsDict["useCustomEchoSpeechServer"] = \ + self.get_widget("useCustomEchoSpeechServerCheckButton").get_active() + self.prefsDict["useCustomEchoForKey"] = \ + self.get_widget("useCustomEchoForKeyCheckButton").get_active() + self.prefsDict["useCustomEchoForCharacter"] = \ + self.get_widget("useCustomEchoForCharacterCheckButton").get_active() + self.prefsDict["useCustomEchoForWord"] = \ + self.get_widget("useCustomEchoForWordCheckButton").get_active() + self.prefsDict["useCustomEchoForSentence"] = \ + self.get_widget("useCustomEchoForSentenceCheckButton").get_active() + + if self.echoVoice is None: + self.echoVoice = acss.ACSS({}) + + # Persist slider values directly so saving does not depend on + # value-changed signal timing. + self.echoVoice[acss.ACSS.RATE] = self.get_widget("echoRateScale").get_value() + self.echoVoice[acss.ACSS.AVERAGE_PITCH] = self.get_widget("echoPitchScale").get_value() + self.echoVoice[acss.ACSS.GAIN] = self.get_widget("echoVolumeScale").get_value() + self.echoVoice['established'] = True + self.prefsDict["echoVoice"] = acss.ACSS(self.echoVoice) + + if self.echoSpeechServersChoice: + self.prefsDict["echoSpeechServerInfo"] = self.echoSpeechServersChoice.getInfo() + else: + self.prefsDict["echoSpeechServerInfo"] = None + def applyButtonClicked(self, widget): """Signal handler for the "clicked" signal for the applyButton GtkButton widget. The user has clicked the Apply button. @@ -4286,6 +4721,7 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): self._refresh_dynamic_plugin_tabs() braille.checkBrailleSetting() self._initSpeechState() + self._initEchoSpeechState() self._populateKeyBindings() self.__initProfileCombo() @@ -4548,6 +4984,7 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): braille.checkBrailleSetting() self._initSpeechState() + self._initEchoSpeechState() self._populateKeyBindings() diff --git a/src/cthulhu/plugins/AIAssistant/plugin.py b/src/cthulhu/plugins/AIAssistant/plugin.py index f471ca9..6a64018 100644 --- a/src/cthulhu/plugins/AIAssistant/plugin.py +++ b/src/cthulhu/plugins/AIAssistant/plugin.py @@ -325,7 +325,6 @@ class AIAssistant(Plugin): if not self._prefs_widgets: return - enabled = self._prefs_widgets["enable_check"].get_active() provider_values = self._prefs_widgets.get("provider_values", []) provider_index = self._prefs_widgets["provider_combo"].get_active() provider = provider_values[provider_index] if 0 <= provider_index < len(provider_values) else settings.aiProvider @@ -333,12 +332,14 @@ class AIAssistant(Plugin): is_gemini = provider == settings.AI_PROVIDER_GEMINI is_ollama = provider == settings.AI_PROVIDER_OLLAMA - self._prefs_widgets["provider_combo"].set_sensitive(enabled) - self._prefs_widgets["api_key_entry"].set_sensitive(enabled and is_gemini) - self._prefs_widgets["ollama_model_entry"].set_sensitive(enabled and is_ollama) - self._prefs_widgets["ollama_endpoint_entry"].set_sensitive(enabled and is_ollama) - self._prefs_widgets["confirmation_check"].set_sensitive(enabled) - self._prefs_widgets["quality_combo"].set_sensitive(enabled) + # Keep preferences editable even when the feature is disabled so users + # can prepare configuration before turning AI assistant on. + self._prefs_widgets["provider_combo"].set_sensitive(True) + self._prefs_widgets["api_key_entry"].set_sensitive(is_gemini) + self._prefs_widgets["ollama_model_entry"].set_sensitive(is_ollama) + self._prefs_widgets["ollama_endpoint_entry"].set_sensitive(is_ollama) + self._prefs_widgets["confirmation_check"].set_sensitive(True) + self._prefs_widgets["quality_combo"].set_sensitive(True) def refresh_settings(self): """Refresh plugin settings and reinitialize provider. Called when settings change.""" diff --git a/src/cthulhu/plugins/PluginManager/plugin.py b/src/cthulhu/plugins/PluginManager/plugin.py index 0693f2d..a45c58a 100644 --- a/src/cthulhu/plugins/PluginManager/plugin.py +++ b/src/cthulhu/plugins/PluginManager/plugin.py @@ -21,10 +21,8 @@ from gi.repository import Gtk, Gdk, Pango from cthulhu.plugin import Plugin, cthulhu_hookimpl from cthulhu import cthulhu from cthulhu import debug -from cthulhu import settings_manager logger = logging.getLogger(__name__) -_settingsManager = None # Removed - use cthulhu.cthulhuApp.settingsManager class PluginManager(Plugin): @@ -473,25 +471,11 @@ class PluginManager(Plugin): current_general['activePlugins'] = active_plugins cthulhu.cthulhuApp.settingsManager.profile = profile_name - cthulhu.cthulhuApp.settingsManager._setProfileGeneral(current_general) - - pronunciations = cthulhu.cthulhuApp.settingsManager.getPronunciations(profile_name) or {} - keybindings = cthulhu.cthulhuApp.settingsManager.getKeybindings(profile_name) or {} - - backend = cthulhu.cthulhuApp.settingsManager._backend - if backend: - backend.saveProfileSettings( - profile_name, - cthulhu.cthulhuApp.settingsManager.profileGeneral, - pronunciations, - keybindings - ) - debug.printMessage(debug.LEVEL_INFO, f"PluginManager: Settings saved to backend (profile {profile_name})", True) - else: - debug.printMessage(debug.LEVEL_INFO, "PluginManager: No backend available for saving", True) + cthulhu.cthulhuApp.settingsManager.saveProfileSettings(current_general) + debug.printMessage(debug.LEVEL_INFO, f"PluginManager: Settings saved via settings manager (profile {profile_name})", True) except Exception as save_error: - debug.printMessage(debug.LEVEL_INFO, f"PluginManager: Error saving via backend: {save_error}", True) + debug.printMessage(debug.LEVEL_INFO, f"PluginManager: Error saving plugin state: {save_error}", True) debug.printMessage(debug.LEVEL_INFO, f"PluginManager: Updated active plugins: {active_plugins}", True) diff --git a/src/cthulhu/script_utilities.py b/src/cthulhu/script_utilities.py index 9de4b8b..90e86e7 100644 --- a/src/cthulhu/script_utilities.py +++ b/src/cthulhu/script_utilities.py @@ -5122,6 +5122,9 @@ class Utilities: if not event.type.startswith("object:text-changed:insert"): return False + if not cthulhu.cthulhuApp.settingsManager.getSetting("enableKeyEcho"): + return False + if AXUtilities.is_focusable(event.source) \ and not AXUtilities.is_focused(event.source) \ and event.source != cthulhu_state.locusOfFocus: diff --git a/src/cthulhu/scripts/default.py b/src/cthulhu/scripts/default.py index 16b6444..b8d4b6b 100644 --- a/src/cthulhu/scripts/default.py +++ b/src/cthulhu/scripts/default.py @@ -44,6 +44,7 @@ from gi.repository import Gdk import re import time +import cthulhu.acss as acss import cthulhu.braille as braille import cthulhu.cmdnames as cmdnames import cthulhu.dbus_service as dbus_service @@ -1818,6 +1819,9 @@ class Script(script.Script): if len(string) != 1: return + if not cthulhu.cthulhuApp.settingsManager.getSetting('enableKeyEcho'): + return + if cthulhu.cthulhuApp.settingsManager.getSetting('enableEchoBySentence') \ and self.echoPreviousSentence(event.source): return @@ -1967,6 +1971,15 @@ class Script(script.Script): debug.printMessage(debug.LEVEL_INFO, msg, True) return + # Some toolkits emit transient window:deactivate/window:activate pairs + # while the same window remains active. Treat those as noise so we do + # not clear state and force a full script/settings reactivation. + AXObject.clear_cache(event.source) + if AXUtilities.is_active(event.source): + msg = "DEFAULT: Ignoring event. Source window still active." + debug.printMessage(debug.LEVEL_INFO, msg, True) + return + if self.flatReviewPresenter.is_active(): self.flatReviewPresenter.quit() @@ -2219,9 +2232,12 @@ class Script(script.Script): sentence = self.utilities.substring(obj, sentenceStartOffset + 1, sentenceEndOffset + 1) - voice = self.speechGenerator.voice(obj=obj, string=sentence) sentence = self.utilities.adjustForRepeats(sentence) - self.speakMessage(sentence, voice) + if self._shouldUseCustomEchoVoice("sentence"): + speech.speakEchoText(sentence, self._getCustomEchoVoice()) + else: + voice = self.speechGenerator.voice(obj=obj, string=sentence) + self.speakMessage(sentence, voice) return True def echoPreviousWord(self, obj, offset=None): @@ -2288,9 +2304,12 @@ class Script(script.Script): word = self.utilities.\ substring(obj, wordStartOffset + 1, wordEndOffset + 1) - voice = self.speechGenerator.voice(obj=obj, string=word) word = self.utilities.adjustForRepeats(word) - self.speakMessage(word, voice) + if self._shouldUseCustomEchoVoice("word"): + speech.speakEchoText(word, self._getCustomEchoVoice()) + else: + voice = self.speechGenerator.voice(obj=obj, string=word) + self.speakMessage(word, voice) return True def sayCharacter(self, obj): @@ -2892,6 +2911,9 @@ class Script(script.Script): if not event.shouldEcho or event.isCthulhuModified(): return False + if not cthulhu.cthulhuApp.settingsManager.getSetting('enableKeyEcho'): + return False + role = AXObject.get_role(cthulhu_state.locusOfFocus) if role in [Atspi.Role.DIALOG, Atspi.Role.FRAME, Atspi.Role.WINDOW]: focusedObject = AXUtilities.get_focused_object(cthulhu_state.activeWindow) @@ -3332,6 +3354,47 @@ class Script(script.Script): # # ######################################################################## + def _shouldUseCustomEchoVoice(self, echoType): + """Returns True if custom echo settings should be used for the given type.""" + + settingForType = { + "key": "useCustomEchoForKey", + "character": "useCustomEchoForCharacter", + "word": "useCustomEchoForWord", + "sentence": "useCustomEchoForSentence", + } + + settingName = settingForType.get(echoType) + if not settingName: + return False + + settingsManager = cthulhu.cthulhuApp.settingsManager + if not settingsManager.getSetting("enableKeyEcho"): + return False + + if not settingsManager.getSetting("useCustomEchoVoice"): + return False + + return settingsManager.getSetting(settingName) + + def _getCustomEchoVoice(self): + """Returns the effective ACSS voice for custom echo output.""" + + settingsManager = cthulhu.cthulhuApp.settingsManager + voices = settingsManager.getSetting("voices") or {} + defaultVoice = acss.ACSS(voices.get(settings.DEFAULT_VOICE, {})) + + echoVoice = settingsManager.getSetting("echoVoice") + if not echoVoice: + return defaultVoice + + try: + defaultVoice.update(acss.ACSS(echoVoice)) + except Exception: + debug.printException(debug.LEVEL_INFO) + + return defaultVoice + def speakKeyEvent(self, event): """Method to speak a keyboard event. Scripts should use this method rather than calling speech.speakKeyEvent directly.""" @@ -3340,6 +3403,10 @@ class Script(script.Script): if event.is_printable_key(): string = event.event_string + if self._shouldUseCustomEchoVoice("key"): + speech.speakEchoKeyEvent(event, self._getCustomEchoVoice()) + return + voice = self.speechGenerator.voice(string=string) speech.speakKeyEvent(event, voice) @@ -3347,6 +3414,10 @@ class Script(script.Script): """Method to speak a single character. Scripts should use this method rather than calling speech.speakCharacter directly.""" + if self._shouldUseCustomEchoVoice("character"): + speech.speakEchoCharacter(character, self._getCustomEchoVoice()) + return + voice = self.speechGenerator.voice(string=character) speech.speakCharacter(character, voice) diff --git a/src/cthulhu/scripts/web/speech_generator.py b/src/cthulhu/scripts/web/speech_generator.py index 11b57f2..8048c4b 100644 --- a/src/cthulhu/scripts/web/speech_generator.py +++ b/src/cthulhu/scripts/web/speech_generator.py @@ -572,7 +572,9 @@ class SpeechGenerator(speech_generator.SpeechGenerator): and soundEnabled: roleSoundIcon = sound_theme_manager.getManager().getRoleSoundIcon(role) if roleSoundPresentation == settings.ROLE_SOUND_PRESENTATION_SOUND_ONLY \ - and soundEnabled and roleSoundIcon: + and soundEnabled: + # Stateful controls present their role via state sounds; suppress + # spoken role names here even if no dedicated role icon exists. if AXUtilities.is_check_box(obj) \ or AXUtilities.is_check_menu_item(obj) \ or AXUtilities.is_radio_button(obj) \ diff --git a/src/cthulhu/settings.py b/src/cthulhu/settings.py index 26b9147..2b6b7ed 100644 --- a/src/cthulhu/settings.py +++ b/src/cthulhu/settings.py @@ -53,6 +53,14 @@ userCustomizableSettings = [ "enableEchoByWord", "enableEchoBySentence", "enableKeyEcho", + "useCustomEchoVoice", + "useCustomEchoSpeechServer", + "echoSpeechServerInfo", + "echoVoice", + "useCustomEchoForKey", + "useCustomEchoForCharacter", + "useCustomEchoForWord", + "useCustomEchoForSentence", "gameMode", "nvda2cthulhuTranslateEnabled", "enableAlphabeticKeys", @@ -352,6 +360,14 @@ enableDiacriticalKeys = False enableEchoByCharacter = False enableEchoByWord = False enableEchoBySentence = False +useCustomEchoVoice = False +useCustomEchoSpeechServer = False +echoSpeechServerInfo = None +echoVoice = ACSS({}) +useCustomEchoForKey = True +useCustomEchoForCharacter = True +useCustomEchoForWord = False +useCustomEchoForSentence = False presentLockingKeys = None # Mouse review diff --git a/src/cthulhu/settings_manager.py b/src/cthulhu/settings_manager.py index 2b18db7..61e9bda 100644 --- a/src/cthulhu/settings_manager.py +++ b/src/cthulhu/settings_manager.py @@ -314,6 +314,13 @@ class SettingsManager(object): converted_voices[voice_type] = voice_def general["voices"] = converted_voices + echo_voice = general.get("echoVoice") + if isinstance(echo_voice, dict): + try: + general["echoVoice"] = ACSS(echo_voice) + except Exception: + general["echoVoice"] = echo_voice + return general def getDefaultSetting(self, settingName: str) -> Any: @@ -379,6 +386,18 @@ class SettingsManager(object): def getSetting(self, settingName: str) -> Any: return getattr(settings, settingName, None) + def _getListSetting(self, settingName: str) -> List[str]: + value = self.getSetting(settingName) + if isinstance(value, (list, tuple)): + return [item for item in value if isinstance(item, str)] + return [] + + def _ensurePluginPersistenceSettings(self, general: Dict[str, Any]) -> None: + if 'activePlugins' not in general: + general['activePlugins'] = self._getListSetting('activePlugins') + if 'pluginSources' not in general: + general['pluginSources'] = self._getListSetting('pluginSources') + def getVoiceLocale(self, voice: str = 'default') -> str: voices = self.getSetting('voices') v = ACSS(voices.get(voice, {})) @@ -620,12 +639,24 @@ class SettingsManager(object): debug.printMessage(debug.LEVEL_INFO, msg, True) self.profileGeneral = {} + alwaysPersistKeys = { + 'useCustomEchoVoice', + 'useCustomEchoSpeechServer', + 'useCustomEchoForKey', + 'useCustomEchoForCharacter', + 'useCustomEchoForWord', + 'useCustomEchoForSentence', + } for key, value in general.items(): if key in ['startingProfile', 'activeProfile']: continue elif key == 'profile': self.profileGeneral[key] = value + elif key in ['activePlugins', 'pluginSources']: + self.profileGeneral[key] = copy.deepcopy(value) + elif key in alwaysPersistKeys: + self.profileGeneral[key] = copy.deepcopy(value) elif value != self.defaultGeneral.get(key): self.profileGeneral[key] = value elif self.general.get(key) != value: @@ -634,6 +665,46 @@ class SettingsManager(object): msg = 'SETTINGS MANAGER: General settings for profile set' debug.printMessage(debug.LEVEL_INFO, msg, True) + def saveProfileSettings( + self, + general: Dict[str, Any], + pronunciations: Optional[Dict[str, Any]] = None, + keybindings: Optional[Dict[str, Any]] = None, + ) -> None: + profileName: Optional[str] = self.profile + profileSetting = general.get('profile') + if isinstance(profileSetting, (list, tuple)) and len(profileSetting) > 1: + profileName = profileSetting[1] + elif not profileName: + defaultProfile = settings.profile + if isinstance(defaultProfile, (list, tuple)) and len(defaultProfile) > 1: + profileName = defaultProfile[1] + else: + profileName = 'default' + + self.profile = profileName + generalCopy = dict(general) + self._ensurePluginPersistenceSettings(generalCopy) + self._setProfileGeneral(generalCopy) + + if self.profile is None: + return + + profileName = self.profile + if pronunciations is None: + pronunciations = self.getPronunciations(profileName) or {} + if keybindings is None: + keybindings = self.getKeybindings(profileName) or {} + + self._setProfilePronunciations(pronunciations) + self._setProfileKeybindings(keybindings) + + if self._backend: + self._backend.saveProfileSettings(self.profile, + self.profileGeneral, + self.profilePronunciations, + self.profileKeybindings) + def _setProfilePronunciations(self, pronunciations: Dict[str, Any]) -> None: """Set the changed pronunciations settings from the defaults' ones as the profile's.""" @@ -664,9 +735,12 @@ class SettingsManager(object): if not self._backend: return + profileScopedKeys = {'activePlugins', 'pluginSources'} appGeneral = {} profileGeneral = self.getGeneralSettings(self.profile) if self.profile else {} for key, value in general.items(): + if key in profileScopedKeys: + continue if value != profileGeneral.get(key): appGeneral[key] = value @@ -705,24 +779,17 @@ class SettingsManager(object): currentProfile = _profile[1] self.profile = currentProfile + self._ensurePluginPersistenceSettings(general) # Elements that need to stay updated in main configuration. self.defaultGeneral['startingProfile'] = general.get('startingProfile', _profile) - self._setProfileGeneral(general) - self._setProfilePronunciations(pronunciations) - self._setProfileKeybindings(keybindings) + self.saveProfileSettings(general, pronunciations, keybindings) tokens = ["SETTINGS MANAGER: Saving for backend", self._backend] debug.printTokens(debug.LEVEL_INFO, tokens, True) - if self._backend and self.profile: - self._backend.saveProfileSettings(self.profile, - self.profileGeneral, - self.profilePronunciations, - self.profileKeybindings) - tokens = ["SETTINGS MANAGER: Settings for", script, "(app:", script.app, ") saved"] debug.printTokens(debug.LEVEL_INFO, tokens, True) return self._enableAccessibility() diff --git a/src/cthulhu/speech.py b/src/cthulhu/speech.py index 290737c..282f6bf 100644 --- a/src/cthulhu/speech.py +++ b/src/cthulhu/speech.py @@ -69,6 +69,7 @@ def _ensureLogger() -> None: # The speech server to use for all speech operations. # _speechserver: Optional[SpeechServer] = None +_echoSpeechserver: Optional[SpeechServer] = None # The last time something was spoken. _timestamp: float = 0.0 @@ -76,12 +77,15 @@ _timestamp: float = 0.0 # Optional callback for live monitoring of spoken text. _monitorWriteTextCallback: Optional[Callable[[str], None]] = None -def _initSpeechServer(moduleName: Optional[str], speechServerInfo: Optional[Any]) -> None: +def _isSpeechDispatcherFactory(moduleName: Optional[str]) -> bool: + if not moduleName: + return False + return moduleName.split(".")[-1] == "speechdispatcherfactory" - global _speechserver +def _initSpeechServer(moduleName: Optional[str], speechServerInfo: Optional[Any]) -> SpeechServer: if not moduleName: - return + raise Exception("ERROR: No speech server module name provided") factory = None try: @@ -94,35 +98,58 @@ def _initSpeechServer(moduleName: Optional[str], speechServerInfo: Optional[Any] # Now, get the speech server we care about. # - speechServerInfo = settings.speechServerInfo + speech_server = None if factory: if speechServerInfo: - _speechserver = factory.SpeechServer.getSpeechServer(speechServerInfo) # type: ignore + speech_server = factory.SpeechServer.getSpeechServer(speechServerInfo) # type: ignore - if not _speechserver: - _speechserver = factory.SpeechServer.getSpeechServer() # type: ignore + if not speech_server: + speech_server = factory.SpeechServer.getSpeechServer() # type: ignore if speechServerInfo: tokens = ["SPEECH: Invalid speechServerInfo:", speechServerInfo] debug.printTokens(debug.LEVEL_INFO, tokens, True) - if not _speechserver: + if not speech_server: raise Exception(f"ERROR: No speech server for factory: {moduleName}") + return speech_server + +def _refreshEchoSpeechServer() -> None: + global _echoSpeechserver + _echoSpeechserver = None + + if not settings.enableSpeech or not settings.useCustomEchoSpeechServer: + return + + if not _isSpeechDispatcherFactory(settings.speechServerFactory): + return + + try: + _echoSpeechserver = _initSpeechServer( + settings.speechServerFactory, + settings.echoSpeechServerInfo + ) + except Exception: + debug.printException(debug.LEVEL_INFO) + _echoSpeechserver = None + def init() -> None: + global _speechserver debug.printMessage(debug.LEVEL_INFO, 'SPEECH: Initializing', True) if _speechserver: debug.printMessage(debug.LEVEL_INFO, 'SPEECH: Already initialized', True) + _refreshEchoSpeechServer() return chosenModuleName = settings.speechServerFactory try: - _initSpeechServer(chosenModuleName, settings.speechServerInfo) + _speechserver = _initSpeechServer(chosenModuleName, settings.speechServerInfo) except Exception: moduleNames = settings.speechFactoryModules for moduleName in moduleNames: if moduleName != settings.speechServerFactory: try: - _initSpeechServer(moduleName, None) + _speechserver = _initSpeechServer(moduleName, None) if _speechserver: chosenModuleName = moduleName break @@ -144,6 +171,7 @@ def init() -> None: msg = 'SPEECH: Not available' debug.printMessage(debug.LEVEL_INFO, msg, True) + _refreshEchoSpeechServer() debug.printMessage(debug.LEVEL_INFO, 'SPEECH: Initialized', True) def checkSpeechSetting() -> None: @@ -157,6 +185,9 @@ def checkSpeechSetting() -> None: if not _speechserver: init() + return + + _refreshEchoSpeechServer() def getSpeechServer() -> Optional[SpeechServer]: """Returns the speech server instance.""" @@ -170,6 +201,7 @@ def setSpeechServer(speechServer: SpeechServer) -> None: """ global _speechserver _speechserver = speechServer + _refreshEchoSpeechServer() def set_monitor_callbacks(writeText: Optional[Callable[[str], None]] = None) -> None: """Sets runtime callbacks for live speech monitoring.""" @@ -418,6 +450,35 @@ def speakKeyEvent(event: Any, acss: Optional[Any] = None) -> None: if _speechserver: _speechserver.speakKeyEvent(event, acss) # type: ignore +def speakEchoKeyEvent(event: Any, acss: Optional[Any] = None) -> None: + """Speaks a key event for key/typing echo using echo speech settings.""" + + _ensureLogger() + + if settings.silenceSpeech: + return + + global _timestamp + if _timestamp: + msg = f"SPEECH: Last spoke {time.time() - _timestamp:.4f} seconds ago" + debug.printMessage(debug.LEVEL_INFO, msg, True) + _timestamp = time.time() + + keyname = event.getKeyName() + lockingStateString = event.getLockingStateString() + acss = __resolveACSS(acss) + msg = f"{keyname} {lockingStateString}" + logLine = f"SPEECH ECHO OUTPUT: '{msg.strip()}' {acss}" + debug.printMessage(debug.LEVEL_INFO, logLine, True) + if log: + log.info(logLine) + + _write_to_monitor(msg.strip()) + + server = _echoSpeechserver or _speechserver + if server: + server.speakKeyEvent(event, acss) # type: ignore + def speakCharacter(character: str, acss: Optional[Any] = None) -> None: """Speaks a single character immediately. @@ -452,6 +513,58 @@ def speakCharacter(character: str, acss: Optional[Any] = None) -> None: if _speechserver: _speechserver.speakCharacter(character, acss=acss) # type: ignore +def speakEchoCharacter(character: str, acss: Optional[Any] = None) -> None: + """Speaks a single character for key/typing echo using echo speech settings.""" + + _ensureLogger() + + if settings.silenceSpeech: + return + + global _timestamp + if _timestamp: + msg = f"SPEECH: Last spoke {time.time() - _timestamp:.4f} seconds ago" + debug.printMessage(debug.LEVEL_INFO, msg, True) + _timestamp = time.time() + + acss = __resolveACSS(acss) + tokens = [f"SPEECH ECHO OUTPUT: '{character}'", acss] + debug.printTokens(debug.LEVEL_INFO, tokens, True) + if log: + log.info(f"SPEECH ECHO OUTPUT: '{character}'") + + _write_to_monitor(character) + + server = _echoSpeechserver or _speechserver + if server: + server.speakCharacter(character, acss=acss) # type: ignore + +def speakEchoText(text: str, acss: Optional[Any] = None, interrupt: bool = True) -> None: + """Speaks text for typing echo using echo speech settings.""" + + _ensureLogger() + + if settings.silenceSpeech: + return + + global _timestamp + if _timestamp: + msg = f"SPEECH: Last spoke {time.time() - _timestamp:.4f} seconds ago" + debug.printMessage(debug.LEVEL_INFO, msg, True) + _timestamp = time.time() + + acss = __resolveACSS(acss) + logLine = f"SPEECH ECHO OUTPUT: '{text}' {acss}" + debug.printMessage(debug.LEVEL_INFO, logLine, True) + if log: + log.info(logLine) + + _write_to_monitor(text) + + server = _echoSpeechserver or _speechserver + if server: + server.speak(text, acss, interrupt) # type: ignore + def getInfo() -> Optional[Any]: info = None if _speechserver: @@ -462,13 +575,18 @@ def getInfo() -> Optional[Any]: def stop() -> None: if _speechserver: _speechserver.stop() # type: ignore + if _echoSpeechserver and _echoSpeechserver != _speechserver: + _echoSpeechserver.stop() # type: ignore def shutdown() -> None: debug.printMessage(debug.LEVEL_INFO, 'SPEECH: Shutting down', True) - global _speechserver + global _speechserver, _echoSpeechserver if _speechserver: _speechserver.shutdownActiveServers() # type: ignore _speechserver = None + elif _echoSpeechserver: + _echoSpeechserver.shutdownActiveServers() # type: ignore + _echoSpeechserver = None def reset(text: Optional[str] = None, acss: Optional[Any] = None) -> None: if _speechserver: diff --git a/src/cthulhu/speech_generator.py b/src/cthulhu/speech_generator.py index 9b96d79..7885c7d 100644 --- a/src/cthulhu/speech_generator.py +++ b/src/cthulhu/speech_generator.py @@ -678,7 +678,9 @@ class SpeechGenerator(generator.Generator): and soundEnabled: roleSoundIcon = sound_theme_manager.getManager().getRoleSoundIcon(role) if roleSoundPresentation == settings.ROLE_SOUND_PRESENTATION_SOUND_ONLY \ - and soundEnabled and roleSoundIcon: + and soundEnabled: + # Stateful controls present their role via state sounds; suppress + # spoken role names here even if no dedicated role icon exists. if AXUtilities.is_check_box(obj) \ or AXUtilities.is_check_menu_item(obj) \ or AXUtilities.is_radio_button(obj) \