diff --git a/distro-packages/Arch-Linux/PKGBUILD b/distro-packages/Arch-Linux/PKGBUILD index 435cdd0..be65c82 100644 --- a/distro-packages/Arch-Linux/PKGBUILD +++ b/distro-packages/Arch-Linux/PKGBUILD @@ -1,7 +1,7 @@ # Maintainer: Storm Dragon pkgname=cthulhu -pkgver=2025.12.27 +pkgver=2025.12.28 pkgrel=1 pkgdesc="Desktop-agnostic screen reader with plugin system, forked from Orca" url="https://git.stormux.org/storm/cthulhu" diff --git a/meson.build b/meson.build index 53233f1..e105bc3 100644 --- a/meson.build +++ b/meson.build @@ -1,5 +1,5 @@ project('cthulhu', - version: '2025.12.28-testing', + version: '2025.12.29-testing', meson_version: '>= 1.0.0', ) diff --git a/sounds/default/button.wav b/sounds/default/button.wav new file mode 100644 index 0000000..0bf8523 Binary files /dev/null and b/sounds/default/button.wav differ diff --git a/src/cthulhu/cthulhu-setup.ui b/src/cthulhu/cthulhu-setup.ui index 04dc7fa..f12d146 100644 --- a/src/cthulhu/cthulhu-setup.ui +++ b/src/cthulhu/cthulhu-setup.ui @@ -1097,6 +1097,51 @@ 1 + + + True + False + 12 + + + True + False + 0 + Element _presentation: + True + roleSoundPresentationCombo + + + + + + False + True + 0 + + + + + True + False + + + + + + + False + True + 1 + + + + + False + True + 2 + + diff --git a/src/cthulhu/cthulhuVersion.py b/src/cthulhu/cthulhuVersion.py index 1ff8320..51fa8f9 100644 --- a/src/cthulhu/cthulhuVersion.py +++ b/src/cthulhu/cthulhuVersion.py @@ -23,5 +23,5 @@ # Forked from Orca screen reader. # Cthulhu project: https://git.stormux.org/storm/cthulhu -version = "2025.12.28" +version = "2025.12.29" codeName = "testing" diff --git a/src/cthulhu/cthulhu_gui_prefs.py b/src/cthulhu/cthulhu_gui_prefs.py index 9d86efb..ff9e7e5 100644 --- a/src/cthulhu/cthulhu_gui_prefs.py +++ b/src/cthulhu/cthulhu_gui_prefs.py @@ -2007,6 +2007,7 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): self.enableModeChangeSoundCheckButton = self.get_widget( "enableModeChangeSoundCheckButton") self.soundThemeCombo = self.get_widget("soundThemeCombo") + self.roleSoundPresentationCombo = self.get_widget("roleSoundPresentationCombo") # Set enable mode change sound checkbox enabled = prefs.get("enableModeChangeSound", settings.enableModeChangeSound) @@ -2035,6 +2036,25 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): else: self.soundThemeCombo.set_active(0) + self._roleSoundPresentationChoices = [ + (settings.ROLE_SOUND_PRESENTATION_SOUND_AND_SPEECH, "Sound and speech"), + (settings.ROLE_SOUND_PRESENTATION_SPEECH_ONLY, "Speech only"), + (settings.ROLE_SOUND_PRESENTATION_SOUND_ONLY, "Sound only"), + ] + self.roleSoundPresentationCombo.remove_all() + for _, label in self._roleSoundPresentationChoices: + self.roleSoundPresentationCombo.append_text(label) + currentPresentation = prefs.get( + "roleSoundPresentation", + settings.roleSoundPresentation + ) + presentationIndex = 0 + for index, (value, _) in enumerate(self._roleSoundPresentationChoices): + if value == currentPresentation: + presentationIndex = index + break + self.roleSoundPresentationCombo.set_active(presentationIndex) + # Update sensitivity based on checkbox self._updateSoundThemeWidgetSensitivity() @@ -2054,6 +2074,14 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): if activeText: self.prefsDict["soundTheme"] = activeText + def roleSoundPresentationComboChanged(self, widget): + """Signal handler for the role sound presentation combo box.""" + activeIndex = widget.get_active() + if activeIndex < 0: + return + value = self._roleSoundPresentationChoices[activeIndex][0] + self.prefsDict["roleSoundPresentation"] = value + def _updateCthulhuModifier(self): combobox = self.get_widget("cthulhuModifierComboBox") keystring = ", ".join(self.prefsDict["cthulhuModifierKeys"]) diff --git a/src/cthulhu/scripts/web/speech_generator.py b/src/cthulhu/scripts/web/speech_generator.py index d21c176..fcf95c7 100644 --- a/src/cthulhu/scripts/web/speech_generator.py +++ b/src/cthulhu/scripts/web/speech_generator.py @@ -43,6 +43,7 @@ from cthulhu import object_properties from cthulhu import cthulhu_state from cthulhu import settings from cthulhu import settings_manager +from cthulhu import sound_theme_manager from cthulhu import speech_generator from cthulhu.ax_object import AXObject from cthulhu.ax_text import AXText @@ -550,7 +551,6 @@ class SpeechGenerator(speech_generator.SpeechGenerator): if roledescription: result = [roledescription] result.extend(self.voice(speech_generator.SYSTEM, obj=obj, **args)) - return result role = args.get('role', AXObject.get_role(obj)) enabled, disabled = self._getEnabledAndDisabledContextRoles() @@ -656,7 +656,23 @@ class SpeechGenerator(speech_generator.SpeechGenerator): and (index == total - 1 or AXObject.get_name(obj) == AXObject.get_name(ancestor)): result.extend(self._generateRoleName(ancestor)) - return result + if not result: + return result + + roleSoundPresentation = _settingsManager.getSetting('roleSoundPresentation') + if roleSoundPresentation == settings.ROLE_SOUND_PRESENTATION_SPEECH_ONLY: + return result + if not _settingsManager.getSetting('enableSound'): + return result + + roleSoundIcon = sound_theme_manager.getManager().getRoleSoundIcon(role) + if not roleSoundIcon: + return result + + if roleSoundPresentation == settings.ROLE_SOUND_PRESENTATION_SOUND_ONLY: + return [roleSoundIcon] + + return [roleSoundIcon] + result def _generatePageSummary(self, obj, **args): if not self._script.utilities.inDocumentContent(obj): diff --git a/src/cthulhu/settings.py b/src/cthulhu/settings.py index ab18cb4..5a2cad0 100644 --- a/src/cthulhu/settings.py +++ b/src/cthulhu/settings.py @@ -78,6 +78,7 @@ userCustomizableSettings = [ "playSoundForState", "playSoundForPositionInSet", "playSoundForValue", + "roleSoundPresentation", "soundTheme", "enableModeChangeSound", "verbalizePunctuationStyle", @@ -309,12 +310,17 @@ textAttributesBrailleIndicator = BRAILLE_UNDERLINE_NONE brailleVerbosityLevel = VERBOSITY_LEVEL_VERBOSE # Sound +ROLE_SOUND_PRESENTATION_SOUND_AND_SPEECH = "sound_and_speech" +ROLE_SOUND_PRESENTATION_SPEECH_ONLY = "speech_only" +ROLE_SOUND_PRESENTATION_SOUND_ONLY = "sound_only" + enableSound = True soundVolume = 0.5 playSoundForRole = False playSoundForState = False playSoundForPositionInSet = False playSoundForValue = False +roleSoundPresentation = ROLE_SOUND_PRESENTATION_SOUND_AND_SPEECH soundTheme = "default" enableModeChangeSound = True diff --git a/src/cthulhu/sound_theme_manager.py b/src/cthulhu/sound_theme_manager.py index 58c2dde..0625227 100644 --- a/src/cthulhu/sound_theme_manager.py +++ b/src/cthulhu/sound_theme_manager.py @@ -38,6 +38,7 @@ __license__ = "LGPL" import os from gi.repository import GLib +from gi.repository import Atspi from . import debug from . import settings_manager @@ -53,6 +54,28 @@ SOUND_BUTTON = "button" SOUND_START = "start" SOUND_STOP = "stop" +ROLE_SOUND_ALIASES = { + Atspi.Role.PUSH_BUTTON: ["button", "push_button"], + Atspi.Role.TOGGLE_BUTTON: ["toggle_button", "button"], + Atspi.Role.RADIO_BUTTON: ["radio_button", "radio"], + Atspi.Role.CHECK_BOX: ["checkbox", "check_box"], +} + +ROLE_STATE_SOUND_BASES = { + Atspi.Role.CHECK_BOX: ["checkbox", "check_box"], + Atspi.Role.CHECK_MENU_ITEM: ["checkbox", "check_box"], + Atspi.Role.RADIO_BUTTON: ["radiobutton", "radio_button"], + Atspi.Role.RADIO_MENU_ITEM: ["radiobutton", "radio_button"], + Atspi.Role.TOGGLE_BUTTON: ["togglebutton", "toggle_button"], + Atspi.Role.SWITCH: ["switch"], +} + +STATE_SOUND_SUFFIXES = { + "checked": ["checked", "on", "pressed", "selected"], + "unchecked": ["unchecked", "off", "not_pressed", "unselected"], + "mixed": ["mixed", "partially_checked", "indeterminate"], +} + # Special theme name for no sounds THEME_NONE = "none" @@ -146,6 +169,60 @@ class SoundThemeManager: return None + def _getRoleSoundCandidates(self, role): + """Return candidate sound names for a given role.""" + candidates = ROLE_SOUND_ALIASES.get(role, []) + if candidates: + return candidates + + roleName = Atspi.role_get_name(role) + if roleName: + return [roleName.replace(' ', '_')] + + return [] + + def _getRoleStateSoundCandidates(self, role, stateKey): + """Return candidate sound names for a given role/state pair.""" + bases = ROLE_STATE_SOUND_BASES.get(role, []) + if not bases: + return [] + + suffixes = STATE_SOUND_SUFFIXES.get(stateKey, []) + if not suffixes: + return [] + + candidates = [] + for base in bases: + for suffix in suffixes: + candidates.append(f"{base}_{suffix}") + return candidates + + def getRoleSoundIcon(self, role, themeName=None): + """Return an Icon for the role sound from the current theme, if any.""" + themeName = themeName or _settingsManager.getSetting('soundTheme') or 'default' + if themeName == THEME_NONE: + return None + + for candidate in self._getRoleSoundCandidates(role): + soundPath = self.getSoundPath(themeName, candidate) + if soundPath: + return Icon(os.path.dirname(soundPath), os.path.basename(soundPath)) + + return None + + def getRoleStateSoundIcon(self, role, stateKey, themeName=None): + """Return an Icon for the role/state sound from the current theme, if any.""" + themeName = themeName or _settingsManager.getSetting('soundTheme') or 'default' + if themeName == THEME_NONE: + return None + + for candidate in self._getRoleStateSoundCandidates(role, stateKey): + soundPath = self.getSoundPath(themeName, candidate) + if soundPath: + return Icon(os.path.dirname(soundPath), os.path.basename(soundPath)) + + return None + def _playThemeSound(self, soundName, interrupt=True, wait=False, requireModeChangeSetting=False, requireSoundSetting=False): """Play a themed sound with optional gating and blocking. diff --git a/src/cthulhu/speech.py b/src/cthulhu/speech.py index 3b394a0..eabffb9 100644 --- a/src/cthulhu/speech.py +++ b/src/cthulhu/speech.py @@ -39,6 +39,8 @@ from . import debug from . import logger from . import settings from . import speech_generator +from . import sound +from .sound_generator import Icon from .speechserver import VoiceFamily from .acss import ACSS @@ -240,7 +242,7 @@ def speak(content, acss=None, interrupt=True): return validTypes = (str, list, speech_generator.Pause, - speech_generator.LineBreak, ACSS) + speech_generator.LineBreak, ACSS, Icon) error = "SPEECH: bad content sent to speak(): '%s'" if not isinstance(content, validTypes): debug.printMessage(debug.LEVEL_INFO, error % content, True) @@ -277,6 +279,14 @@ def speak(content, acss=None, interrupt=True): elif isinstance(element, str): if len(element): toSpeak.append(element) + elif isinstance(element, Icon): + if toSpeak: + string = " ".join(toSpeak) + _speak(string, activeVoice, interrupt) + toSpeak = [] + if element.isValid(): + player = sound.getPlayer() + player.play(element, interrupt=False) elif toSpeak: newVoice = ACSS(acss) newItemsToSpeak = [] diff --git a/src/cthulhu/speech_generator.py b/src/cthulhu/speech_generator.py index 50495a1..03d9988 100644 --- a/src/cthulhu/speech_generator.py +++ b/src/cthulhu/speech_generator.py @@ -49,6 +49,7 @@ from . import messages from . import object_properties from . import settings from . import settings_manager +from . import sound_theme_manager from . import speech from . import text_attribute_names from .ax_object import AXObject @@ -579,6 +580,11 @@ class SpeechGenerator(generator.Generator): result = [] role = args.get('role', AXObject.get_role(obj)) + roleSoundPresentation = _settingsManager.getSetting('roleSoundPresentation') + roleSoundIcon = None + if roleSoundPresentation != settings.ROLE_SOUND_PRESENTATION_SPEECH_ONLY \ + and _settingsManager.getSetting('enableSound'): + roleSoundIcon = sound_theme_manager.getManager().getRoleSoundIcon(role) doNotPresent = [Atspi.Role.UNKNOWN, Atspi.Role.REDUNDANT_OBJECT, @@ -620,6 +626,13 @@ class SpeechGenerator(generator.Generator): if role not in doNotPresent and not result: result.append(self.getLocalizedRoleName(obj, **args)) result.extend(self.voice(SYSTEM, obj=obj, **args)) + + if result and roleSoundIcon: + if roleSoundPresentation == settings.ROLE_SOUND_PRESENTATION_SOUND_ONLY: + return [roleSoundIcon] + if roleSoundPresentation == settings.ROLE_SOUND_PRESENTATION_SOUND_AND_SPEECH: + return [roleSoundIcon] + result + return result def getRoleName(self, obj, **args): @@ -693,6 +706,23 @@ class SpeechGenerator(generator.Generator): # # ##################################################################### + def _applyStateSound(self, result, role, stateKey): + if not result: + return result + + if _settingsManager.getSetting('roleSoundPresentation') \ + == settings.ROLE_SOUND_PRESENTATION_SPEECH_ONLY: + return result + + if not _settingsManager.getSetting('enableSound'): + return result + + icon = sound_theme_manager.getManager().getRoleStateSoundIcon(role, stateKey) + if not icon: + return result + + return [icon] + result + def _generateCheckedState(self, obj, **args): """Returns an array of strings for use by speech and braille that represent the checked state of the object. This is typically @@ -705,6 +735,16 @@ class SpeechGenerator(generator.Generator): result = generator.Generator._generateCheckedState(self, obj, **args) if result: result.extend(self.voice(STATE, obj=obj, **args)) + role = args.get('role', AXObject.get_role(obj)) + if AXUtilities.is_checkable(obj) or AXUtilities.is_check_menu_item(obj): + role = Atspi.Role.CHECK_BOX + if AXUtilities.is_indeterminate(obj): + stateKey = "mixed" + elif AXUtilities.is_checked(obj): + stateKey = "checked" + else: + stateKey = "unchecked" + result = self._applyStateSound(result, role, stateKey) return result def _generateExpandableState(self, obj, **args): @@ -742,6 +782,11 @@ class SpeechGenerator(generator.Generator): _generateMenuItemCheckedState(self, obj, **args) if result: result.extend(self.voice(STATE, obj=obj, **args)) + result = self._applyStateSound( + result, + Atspi.Role.CHECK_MENU_ITEM, + "checked" + ) return result def _generateMultiselectableState(self, obj, **args): @@ -770,6 +815,9 @@ class SpeechGenerator(generator.Generator): result = generator.Generator._generateRadioState(self, obj, **args) if result: result.extend(self.voice(STATE, obj=obj, **args)) + stateKey = "checked" if AXUtilities.is_checked(obj) else "unchecked" + role = args.get('role', AXObject.get_role(obj)) + result = self._applyStateSound(result, role, stateKey) return result def _generateSwitchState(self, obj, **args): @@ -780,6 +828,10 @@ class SpeechGenerator(generator.Generator): result = generator.Generator._generateSwitchState(self, obj, **args) if result: result.extend(self.voice(STATE, obj=obj, **args)) + stateKey = "checked" if (AXUtilities.is_checked(obj) or AXUtilities.is_pressed(obj)) \ + else "unchecked" + role = args.get('role', AXObject.get_role(obj)) + result = self._applyStateSound(result, role, stateKey) return result def _generateToggleState(self, obj, **args): @@ -794,6 +846,10 @@ class SpeechGenerator(generator.Generator): result = generator.Generator._generateToggleState(self, obj, **args) if result: result.extend(self.voice(STATE, obj=obj, **args)) + stateKey = "checked" if (AXUtilities.is_checked(obj) or AXUtilities.is_pressed(obj)) \ + else "unchecked" + role = args.get('role', AXObject.get_role(obj)) + result = self._applyStateSound(result, role, stateKey) return result #####################################################################