From 1d2d727fa2d88039f4beebbb360800b995629e77 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Sun, 22 Feb 2026 01:44:19 -0500 Subject: [PATCH] Requested feature added. It is now possible to customize speech used for echo settings including speech module, voice, rate, volume, pitch. --- distro-packages/Arch-Linux/PKGBUILD | 2 +- meson.build | 2 +- src/cthulhu/backends/toml_backend.py | 34 +++ src/cthulhu/cthulhu-setup.ui | 313 ++++++++++++++++++- src/cthulhu/cthulhuVersion.py | 4 +- src/cthulhu/cthulhu_gui_prefs.py | 439 +++++++++++++++++++++++++++ src/cthulhu/script_utilities.py | 3 + src/cthulhu/scripts/default.py | 70 ++++- src/cthulhu/settings.py | 16 + src/cthulhu/settings_manager.py | 17 ++ src/cthulhu/speech.py | 140 ++++++++- 11 files changed, 1018 insertions(+), 22 deletions(-) diff --git a/distro-packages/Arch-Linux/PKGBUILD b/distro-packages/Arch-Linux/PKGBUILD index 36ae9ce..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.17 +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 c3268b7..ae3763d 100644 --- a/meson.build +++ b/meson.build @@ -1,5 +1,5 @@ project('cthulhu', - version: '2026.02.17-master', + version: '2026.02.22-testing', meson_version: '>= 1.0.0', ) diff --git a/src/cthulhu/backends/toml_backend.py b/src/cthulhu/backends/toml_backend.py index f147888..d8ca97e 100644 --- a/src/cthulhu/backends/toml_backend.py +++ b/src/cthulhu/backends/toml_backend.py @@ -222,6 +222,24 @@ 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. """ @@ -241,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 5ec38f0..933e976 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.17" -codeName = "master" +version = "2026.02.22" +codeName = "testing" diff --git a/src/cthulhu/cthulhu_gui_prefs.py b/src/cthulhu/cthulhu_gui_prefs.py index 078f50e..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. # @@ -2906,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 @@ -3232,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 @@ -3263,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 @@ -3295,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 @@ -3433,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. @@ -3450,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 @@ -4252,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. @@ -4284,6 +4721,7 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): self._refresh_dynamic_plugin_tabs() braille.checkBrailleSetting() self._initSpeechState() + self._initEchoSpeechState() self._populateKeyBindings() self.__initProfileCombo() @@ -4546,6 +4984,7 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): braille.checkBrailleSetting() self._initSpeechState() + self._initEchoSpeechState() self._populateKeyBindings() 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 857711b..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 @@ -2228,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): @@ -2297,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): @@ -2901,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) @@ -3341,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.""" @@ -3349,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) @@ -3356,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/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 c5dfd10..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: @@ -632,6 +639,14 @@ 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']: @@ -640,6 +655,8 @@ class SettingsManager(object): 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: 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: