diff --git a/AGENTS.md b/AGENTS.md index 79b236e..01712c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,6 +14,16 @@ This repository is a screen reader. Prioritize accessibility, correctness, and s - `meson compile -C _build` - `meson install -C _build` +## Runtime target and testing rules +- Make source changes in this repo, not in `~/.local/lib/python*/site-packages/cthulhu/`, unless the user explicitly asks for an installed-package hotfix. +- If you confirm the active import comes from `~/.local/...`, that does **not** mean you should edit there. It means you should update the repo and then run `./build-local.sh` to replace the installed copy for testing. +- Default test/apply workflow for local Cthulhu fixes: + - edit repo files + - run `./build-local.sh` + - reproduce/test against the refreshed `~/.local` install +- If repo and installed behavior differ, prefer rebuilding with `./build-local.sh` over patching the installed package directly. +- Treat direct edits under `~/.local/.../cthulhu/` as an exception path that requires explicit user approval. + ## Coding guidelines - **When modifying existing code:** follow the surrounding code’s conventions. - **When writing new code from scratch:** prefer @@ -55,3 +65,8 @@ This repository is a screen reader. Prioritize accessibility, correctness, and s ## Meson install reminder (important) - If you add new Python modules under `src/cthulhu/`, update `src/cthulhu/meson.build` so they get installed (otherwise imports can fail after install). - If you add a new plugin directory, update `src/cthulhu/plugins/meson.build` and add a `meson.build` in the plugin directory. + +## Common Cthulhu agent mistakes +- Checking the import origin, seeing `~/.local/...`, and then editing the installed package instead of the repo. +- Forgetting that `./build-local.sh` is the normal way to apply repo changes into the installed copy for testing. +- Making repo fixes and then diagnosing the old installed copy without rebuilding. diff --git a/src/cthulhu/cthulhu-setup.ui b/src/cthulhu/cthulhu-setup.ui index 86ffe6d..002ade0 100644 --- a/src/cthulhu/cthulhu-setup.ui +++ b/src/cthulhu/cthulhu-setup.ui @@ -571,7 +571,7 @@ True timeFormatCombo - + @@ -588,7 +588,7 @@ True dateFormatCombo - + diff --git a/src/cthulhu/cthulhu_gui_prefs.py b/src/cthulhu/cthulhu_gui_prefs.py index 55b90fb..97eb24f 100644 --- a/src/cthulhu/cthulhu_gui_prefs.py +++ b/src/cthulhu/cthulhu_gui_prefs.py @@ -71,6 +71,7 @@ from . import text_attribute_names from . import sound_theme_manager from . import script_manager from .ax_object import AXObject +from .ax_utilities import AXUtilities _settingsManager = None # Removed - use cthulhu.cthulhuApp.settingsManager @@ -202,6 +203,7 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): self.profilesCombo = None self.profilesComboModel = None self.startingProfileCombo = None + self._initialFocusSyncAttempts = 0 self._capturedKey = [] self.script = None @@ -1506,6 +1508,16 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): # if not serverInfo: serverInfo = speech.getInfo() + if serverInfo and len(serverInfo) >= 2 and serverInfo[1] == 'default': + defaultFamily = None + voices = self.prefsDict.get("voices", {}) + defaultVoice = voices.get(settings.DEFAULT_VOICE) if voices else None + if defaultVoice: + defaultFamily = acss.ACSS(defaultVoice).get(acss.ACSS.FAMILY) + + resolved = self._resolveSpeechDispatcherServerForFamily(defaultFamily) + if resolved is not None: + serverInfo = resolved.getInfo() valueSet = False i = 0 @@ -1720,6 +1732,118 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): } self.echoVoice['established'] = True + def _resolveSpeechDispatcherServerForFamily(self, family): + """Returns a concrete Speech Dispatcher server matching the voice family.""" + + if not family or not self.speechServersChoices: + return None + + name = family.get(speechserver.VoiceFamily.NAME) + language = family.get(speechserver.VoiceFamily.LANG) + dialect = family.get(speechserver.VoiceFamily.DIALECT) + variant = family.get(speechserver.VoiceFamily.VARIANT) + if not name: + return None + + for server in self.speechServersChoices: + info = server.getInfo() + if not info or len(info) < 2 or info[1] == 'default': + continue + + try: + families = server.getVoiceFamilies() + except Exception: + debug.printException(debug.LEVEL_FINEST) + continue + + for candidate in families: + if candidate.get(speechserver.VoiceFamily.NAME) != name: + continue + if candidate.get(speechserver.VoiceFamily.LANG) != language: + continue + if candidate.get(speechserver.VoiceFamily.DIALECT) != dialect: + continue + if candidate.get(speechserver.VoiceFamily.VARIANT) != variant: + continue + + tokens = [ + "PREFERENCES DIALOG: Resolved voice family", + name, + "to speech server", + info, + ] + debug.printTokens(debug.LEVEL_INFO, tokens, True) + return server + + tokens = [ + "PREFERENCES DIALOG: Could not resolve speech server for family", + family, + ] + debug.printTokens(debug.LEVEL_INFO, tokens, True) + return None + + def _getSpeechServerChoiceForSave(self): + """Returns the server choice that should be persisted for speech settings.""" + + server = self.speechServersChoice + if not server or self.speechSystemsChoice != self._getSpeechDispatcherFactory(): + return server + + info = server.getInfo() + if not info or len(info) < 2 or info[1] != 'default': + return server + + defaultFamily = None + if self.defaultVoice is not None: + defaultFamily = self.defaultVoice.get(acss.ACSS.FAMILY) + + resolved = self._resolveSpeechDispatcherServerForFamily(defaultFamily) + return resolved or server + + def _getEchoSpeechServerFamilyForSave(self): + """Returns the most specific echo family to use for server resolution.""" + + family = None + if self.echoVoice is not None: + family = self.echoVoice.get(acss.ACSS.FAMILY) + + if family: + return family + + if self.defaultVoice is not None: + return self.defaultVoice.get(acss.ACSS.FAMILY) + + return None + + def _getEchoSpeechServerChoiceForSave(self): + """Returns the echo speech server choice that should be persisted.""" + + server = self.echoSpeechServersChoice + if server is None: + return None + + info = server.getInfo() + if not info or len(info) < 2 or info[1] != 'default': + return server + + family = self._getEchoSpeechServerFamilyForSave() + resolved = self._resolveSpeechDispatcherServerForFamily(family) + if resolved is not None: + return resolved + + speechServer = self._getSpeechServerChoiceForSave() + if speechServer is not None: + speechInfo = speechServer.getInfo() + if speechInfo and len(speechInfo) >= 2 and speechInfo[1] != 'default': + tokens = [ + "PREFERENCES DIALOG: Falling back to main speech server for echo", + speechInfo, + ] + debug.printTokens(debug.LEVEL_INFO, tokens, True) + return speechServer + + return server + def _populateEchoSpeechFamilies(self, families): """Populate the echo family combobox from the provided families list.""" @@ -1873,6 +1997,23 @@ print(json.dumps(result)) self._setupEchoSpeechFamilies() return + if serverInfo and len(serverInfo) >= 2 and serverInfo[1] == 'default': + family = self._getEchoSpeechServerFamilyForSave() + resolved = self._resolveSpeechDispatcherServerForFamily(family) + if resolved is None: + speechServer = self._getSpeechServerChoiceForSave() + if speechServer is not None: + speechInfo = speechServer.getInfo() + if speechInfo and len(speechInfo) >= 2 and speechInfo[1] != 'default': + resolved = speechServer + tokens = [ + "PREFERENCES DIALOG: Reusing main speech server for echo", + speechInfo, + ] + debug.printTokens(debug.LEVEL_INFO, tokens, True) + if resolved is not None: + serverInfo = resolved.getInfo() + valueSet = False for i, server in enumerate(self.echoSpeechServersChoices): info = server.getInfo() @@ -3148,6 +3289,68 @@ print(json.dumps(result)) cthulhuSetupWindow.set_title(title) cthulhuSetupWindow.show() + self._initialFocusSyncAttempts = 0 + GLib.idle_add(self._set_initial_window_state) + GLib.idle_add(self._set_initial_gtk_focus) + GLib.timeout_add(50, self._set_initial_window_state) + + def _set_initial_gtk_focus(self): + """Give GTK focus to a real preferences control as soon as the dialog appears.""" + + candidateIds = [ + "generalDesktopButton", + "generalLaptopButton", + "availableProfilesComboBox1", + "speechSupportCheckButton", + "notebook", + ] + cthulhuSetupWindow = self.get_widget("cthulhuSetupWindow") + for widgetId in candidateIds: + widget = self.get_widget(widgetId) + if not widget.get_visible() or not widget.get_sensitive(): + continue + if not widget.get_can_focus(): + continue + + debug.printMessage( + debug.LEVEL_INFO, + f"PREFERENCES DIALOG: Setting initial GTK focus to {widgetId}", + True, + ) + cthulhuSetupWindow.set_focus(widget) + widget.grab_focus() + return False + + debug.printMessage( + debug.LEVEL_INFO, + "PREFERENCES DIALOG: No focusable initial GTK widget found", + True, + ) + return False + + def _set_initial_window_state(self): + """Sync Cthulhu's active window to preferences without forcing dialog focus.""" + + self._initialFocusSyncAttempts += 1 + activeWindow = AXUtilities.find_active_window() + if activeWindow is None: + return self._initialFocusSyncAttempts < 5 + + app = AXObject.get_application(activeWindow) + appName = (AXObject.get_name(app) or "").lower() + if appName != "cthulhu": + return self._initialFocusSyncAttempts < 5 + + debug.printTokens( + debug.LEVEL_INFO, + ["PREFERENCES DIALOG: Syncing active window to", activeWindow], + True, + ) + cthulhu.setActiveWindow(activeWindow, notifyScript=False) + + tokens = ["PREFERENCES DIALOG: Synced initial window state to", activeWindow] + debug.printTokens(debug.LEVEL_INFO, tokens, True) + return False def _initComboBox(self, combobox): """Initialize the given combo box to take a list of int/str pairs. @@ -4648,9 +4851,10 @@ print(json.dumps(result)) self.prefsDict["speechServerFactory"] = \ self.speechSystemsChoice.__name__ - if self.speechServersChoice: + speechServerChoice = self._getSpeechServerChoiceForSave() + if speechServerChoice: self.prefsDict["speechServerInfo"] = \ - self.speechServersChoice.getInfo() + speechServerChoice.getInfo() if self.defaultVoice is not None: self.prefsDict["voices"] = { @@ -4684,8 +4888,9 @@ print(json.dumps(result)) self.echoVoice['established'] = True self.prefsDict["echoVoice"] = acss.ACSS(self.echoVoice) - if self.echoSpeechServersChoice: - self.prefsDict["echoSpeechServerInfo"] = self.echoSpeechServersChoice.getInfo() + echoSpeechServerChoice = self._getEchoSpeechServerChoiceForSave() + if echoSpeechServerChoice: + self.prefsDict["echoSpeechServerInfo"] = echoSpeechServerChoice.getInfo() else: self.prefsDict["echoSpeechServerInfo"] = None diff --git a/src/cthulhu/cthulhu_state.py b/src/cthulhu/cthulhu_state.py index 8cbb238..586e36d 100644 --- a/src/cthulhu/cthulhu_state.py +++ b/src/cthulhu/cthulhu_state.py @@ -47,6 +47,11 @@ __license__ = "LGPL" # locusOfFocus: Optional[Any] = None # Actually: Optional[Atspi.Accessible] +# A pending focused object from a Cthulhu-owned window that should be used +# for keyboard-event context until the queued focus event is processed. +# +pendingSelfHostedFocus: Optional[Any] = None # Actually: Optional[Atspi.Accessible] + # The currently active window. # activeWindow: Optional[Any] = None # Actually: Optional[Atspi.Accessible] diff --git a/src/cthulhu/event_manager.py b/src/cthulhu/event_manager.py index 5f5c1da..8dc37b7 100644 --- a/src/cthulhu/event_manager.py +++ b/src/cthulhu/event_manager.py @@ -70,7 +70,9 @@ class EventManager: self._dequeueCount: int = 0 self._cmdlineCache: Dict[int, str] = {} self._eventQueue: queue.Queue[Any] = queue.Queue(0) + self._prioritizedEvent: Optional[Atspi.Event] = None self._gidleId: int = 0 + self._prioritizedIdleId: int = 0 self._gidleLock: threading.Lock = threading.Lock() self._gilSleepTime: float = 0.00001 self._synchronousToolkits: List[str] = ['VCL'] @@ -284,6 +286,12 @@ class EventManager: if self._isDuplicateEvent(event): return _ignore_with_reason("duplicate", "duplicate event") + if self._isSelfHostedFocusClearedEvent(event): + return _ignore_with_reason("self-hosted-focus-cleared", "self-hosted focused=false event") + + if self._isRedundantSelfHostedPropertyEvent(event): + return _ignore_with_reason("self-hosted-redundant-property", "self-hosted redundant property event") + # Thunderbird spams us with these when a message list thread is expanded or collapsed. if event.type.endswith('system') \ and AXObject.get_name(app).lower().startswith('thunderbird'): @@ -596,6 +604,11 @@ class EventManager: self._enqueueCount -= 1 return + if isObjectEvent and self._prioritizeSelfHostedFocusedEvent(e): + if debug.debugEventQueue: + self._enqueueCount -= 1 + return + self._queuePrintln(e) if self._inFlood() and self._prioritizeDuringFlood(e): @@ -628,6 +641,73 @@ class EventManager: if debug.debugEventQueue: self._enqueueCount -= 1 + def _isSelfHostedFocusedEvent(self, event: Atspi.Event) -> bool: + """Returns True if the event is a newly-focused event from Cthulhu.""" + + if not event.type.startswith("object:state-changed:focused") or not event.detail1: + return False + + app = AXObject.get_application(event.source) + appName = (AXObject.get_name(app) or "").lower() + return appName == "cthulhu" + + def _isSelfHostedFocusClearedEvent(self, event: Atspi.Event) -> bool: + """Returns True if the event is a focus-lost event from Cthulhu.""" + + if not event.type.startswith("object:state-changed:focused") or event.detail1: + return False + + app = AXObject.get_application(event.source) + appName = (AXObject.get_name(app) or "").lower() + return appName == "cthulhu" + + def _isRedundantSelfHostedPropertyEvent(self, event: Atspi.Event) -> bool: + """Returns True if the event is redundant prefs startup noise from Cthulhu.""" + + app = AXObject.get_application(event.source) + appName = (AXObject.get_name(app) or "").lower() + if appName != "cthulhu": + return False + + if event.type.startswith("object:property-change:accessible-name"): + return AXUtilities.is_combo_box(event.source) or AXUtilities.is_table_cell(event.source) + + if event.type.startswith("object:property-change:accessible-value"): + return AXObject.get_role(event.source) == Atspi.Role.SLIDER \ + and event.source != cthulhu_state.locusOfFocus + + return False + + def _prioritizeSelfHostedFocusedEvent(self, event: Atspi.Event) -> bool: + """Schedules the latest focused-child event from Cthulhu ahead of the normal queue.""" + + if not self._isSelfHostedFocusedEvent(event): + return False + + tokens = ["EVENT MANAGER: Prioritizing self-hosted focused event for", event.source] + debug.printTokens(debug.LEVEL_INFO, tokens, True) + + script = self._get_scriptForEvent(event) + if script is not None: + script.eventCache[event.type] = (event, time.time()) + + self._gidleLock.acquire() + try: + replaced = self._prioritizedEvent is not None + self._prioritizedEvent = event + cthulhu_state.pendingSelfHostedFocus = event.source + if not self._prioritizedIdleId: + self._prioritizedIdleId = GLib.idle_add( + self._dequeuePrioritizedEvent, + priority=GLib.PRIORITY_HIGH_IDLE, + ) + finally: + self._gidleLock.release() + + msg = f"EVENT MANAGER: Prioritized self-hosted focused event. Replaced pending event: {replaced}" + debug.printMessage(debug.LEVEL_INFO, msg, True) + + return True def _isNoFocus(self) -> bool: if cthulhu_state.locusOfFocus or cthulhu_state.activeWindow or cthulhu_state.activeScript: return False @@ -645,6 +725,60 @@ class EventManager: defaultScript.idleMessage() return False + def _dequeuePrioritizedEvent(self) -> bool: + """Handles prioritized focused events from Cthulhu-owned windows.""" + + self._gidleLock.acquire() + try: + event = self._prioritizedEvent + self._prioritizedEvent = None + self._prioritizedIdleId = 0 + if event is not None and cthulhu_state.pendingSelfHostedFocus == event.source: + cthulhu_state.pendingSelfHostedFocus = None + finally: + self._gidleLock.release() + + if event is None: + return False + + tokens = ["EVENT MANAGER: Dequeued prioritized event", event] + debug.printTokens(debug.LEVEL_INFO, tokens, True) + + debugging = not debug.eventDebugFilter or debug.eventDebugFilter.match(event.type) + if debugging: + startTime = time.time() + msg = ( + f"\nvvvvv PROCESS OBJECT EVENT {event.type} " + f"(prioritized) vvvvv" + ) + debug.printMessage(debug.eventDebugLevel, msg, False) + + try: + self._processObjectEvent(event) + if self._didSuspendEventsFor(event): + self._unsuspendEvents(event) + elif self._eventsSuspended and self._shouldUnsuspendEventsFor(event): + self._unsuspendEvents(event, force=True) + except Exception: + debug.printException(debug.LEVEL_SEVERE) + + if debugging: + msg = ( + f"TOTAL PROCESSING TIME: {time.time() - startTime:.4f}" + f"\n^^^^^ PROCESS OBJECT EVENT {event.type} ^^^^^\n" + ) + debug.printMessage(debug.eventDebugLevel, msg, False) + + self._gidleLock.acquire() + try: + hasMore = self._prioritizedEvent is not None + if not hasMore and self._eventQueue.qsize() and not self._gidleId: + self._gidleId = GLib.idle_add(self._dequeue) + finally: + self._gidleLock.release() + + return hasMore + def _dequeue(self) -> bool: """Handles all events destined for scripts. Called by the GTK idle thread.""" @@ -657,7 +791,13 @@ class EventManager: self._dequeueCount += 1 try: - event = self._eventQueue.get_nowait() + fromPriority = False + self._gidleLock.acquire() + try: + event = self._eventQueue.get_nowait() + finally: + self._gidleLock.release() + self._queuePrintln(event, isEnqueue=False) inputEvents = (input_event.KeyboardEvent, input_event.BrailleEvent) if isinstance(event, inputEvents): diff --git a/src/cthulhu/focus_manager.py b/src/cthulhu/focus_manager.py index b81473f..3625db2 100644 --- a/src/cthulhu/focus_manager.py +++ b/src/cthulhu/focus_manager.py @@ -116,6 +116,13 @@ class FocusManager: _log_tokens(["Focused object in", self._window, "is", result]) return result + def active_window_is_cthulhu(self) -> bool: + """Returns True if the active window belongs to Cthulhu itself.""" + + app = AXObject.get_application(self._window) + appName = (AXObject.get_name(app) or "").lower() + return appName == "cthulhu" + def focus_and_window_are_unknown(self) -> bool: """Returns True if we have no knowledge about what is focused.""" @@ -350,18 +357,39 @@ class FocusManager: self._window = frame cthulhu_state.activeWindow = frame + contextObject = self._focus if set_window_as_focus: self.set_locus_of_focus(None, self._window, notify_script) elif not (self.focus_is_active_window() or self.focus_is_in_active_window()): _log_tokens(["Focus", self._focus, "is not in", self._window], stack=True) - # Don't update the focus to the active window if we can't get to the active window - # from the focused object. https://bugreports.qt.io/browse/QTBUG-130116 - if not AXObject.has_broken_ancestry(self._focus): - self.set_locus_of_focus(None, self._window, notify_script=True) + if self.active_window_is_cthulhu(): + _log_tokens( + ["Skipping focused-object lookup and script activation for self-hosted window", self._window], + "self-window", + ) + return + else: + focusedObject = self.find_focused_object() + if focusedObject is not None and focusedObject != self._window: + _log_tokens(["Using focused object", focusedObject, "from active window", self._window]) + self.set_locus_of_focus(None, focusedObject, notify_script=True) + contextObject = focusedObject + elif self._focus is None: + _log_tokens(["No previous focus. Falling back to active window", self._window]) + self.set_locus_of_focus(None, self._window, notify_script=True) + contextObject = self._window + # Don't update the focus to the active window if we can't get to the active window + # from the focused object. https://bugreports.qt.io/browse/QTBUG-130116 + elif not AXObject.has_broken_ancestry(self._focus): + self.set_locus_of_focus(None, self._window, notify_script=True) + contextObject = self._window - app = _get_ax_utilities().get_application(self._focus) - self.app.scriptManager.activate_script_for_context(app, self._focus, "focus: active-window") + if contextObject is None: + contextObject = self._window + + app = _get_ax_utilities().get_application(contextObject) + self.app.scriptManager.activate_script_for_context(app, contextObject, "focus: active-window") @dbus_service.command def toggle_presentation_mode( diff --git a/src/cthulhu/input_event_manager.py b/src/cthulhu/input_event_manager.py index 9959329..e816655 100644 --- a/src/cthulhu/input_event_manager.py +++ b/src/cthulhu/input_event_manager.py @@ -306,6 +306,10 @@ class InputEventManager: return False manager = focus_manager.get_manager() + pendingFocus = cthulhu_state.pendingSelfHostedFocus + if pendingFocus is not None: + tokens = ["INPUT EVENT MANAGER: Using pending self-hosted focus for keyboard event:", pendingFocus] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) if pressed: window = manager.get_active_window() if not AXUtilities.can_be_active_window(window, clear_cache=True): @@ -322,7 +326,7 @@ class InputEventManager: window = None manager.set_active_window(None, notify_script=True) event.set_window(window) - event.set_object(manager.get_locus_of_focus()) + event.set_object(pendingFocus or manager.get_locus_of_focus()) event.set_script(script_manager.get_manager().get_active_script()) elif self.last_event_was_keyboard(): assert isinstance(self._last_input_event, input_event.KeyboardEvent) @@ -331,7 +335,7 @@ class InputEventManager: event.set_script(self._last_input_event.get_script()) else: event.set_window(manager.get_active_window()) - event.set_object(manager.get_locus_of_focus()) + event.set_object(pendingFocus or manager.get_locus_of_focus()) event.set_script(script_manager.get_manager().get_active_script()) event._finalize_initialization() diff --git a/src/cthulhu/scripts/default.py b/src/cthulhu/scripts/default.py index b8d4b6b..ba5c895 100644 --- a/src/cthulhu/scripts/default.py +++ b/src/cthulhu/scripts/default.py @@ -1925,6 +1925,14 @@ class Script(script.Script): self.pointOfReference = {} cthulhu.setActiveWindow(window) + + app = AXObject.get_application(window) + appName = (AXObject.get_name(app) or "").lower() + if appName == "cthulhu": + msg = "DEFAULT: Self-hosted window activated. Waiting for focused child event." + debug.printMessage(debug.LEVEL_INFO, msg, True) + return + if self.utilities.isKeyGrabEvent(event): msg = "DEFAULT: Ignoring event. Likely from key grab." debug.printMessage(debug.LEVEL_INFO, msg, True) diff --git a/src/cthulhu/speech.py b/src/cthulhu/speech.py index 282f6bf..7d18353 100644 --- a/src/cthulhu/speech.py +++ b/src/cthulhu/speech.py @@ -82,6 +82,108 @@ def _isSpeechDispatcherFactory(moduleName: Optional[str]) -> bool: return False return moduleName.split(".")[-1] == "speechdispatcherfactory" +def _matchesVoiceFamily(candidateFamily: Any, targetFamily: Any) -> bool: + if not candidateFamily or not targetFamily: + return False + + for key in (VoiceFamily.NAME, VoiceFamily.LANG, VoiceFamily.DIALECT, VoiceFamily.VARIANT): + if candidateFamily.get(key) != targetFamily.get(key): + return False + + return True + +def _resolveSpeechDispatcherServerInfo( + moduleName: Optional[str], + speechServerInfo: Optional[Any], + voice: Optional[Any] = None, + fallbackServerInfo: Optional[Any] = None, +) -> Optional[Any]: + + if not _isSpeechDispatcherFactory(moduleName): + return speechServerInfo + + if speechServerInfo and len(speechServerInfo) >= 2 and speechServerInfo[1] != "default": + return speechServerInfo + + if fallbackServerInfo and len(fallbackServerInfo) >= 2 and fallbackServerInfo[1] != "default": + tokens = [ + "SPEECH: Resolving Speech Dispatcher server via fallback server info:", + fallbackServerInfo, + ] + debug.printTokens(debug.LEVEL_INFO, tokens, True) + return fallbackServerInfo + + family = None + if voice: + try: + family = ACSS(voice).get(ACSS.FAMILY) + except Exception: + debug.printException(debug.LEVEL_INFO) + + if not family: + try: + family = ACSS(settings.voices.get(settings.DEFAULT_VOICE, {})).get(ACSS.FAMILY) + except Exception: + debug.printException(debug.LEVEL_INFO) + family = None + + if not family: + return speechServerInfo + + factory = None + try: + factory = importlib.import_module(f"cthulhu.{moduleName}") + except Exception: + try: + factory = importlib.import_module(moduleName) + except Exception: + debug.printException(debug.LEVEL_INFO) + + if factory is None: + return speechServerInfo + + try: + servers = factory.SpeechServer.getSpeechServers() # type: ignore[attr-defined] + except Exception: + debug.printException(debug.LEVEL_INFO) + return speechServerInfo + + for server in servers: + try: + info = server.getInfo() + except Exception: + debug.printException(debug.LEVEL_INFO) + continue + + if not info or len(info) < 2 or info[1] == "default": + continue + + try: + families = server.getVoiceFamilies() or [] + except Exception: + debug.printException(debug.LEVEL_INFO) + continue + + for candidate in families: + if not _matchesVoiceFamily(candidate, family): + continue + + tokens = [ + "SPEECH: Resolved Speech Dispatcher server info", + info, + "for voice family", + family, + ] + debug.printTokens(debug.LEVEL_INFO, tokens, True) + return info + + tokens = [ + "SPEECH: Could not resolve Speech Dispatcher server info for voice family", + family, + ] + debug.printTokens(debug.LEVEL_INFO, tokens, True) + return speechServerInfo + def _initSpeechServer(moduleName: Optional[str], speechServerInfo: Optional[Any]) -> SpeechServer: if not moduleName: @@ -125,9 +227,24 @@ def _refreshEchoSpeechServer() -> None: return try: + resolvedInfo = _resolveSpeechDispatcherServerInfo( + settings.speechServerFactory, + settings.echoSpeechServerInfo, + settings.echoVoice, + settings.speechServerInfo, + ) + if resolvedInfo != settings.echoSpeechServerInfo: + tokens = [ + "SPEECH: Updating echo speech server info from", + settings.echoSpeechServerInfo, + "to", + resolvedInfo, + ] + debug.printTokens(debug.LEVEL_INFO, tokens, True) + settings.echoSpeechServerInfo = resolvedInfo _echoSpeechserver = _initSpeechServer( settings.speechServerFactory, - settings.echoSpeechServerInfo + resolvedInfo ) except Exception: debug.printException(debug.LEVEL_INFO)