Compare commits
7
Commits
c4890f31af
...
03b3880622
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03b3880622 | ||
|
|
f4af54228a | ||
|
|
518d2b3bb6 | ||
|
|
7043f08dab | ||
|
|
d0bc7d8a3a | ||
|
|
438fae4fef | ||
|
|
a7cd1d033a |
@@ -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.
|
||||
|
||||
@@ -204,10 +204,21 @@ class AXHypertext:
|
||||
|
||||
@staticmethod
|
||||
def find_child_at_offset(obj: Atspi.Accessible, offset: int) -> Optional[Atspi.Accessible]:
|
||||
"""Attempts to correct for off-by-one brokenness in implementations"""
|
||||
"""Returns the child at offset, correcting for broken hypertext offset mappings."""
|
||||
|
||||
if child := AXHypertext.get_child_at_offset(obj, offset):
|
||||
return child
|
||||
offset_in_parent = AXHypertext.get_character_offset_in_parent(child)
|
||||
if offset_in_parent == offset:
|
||||
return child
|
||||
|
||||
tokens = [
|
||||
f"AXHypertext: Child at offset {offset} in",
|
||||
obj,
|
||||
"is",
|
||||
child,
|
||||
f"but reports offset {offset_in_parent}",
|
||||
]
|
||||
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
|
||||
|
||||
if child_before := AXHypertext.get_child_at_offset(obj, offset - 1):
|
||||
offset_in_parent = AXHypertext.get_character_offset_in_parent(child_before)
|
||||
@@ -225,6 +236,30 @@ class AXHypertext:
|
||||
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
|
||||
return child_after
|
||||
|
||||
for i in range(AXHypertext._get_link_count(obj)):
|
||||
link = AXHypertext._get_link_at_index(obj, i)
|
||||
if link is None or AXHypertext.get_link_start_offset(link) != offset:
|
||||
continue
|
||||
|
||||
try:
|
||||
child = Atspi.Hyperlink.get_object(link, 0)
|
||||
except GLib.GError as error:
|
||||
msg = f"AXHypertext: Exception in find_child_at_offset: {error}"
|
||||
debug.print_message(debug.LEVEL_INFO, msg, True)
|
||||
continue
|
||||
|
||||
if child is None:
|
||||
continue
|
||||
|
||||
tokens = [
|
||||
f"AXHypertext: Child at offset {offset} in",
|
||||
obj,
|
||||
"found via link enumeration:",
|
||||
child,
|
||||
]
|
||||
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
|
||||
return child
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -571,7 +571,7 @@
|
||||
<property name="use_underline">True</property>
|
||||
<property name="mnemonic_widget">timeFormatCombo</property>
|
||||
<accessibility>
|
||||
<relation type="label-for" target="availableProfilesComboBox1"/>
|
||||
<relation type="label-for" target="timeFormatCombo"/>
|
||||
</accessibility>
|
||||
</object>
|
||||
<packing>
|
||||
@@ -588,7 +588,7 @@
|
||||
<property name="use_underline">True</property>
|
||||
<property name="mnemonic_widget">dateFormatCombo</property>
|
||||
<accessibility>
|
||||
<relation type="label-for" target="availableProfilesComboBox2"/>
|
||||
<relation type="label-for" target="dateFormatCombo"/>
|
||||
</accessibility>
|
||||
</object>
|
||||
<packing>
|
||||
@@ -1053,6 +1053,51 @@
|
||||
<property name="can_focus">False</property>
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="spacing">6</property>
|
||||
<child>
|
||||
<object class="GtkBox" id="soundSinkHBox">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="spacing">12</property>
|
||||
<child>
|
||||
<object class="GtkLabel" id="soundSinkLabel">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="xalign">0</property>
|
||||
<property name="label" translatable="yes" comments="Translators: This is the label for a combo box where users can choose which audio backend Cthulhu should use.">Audio _backend:</property>
|
||||
<property name="use_underline">True</property>
|
||||
<property name="mnemonic_widget">soundSinkCombo</property>
|
||||
<accessibility>
|
||||
<relation type="label-for" target="soundSinkCombo"/>
|
||||
</accessibility>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">0</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkComboBoxText" id="soundSinkCombo">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<signal name="changed" handler="soundSinkComboChanged" swapped="no"/>
|
||||
<accessibility>
|
||||
<relation type="labelled-by" target="soundSinkLabel"/>
|
||||
</accessibility>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">1</property>
|
||||
</packing>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">0</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox" id="soundThemeHBox">
|
||||
<property name="visible">True</property>
|
||||
@@ -1095,7 +1140,7 @@
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">0</property>
|
||||
<property name="position">1</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
@@ -1140,7 +1185,7 @@
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">1</property>
|
||||
<property name="position">2</property>
|
||||
</packing>
|
||||
</child>
|
||||
</object>
|
||||
@@ -1151,7 +1196,7 @@
|
||||
<object class="GtkLabel" id="soundThemeTitleLabel">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="label" translatable="yes" comments="Translators: This is the title of a section in the preferences dialog containing sound theme options.">Sound Theme</property>
|
||||
<property name="label" translatable="yes" comments="Translators: This is the title of a section in the preferences dialog containing sound options.">Sound</property>
|
||||
<attributes>
|
||||
<attribute name="weight" value="bold"/>
|
||||
</attributes>
|
||||
|
||||
+10
-2
@@ -769,7 +769,7 @@ def shutdown(script: Optional[Any] = None, inputEvent: Optional[Any] = None) ->
|
||||
cthulhu_state.activeScript.presentationInterrupt()
|
||||
|
||||
cthulhuApp.getSignalManager().emitSignal('stop-application-completed')
|
||||
sound_theme_manager.getManager().playStopSound(wait=True)
|
||||
sound_theme_manager.getManager().playStopSound(wait=True, timeoutSeconds=1)
|
||||
cthulhuApp.getPluginSystemManager().unloadAllPlugins(ForceAllPlugins=True)
|
||||
|
||||
# Deactivate the event manager first so that it clears its queue and will not
|
||||
@@ -893,7 +893,14 @@ def main() -> int:
|
||||
debug.printMessage(debug.LEVEL_INFO, "CTHULHU: Initialized.", True)
|
||||
|
||||
script = cthulhu_state.activeScript
|
||||
sound_theme_manager.getManager().playStartSound(wait=True)
|
||||
sound_theme_manager.getManager().playStartSound(wait=False)
|
||||
soundFailureReason = sound.getSoundSystemFailureReason()
|
||||
if soundFailureReason:
|
||||
debug.printMessage(
|
||||
debug.LEVEL_INFO,
|
||||
f"CTHULHU: Startup sound failed. Continuing without sound. Reason: {soundFailureReason}",
|
||||
True
|
||||
)
|
||||
cthulhuApp.getSignalManager().emitSignal('start-application-completed')
|
||||
if script:
|
||||
window = script.utilities.activeWindow()
|
||||
@@ -944,6 +951,7 @@ class Cthulhu(GObject.Object):
|
||||
self.settingsManager: SettingsManager = settings_manager.SettingsManager(self) # Directly instantiate
|
||||
self.eventManager: EventManager = event_manager.EventManager(self) # Directly instantiate
|
||||
self.scriptManager: ScriptManager = script_manager.ScriptManager(self) # Directly instantiate
|
||||
script_manager._manager = self.scriptManager
|
||||
self.logger: logger.Logger = logger.Logger() # Directly instantiate
|
||||
self.signalManager: SignalManager = signal_manager.SignalManager(self)
|
||||
self.dynamicApiManager: DynamicApiManager = dynamic_api_manager.DynamicApiManager(self)
|
||||
|
||||
@@ -60,6 +60,7 @@ from . import cthulhu_gui_profile
|
||||
from . import cthulhu_state
|
||||
from . import settings
|
||||
from . import settings_manager
|
||||
from . import sound_sink
|
||||
from . import input_event
|
||||
from . import input_event_manager
|
||||
from . import keybindings
|
||||
@@ -71,6 +72,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
|
||||
|
||||
@@ -194,6 +196,7 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
|
||||
self.savedPitch = None
|
||||
self.savedRate = None
|
||||
self.soundThemeCombo = None
|
||||
self.soundSinkCombo = None
|
||||
self.roleSoundPresentationCombo = None
|
||||
self._isInitialSetup = False
|
||||
self._updatingSpeechFamilies = False
|
||||
@@ -202,6 +205,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 +1510,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 +1734,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 +1999,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()
|
||||
@@ -2961,15 +3104,37 @@ print(json.dumps(result))
|
||||
self.ocrCopyToClipboardCheckButton.set_active(copyToClipboard)
|
||||
|
||||
def _initSoundThemeState(self):
|
||||
"""Initialize Sound Theme widgets with current settings."""
|
||||
"""Initialize sound widgets with current settings."""
|
||||
prefs = self.prefsDict
|
||||
|
||||
# Get widget references
|
||||
self.soundSinkCombo = self.get_widget("soundSinkCombo")
|
||||
self.soundThemeCombo = self.get_widget("soundThemeCombo")
|
||||
self.roleSoundPresentationCombo = self.get_widget("roleSoundPresentationCombo")
|
||||
self.soundSinkCombo.set_can_focus(False)
|
||||
self.soundThemeCombo.set_can_focus(False)
|
||||
self.roleSoundPresentationCombo.set_can_focus(False)
|
||||
|
||||
self._soundSinkChoices = [
|
||||
(settings.SOUND_SINK_AUTO, guilabels.SOUND_BACKEND_AUTO),
|
||||
(settings.SOUND_SINK_PIPEWIRE, guilabels.SOUND_BACKEND_PIPEWIRE),
|
||||
(settings.SOUND_SINK_PULSE, guilabels.SOUND_BACKEND_PULSE),
|
||||
(settings.SOUND_SINK_ALSA, guilabels.SOUND_BACKEND_ALSA),
|
||||
]
|
||||
self.soundSinkCombo.remove_all()
|
||||
for _, label in self._soundSinkChoices:
|
||||
self.soundSinkCombo.append_text(label)
|
||||
runtimeSoundSink = cthulhu.cthulhuApp.settingsManager.getSetting("soundSink")
|
||||
currentSink = sound_sink.normalize_sound_sink_choice(
|
||||
prefs.get("soundSink", runtimeSoundSink if runtimeSoundSink is not None else settings.soundSink)
|
||||
)
|
||||
sinkIndex = 0
|
||||
for index, (value, _) in enumerate(self._soundSinkChoices):
|
||||
if value == currentSink:
|
||||
sinkIndex = index
|
||||
break
|
||||
self.soundSinkCombo.set_active(sinkIndex)
|
||||
|
||||
# Populate sound theme combo box
|
||||
themeManager = sound_theme_manager.getManager()
|
||||
availableThemes = themeManager.getAvailableThemes()
|
||||
@@ -3018,6 +3183,14 @@ print(json.dumps(result))
|
||||
if activeText:
|
||||
self.prefsDict["soundTheme"] = activeText
|
||||
|
||||
def soundSinkComboChanged(self, widget):
|
||||
"""Signal handler for the sound backend combo box."""
|
||||
activeIndex = widget.get_active()
|
||||
if activeIndex < 0:
|
||||
return
|
||||
value = self._soundSinkChoices[activeIndex][0]
|
||||
self.prefsDict["soundSink"] = value
|
||||
|
||||
def roleSoundPresentationComboChanged(self, widget):
|
||||
"""Signal handler for the role sound presentation combo box."""
|
||||
activeIndex = widget.get_active()
|
||||
@@ -3148,6 +3321,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.
|
||||
@@ -4641,6 +4876,11 @@ print(json.dumps(result))
|
||||
if not self._isInitialSetup:
|
||||
self.restoreSettings()
|
||||
|
||||
if self.soundSinkCombo is not None:
|
||||
activeIndex = self.soundSinkCombo.get_active()
|
||||
if activeIndex >= 0:
|
||||
self.prefsDict["soundSink"] = self._soundSinkChoices[activeIndex][0]
|
||||
|
||||
enable = self.get_widget("speechSupportCheckButton").get_active()
|
||||
self.prefsDict["enableSpeech"] = enable
|
||||
|
||||
@@ -4648,9 +4888,24 @@ 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()
|
||||
else:
|
||||
activeSpeechInfo = speech.getInfo()
|
||||
if activeSpeechInfo:
|
||||
self.prefsDict["speechServerInfo"] = activeSpeechInfo
|
||||
|
||||
runtimeSpeechServerInfo = cthulhu.cthulhuApp.settingsManager.getSetting(
|
||||
"speechServerInfo"
|
||||
)
|
||||
if runtimeSpeechServerInfo and not self.prefsDict.get("speechServerInfo"):
|
||||
self.prefsDict["speechServerInfo"] = runtimeSpeechServerInfo
|
||||
|
||||
existingSpeechServerInfo = self.prefsDict.get("speechServerInfo")
|
||||
if existingSpeechServerInfo:
|
||||
self.prefsDict["speechServerInfo"] = existingSpeechServerInfo
|
||||
|
||||
if self.defaultVoice is not None:
|
||||
self.prefsDict["voices"] = {
|
||||
@@ -4684,8 +4939,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
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -938,9 +938,26 @@ USE_STRUCTURAL_NAVIGATION = _("Enable _structural navigation")
|
||||
# audio files that Cthulhu plays for various events.
|
||||
SOUND_THEME = _("Sound _theme:")
|
||||
|
||||
# Translators: This is the label for a combo box in the preferences dialog
|
||||
# where users can choose which audio backend Cthulhu should use.
|
||||
SOUND_BACKEND = _("Audio _backend:")
|
||||
|
||||
# Translators: This is a sound backend option which lets Cthulhu choose a
|
||||
# backend automatically.
|
||||
SOUND_BACKEND_AUTO = _("Automatic")
|
||||
|
||||
# Translators: This is a sound backend option which forces PipeWire.
|
||||
SOUND_BACKEND_PIPEWIRE = _("PipeWire")
|
||||
|
||||
# Translators: This is a sound backend option which forces PulseAudio.
|
||||
SOUND_BACKEND_PULSE = _("PulseAudio")
|
||||
|
||||
# Translators: This is a sound backend option which forces ALSA.
|
||||
SOUND_BACKEND_ALSA = _("ALSA")
|
||||
|
||||
# Translators: This is the title of a frame in the preferences dialog
|
||||
# containing sound theme options.
|
||||
SOUND_THEME_TITLE = _("Sound Theme")
|
||||
# containing sound options.
|
||||
SOUND_THEME_TITLE = _("Sound")
|
||||
|
||||
# Translators: This refers to the amount of information Cthulhu provides about a
|
||||
# particular object that receives focus.
|
||||
|
||||
@@ -277,6 +277,23 @@ class InputEventManager:
|
||||
self._last_input_event = event
|
||||
self._last_non_modifier_key_event = None
|
||||
|
||||
@staticmethod
|
||||
def _get_top_level_window(obj: Optional[Atspi.Accessible]) -> Optional[Atspi.Accessible]:
|
||||
"""Returns the top-level window containing obj, if one can be found."""
|
||||
|
||||
if obj is None:
|
||||
return None
|
||||
|
||||
if AXUtilities.is_frame(obj) or AXUtilities.is_window(obj) or AXUtilities.is_dialog_or_alert(obj):
|
||||
return obj
|
||||
|
||||
return AXObject.find_ancestor(
|
||||
obj,
|
||||
lambda x: AXUtilities.is_frame(x)
|
||||
or AXUtilities.is_window(x)
|
||||
or AXUtilities.is_dialog_or_alert(x),
|
||||
)
|
||||
|
||||
# pylint: disable=too-many-arguments
|
||||
# pylint: disable=too-many-positional-arguments
|
||||
def process_keyboard_event(
|
||||
@@ -306,6 +323,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):
|
||||
@@ -316,13 +337,27 @@ class InputEventManager:
|
||||
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
|
||||
manager.set_active_window(window)
|
||||
else:
|
||||
# One example: Brave's popup menus live in frames which lack the active state.
|
||||
tokens = ["WARNING:", window, "cannot be active window. No alternative found."]
|
||||
debug.print_tokens(debug.LEVEL_WARNING, tokens, True)
|
||||
window = None
|
||||
manager.set_active_window(None, notify_script=True)
|
||||
focus_window = self._get_top_level_window(pendingFocus or manager.get_locus_of_focus())
|
||||
if focus_window is not None:
|
||||
window = focus_window
|
||||
tokens = [
|
||||
"INPUT EVENT MANAGER: Recovering active window from locus of focus:",
|
||||
window,
|
||||
]
|
||||
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
|
||||
manager.set_active_window(window)
|
||||
else:
|
||||
# One example: Brave's popup menus live in frames which lack the active
|
||||
# state. Failing to revalidate the window on a key press is inconclusive;
|
||||
# do not wipe out the last known window and focus state.
|
||||
tokens = [
|
||||
"WARNING:",
|
||||
window,
|
||||
"cannot be confirmed as active. No alternative found; preserving existing context.",
|
||||
]
|
||||
debug.print_tokens(debug.LEVEL_WARNING, tokens, 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 +366,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()
|
||||
|
||||
@@ -86,6 +86,8 @@ cthulhu_python_sources = files([
|
||||
'signal_manager.py',
|
||||
'sleep_mode_manager.py',
|
||||
'sound.py',
|
||||
'sound_helper.py',
|
||||
'sound_sink.py',
|
||||
'sound_generator.py',
|
||||
'sound_theme_manager.py',
|
||||
'speech_and_verbosity_manager.py',
|
||||
|
||||
@@ -41,6 +41,7 @@ else:
|
||||
_gstreamerAvailable, args = Gst.init_check(None)
|
||||
|
||||
from . import debug
|
||||
from . import sound_sink
|
||||
|
||||
|
||||
class PiperAudioPlayer:
|
||||
@@ -84,6 +85,7 @@ class PiperAudioPlayer:
|
||||
|
||||
try:
|
||||
self._pipeline = Gst.Pipeline.new("piper-audio")
|
||||
configuredSink = sound_sink.get_configured_sound_sink()
|
||||
|
||||
self._appsrc = Gst.ElementFactory.make("appsrc", "source")
|
||||
if self._appsrc is None:
|
||||
@@ -110,9 +112,9 @@ class PiperAudioPlayer:
|
||||
debug.printMessage(debug.LEVEL_WARNING, msg, True)
|
||||
return False
|
||||
|
||||
sink = Gst.ElementFactory.make("autoaudiosink", "sink")
|
||||
sink, sinkName, sinkError = sound_sink.create_audio_sink("sink", configuredSink)
|
||||
if sink is None:
|
||||
msg = 'PIPER AUDIO: Failed to create autoaudiosink element'
|
||||
msg = f'PIPER AUDIO: {sinkError or "Failed to create audio sink"}'
|
||||
debug.printMessage(debug.LEVEL_WARNING, msg, True)
|
||||
return False
|
||||
|
||||
@@ -141,7 +143,10 @@ class PiperAudioPlayer:
|
||||
bus.connect("message", self._onMessage)
|
||||
|
||||
self._initialized = True
|
||||
msg = 'PIPER AUDIO: Pipeline initialized successfully'
|
||||
msg = (
|
||||
f'PIPER AUDIO: Pipeline initialized successfully using '
|
||||
f'{sinkName} (soundSink={configuredSink})'
|
||||
)
|
||||
debug.printMessage(debug.LEVEL_INFO, msg, True)
|
||||
return True
|
||||
|
||||
|
||||
@@ -473,5 +473,5 @@ def get_manager() -> Optional[ScriptManager]:
|
||||
if _manager is None:
|
||||
from . import cthulhu
|
||||
if cthulhu.cthulhuApp:
|
||||
_manager = ScriptManager(cthulhu.cthulhuApp)
|
||||
_manager = cthulhu.cthulhuApp.scriptManager
|
||||
return _manager
|
||||
|
||||
@@ -34,6 +34,7 @@ __license__ = "LGPL"
|
||||
import cthulhu.messages as messages
|
||||
import cthulhu.scripts.default as default
|
||||
import cthulhu.settings as settings
|
||||
from cthulhu.ax_object import AXObject
|
||||
from cthulhu.ax_utilities import AXUtilities
|
||||
|
||||
|
||||
@@ -49,8 +50,15 @@ class Script(default.Script):
|
||||
"""Callback for window:create accessibility events."""
|
||||
|
||||
allLabels = AXUtilities.find_all_labels(event.source)
|
||||
texts = [self.utilities.displayedText(acc) for acc in allLabels]
|
||||
text = f"{messages.NOTIFICATION} {' '.join(texts)}"
|
||||
texts = []
|
||||
for acc in allLabels:
|
||||
text = self.utilities.displayedText(acc) or AXObject.get_name(acc)
|
||||
if text:
|
||||
texts.append(text)
|
||||
|
||||
text = messages.NOTIFICATION
|
||||
if texts:
|
||||
text = f"{text} {' '.join(texts)}"
|
||||
|
||||
voice = self.speechGenerator.voice(obj=event.source, string=text)
|
||||
self.speakMessage(text, voice=voice)
|
||||
|
||||
@@ -203,19 +203,24 @@ class Script(Chromium.Script):
|
||||
if AXUtilities.is_notification(obj) or AXUtilities.is_alert(obj):
|
||||
return obj
|
||||
|
||||
def isNotificationRole(candidate):
|
||||
return AXUtilities.is_notification(candidate) or AXUtilities.is_alert(candidate)
|
||||
|
||||
ancestorNotification = AXObject.find_ancestor(obj, isNotificationRole)
|
||||
if ancestorNotification:
|
||||
return ancestorNotification
|
||||
|
||||
liveAttr = AXObject.get_attribute(obj, 'live')
|
||||
containerLive = AXObject.get_attribute(obj, 'container-live')
|
||||
if liveAttr in ['assertive', 'polite'] or containerLive in ['assertive', 'polite']:
|
||||
return obj
|
||||
|
||||
def isNotificationCandidate(candidate):
|
||||
if AXUtilities.is_notification(candidate) or AXUtilities.is_alert(candidate):
|
||||
return True
|
||||
def isLiveRegionCandidate(candidate):
|
||||
candidateLive = AXObject.get_attribute(candidate, 'live')
|
||||
candidateContainerLive = AXObject.get_attribute(candidate, 'container-live')
|
||||
return candidateLive in ['assertive', 'polite'] or candidateContainerLive in ['assertive', 'polite']
|
||||
|
||||
return AXObject.find_ancestor(obj, isNotificationCandidate)
|
||||
return AXObject.find_ancestor(obj, isLiveRegionCandidate)
|
||||
|
||||
def _presentSteamNotification(self, obj):
|
||||
"""Speak and save the notification.
|
||||
@@ -363,6 +368,27 @@ class Script(Chromium.Script):
|
||||
return f"{baseText} {timestampText}"
|
||||
return f"{baseText}. {timestampText}"
|
||||
|
||||
def _getSteamNotificationIdentity(self, obj):
|
||||
if obj is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
path = AXObject.get_path(obj)
|
||||
except Exception:
|
||||
path = None
|
||||
|
||||
if path is not None:
|
||||
return tuple(path)
|
||||
|
||||
try:
|
||||
return hash(obj)
|
||||
except TypeError:
|
||||
return id(obj)
|
||||
|
||||
def _combineSteamNotificationFragments(self, firstText, secondText):
|
||||
combined = f"{firstText} {secondText}"
|
||||
return self._normalizeSteamNotificationText(combined)
|
||||
|
||||
def _steamTextContains(self, text, other):
|
||||
textNorm = self._normalizeSteamNotificationText(text).lower()
|
||||
otherNorm = self._normalizeSteamNotificationText(other).lower()
|
||||
@@ -376,6 +402,7 @@ class Script(Chromium.Script):
|
||||
text = self._normalizeSteamNotificationText(text)
|
||||
if not text:
|
||||
return
|
||||
sourceKey = self._getSteamNotificationIdentity(obj)
|
||||
|
||||
pending = self._steamPendingNotification
|
||||
if self._isSteamRelativeTimestamp(text):
|
||||
@@ -386,6 +413,7 @@ class Script(Chromium.Script):
|
||||
pending["text"] = self._appendSteamTimestamp(pending["text"], text)
|
||||
if obj:
|
||||
pending["obj"] = obj
|
||||
pending["sourceKey"] = sourceKey
|
||||
self._resetSteamPendingTimer()
|
||||
return
|
||||
|
||||
@@ -393,7 +421,8 @@ class Script(Chromium.Script):
|
||||
"text": text,
|
||||
"obj": obj,
|
||||
"timerId": None,
|
||||
"timestampOnly": True
|
||||
"timestampOnly": True,
|
||||
"sourceKey": sourceKey
|
||||
}
|
||||
self._resetSteamPendingTimer()
|
||||
return
|
||||
@@ -405,24 +434,39 @@ class Script(Chromium.Script):
|
||||
pending["timestampOnly"] = False
|
||||
if obj:
|
||||
pending["obj"] = obj
|
||||
pending["sourceKey"] = sourceKey
|
||||
self._resetSteamPendingTimer()
|
||||
return
|
||||
|
||||
if text == pendingText:
|
||||
if obj:
|
||||
pending["obj"] = obj
|
||||
pending["sourceKey"] = sourceKey
|
||||
return
|
||||
|
||||
if self._steamTextContains(text, pendingText):
|
||||
pending["text"] = text
|
||||
if obj:
|
||||
pending["obj"] = obj
|
||||
pending["sourceKey"] = sourceKey
|
||||
self._resetSteamPendingTimer()
|
||||
return
|
||||
|
||||
if self._steamTextContains(pendingText, text):
|
||||
if obj:
|
||||
pending["obj"] = obj
|
||||
pending["sourceKey"] = sourceKey
|
||||
return
|
||||
|
||||
# Steam often emits multi-line toasts as separate live-region fragments.
|
||||
# Keep fragments from the same toast together instead of speaking the first
|
||||
# line immediately and the complete toast later.
|
||||
if sourceKey is not None and sourceKey == pending.get("sourceKey"):
|
||||
pending["text"] = self._combineSteamNotificationFragments(pendingText, text)
|
||||
if obj:
|
||||
pending["obj"] = obj
|
||||
pending["sourceKey"] = sourceKey
|
||||
self._resetSteamPendingTimer()
|
||||
return
|
||||
|
||||
self._flushSteamPendingNotification(fromTimer=False)
|
||||
@@ -431,7 +475,8 @@ class Script(Chromium.Script):
|
||||
"text": text,
|
||||
"obj": obj,
|
||||
"timerId": None,
|
||||
"timestampOnly": False
|
||||
"timestampOnly": False,
|
||||
"sourceKey": sourceKey
|
||||
}
|
||||
self._resetSteamPendingTimer()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1450,10 +1450,36 @@ class Script(default.Script):
|
||||
|
||||
return None
|
||||
|
||||
def _getClickableActivationTarget(self):
|
||||
obj = cthulhu_state.locusOfFocus
|
||||
if self.inFocusMode():
|
||||
return obj
|
||||
|
||||
if not self.utilities.inDocumentContent(obj):
|
||||
return obj
|
||||
|
||||
contextObj, _ = self.utilities.getCaretContext(searchIfNeeded=False)
|
||||
if contextObj and self.utilities.inDocumentContent(contextObj):
|
||||
return contextObj
|
||||
|
||||
return obj
|
||||
|
||||
def _performClickableAction(self, obj):
|
||||
from cthulhu import ax_object
|
||||
|
||||
actionNames = ["click", "click-ancestor", "press", "jump", "open", "activate"]
|
||||
for actionName in actionNames:
|
||||
if not ax_object.AXObject.has_action(obj, actionName):
|
||||
continue
|
||||
if ax_object.AXObject.do_named_action(obj, actionName):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _tryClickableActivation(self, keyboardEvent):
|
||||
"""Try to activate clickable element - returns True if we should consume the event."""
|
||||
|
||||
obj = cthulhu_state.locusOfFocus
|
||||
|
||||
obj = self._getClickableActivationTarget()
|
||||
if not obj or not self.utilities.inDocumentContent(obj):
|
||||
return False
|
||||
|
||||
@@ -1474,31 +1500,36 @@ class Script(default.Script):
|
||||
|
||||
# First try the standard clickable detection
|
||||
if self.utilities.isClickableElement(obj):
|
||||
from cthulhu import ax_event_synthesizer
|
||||
# Give immediate feedback that activation is starting
|
||||
self.presentMessage("Activating...")
|
||||
result = self._performClickableAction(obj)
|
||||
if result:
|
||||
self._presentDelayedMessage("Element activated", 50)
|
||||
return True
|
||||
|
||||
from cthulhu import ax_event_synthesizer
|
||||
result = ax_event_synthesizer.AXEventSynthesizer.click_object(obj)
|
||||
if result:
|
||||
# Schedule success message after a brief delay
|
||||
self._presentDelayedMessage("Element activated", 50)
|
||||
# Try to restore focus to the clicked element after a brief delay
|
||||
self._restoreFocusAfterClick(original_focus)
|
||||
return True
|
||||
|
||||
|
||||
# If that didn't work, try a more permissive approach for any element with click action
|
||||
from cthulhu import ax_object
|
||||
if ax_object.AXObject.has_action(obj, "click"):
|
||||
from cthulhu import ax_event_synthesizer
|
||||
# Give immediate feedback that activation is starting
|
||||
if ax_object.AXObject.has_action(obj, "click") \
|
||||
or ax_object.AXObject.has_action(obj, "click-ancestor"):
|
||||
self.presentMessage("Activating...")
|
||||
result = self._performClickableAction(obj)
|
||||
if result:
|
||||
self._presentDelayedMessage("Element activated", 50)
|
||||
return True
|
||||
|
||||
from cthulhu import ax_event_synthesizer
|
||||
result = ax_event_synthesizer.AXEventSynthesizer.click_object(obj)
|
||||
if result:
|
||||
# Schedule success message after a brief delay
|
||||
self._presentDelayedMessage("Element activated", 50)
|
||||
# Try to restore focus to the clicked element after a brief delay
|
||||
self._restoreFocusAfterClick(original_focus)
|
||||
return True
|
||||
|
||||
|
||||
return False
|
||||
|
||||
def _restoreFocusAfterClick(self, original_focus):
|
||||
|
||||
@@ -618,8 +618,24 @@ class Utilities(script_utilities.Utilities):
|
||||
return self.queryNonEmptyText(obj, False) is None
|
||||
|
||||
def isHidden(self, obj):
|
||||
attrs = self.objectAttributes(obj, False)
|
||||
return attrs.get('hidden', False)
|
||||
hiddenValues = {"true", "1", "yes"}
|
||||
hiddenStyles = {"none", "hidden", "collapse"}
|
||||
|
||||
current = obj
|
||||
while current and self.inDocumentContent(current):
|
||||
attrs = self.objectAttributes(current, False)
|
||||
hidden = str(attrs.get("hidden", "")).lower()
|
||||
display = str(attrs.get("display", "")).lower()
|
||||
visibility = str(attrs.get("visibility", "")).lower()
|
||||
if hidden in hiddenValues or display in hiddenStyles or visibility in hiddenStyles:
|
||||
return True
|
||||
|
||||
parent = AXObject.get_parent(current)
|
||||
if parent == current:
|
||||
break
|
||||
current = parent
|
||||
|
||||
return False
|
||||
|
||||
def _isOrIsIn(self, child, parent):
|
||||
if not (child and parent):
|
||||
@@ -4772,13 +4788,23 @@ class Utilities(script_utilities.Utilities):
|
||||
startTime = time.time()
|
||||
rv = None
|
||||
if AXUtilities.is_focusable(obj):
|
||||
tokens = ["WEB: Focusable object can have caret context", obj]
|
||||
debug.printTokens(debug.LEVEL_INFO, tokens, True)
|
||||
rv = True
|
||||
if self.isHidden(obj):
|
||||
tokens = ["WEB: Hidden object cannot have caret context", obj]
|
||||
debug.printTokens(debug.LEVEL_INFO, tokens, True)
|
||||
rv = False
|
||||
else:
|
||||
tokens = ["WEB: Focusable object can have caret context", obj]
|
||||
debug.printTokens(debug.LEVEL_INFO, tokens, True)
|
||||
rv = True
|
||||
elif AXUtilities.is_editable(obj):
|
||||
tokens = ["WEB: Editable object can have caret context", obj]
|
||||
debug.printTokens(debug.LEVEL_INFO, tokens, True)
|
||||
rv = True
|
||||
if self.isHidden(obj):
|
||||
tokens = ["WEB: Hidden object cannot have caret context", obj]
|
||||
debug.printTokens(debug.LEVEL_INFO, tokens, True)
|
||||
rv = False
|
||||
else:
|
||||
tokens = ["WEB: Editable object can have caret context", obj]
|
||||
debug.printTokens(debug.LEVEL_INFO, tokens, True)
|
||||
rv = True
|
||||
elif AXUtilities.is_landmark(obj):
|
||||
tokens = ["WEB: Landmark can have caret context", obj]
|
||||
debug.printTokens(debug.LEVEL_INFO, tokens, True)
|
||||
|
||||
@@ -85,6 +85,7 @@ userCustomizableSettings = [
|
||||
"brailleLinkIndicator",
|
||||
"enableSound",
|
||||
"soundVolume",
|
||||
"soundSink",
|
||||
"playSoundForRole",
|
||||
"playSoundForState",
|
||||
"playSoundForPositionInSet",
|
||||
@@ -333,9 +334,20 @@ brailleVerbosityLevel = VERBOSITY_LEVEL_VERBOSE
|
||||
ROLE_SOUND_PRESENTATION_SOUND_AND_SPEECH = "sound_and_speech"
|
||||
ROLE_SOUND_PRESENTATION_SPEECH_ONLY = "speech_only"
|
||||
ROLE_SOUND_PRESENTATION_SOUND_ONLY = "sound_only"
|
||||
SOUND_SINK_AUTO = "auto"
|
||||
SOUND_SINK_PIPEWIRE = "pipewire"
|
||||
SOUND_SINK_PULSE = "pulse"
|
||||
SOUND_SINK_ALSA = "alsa"
|
||||
SOUND_SINK_VALUES = (
|
||||
SOUND_SINK_AUTO,
|
||||
SOUND_SINK_PIPEWIRE,
|
||||
SOUND_SINK_PULSE,
|
||||
SOUND_SINK_ALSA,
|
||||
)
|
||||
|
||||
enableSound = True
|
||||
soundVolume = 0.5
|
||||
soundSink = SOUND_SINK_AUTO
|
||||
playSoundForRole = False
|
||||
playSoundForState = False
|
||||
playSoundForPositionInSet = False
|
||||
|
||||
+498
-149
@@ -31,9 +31,14 @@ __date__ = "$Date:$"
|
||||
__copyright__ = "Copyright (c) 2016 Cthulhu Team"
|
||||
__license__ = "LGPL"
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from typing import Any, Optional, Tuple
|
||||
|
||||
import gi
|
||||
from gi.repository import GLib
|
||||
from typing import Optional, Any
|
||||
|
||||
try:
|
||||
gi.require_version('Gst', '1.0')
|
||||
@@ -41,138 +46,66 @@ try:
|
||||
except Exception:
|
||||
_gstreamerAvailable: bool = False
|
||||
else:
|
||||
_gstreamerAvailable, args = Gst.init_check(None)
|
||||
_gstreamerAvailable, _args = Gst.init_check(None)
|
||||
|
||||
from . import debug
|
||||
from . import settings
|
||||
from . import sound_sink
|
||||
from .sound_generator import Icon, Tone
|
||||
|
||||
_soundSystemFailureReason: Optional[str] = None
|
||||
|
||||
|
||||
class _PendingResponse:
|
||||
def __init__(self) -> None:
|
||||
self.event = threading.Event()
|
||||
self.response: Optional[dict[str, Any]] = None
|
||||
|
||||
|
||||
class Player:
|
||||
"""Plays Icons and Tones."""
|
||||
"""Plays Icons and Tones through a persistent worker process."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._initialized: bool = False
|
||||
self._source: Optional[Any] = None # Optional[Gst.Element]
|
||||
self._sink: Optional[Any] = None # Optional[Gst.Element]
|
||||
self._player: Optional[Any] = None # Optional[Gst.Element]
|
||||
self._pipeline: Optional[Any] = None # Optional[Gst.Pipeline]
|
||||
self._initialized = False
|
||||
self._workerProcess: Optional[subprocess.Popen[str]] = None
|
||||
self._workerSink: Optional[str] = None
|
||||
self._workerRestartRequired = False
|
||||
self._workerRestartReason: Optional[str] = None
|
||||
self._workerLock = threading.RLock()
|
||||
self._responseLock = threading.Lock()
|
||||
self._pendingResponses: dict[int, _PendingResponse] = {}
|
||||
self._nextRequestId = 1
|
||||
|
||||
if not _gstreamerAvailable:
|
||||
msg = 'SOUND ERROR: Gstreamer is not available'
|
||||
debug.printMessage(debug.LEVEL_INFO, msg, True)
|
||||
debug.printMessage(debug.LEVEL_INFO, 'SOUND ERROR: Gstreamer is not available', True)
|
||||
return
|
||||
|
||||
self.init()
|
||||
@staticmethod
|
||||
def _get_configured_volume() -> float:
|
||||
"""Returns the configured sound volume with a safe fallback."""
|
||||
|
||||
def _onPlayerMessage(self, bus: Any, message: Any) -> None: # bus: Gst.Bus, message: Gst.Message
|
||||
if message.type == Gst.MessageType.EOS:
|
||||
self._player.set_state(Gst.State.NULL)
|
||||
elif message.type == Gst.MessageType.ERROR:
|
||||
self._player.set_state(Gst.State.NULL)
|
||||
error, info = message.parse_error()
|
||||
msg = f'SOUND ERROR: {error}'
|
||||
debug.printMessage(debug.LEVEL_INFO, msg, True)
|
||||
try:
|
||||
from . import cthulhu
|
||||
if cthulhu.cthulhuApp is not None:
|
||||
volume = cthulhu.cthulhuApp.settingsManager.getSetting('soundVolume')
|
||||
return max(0.0, float(volume))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _onPipelineMessage(self, bus: Any, message: Any) -> None: # bus: Gst.Bus, message: Gst.Message
|
||||
if message.type == Gst.MessageType.EOS:
|
||||
self._pipeline.set_state(Gst.State.NULL)
|
||||
elif message.type == Gst.MessageType.ERROR:
|
||||
self._pipeline.set_state(Gst.State.NULL)
|
||||
error, info = message.parse_error()
|
||||
msg = f'SOUND ERROR: {error}'
|
||||
debug.printMessage(debug.LEVEL_INFO, msg, True)
|
||||
|
||||
def _onTimeout(self, element: Any) -> bool: # element: Gst.Element
|
||||
element.set_state(Gst.State.NULL)
|
||||
return False
|
||||
|
||||
def _playIcon(self, icon: Icon, interrupt: bool = True) -> None:
|
||||
"""Plays a sound icon, interrupting the current play first unless specified."""
|
||||
|
||||
if interrupt:
|
||||
self._player.set_state(Gst.State.NULL)
|
||||
|
||||
self._player.set_property('uri', f'file://{icon.path}')
|
||||
self._player.set_state(Gst.State.PLAYING)
|
||||
|
||||
def _playIconAndWait(self, icon: Icon, interrupt: bool = True, timeout_seconds: Optional[int] = 10) -> bool:
|
||||
"""Plays a sound icon and waits for completion."""
|
||||
|
||||
if interrupt:
|
||||
self._player.set_state(Gst.State.NULL)
|
||||
|
||||
self._player.set_property('uri', f'file://{icon.path}')
|
||||
self._player.set_state(Gst.State.PLAYING)
|
||||
|
||||
bus = self._player.get_bus()
|
||||
if not bus:
|
||||
return False
|
||||
|
||||
if timeout_seconds is None:
|
||||
timeout_ns = Gst.CLOCK_TIME_NONE
|
||||
else:
|
||||
timeout_ns = int(timeout_seconds * Gst.SECOND)
|
||||
|
||||
message = bus.timed_pop_filtered(
|
||||
timeout_ns,
|
||||
Gst.MessageType.EOS | Gst.MessageType.ERROR
|
||||
)
|
||||
if message and message.type == Gst.MessageType.ERROR:
|
||||
error, info = message.parse_error()
|
||||
msg = f'SOUND ERROR: {error}'
|
||||
debug.printMessage(debug.LEVEL_INFO, msg, True)
|
||||
|
||||
self._player.set_state(Gst.State.NULL)
|
||||
return message is not None and message.type == Gst.MessageType.EOS
|
||||
|
||||
def _playTone(self, tone: Tone, interrupt: bool = True) -> None:
|
||||
"""Plays a tone, interrupting the current play first unless specified."""
|
||||
|
||||
if interrupt:
|
||||
self._pipeline.set_state(Gst.State.NULL)
|
||||
|
||||
self._source.set_property('volume', tone.volume)
|
||||
self._source.set_property('freq', tone.frequency)
|
||||
self._source.set_property('wave', tone.wave)
|
||||
self._pipeline.set_state(Gst.State.PLAYING)
|
||||
duration = int(1000 * tone.duration)
|
||||
GLib.timeout_add(duration, self._onTimeout, self._pipeline)
|
||||
return max(0.0, float(settings.soundVolume))
|
||||
|
||||
def init(self) -> None:
|
||||
"""(Re)Initializes the Player."""
|
||||
|
||||
if self._initialized:
|
||||
return
|
||||
"""(Re)Initializes the persistent worker."""
|
||||
|
||||
if not _gstreamerAvailable:
|
||||
return
|
||||
|
||||
self._player = Gst.ElementFactory.make('playbin', 'player')
|
||||
if self._player is None:
|
||||
msg = 'SOUND ERROR: Gstreamer is available, but player is None'
|
||||
debug.printMessage(debug.LEVEL_INFO, msg, True)
|
||||
return
|
||||
with self._workerLock:
|
||||
if self._ensureWorkerLocked():
|
||||
self._initialized = True
|
||||
clearSoundSystemFailure()
|
||||
|
||||
bus = self._player.get_bus()
|
||||
bus.add_signal_watch()
|
||||
bus.connect("message", self._onPlayerMessage)
|
||||
|
||||
self._pipeline = Gst.Pipeline(name='cthulhu-pipeline')
|
||||
bus = self._pipeline.get_bus()
|
||||
bus.add_signal_watch()
|
||||
bus.connect("message", self._onPipelineMessage)
|
||||
|
||||
self._source = Gst.ElementFactory.make('audiotestsrc', 'src')
|
||||
self._sink = Gst.ElementFactory.make('autoaudiosink', 'output')
|
||||
if self._source is None or self._sink is None:
|
||||
return
|
||||
|
||||
self._pipeline.add(self._source)
|
||||
self._pipeline.add(self._sink)
|
||||
self._source.link(self._sink)
|
||||
|
||||
self._initialized = True
|
||||
|
||||
def play(self, item, interrupt=True):
|
||||
def play(self, item: Any, interrupt: bool = True) -> None:
|
||||
"""Plays a sound, interrupting the current play first unless specified."""
|
||||
|
||||
if isinstance(item, Icon):
|
||||
@@ -180,56 +113,472 @@ class Player:
|
||||
elif isinstance(item, Tone):
|
||||
self._playTone(item, interrupt)
|
||||
else:
|
||||
tokens = ["SOUND ERROR:", item, "is not an Icon or Tone"]
|
||||
debug.printTokens(debug.LEVEL_INFO, tokens, True)
|
||||
debug.printTokens(debug.LEVEL_INFO, ["SOUND ERROR:", item, "is not an Icon or Tone"], True)
|
||||
|
||||
def playAndWait(self, item, interrupt=True, timeout_seconds=10):
|
||||
def playAndWait(self, item: Any, interrupt: bool = True, timeout_seconds: int = 10) -> bool:
|
||||
"""Plays a sound and blocks until completion or timeout."""
|
||||
|
||||
if not self._player:
|
||||
if _gstreamerAvailable and not self._initialized:
|
||||
self.init()
|
||||
if not self._player:
|
||||
return False
|
||||
|
||||
if isinstance(item, Icon):
|
||||
return self._playIconAndWait(
|
||||
item,
|
||||
interrupt=interrupt,
|
||||
timeout_seconds=timeout_seconds
|
||||
)
|
||||
|
||||
return self._playIconAndWait(item, interrupt=interrupt, timeout_seconds=timeout_seconds)
|
||||
if isinstance(item, Tone):
|
||||
return self._playToneAndWait(item, interrupt=interrupt, timeout_seconds=timeout_seconds)
|
||||
self.play(item, interrupt)
|
||||
return False
|
||||
|
||||
def stop(self, element=None):
|
||||
"""Stops play."""
|
||||
def stop(self, _element: Any = None) -> None:
|
||||
"""Stops current sound playback."""
|
||||
|
||||
if not _gstreamerAvailable:
|
||||
return
|
||||
self._sendWorkerCommand({"action": "stop"}, waitForResponse=False)
|
||||
|
||||
if element:
|
||||
element.set_state(Gst.State.NULL)
|
||||
return
|
||||
|
||||
if self._player:
|
||||
self._player.set_state(Gst.State.NULL)
|
||||
|
||||
if self._pipeline:
|
||||
self._pipeline.set_state(Gst.State.NULL)
|
||||
|
||||
def shutdown(self):
|
||||
def shutdown(self) -> None:
|
||||
"""Shuts down the sound utilities."""
|
||||
|
||||
global _gstreamerAvailable
|
||||
if not _gstreamerAvailable:
|
||||
return
|
||||
|
||||
self.stop()
|
||||
self._initialized = False
|
||||
_gstreamerAvailable = False
|
||||
with self._workerLock:
|
||||
self._stopWorkerLocked("Sound system shutdown")
|
||||
self._initialized = False
|
||||
|
||||
def _playIcon(self, icon: Icon, interrupt: bool = True) -> None:
|
||||
if not icon.isValid():
|
||||
return
|
||||
|
||||
success, reason = self._sendWorkerCommand(
|
||||
{
|
||||
"action": "play_file",
|
||||
"path": icon.path,
|
||||
"volume": self._get_configured_volume(),
|
||||
"interrupt": interrupt,
|
||||
},
|
||||
waitForResponse=False,
|
||||
)
|
||||
if not success and reason:
|
||||
debug.printMessage(debug.LEVEL_INFO, f"SOUND ERROR: {reason}", True)
|
||||
|
||||
def _playIconAndWait(
|
||||
self,
|
||||
icon: Icon,
|
||||
interrupt: bool = True,
|
||||
timeout_seconds: Optional[int] = 10,
|
||||
) -> bool:
|
||||
if not icon.isValid():
|
||||
return False
|
||||
|
||||
timeout = float((timeout_seconds or 10) + 2)
|
||||
success, reason = self._sendWorkerCommand(
|
||||
{
|
||||
"action": "play_file",
|
||||
"path": icon.path,
|
||||
"volume": self._get_configured_volume(),
|
||||
"interrupt": interrupt,
|
||||
},
|
||||
waitForResponse=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
if not success and reason:
|
||||
debug.printMessage(debug.LEVEL_INFO, f"SOUND ERROR: {reason}", True)
|
||||
return success
|
||||
|
||||
def _playTone(self, tone: Tone, interrupt: bool = True) -> None:
|
||||
success, reason = self._sendWorkerCommand(
|
||||
self._buildToneCommand(tone, interrupt),
|
||||
waitForResponse=False,
|
||||
)
|
||||
if not success and reason:
|
||||
debug.printMessage(debug.LEVEL_INFO, f"SOUND ERROR: {reason}", True)
|
||||
|
||||
def _playToneAndWait(
|
||||
self,
|
||||
tone: Tone,
|
||||
interrupt: bool = True,
|
||||
timeout_seconds: Optional[int] = 10,
|
||||
) -> bool:
|
||||
timeout = max(float(timeout_seconds or 10), float(tone.duration) + 2.0)
|
||||
success, reason = self._sendWorkerCommand(
|
||||
self._buildToneCommand(tone, interrupt),
|
||||
waitForResponse=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
if not success and reason:
|
||||
debug.printMessage(debug.LEVEL_INFO, f"SOUND ERROR: {reason}", True)
|
||||
return success
|
||||
|
||||
def _buildToneCommand(self, tone: Tone, interrupt: bool) -> dict[str, Any]:
|
||||
return {
|
||||
"action": "play_tone",
|
||||
"duration": tone.duration,
|
||||
"frequency": tone.frequency,
|
||||
"volume": tone.volume,
|
||||
"wave": tone.wave,
|
||||
"interrupt": interrupt,
|
||||
}
|
||||
|
||||
def _ensureWorkerLocked(self) -> bool:
|
||||
configuredSink = sound_sink.get_configured_sound_sink()
|
||||
|
||||
if self._workerProcess is not None and self._workerProcess.poll() is None:
|
||||
if self._workerRestartRequired:
|
||||
reason = self._consumeWorkerRestartReason()
|
||||
debug.printMessage(
|
||||
debug.LEVEL_INFO,
|
||||
f"SOUND: Restarting persistent worker after recovery request: {reason}",
|
||||
True,
|
||||
)
|
||||
self._stopWorkerLocked(reason)
|
||||
elif self._workerSink == configuredSink:
|
||||
return True
|
||||
else:
|
||||
debug.printMessage(
|
||||
debug.LEVEL_INFO,
|
||||
f"SOUND: Restarting persistent worker for soundSink={configuredSink}",
|
||||
True,
|
||||
)
|
||||
self._stopWorkerLocked("Sound sink changed")
|
||||
|
||||
return self._startWorkerLocked(configuredSink)
|
||||
|
||||
def _startWorkerLocked(self, configuredSink: str) -> bool:
|
||||
environment = _buildSoundHelperEnvironment()
|
||||
command = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"cthulhu.sound_helper",
|
||||
"--worker",
|
||||
"--sound-sink",
|
||||
configuredSink,
|
||||
]
|
||||
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
env=environment,
|
||||
)
|
||||
except Exception as error:
|
||||
reason = f"Failed to start persistent sound worker: {error}"
|
||||
disableSoundSystem(reason)
|
||||
debug.printMessage(debug.LEVEL_INFO, f"SOUND ERROR: {reason}", True)
|
||||
return False
|
||||
|
||||
if process.stdin is None or process.stdout is None or process.stderr is None:
|
||||
try:
|
||||
process.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
reason = "Persistent sound worker is missing stdio pipes"
|
||||
disableSoundSystem(reason)
|
||||
debug.printMessage(debug.LEVEL_INFO, f"SOUND ERROR: {reason}", True)
|
||||
return False
|
||||
|
||||
self._workerProcess = process
|
||||
self._workerSink = configuredSink
|
||||
self._workerRestartRequired = False
|
||||
self._workerRestartReason = None
|
||||
self._startWorkerThreads(process)
|
||||
|
||||
success, reason = self._sendWorkerCommandLocked(
|
||||
{"action": "ping"},
|
||||
waitForResponse=True,
|
||||
timeout=2.0,
|
||||
allowRestart=False,
|
||||
)
|
||||
if not success:
|
||||
self._stopWorkerLocked(reason or "Persistent sound worker failed to initialize")
|
||||
disableSoundSystem(reason or "Persistent sound worker failed to initialize")
|
||||
if reason:
|
||||
debug.printMessage(debug.LEVEL_INFO, f"SOUND ERROR: {reason}", True)
|
||||
return False
|
||||
|
||||
debug.printMessage(
|
||||
debug.LEVEL_INFO,
|
||||
f"SOUND: Using persistent worker for icon playback (soundSink={configuredSink})",
|
||||
True,
|
||||
)
|
||||
debug.printMessage(
|
||||
debug.LEVEL_INFO,
|
||||
f"SOUND: Using persistent worker for tone playback (soundSink={configuredSink})",
|
||||
True,
|
||||
)
|
||||
return True
|
||||
|
||||
def _startWorkerThreads(self, process: subprocess.Popen[str]) -> None:
|
||||
threading.Thread(
|
||||
target=self._readWorkerStdout,
|
||||
args=(process,),
|
||||
daemon=True,
|
||||
).start()
|
||||
threading.Thread(
|
||||
target=self._readWorkerStderr,
|
||||
args=(process,),
|
||||
daemon=True,
|
||||
).start()
|
||||
threading.Thread(
|
||||
target=self._watchWorkerExit,
|
||||
args=(process,),
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
def _readWorkerStdout(self, process: subprocess.Popen[str]) -> None:
|
||||
assert process.stdout is not None
|
||||
for line in process.stdout:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
response = json.loads(line)
|
||||
except Exception:
|
||||
debug.printMessage(debug.LEVEL_INFO, f"SOUND ERROR: Invalid worker response: {line}", True)
|
||||
continue
|
||||
|
||||
requestId = response.get("id")
|
||||
if requestId is None:
|
||||
continue
|
||||
|
||||
with self._responseLock:
|
||||
pending = self._pendingResponses.pop(int(requestId), None)
|
||||
|
||||
if pending is None:
|
||||
continue
|
||||
|
||||
pending.response = response
|
||||
pending.event.set()
|
||||
|
||||
def _readWorkerStderr(self, process: subprocess.Popen[str]) -> None:
|
||||
assert process.stderr is not None
|
||||
for line in process.stderr:
|
||||
message = line.strip()
|
||||
if message:
|
||||
self._handleWorkerDiagnostic(message)
|
||||
debug.printMessage(debug.LEVEL_INFO, f"SOUND WORKER: {message}", True)
|
||||
|
||||
def _watchWorkerExit(self, process: subprocess.Popen[str]) -> None:
|
||||
returnCode = process.wait()
|
||||
reason = _formatWorkerFailure(returnCode)
|
||||
|
||||
with self._workerLock:
|
||||
if self._workerProcess is process:
|
||||
self._workerProcess = None
|
||||
self._workerSink = None
|
||||
|
||||
self._failPendingResponses(reason)
|
||||
|
||||
if returnCode != 0:
|
||||
debug.printMessage(debug.LEVEL_INFO, f"SOUND ERROR: {reason}", True)
|
||||
|
||||
def _failPendingResponses(self, reason: str) -> None:
|
||||
with self._responseLock:
|
||||
pendingResponses = list(self._pendingResponses.values())
|
||||
self._pendingResponses.clear()
|
||||
|
||||
for pending in pendingResponses:
|
||||
pending.response = {"ok": False, "error": reason}
|
||||
pending.event.set()
|
||||
|
||||
def _stopWorkerLocked(self, reason: str) -> None:
|
||||
process = self._workerProcess
|
||||
self._workerProcess = None
|
||||
self._workerSink = None
|
||||
self._workerRestartRequired = False
|
||||
self._workerRestartReason = None
|
||||
|
||||
if process is None:
|
||||
return
|
||||
|
||||
if process.poll() is None:
|
||||
try:
|
||||
assert process.stdin is not None
|
||||
process.stdin.write(json.dumps({"action": "shutdown"}) + "\n")
|
||||
process.stdin.flush()
|
||||
process.wait(timeout=1.0)
|
||||
except Exception:
|
||||
try:
|
||||
process.terminate()
|
||||
process.wait(timeout=1.0)
|
||||
except Exception:
|
||||
try:
|
||||
process.kill()
|
||||
process.wait(timeout=1.0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._failPendingResponses(reason)
|
||||
|
||||
def _sendWorkerCommand(
|
||||
self,
|
||||
command: dict[str, Any],
|
||||
waitForResponse: bool,
|
||||
timeout: float = 2.0,
|
||||
) -> Tuple[bool, Optional[str]]:
|
||||
with self._workerLock:
|
||||
return self._sendWorkerCommandLocked(command, waitForResponse, timeout)
|
||||
|
||||
def _sendWorkerCommandLocked(
|
||||
self,
|
||||
command: dict[str, Any],
|
||||
waitForResponse: bool,
|
||||
timeout: float = 2.0,
|
||||
allowRestart: bool = True,
|
||||
) -> Tuple[bool, Optional[str]]:
|
||||
if not self._ensureWorkerLocked():
|
||||
return False, getSoundSystemFailureReason() or "Persistent sound worker is unavailable"
|
||||
|
||||
process = self._workerProcess
|
||||
if process is None or process.stdin is None:
|
||||
if allowRestart:
|
||||
self._stopWorkerLocked("Worker pipes disappeared")
|
||||
if self._ensureWorkerLocked():
|
||||
return self._sendWorkerCommandLocked(command, waitForResponse, timeout, allowRestart=False)
|
||||
return False, "Persistent sound worker stdin is unavailable"
|
||||
|
||||
requestId: Optional[int] = None
|
||||
pending: Optional[_PendingResponse] = None
|
||||
payload = dict(command)
|
||||
|
||||
if waitForResponse:
|
||||
requestId = self._allocateRequestId()
|
||||
pending = _PendingResponse()
|
||||
payload["id"] = requestId
|
||||
with self._responseLock:
|
||||
self._pendingResponses[requestId] = pending
|
||||
|
||||
try:
|
||||
process.stdin.write(json.dumps(payload) + "\n")
|
||||
process.stdin.flush()
|
||||
except Exception as error:
|
||||
if requestId is not None:
|
||||
with self._responseLock:
|
||||
self._pendingResponses.pop(requestId, None)
|
||||
if allowRestart:
|
||||
self._stopWorkerLocked(f"Worker write failed: {error}")
|
||||
if self._ensureWorkerLocked():
|
||||
return self._sendWorkerCommandLocked(command, waitForResponse, timeout, allowRestart=False)
|
||||
return False, f"Worker write failed: {error}"
|
||||
|
||||
if not waitForResponse or pending is None:
|
||||
return True, None
|
||||
|
||||
if pending.event.wait(timeout):
|
||||
response = pending.response or {"ok": False, "error": "No worker response"}
|
||||
if not bool(response.get("ok")):
|
||||
self._maybeMarkWorkerRestartRequired(response.get("error"))
|
||||
return bool(response.get("ok")), response.get("error")
|
||||
|
||||
with self._responseLock:
|
||||
self._pendingResponses.pop(requestId, None)
|
||||
|
||||
if allowRestart and (self._workerProcess is None or self._workerProcess.poll() is not None):
|
||||
self._stopWorkerLocked("Worker exited while waiting for response")
|
||||
if self._ensureWorkerLocked():
|
||||
return self._sendWorkerCommandLocked(command, waitForResponse, timeout, allowRestart=False)
|
||||
|
||||
reason = f"Persistent sound worker timed out after {timeout:.1f} seconds"
|
||||
self._markWorkerRestartRequired(reason)
|
||||
return False, reason
|
||||
|
||||
def _allocateRequestId(self) -> int:
|
||||
with self._responseLock:
|
||||
requestId = self._nextRequestId
|
||||
self._nextRequestId += 1
|
||||
return requestId
|
||||
|
||||
def _handleWorkerDiagnostic(self, message: str) -> None:
|
||||
normalizedMessage = str(message).strip()
|
||||
if not normalizedMessage.startswith("RECOVERY REQUIRED:"):
|
||||
return
|
||||
|
||||
reason = normalizedMessage.split(":", 1)[1].strip() or "Worker requested recovery"
|
||||
self._markWorkerRestartRequired(reason)
|
||||
|
||||
def _markWorkerRestartRequired(self, reason: Optional[str]) -> None:
|
||||
normalizedReason = str(reason or "").strip() or "Worker requested recovery"
|
||||
with self._workerLock:
|
||||
self._workerRestartRequired = True
|
||||
self._workerRestartReason = normalizedReason
|
||||
|
||||
def _consumeWorkerRestartReason(self) -> str:
|
||||
reason = self._workerRestartReason or "Worker requested recovery"
|
||||
self._workerRestartRequired = False
|
||||
self._workerRestartReason = None
|
||||
return reason
|
||||
|
||||
def _maybeMarkWorkerRestartRequired(self, reason: Optional[str]) -> None:
|
||||
normalizedReason = str(reason or "").strip().lower()
|
||||
if normalizedReason in {"", "interrupted", "stopped", "shutdown", "stdin closed"}:
|
||||
return
|
||||
|
||||
self._markWorkerRestartRequired(reason)
|
||||
|
||||
|
||||
def disableSoundSystem(reason: str) -> None:
|
||||
global _soundSystemFailureReason
|
||||
|
||||
if _soundSystemFailureReason == reason:
|
||||
return
|
||||
|
||||
_soundSystemFailureReason = reason
|
||||
debug.printMessage(debug.LEVEL_INFO, f"SOUND: Disabling sound system. Reason: {reason}", True)
|
||||
|
||||
|
||||
def getSoundSystemFailureReason() -> Optional[str]:
|
||||
return _soundSystemFailureReason
|
||||
|
||||
|
||||
def isSoundSystemAvailable() -> bool:
|
||||
return _soundSystemFailureReason is None
|
||||
|
||||
|
||||
def clearSoundSystemFailure() -> None:
|
||||
global _soundSystemFailureReason
|
||||
_soundSystemFailureReason = None
|
||||
|
||||
|
||||
def _buildSoundHelperEnvironment() -> dict[str, str]:
|
||||
pythonPathEntries = []
|
||||
for path in sys.path:
|
||||
if not path:
|
||||
path = os.getcwd()
|
||||
if os.path.isdir(path) and path not in pythonPathEntries:
|
||||
pythonPathEntries.append(path)
|
||||
|
||||
environment = os.environ.copy()
|
||||
if pythonPathEntries:
|
||||
environment["PYTHONPATH"] = os.pathsep.join(pythonPathEntries)
|
||||
return environment
|
||||
|
||||
|
||||
def _formatWorkerFailure(returnCode: int) -> str:
|
||||
if returnCode < 0:
|
||||
return f"Sound worker exited via signal {-returnCode}"
|
||||
return f"Sound worker exited with status {returnCode}"
|
||||
|
||||
|
||||
_player = Player()
|
||||
|
||||
def getPlayer():
|
||||
|
||||
def getPlayer() -> Player:
|
||||
return _player
|
||||
|
||||
|
||||
def play(item: Any, interrupt: bool = True) -> None:
|
||||
_player.play(item, interrupt)
|
||||
|
||||
|
||||
def playIconSafely(icon: Icon, timeoutSeconds: int = 10) -> Tuple[bool, Optional[str]]:
|
||||
if not icon.isValid():
|
||||
return False, f"Invalid sound icon: {icon.path}"
|
||||
|
||||
return _player._sendWorkerCommand(
|
||||
{
|
||||
"action": "play_file",
|
||||
"path": icon.path,
|
||||
"volume": Player._get_configured_volume(),
|
||||
"interrupt": True,
|
||||
},
|
||||
waitForResponse=True,
|
||||
timeout=max(0.1, float(timeoutSeconds)),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
#!/usr/bin/env python3
|
||||
#
|
||||
# Copyright (c) 2026 Stormux
|
||||
#
|
||||
# This library is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU Lesser General Public
|
||||
# License as published by the Free Software Foundation; either
|
||||
# version 2.1 of the License, or (at your option) any later version.
|
||||
#
|
||||
# This library is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
# Lesser General Public License for more details.
|
||||
|
||||
"""Persistent sound helper used to isolate theme and tone playback crashes."""
|
||||
|
||||
import argparse
|
||||
from collections import deque
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
import threading
|
||||
from typing import Any, Optional
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version('GLib', '2.0')
|
||||
gi.require_version('Gst', '1.0')
|
||||
from gi.repository import GLib, Gst
|
||||
|
||||
from . import settings
|
||||
from . import sound_sink
|
||||
|
||||
|
||||
def _clamp_volume(volume: Any) -> float:
|
||||
try:
|
||||
return max(0.0, float(volume))
|
||||
except Exception:
|
||||
return 1.0
|
||||
|
||||
|
||||
def _write_json_line(payload: dict[str, Any]) -> None:
|
||||
sys.stdout.write(json.dumps(payload) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def _report_recovery_required(message: str) -> None:
|
||||
print(f"RECOVERY REQUIRED: {message}", file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
def _create_file_player(
|
||||
playerId: str,
|
||||
soundSink: Optional[str] = None,
|
||||
) -> tuple[Optional[Any], Optional[str], Optional[str]]:
|
||||
player = Gst.ElementFactory.make("playbin", playerId)
|
||||
if player is None:
|
||||
return None, None, "Failed to create playbin for file playback"
|
||||
|
||||
configuredSink = sound_sink.normalize_sound_sink_choice(soundSink)
|
||||
if configuredSink == settings.SOUND_SINK_AUTO:
|
||||
return player, "playbin-default", None
|
||||
|
||||
audioSink, sinkName, sinkError = sound_sink.create_audio_sink(
|
||||
f"{playerId}-output",
|
||||
configuredSink,
|
||||
)
|
||||
if audioSink is None:
|
||||
return None, sinkName, sinkError or f"Failed to create audio sink for {configuredSink}"
|
||||
|
||||
player.set_property("audio-sink", audioSink)
|
||||
return player, sinkName or configuredSink, None
|
||||
|
||||
|
||||
class SoundWorker:
|
||||
"""Runs a long-lived GStreamer worker for icon and tone playback."""
|
||||
|
||||
def __init__(self, soundSink: Optional[str] = None) -> None:
|
||||
available, _args = Gst.init_check(None)
|
||||
if not available:
|
||||
raise RuntimeError("GStreamer is not available")
|
||||
|
||||
self._loop = GLib.MainLoop()
|
||||
self._queue: deque[dict[str, Any]] = deque()
|
||||
self._currentCommand: Optional[dict[str, Any]] = None
|
||||
self._toneTimeoutId = 0
|
||||
|
||||
self._filePlayer, fileSinkName, fileSinkError = _create_file_player(
|
||||
"cthulhu-sound-worker-file",
|
||||
soundSink,
|
||||
)
|
||||
if self._filePlayer is None:
|
||||
raise RuntimeError(fileSinkError or "Failed to create file playback player")
|
||||
|
||||
fileBus = self._filePlayer.get_bus()
|
||||
if fileBus is None:
|
||||
raise RuntimeError("No bus available for file playback player")
|
||||
fileBus.add_signal_watch()
|
||||
fileBus.connect("message", self._onFileMessage)
|
||||
|
||||
self._tonePipeline = Gst.Pipeline.new('cthulhu-sound-worker-tone')
|
||||
if self._tonePipeline is None:
|
||||
raise RuntimeError("Failed to create tone pipeline")
|
||||
|
||||
self._toneSource = Gst.ElementFactory.make('audiotestsrc', 'cthulhu-sound-worker-source')
|
||||
self._toneVolume = Gst.ElementFactory.make('volume', 'cthulhu-sound-worker-volume')
|
||||
toneSink, toneSinkName, toneSinkError = sound_sink.create_audio_sink(
|
||||
'cthulhu-sound-worker-tone-output',
|
||||
soundSink
|
||||
)
|
||||
if self._toneSource is None or toneSink is None:
|
||||
raise RuntimeError(toneSinkError or "Failed to create tone playback pipeline")
|
||||
|
||||
self._toneSink = toneSink
|
||||
self._tonePipeline.add(self._toneSource)
|
||||
if self._toneVolume is not None:
|
||||
self._tonePipeline.add(self._toneVolume)
|
||||
self._tonePipeline.add(self._toneSink)
|
||||
if self._toneVolume is not None:
|
||||
if not self._toneSource.link(self._toneVolume):
|
||||
raise RuntimeError("Failed to link tone source to volume")
|
||||
if not self._toneVolume.link(self._toneSink):
|
||||
raise RuntimeError("Failed to link tone volume to sink")
|
||||
elif not self._toneSource.link(self._toneSink):
|
||||
raise RuntimeError("Failed to link tone source to sink")
|
||||
|
||||
toneBus = self._tonePipeline.get_bus()
|
||||
if toneBus is None:
|
||||
raise RuntimeError("No bus available for tone pipeline")
|
||||
toneBus.add_signal_watch()
|
||||
toneBus.connect("message", self._onToneMessage)
|
||||
|
||||
self._fileSinkName = fileSinkName or "unknown"
|
||||
self._toneSinkName = toneSinkName or "unknown"
|
||||
|
||||
def run(self) -> int:
|
||||
self._report_worker_ready()
|
||||
readerThread = threading.Thread(target=self._read_commands, daemon=True)
|
||||
readerThread.start()
|
||||
try:
|
||||
self._loop.run()
|
||||
return 0
|
||||
finally:
|
||||
self._clear_tone_timeout()
|
||||
self._filePlayer.set_state(Gst.State.NULL)
|
||||
self._tonePipeline.set_state(Gst.State.NULL)
|
||||
|
||||
def _report_worker_ready(self) -> None:
|
||||
print(
|
||||
f"Persistent sound worker ready: file={self._fileSinkName} tone={self._toneSinkName}",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
|
||||
def _read_commands(self) -> None:
|
||||
try:
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
command = json.loads(line)
|
||||
except Exception as error:
|
||||
print(f"Invalid worker command: {error}", file=sys.stderr, flush=True)
|
||||
continue
|
||||
GLib.idle_add(self._handle_command, command)
|
||||
finally:
|
||||
GLib.idle_add(self._quit_from_stdin_close)
|
||||
|
||||
def _quit_from_stdin_close(self) -> bool:
|
||||
self._interrupt_current("stdin closed")
|
||||
self._interrupt_queued("stdin closed")
|
||||
self._loop.quit()
|
||||
return False
|
||||
|
||||
def _handle_command(self, command: dict[str, Any]) -> bool:
|
||||
action = str(command.get("action", "")).strip().lower()
|
||||
requestId = command.get("id")
|
||||
|
||||
if action == "ping":
|
||||
self._respond(requestId, True)
|
||||
return False
|
||||
|
||||
if action == "shutdown":
|
||||
self._interrupt_current("shutdown")
|
||||
self._interrupt_queued("shutdown")
|
||||
self._respond(requestId, True)
|
||||
self._loop.quit()
|
||||
return False
|
||||
|
||||
if action == "stop":
|
||||
self._interrupt_current("stopped")
|
||||
self._interrupt_queued("stopped")
|
||||
self._respond(requestId, True)
|
||||
return False
|
||||
|
||||
if action not in {"play_file", "play_tone"}:
|
||||
self._respond(requestId, False, f"Unknown worker action: {action}")
|
||||
return False
|
||||
|
||||
interrupt = bool(command.get("interrupt", True))
|
||||
if interrupt:
|
||||
self._interrupt_current("interrupted")
|
||||
self._interrupt_queued("interrupted")
|
||||
elif self._currentCommand is not None:
|
||||
self._queue.append(command)
|
||||
return False
|
||||
|
||||
if self._currentCommand is None:
|
||||
self._start_command(command)
|
||||
else:
|
||||
self._queue.appendleft(command)
|
||||
|
||||
return False
|
||||
|
||||
def _start_command(self, command: dict[str, Any]) -> None:
|
||||
action = command["action"]
|
||||
if action == "play_file":
|
||||
self._start_file_command(command)
|
||||
return
|
||||
if action == "play_tone":
|
||||
self._start_tone_command(command)
|
||||
return
|
||||
self._respond(command.get("id"), False, f"Unsupported worker action: {action}")
|
||||
self._start_next_command()
|
||||
|
||||
def _start_file_command(self, command: dict[str, Any]) -> None:
|
||||
soundPath = pathlib.Path(str(command.get("path", ""))).expanduser()
|
||||
if not soundPath.is_file():
|
||||
self._respond(command.get("id"), False, f"Missing sound file: {soundPath}")
|
||||
self._start_next_command()
|
||||
return
|
||||
|
||||
self._clear_tone_timeout()
|
||||
self._tonePipeline.set_state(Gst.State.NULL)
|
||||
self._filePlayer.set_state(Gst.State.NULL)
|
||||
|
||||
self._currentCommand = command
|
||||
self._filePlayer.set_property("uri", soundPath.resolve().as_uri())
|
||||
self._filePlayer.set_property("volume", _clamp_volume(command.get("volume", 1.0)))
|
||||
stateChange = self._filePlayer.set_state(Gst.State.PLAYING)
|
||||
if stateChange == Gst.StateChangeReturn.FAILURE:
|
||||
self._currentCommand = None
|
||||
reason = f"Failed to start playback for {soundPath}"
|
||||
_report_recovery_required(reason)
|
||||
self._respond(command.get("id"), False, reason)
|
||||
self._start_next_command()
|
||||
|
||||
def _start_tone_command(self, command: dict[str, Any]) -> None:
|
||||
self._filePlayer.set_state(Gst.State.NULL)
|
||||
self._tonePipeline.set_state(Gst.State.NULL)
|
||||
self._clear_tone_timeout()
|
||||
|
||||
try:
|
||||
durationSeconds = max(0.0, float(command.get("duration", 0.0)))
|
||||
frequency = max(0, min(20000, int(command.get("frequency", 0))))
|
||||
wave = int(command.get("wave", 0))
|
||||
except Exception as error:
|
||||
self._respond(command.get("id"), False, f"Invalid tone request: {error}")
|
||||
self._start_next_command()
|
||||
return
|
||||
|
||||
self._currentCommand = command
|
||||
if self._toneVolume is not None:
|
||||
self._toneVolume.set_property('volume', _clamp_volume(command.get("volume", 1.0)))
|
||||
self._toneSource.set_property('volume', 1.0)
|
||||
else:
|
||||
self._toneSource.set_property('volume', _clamp_volume(command.get("volume", 1.0)))
|
||||
self._toneSource.set_property('freq', frequency)
|
||||
self._toneSource.set_property('wave', wave)
|
||||
|
||||
stateChange = self._tonePipeline.set_state(Gst.State.PLAYING)
|
||||
if stateChange == Gst.StateChangeReturn.FAILURE:
|
||||
self._currentCommand = None
|
||||
reason = "Failed to start tone playback"
|
||||
_report_recovery_required(reason)
|
||||
self._respond(command.get("id"), False, reason)
|
||||
self._start_next_command()
|
||||
return
|
||||
|
||||
durationMs = max(1, int(durationSeconds * 1000))
|
||||
self._toneTimeoutId = GLib.timeout_add(durationMs, self._finish_tone_playback)
|
||||
|
||||
def _finish_tone_playback(self) -> bool:
|
||||
self._toneTimeoutId = 0
|
||||
self._tonePipeline.set_state(Gst.State.NULL)
|
||||
self._finish_current(True)
|
||||
return False
|
||||
|
||||
def _clear_tone_timeout(self) -> None:
|
||||
if self._toneTimeoutId:
|
||||
GLib.source_remove(self._toneTimeoutId)
|
||||
self._toneTimeoutId = 0
|
||||
|
||||
def _interrupt_current(self, reason: str) -> None:
|
||||
if self._currentCommand is None:
|
||||
self._filePlayer.set_state(Gst.State.NULL)
|
||||
self._tonePipeline.set_state(Gst.State.NULL)
|
||||
self._clear_tone_timeout()
|
||||
return
|
||||
|
||||
current = self._currentCommand
|
||||
self._currentCommand = None
|
||||
self._filePlayer.set_state(Gst.State.NULL)
|
||||
self._tonePipeline.set_state(Gst.State.NULL)
|
||||
self._clear_tone_timeout()
|
||||
self._respond(current.get("id"), False, reason)
|
||||
|
||||
def _interrupt_queued(self, reason: str) -> None:
|
||||
while self._queue:
|
||||
queued = self._queue.popleft()
|
||||
self._respond(queued.get("id"), False, reason)
|
||||
|
||||
def _finish_current(self, success: bool, error: Optional[str] = None) -> None:
|
||||
current = self._currentCommand
|
||||
self._currentCommand = None
|
||||
if current is not None:
|
||||
self._respond(current.get("id"), success, error)
|
||||
self._start_next_command()
|
||||
|
||||
def _start_next_command(self) -> None:
|
||||
if self._currentCommand is not None or not self._queue:
|
||||
return
|
||||
self._start_command(self._queue.popleft())
|
||||
|
||||
def _respond(self, requestId: Any, success: bool, error: Optional[str] = None) -> None:
|
||||
if requestId is None:
|
||||
return
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"id": requestId,
|
||||
"ok": bool(success),
|
||||
}
|
||||
if error:
|
||||
payload["error"] = str(error)
|
||||
_write_json_line(payload)
|
||||
|
||||
def _onFileMessage(self, _bus: Any, message: Any) -> None:
|
||||
if self._currentCommand is None or self._currentCommand.get("action") != "play_file":
|
||||
return
|
||||
|
||||
if message.type == Gst.MessageType.EOS:
|
||||
self._filePlayer.set_state(Gst.State.NULL)
|
||||
self._finish_current(True)
|
||||
return
|
||||
|
||||
if message.type == Gst.MessageType.ERROR:
|
||||
self._filePlayer.set_state(Gst.State.NULL)
|
||||
error, _info = message.parse_error()
|
||||
_report_recovery_required(str(error))
|
||||
print(str(error), file=sys.stderr, flush=True)
|
||||
self._finish_current(False, str(error))
|
||||
|
||||
def _onToneMessage(self, _bus: Any, message: Any) -> None:
|
||||
if self._currentCommand is None or self._currentCommand.get("action") != "play_tone":
|
||||
return
|
||||
|
||||
if message.type != Gst.MessageType.ERROR:
|
||||
return
|
||||
|
||||
self._tonePipeline.set_state(Gst.State.NULL)
|
||||
self._clear_tone_timeout()
|
||||
error, _info = message.parse_error()
|
||||
_report_recovery_required(str(error))
|
||||
print(str(error), file=sys.stderr, flush=True)
|
||||
self._finish_current(False, str(error))
|
||||
|
||||
|
||||
def play_file_once(
|
||||
soundPath: str,
|
||||
timeoutSeconds: int = 10,
|
||||
soundSink: Optional[str] = None,
|
||||
volume: float = 1.0,
|
||||
) -> int:
|
||||
available, _args = Gst.init_check(None)
|
||||
if not available:
|
||||
print("GStreamer is not available", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
player, sinkName, sinkError = _create_file_player(
|
||||
"cthulhu-sound-helper-once",
|
||||
soundSink,
|
||||
)
|
||||
if player is None:
|
||||
print(sinkError or "Failed to create one-shot file playback player", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
player.set_property("uri", pathlib.Path(soundPath).resolve().as_uri())
|
||||
player.set_property("volume", _clamp_volume(volume))
|
||||
player.set_state(Gst.State.PLAYING)
|
||||
|
||||
bus = player.get_bus()
|
||||
if bus is None:
|
||||
player.set_state(Gst.State.NULL)
|
||||
print("No bus available for one-shot playback", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
timeoutNs = int(timeoutSeconds * Gst.SECOND)
|
||||
try:
|
||||
message = bus.timed_pop_filtered(
|
||||
timeoutNs,
|
||||
Gst.MessageType.EOS | Gst.MessageType.ERROR
|
||||
)
|
||||
if message is None:
|
||||
print(
|
||||
f"Sound helper timed out after {timeoutSeconds} seconds on sink {sinkName}",
|
||||
file=sys.stderr
|
||||
)
|
||||
return 1
|
||||
if message.type == Gst.MessageType.ERROR:
|
||||
error, _info = message.parse_error()
|
||||
print(str(error), file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
finally:
|
||||
player.set_state(Gst.State.NULL)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(add_help=False)
|
||||
parser.add_argument("--worker", action="store_true")
|
||||
parser.add_argument("--play-file")
|
||||
parser.add_argument("--sound-sink")
|
||||
parser.add_argument("--volume", type=float, default=1.0)
|
||||
parser.add_argument("--timeout-seconds", type=int, default=10)
|
||||
args, _unknown = parser.parse_known_args()
|
||||
|
||||
if args.worker:
|
||||
worker = SoundWorker(args.sound_sink)
|
||||
return worker.run()
|
||||
|
||||
if args.play_file:
|
||||
return play_file_once(
|
||||
args.play_file,
|
||||
args.timeout_seconds,
|
||||
args.sound_sink,
|
||||
args.volume,
|
||||
)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env python3
|
||||
#
|
||||
# Copyright (c) 2026 Stormux
|
||||
#
|
||||
# This library is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU Lesser General Public
|
||||
# License as published by the Free Software Foundation; either
|
||||
# version 2.1 of the License, or (at your option) any later version.
|
||||
#
|
||||
# This library is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
# Lesser General Public License for more details.
|
||||
|
||||
"""Utilities for selecting GStreamer audio sinks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional, Tuple
|
||||
|
||||
import gi
|
||||
|
||||
try:
|
||||
gi.require_version('Gst', '1.0')
|
||||
from gi.repository import Gst
|
||||
except Exception:
|
||||
_gstreamerAvailable: bool = False
|
||||
else:
|
||||
_gstreamerAvailable, _args = Gst.init_check(None)
|
||||
|
||||
from . import settings
|
||||
|
||||
_SINK_ELEMENT_BY_SETTING = {
|
||||
settings.SOUND_SINK_PIPEWIRE: "pipewiresink",
|
||||
settings.SOUND_SINK_PULSE: "pulsesink",
|
||||
settings.SOUND_SINK_ALSA: "alsasink",
|
||||
}
|
||||
|
||||
_AUTO_SINK_ELEMENTS = [
|
||||
"autoaudiosink",
|
||||
"pipewiresink",
|
||||
"pulsesink",
|
||||
"alsasink",
|
||||
]
|
||||
|
||||
|
||||
def normalize_sound_sink_choice(soundSink: Optional[str]) -> str:
|
||||
"""Return a valid sound sink setting value."""
|
||||
|
||||
if soundSink is None:
|
||||
return settings.SOUND_SINK_AUTO
|
||||
|
||||
choice = str(soundSink).strip().lower()
|
||||
if choice in settings.SOUND_SINK_VALUES:
|
||||
return choice
|
||||
|
||||
return settings.SOUND_SINK_AUTO
|
||||
|
||||
|
||||
def get_configured_sound_sink() -> str:
|
||||
"""Return the configured sound sink, falling back to defaults."""
|
||||
|
||||
try:
|
||||
from . import cthulhu
|
||||
if cthulhu.cthulhuApp is not None:
|
||||
configured = cthulhu.cthulhuApp.settingsManager.getSetting("soundSink")
|
||||
return normalize_sound_sink_choice(configured)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return normalize_sound_sink_choice(getattr(settings, "soundSink", settings.SOUND_SINK_AUTO))
|
||||
|
||||
|
||||
def _get_sink_element_candidates(soundSink: str) -> list[str]:
|
||||
if soundSink == settings.SOUND_SINK_AUTO:
|
||||
return list(_AUTO_SINK_ELEMENTS)
|
||||
|
||||
elementName = _SINK_ELEMENT_BY_SETTING.get(soundSink)
|
||||
if elementName:
|
||||
return [elementName]
|
||||
|
||||
return list(_AUTO_SINK_ELEMENTS)
|
||||
|
||||
|
||||
def create_audio_sink(
|
||||
sinkId: str,
|
||||
soundSink: Optional[str] = None,
|
||||
) -> Tuple[Optional[Any], Optional[str], Optional[str]]:
|
||||
"""Create a configured GStreamer audio sink."""
|
||||
|
||||
if not _gstreamerAvailable:
|
||||
return None, None, "GStreamer is not available"
|
||||
|
||||
configuredSink = normalize_sound_sink_choice(soundSink or get_configured_sound_sink())
|
||||
triedCandidates = []
|
||||
|
||||
for elementName in _get_sink_element_candidates(configuredSink):
|
||||
factory = Gst.ElementFactory.find(elementName)
|
||||
if factory is None:
|
||||
triedCandidates.append(f"{elementName} (unavailable)")
|
||||
continue
|
||||
|
||||
sink = Gst.ElementFactory.make(elementName, sinkId)
|
||||
if sink is None:
|
||||
triedCandidates.append(f"{elementName} (failed to create)")
|
||||
continue
|
||||
|
||||
return sink, elementName, None
|
||||
|
||||
triedText = ", ".join(triedCandidates) if triedCandidates else "none"
|
||||
reason = (
|
||||
f"No usable audio sink for soundSink={configuredSink}. "
|
||||
f"Tried: {triedText}"
|
||||
)
|
||||
return None, None, reason
|
||||
@@ -317,7 +317,7 @@ class SoundThemeManager:
|
||||
return None
|
||||
|
||||
def _playThemeSound(self, soundName, interrupt=True, wait=False,
|
||||
requireSoundSetting=False):
|
||||
requireSoundSetting=False, timeoutSeconds=10):
|
||||
"""Play a themed sound with optional gating and blocking.
|
||||
|
||||
Args:
|
||||
@@ -349,9 +349,17 @@ class SoundThemeManager:
|
||||
try:
|
||||
icon = Icon(os.path.dirname(soundPath), os.path.basename(soundPath))
|
||||
if icon.isValid():
|
||||
if wait:
|
||||
success, reason = sound.playIconSafely(icon, timeoutSeconds=timeoutSeconds)
|
||||
if not success:
|
||||
failureReason = reason or f"Failed to play sound '{soundName}'"
|
||||
msg = (
|
||||
"SOUND THEME: Failed to play helper-isolated sound "
|
||||
f"'{soundName}'. Continuing with sound enabled. Reason: {failureReason}"
|
||||
)
|
||||
debug.printMessage(debug.LEVEL_INFO, msg, True)
|
||||
return success
|
||||
player = sound.getPlayer()
|
||||
if wait and hasattr(player, "playAndWait"):
|
||||
return player.playAndWait(icon, interrupt=interrupt)
|
||||
player.play(icon, interrupt=interrupt)
|
||||
return True
|
||||
except Exception as e:
|
||||
@@ -360,7 +368,7 @@ class SoundThemeManager:
|
||||
|
||||
return False
|
||||
|
||||
def playSound(self, soundName, interrupt=True, wait=False):
|
||||
def playSound(self, soundName, interrupt=True, wait=False, timeoutSeconds=10):
|
||||
"""Play a sound from the current theme if enabled.
|
||||
|
||||
Args:
|
||||
@@ -374,7 +382,9 @@ class SoundThemeManager:
|
||||
return self._playThemeSound(
|
||||
soundName,
|
||||
interrupt=interrupt,
|
||||
wait=wait
|
||||
wait=wait,
|
||||
requireSoundSetting=True,
|
||||
timeoutSeconds=timeoutSeconds
|
||||
)
|
||||
|
||||
def playFocusModeSound(self):
|
||||
@@ -389,22 +399,24 @@ class SoundThemeManager:
|
||||
"""Play sound for button focus (future use)."""
|
||||
return self.playSound(SOUND_BUTTON)
|
||||
|
||||
def playStartSound(self, wait=False):
|
||||
def playStartSound(self, wait=False, timeoutSeconds=10):
|
||||
"""Play sound for application startup."""
|
||||
return self._playThemeSound(
|
||||
SOUND_START,
|
||||
interrupt=True,
|
||||
wait=wait,
|
||||
requireSoundSetting=True
|
||||
requireSoundSetting=True,
|
||||
timeoutSeconds=timeoutSeconds
|
||||
)
|
||||
|
||||
def playStopSound(self, wait=False):
|
||||
def playStopSound(self, wait=False, timeoutSeconds=10):
|
||||
"""Play sound for application shutdown."""
|
||||
return self._playThemeSound(
|
||||
SOUND_STOP,
|
||||
interrupt=True,
|
||||
wait=wait,
|
||||
requireSoundSetting=True
|
||||
requireSoundSetting=True,
|
||||
timeoutSeconds=timeoutSeconds
|
||||
)
|
||||
|
||||
_manager = None
|
||||
|
||||
+118
-1
@@ -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)
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import importlib
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
|
||||
from cthulhu import messages
|
||||
|
||||
notification_script = importlib.import_module("cthulhu.scripts.apps.notification-daemon.script")
|
||||
|
||||
|
||||
class NotificationDaemonTests(unittest.TestCase):
|
||||
def test_window_created_ignores_empty_labels(self):
|
||||
testScript = notification_script.Script.__new__(notification_script.Script)
|
||||
labelEmpty = object()
|
||||
labelText = object()
|
||||
eventSource = object()
|
||||
event = mock.Mock(source=eventSource)
|
||||
|
||||
testScript.utilities = mock.Mock()
|
||||
testScript.utilities.displayedText.side_effect = lambda obj: {
|
||||
labelEmpty: None,
|
||||
labelText: "this is a test",
|
||||
}.get(obj)
|
||||
testScript.speechGenerator = mock.Mock()
|
||||
testScript.speechGenerator.voice.return_value = object()
|
||||
testScript.speakMessage = mock.Mock()
|
||||
testScript.displayBrailleMessage = mock.Mock()
|
||||
testScript.notificationPresenter = mock.Mock()
|
||||
|
||||
with (
|
||||
mock.patch.object(
|
||||
notification_script.AXUtilities,
|
||||
"find_all_labels",
|
||||
return_value=[labelEmpty, labelText],
|
||||
),
|
||||
mock.patch.object(
|
||||
notification_script.AXObject,
|
||||
"get_name",
|
||||
side_effect=lambda obj: {
|
||||
labelEmpty: "",
|
||||
labelText: "",
|
||||
}.get(obj, ""),
|
||||
),
|
||||
):
|
||||
testScript.onWindowCreated(event)
|
||||
|
||||
expectedText = f"{messages.NOTIFICATION} this is a test"
|
||||
testScript.speechGenerator.voice.assert_called_once_with(obj=eventSource, string=expectedText)
|
||||
testScript.speakMessage.assert_called_once_with(expectedText, voice=testScript.speechGenerator.voice.return_value)
|
||||
testScript.displayBrailleMessage.assert_called_once()
|
||||
testScript.notificationPresenter.save_notification.assert_called_once_with(expectedText)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,71 @@
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
|
||||
from cthulhu import settings
|
||||
from cthulhu import sound_helper
|
||||
|
||||
|
||||
class _FakePlaybin:
|
||||
def __init__(self):
|
||||
self.properties = {}
|
||||
|
||||
def set_property(self, name, value):
|
||||
self.properties[name] = value
|
||||
|
||||
|
||||
class SoundHelperBackendTests(unittest.TestCase):
|
||||
def test_create_file_player_uses_playbin_default_sink_for_auto(self):
|
||||
fakePlaybin = _FakePlaybin()
|
||||
|
||||
with (
|
||||
mock.patch.object(
|
||||
sound_helper.Gst.ElementFactory,
|
||||
"make",
|
||||
return_value=fakePlaybin,
|
||||
) as makeElement,
|
||||
mock.patch.object(
|
||||
sound_helper.sound_sink,
|
||||
"create_audio_sink",
|
||||
) as createAudioSink,
|
||||
):
|
||||
player, sinkName, error = sound_helper._create_file_player("worker-file", settings.SOUND_SINK_AUTO)
|
||||
|
||||
self.assertIs(player, fakePlaybin)
|
||||
self.assertEqual(sinkName, "playbin-default")
|
||||
self.assertIsNone(error)
|
||||
createAudioSink.assert_not_called()
|
||||
makeElement.assert_called_once_with("playbin", "worker-file")
|
||||
self.assertNotIn("audio-sink", fakePlaybin.properties)
|
||||
|
||||
def test_create_file_player_sets_explicit_sink_when_requested(self):
|
||||
fakePlaybin = _FakePlaybin()
|
||||
fakeSink = object()
|
||||
|
||||
with (
|
||||
mock.patch.object(
|
||||
sound_helper.Gst.ElementFactory,
|
||||
"make",
|
||||
return_value=fakePlaybin,
|
||||
) as makeElement,
|
||||
mock.patch.object(
|
||||
sound_helper.sound_sink,
|
||||
"create_audio_sink",
|
||||
return_value=(fakeSink, "pulsesink", None),
|
||||
) as createAudioSink,
|
||||
):
|
||||
player, sinkName, error = sound_helper._create_file_player("worker-file", settings.SOUND_SINK_PULSE)
|
||||
|
||||
self.assertIs(player, fakePlaybin)
|
||||
self.assertEqual(sinkName, "pulsesink")
|
||||
self.assertIsNone(error)
|
||||
makeElement.assert_called_once_with("playbin", "worker-file")
|
||||
createAudioSink.assert_called_once()
|
||||
self.assertIs(fakePlaybin.properties["audio-sink"], fakeSink)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,93 @@
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
|
||||
soundGeneratorStub = types.ModuleType("cthulhu.sound_generator")
|
||||
|
||||
|
||||
class _Icon:
|
||||
def __init__(self, path="", name=""):
|
||||
self.path = path
|
||||
self.name = name
|
||||
|
||||
def isValid(self):
|
||||
return True
|
||||
|
||||
|
||||
class _Tone:
|
||||
def __init__(self, duration=0.1, frequency=440, volume=1.0, wave=0):
|
||||
self.duration = duration
|
||||
self.frequency = frequency
|
||||
self.volume = volume
|
||||
self.wave = wave
|
||||
|
||||
|
||||
soundGeneratorStub.Icon = _Icon
|
||||
soundGeneratorStub.Tone = _Tone
|
||||
sys.modules.setdefault("cthulhu.sound_generator", soundGeneratorStub)
|
||||
|
||||
from cthulhu import settings
|
||||
from cthulhu import sound
|
||||
from cthulhu import sound_sink
|
||||
|
||||
|
||||
class _FakeProcess:
|
||||
def poll(self):
|
||||
return None
|
||||
|
||||
|
||||
class SoundSinkTests(unittest.TestCase):
|
||||
def test_auto_sink_prefers_autoaudiosink(self):
|
||||
candidates = sound_sink._get_sink_element_candidates(settings.SOUND_SINK_AUTO)
|
||||
self.assertGreaterEqual(len(candidates), 1)
|
||||
self.assertEqual(candidates[0], "autoaudiosink")
|
||||
|
||||
|
||||
class PlayerRecoveryTests(unittest.TestCase):
|
||||
def test_worker_diagnostic_marks_restart_required(self):
|
||||
player = sound.Player()
|
||||
|
||||
player._handleWorkerDiagnostic("RECOVERY REQUIRED: lost audio sink")
|
||||
|
||||
self.assertTrue(player._workerRestartRequired)
|
||||
self.assertEqual(player._workerRestartReason, "lost audio sink")
|
||||
|
||||
def test_ensure_worker_restarts_when_recovery_is_required(self):
|
||||
player = sound.Player()
|
||||
player._workerProcess = _FakeProcess()
|
||||
player._workerSink = settings.SOUND_SINK_AUTO
|
||||
player._workerRestartRequired = True
|
||||
player._workerRestartReason = "lost audio sink"
|
||||
|
||||
stopReasons = []
|
||||
startedSinks = []
|
||||
|
||||
with (
|
||||
mock.patch.object(
|
||||
sound_sink,
|
||||
"get_configured_sound_sink",
|
||||
return_value=settings.SOUND_SINK_AUTO,
|
||||
),
|
||||
mock.patch.object(
|
||||
player,
|
||||
"_stopWorkerLocked",
|
||||
side_effect=lambda reason: stopReasons.append(reason),
|
||||
),
|
||||
mock.patch.object(
|
||||
player,
|
||||
"_startWorkerLocked",
|
||||
side_effect=lambda configuredSink: startedSinks.append(configuredSink) or True,
|
||||
),
|
||||
):
|
||||
self.assertTrue(player._ensureWorkerLocked())
|
||||
|
||||
self.assertEqual(stopReasons, ["lost audio sink"])
|
||||
self.assertEqual(startedSinks, [settings.SOUND_SINK_AUTO])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,85 @@
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gdk", "3.0")
|
||||
gi.require_version("Gtk", "3.0")
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
|
||||
soundGeneratorModule = sys.modules.get("cthulhu.sound_generator")
|
||||
if soundGeneratorModule is not None and not hasattr(soundGeneratorModule, "SoundGenerator"):
|
||||
class _StubSoundGenerator:
|
||||
pass
|
||||
|
||||
soundGeneratorModule.SoundGenerator = _StubSoundGenerator
|
||||
|
||||
from cthulhu.ax_object import AXObject
|
||||
from cthulhu.ax_utilities import AXUtilities
|
||||
from cthulhu.scripts.apps.steamwebhelper import script as steam_script
|
||||
|
||||
|
||||
class SteamNotificationRootTests(unittest.TestCase):
|
||||
def test_prefers_notification_ancestor_over_live_region_descendant(self):
|
||||
testScript = steam_script.Script.__new__(steam_script.Script)
|
||||
fragment = object()
|
||||
notification = object()
|
||||
|
||||
def get_attribute(obj, name):
|
||||
if obj is fragment and name == "container-live":
|
||||
return "assertive"
|
||||
return None
|
||||
|
||||
def find_ancestor(obj, predicate):
|
||||
if obj is fragment and predicate(notification):
|
||||
return notification
|
||||
return None
|
||||
|
||||
with (
|
||||
mock.patch.object(AXUtilities, "is_notification", side_effect=lambda obj: obj is notification),
|
||||
mock.patch.object(AXUtilities, "is_alert", return_value=False),
|
||||
mock.patch.object(AXObject, "get_attribute", side_effect=get_attribute),
|
||||
mock.patch.object(AXObject, "find_ancestor", side_effect=find_ancestor),
|
||||
):
|
||||
result = testScript._findSteamNotificationRoot(fragment)
|
||||
|
||||
self.assertIs(result, notification)
|
||||
|
||||
|
||||
class SteamNotificationQueueTests(unittest.TestCase):
|
||||
def test_merges_non_overlapping_fragments_for_same_notification(self):
|
||||
testScript = steam_script.Script.__new__(steam_script.Script)
|
||||
notification = object()
|
||||
|
||||
testScript._lastSteamNotification = ("", 0.0)
|
||||
testScript._steamPendingNotification = None
|
||||
testScript._steamRelativeTimePattern = re.compile(
|
||||
r"^(?:just now|now|\d+\s+(?:second|minute|hour|day|week|month|year)s?\s+ago)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
testScript._resetSteamPendingTimer = mock.Mock()
|
||||
testScript._presentSteamNotificationTextNow = mock.Mock()
|
||||
|
||||
testScript._queueSteamNotification("username", notification)
|
||||
testScript._queueSteamNotification("Playing: Borderlands 2", notification)
|
||||
|
||||
testScript._presentSteamNotificationTextNow.assert_not_called()
|
||||
self.assertEqual(
|
||||
testScript._steamPendingNotification["text"],
|
||||
"username Playing: Game Title",
|
||||
)
|
||||
|
||||
testScript._flushSteamPendingNotification(fromTimer=True)
|
||||
|
||||
testScript._presentSteamNotificationTextNow.assert_called_once_with(
|
||||
"username Playing: Game Title",
|
||||
notification,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,112 @@
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gdk", "3.0")
|
||||
gi.require_version("Gtk", "3.0")
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
|
||||
soundGeneratorModule = sys.modules.get("cthulhu.sound_generator")
|
||||
if soundGeneratorModule is not None and not hasattr(soundGeneratorModule, "SoundGenerator"):
|
||||
class _StubSoundGenerator:
|
||||
pass
|
||||
|
||||
soundGeneratorModule.SoundGenerator = _StubSoundGenerator
|
||||
|
||||
from cthulhu import ax_object
|
||||
from cthulhu import ax_utilities
|
||||
from cthulhu.scripts.web import script as web_script
|
||||
from cthulhu.scripts.web import script_utilities
|
||||
|
||||
|
||||
class WebClickableActivationTests(unittest.TestCase):
|
||||
def test_return_activates_click_ancestor_on_caret_context(self):
|
||||
testScript = web_script.Script.__new__(web_script.Script)
|
||||
caretObject = object()
|
||||
documentObject = object()
|
||||
|
||||
testScript.utilities = mock.Mock()
|
||||
testScript.utilities.inDocumentContent.return_value = True
|
||||
testScript.utilities.getCaretContext.return_value = (caretObject, 0)
|
||||
testScript.utilities.isClickableElement.return_value = False
|
||||
testScript.inFocusMode = mock.Mock(return_value=False)
|
||||
testScript.presentMessage = mock.Mock()
|
||||
testScript._presentDelayedMessage = mock.Mock()
|
||||
testScript._restoreFocusAfterClick = mock.Mock()
|
||||
|
||||
def has_action(obj, action_name):
|
||||
return obj is caretObject and action_name == "click-ancestor"
|
||||
|
||||
with (
|
||||
mock.patch.object(web_script.cthulhu_state, "locusOfFocus", documentObject),
|
||||
mock.patch.object(ax_utilities.AXUtilities, "is_entry", return_value=False),
|
||||
mock.patch.object(ax_utilities.AXUtilities, "is_text", return_value=False),
|
||||
mock.patch.object(ax_utilities.AXUtilities, "is_password_text", return_value=False),
|
||||
mock.patch.object(ax_utilities.AXUtilities, "is_combo_box", return_value=False),
|
||||
mock.patch.object(ax_utilities.AXUtilities, "is_button", return_value=False),
|
||||
mock.patch.object(ax_utilities.AXUtilities, "is_push_button", return_value=False),
|
||||
mock.patch.object(ax_utilities.AXUtilities, "is_link", return_value=False),
|
||||
mock.patch.object(ax_object.AXObject, "has_action", side_effect=has_action),
|
||||
mock.patch.object(ax_object.AXObject, "do_named_action", return_value=True) as doAction,
|
||||
mock.patch("cthulhu.ax_event_synthesizer.AXEventSynthesizer.click_object") as clickObject,
|
||||
):
|
||||
self.assertTrue(testScript._tryClickableActivation(mock.Mock(event_string="Return")))
|
||||
|
||||
doAction.assert_called_once_with(caretObject, "click-ancestor")
|
||||
clickObject.assert_not_called()
|
||||
|
||||
|
||||
class WebHiddenPopupTests(unittest.TestCase):
|
||||
def test_hidden_ancestor_marks_descendant_hidden(self):
|
||||
utilities = script_utilities.Utilities.__new__(script_utilities.Utilities)
|
||||
child = object()
|
||||
hiddenParent = object()
|
||||
|
||||
utilities.inDocumentContent = mock.Mock(return_value=True)
|
||||
utilities.objectAttributes = mock.Mock(
|
||||
side_effect=lambda obj, useCache=False: {
|
||||
child: {"display": "block"},
|
||||
hiddenParent: {"display": "none"},
|
||||
}.get(obj, {})
|
||||
)
|
||||
|
||||
with mock.patch.object(
|
||||
script_utilities.AXObject,
|
||||
"get_parent",
|
||||
side_effect=lambda obj: hiddenParent if obj is child else None,
|
||||
):
|
||||
self.assertTrue(utilities.isHidden(child))
|
||||
|
||||
def test_focusable_hidden_object_cannot_have_caret_context(self):
|
||||
utilities = script_utilities.Utilities.__new__(script_utilities.Utilities)
|
||||
hiddenObject = object()
|
||||
|
||||
utilities._canHaveCaretContextDecision = {}
|
||||
utilities.isZombie = mock.Mock(return_value=False)
|
||||
utilities.isStaticTextLeaf = mock.Mock(return_value=False)
|
||||
utilities.isUselessEmptyElement = mock.Mock(return_value=False)
|
||||
utilities.isOffScreenLabel = mock.Mock(return_value=False)
|
||||
utilities.isNonNavigablePopup = mock.Mock(return_value=False)
|
||||
utilities.isUselessImage = mock.Mock(return_value=False)
|
||||
utilities.isEmptyAnchor = mock.Mock(return_value=False)
|
||||
utilities.isEmptyToolTip = mock.Mock(return_value=False)
|
||||
utilities.isParentOfNullChild = mock.Mock(return_value=False)
|
||||
utilities.isPseudoElement = mock.Mock(return_value=False)
|
||||
utilities.isFakePlaceholderForEntry = mock.Mock(return_value=False)
|
||||
utilities.isNonInteractiveDescendantOfControl = mock.Mock(return_value=False)
|
||||
utilities.isHidden = mock.Mock(return_value=True)
|
||||
utilities.hasNoSize = mock.Mock(return_value=False)
|
||||
|
||||
with (
|
||||
mock.patch.object(script_utilities.AXObject, "is_dead", return_value=False),
|
||||
mock.patch.object(script_utilities.AXUtilities, "is_focusable", return_value=True),
|
||||
):
|
||||
self.assertFalse(utilities._canHaveCaretContext(hiddenObject))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user