Merge branch 'testing'

# Conflicts:
#	distro-packages/Arch-Linux/PKGBUILD
#	meson.build
#	src/cthulhu/cthulhuVersion.py
This commit is contained in:
Storm Dragon
2026-03-02 15:37:50 -05:00
18 changed files with 1127 additions and 92 deletions
-8
View File
@@ -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"
+1 -1
View File
@@ -1,7 +1,7 @@
# Maintainer: Storm Dragon <storm_dragon@stormux.org>
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"
+1 -11
View File
@@ -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()
-1
View File
@@ -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
+48 -2
View File
@@ -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:
+310 -3
View File
@@ -136,6 +136,24 @@
<property name="step_increment">0.10000000149</property>
<property name="page_increment">1</property>
</object>
<object class="GtkAdjustment" id="echoPitchAdjustment">
<property name="upper">10</property>
<property name="value">5</property>
<property name="step_increment">0.10000000149</property>
<property name="page_increment">1</property>
</object>
<object class="GtkAdjustment" id="echoRateAdjustment">
<property name="upper">100</property>
<property name="value">50</property>
<property name="step_increment">1</property>
<property name="page_increment">10</property>
</object>
<object class="GtkAdjustment" id="echoVolumeAdjustment">
<property name="upper">10</property>
<property name="value">10</property>
<property name="step_increment">0.10000000149</property>
<property name="page_increment">1</property>
</object>
<object class="GtkDialog" id="cthulhuSetupWindow">
<property name="can_focus">False</property>
<property name="title" translatable="yes">Cthulhu Preferences</property>
@@ -3037,6 +3055,295 @@
<property name="top_attach">1</property>
</packing>
</child>
<child>
<object class="GtkFrame" id="echoVoiceFrame">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="label_xalign">0</property>
<property name="shadow_type">none</property>
<child>
<object class="GtkAlignment" id="echoVoiceAlignment">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="left_padding">12</property>
<child>
<object class="GtkGrid" id="echoVoiceGrid">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="row_spacing">10</property>
<property name="column_spacing">8</property>
<child>
<object class="GtkCheckButton" id="useCustomEchoVoiceCheckButton">
<property name="label" translatable="yes">Use c_ustom echo voice settings</property>
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">False</property>
<property name="use_underline">True</property>
<property name="draw_indicator">True</property>
<signal name="toggled" handler="useCustomEchoVoiceToggled" swapped="no"/>
</object>
<packing>
<property name="left_attach">0</property>
<property name="top_attach">0</property>
<property name="width">2</property>
</packing>
</child>
<child>
<object class="GtkCheckButton" id="useCustomEchoSpeechServerCheckButton">
<property name="label" translatable="yes">Use custom _speech-dispatcher module for echo</property>
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">False</property>
<property name="use_underline">True</property>
<property name="draw_indicator">True</property>
<signal name="toggled" handler="useCustomEchoSpeechServerToggled" swapped="no"/>
</object>
<packing>
<property name="left_attach">0</property>
<property name="top_attach">1</property>
<property name="width">2</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="echoSpeechServersLabel">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="xalign">1</property>
<property name="label" translatable="yes">Echo _module:</property>
<property name="use_underline">True</property>
<property name="mnemonic_widget">echoSpeechServers</property>
<accessibility>
<relation type="label-for" target="echoSpeechServers"/>
</accessibility>
</object>
<packing>
<property name="left_attach">0</property>
<property name="top_attach">2</property>
</packing>
</child>
<child>
<object class="GtkComboBox" id="echoSpeechServers">
<property name="visible">True</property>
<property name="can_focus">False</property>
<signal name="changed" handler="echoSpeechServersChanged" swapped="no"/>
<accessibility>
<relation type="labelled-by" target="echoSpeechServersLabel"/>
</accessibility>
</object>
<packing>
<property name="left_attach">1</property>
<property name="top_attach">2</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="echoSpeechFamiliesLabel">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="xalign">1</property>
<property name="label" translatable="yes">Echo _voice:</property>
<property name="use_underline">True</property>
<property name="mnemonic_widget">echoSpeechFamilies</property>
<accessibility>
<relation type="label-for" target="echoSpeechFamilies"/>
</accessibility>
</object>
<packing>
<property name="left_attach">0</property>
<property name="top_attach">3</property>
</packing>
</child>
<child>
<object class="GtkComboBox" id="echoSpeechFamilies">
<property name="visible">True</property>
<property name="can_focus">False</property>
<signal name="changed" handler="echoSpeechFamiliesChanged" swapped="no"/>
<accessibility>
<relation type="labelled-by" target="echoSpeechFamiliesLabel"/>
</accessibility>
</object>
<packing>
<property name="left_attach">1</property>
<property name="top_attach">3</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="echoRateLabel">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="xalign">1</property>
<property name="label" translatable="yes">Echo ra_te:</property>
<property name="use_underline">True</property>
<property name="mnemonic_widget">echoRateScale</property>
<accessibility>
<relation type="label-for" target="echoRateScale"/>
</accessibility>
</object>
<packing>
<property name="left_attach">0</property>
<property name="top_attach">4</property>
</packing>
</child>
<child>
<object class="GtkScale" id="echoRateScale">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="adjustment">echoRateAdjustment</property>
<property name="round_digits">0</property>
<property name="digits">0</property>
<property name="value_pos">right</property>
<signal name="value-changed" handler="echoRateValueChanged" swapped="no"/>
</object>
<packing>
<property name="left_attach">1</property>
<property name="top_attach">4</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="echoPitchLabel">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="xalign">1</property>
<property name="label" translatable="yes">Echo pi_tch:</property>
<property name="use_underline">True</property>
<property name="mnemonic_widget">echoPitchScale</property>
<accessibility>
<relation type="label-for" target="echoPitchScale"/>
</accessibility>
</object>
<packing>
<property name="left_attach">0</property>
<property name="top_attach">5</property>
</packing>
</child>
<child>
<object class="GtkScale" id="echoPitchScale">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="adjustment">echoPitchAdjustment</property>
<property name="round_digits">1</property>
<property name="value_pos">right</property>
<signal name="value-changed" handler="echoPitchValueChanged" swapped="no"/>
</object>
<packing>
<property name="left_attach">1</property>
<property name="top_attach">5</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="echoVolumeLabel">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="xalign">1</property>
<property name="label" translatable="yes">Echo vo_lume:</property>
<property name="use_underline">True</property>
<property name="mnemonic_widget">echoVolumeScale</property>
<accessibility>
<relation type="label-for" target="echoVolumeScale"/>
</accessibility>
</object>
<packing>
<property name="left_attach">0</property>
<property name="top_attach">6</property>
</packing>
</child>
<child>
<object class="GtkScale" id="echoVolumeScale">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="adjustment">echoVolumeAdjustment</property>
<property name="round_digits">1</property>
<property name="value_pos">right</property>
<signal name="value-changed" handler="echoVolumeValueChanged" swapped="no"/>
</object>
<packing>
<property name="left_attach">1</property>
<property name="top_attach">6</property>
</packing>
</child>
<child>
<object class="GtkCheckButton" id="useCustomEchoForKeyCheckButton">
<property name="label" translatable="yes">Use custom voice for _key echo</property>
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">False</property>
<property name="use_underline">True</property>
<property name="draw_indicator">True</property>
<signal name="toggled" handler="checkButtonToggled" swapped="no"/>
</object>
<packing>
<property name="left_attach">0</property>
<property name="top_attach">7</property>
<property name="width">2</property>
</packing>
</child>
<child>
<object class="GtkCheckButton" id="useCustomEchoForCharacterCheckButton">
<property name="label" translatable="yes">Use custom voice for _character echo</property>
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">False</property>
<property name="use_underline">True</property>
<property name="draw_indicator">True</property>
<signal name="toggled" handler="checkButtonToggled" swapped="no"/>
</object>
<packing>
<property name="left_attach">0</property>
<property name="top_attach">8</property>
<property name="width">2</property>
</packing>
</child>
<child>
<object class="GtkCheckButton" id="useCustomEchoForWordCheckButton">
<property name="label" translatable="yes">Use custom voice for _word echo</property>
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">False</property>
<property name="use_underline">True</property>
<property name="draw_indicator">True</property>
<signal name="toggled" handler="checkButtonToggled" swapped="no"/>
</object>
<packing>
<property name="left_attach">0</property>
<property name="top_attach">9</property>
<property name="width">2</property>
</packing>
</child>
<child>
<object class="GtkCheckButton" id="useCustomEchoForSentenceCheckButton">
<property name="label" translatable="yes">Use custom voice for _sentence echo</property>
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">False</property>
<property name="use_underline">True</property>
<property name="draw_indicator">True</property>
<signal name="toggled" handler="checkButtonToggled" swapped="no"/>
</object>
<packing>
<property name="left_attach">0</property>
<property name="top_attach">10</property>
<property name="width">2</property>
</packing>
</child>
</object>
</child>
</object>
</child>
<child type="label">
<object class="GtkLabel" id="echoVoiceLabel">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="label" translatable="yes">Echo Voice</property>
<attributes>
<attribute name="weight" value="bold"/>
</attributes>
</object>
</child>
</object>
<packing>
<property name="left_attach">0</property>
<property name="top_attach">2</property>
</packing>
</child>
<child>
<object class="GtkCheckButton" id="enableEchoByCharacterCheckButton">
<property name="label" translatable="yes" comments="Translators: When this option is enabled, inserted text of length 1 is spoken.">Enable echo by cha_racter</property>
@@ -3049,7 +3356,7 @@
</object>
<packing>
<property name="left_attach">0</property>
<property name="top_attach">2</property>
<property name="top_attach">3</property>
</packing>
</child>
<child>
@@ -3064,7 +3371,7 @@
</object>
<packing>
<property name="left_attach">0</property>
<property name="top_attach">3</property>
<property name="top_attach">4</property>
</packing>
</child>
<child>
@@ -3079,7 +3386,7 @@
</object>
<packing>
<property name="left_attach">0</property>
<property name="top_attach">4</property>
<property name="top_attach">5</property>
</packing>
</child>
</object>
+1 -1
View File
@@ -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"
+450 -13
View File
@@ -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()
+8 -7
View File
@@ -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."""
+3 -19
View File
@@ -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)
+3
View File
@@ -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:
+75 -4
View File
@@ -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)
+3 -1
View File
@@ -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) \
+16
View File
@@ -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
+76 -9
View File
@@ -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()
+129 -11
View File
@@ -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:
+3 -1
View File
@@ -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) \