More work on sound support.

This commit is contained in:
Storm Dragon
2025-12-29 19:23:20 -05:00
parent b818b685bd
commit 97c9253372
11 changed files with 244 additions and 6 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
# Maintainer: Storm Dragon <storm_dragon@stormux.org> # Maintainer: Storm Dragon <storm_dragon@stormux.org>
pkgname=cthulhu pkgname=cthulhu
pkgver=2025.12.27 pkgver=2025.12.28
pkgrel=1 pkgrel=1
pkgdesc="Desktop-agnostic screen reader with plugin system, forked from Orca" pkgdesc="Desktop-agnostic screen reader with plugin system, forked from Orca"
url="https://git.stormux.org/storm/cthulhu" url="https://git.stormux.org/storm/cthulhu"
+1 -1
View File
@@ -1,5 +1,5 @@
project('cthulhu', project('cthulhu',
version: '2025.12.28-testing', version: '2025.12.29-testing',
meson_version: '>= 1.0.0', meson_version: '>= 1.0.0',
) )
Binary file not shown.
+45
View File
@@ -1097,6 +1097,51 @@
<property name="position">1</property> <property name="position">1</property>
</packing> </packing>
</child> </child>
<child>
<object class="GtkBox" id="roleSoundPresentationHBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="spacing">12</property>
<child>
<object class="GtkLabel" id="roleSoundPresentationLabel">
<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 how Cthulhu presents element roles when a role sound is available.">Element _presentation:</property>
<property name="use_underline">True</property>
<property name="mnemonic_widget">roleSoundPresentationCombo</property>
<accessibility>
<relation type="label-for" target="roleSoundPresentationCombo"/>
</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="roleSoundPresentationCombo">
<property name="visible">True</property>
<property name="can_focus">False</property>
<signal name="changed" handler="roleSoundPresentationComboChanged" swapped="no"/>
<accessibility>
<relation type="labelled-by" target="roleSoundPresentationLabel"/>
</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">2</property>
</packing>
</child>
</object> </object>
</child> </child>
</object> </object>
+1 -1
View File
@@ -23,5 +23,5 @@
# Forked from Orca screen reader. # Forked from Orca screen reader.
# Cthulhu project: https://git.stormux.org/storm/cthulhu # Cthulhu project: https://git.stormux.org/storm/cthulhu
version = "2025.12.28" version = "2025.12.29"
codeName = "testing" codeName = "testing"
+28
View File
@@ -2007,6 +2007,7 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
self.enableModeChangeSoundCheckButton = self.get_widget( self.enableModeChangeSoundCheckButton = self.get_widget(
"enableModeChangeSoundCheckButton") "enableModeChangeSoundCheckButton")
self.soundThemeCombo = self.get_widget("soundThemeCombo") self.soundThemeCombo = self.get_widget("soundThemeCombo")
self.roleSoundPresentationCombo = self.get_widget("roleSoundPresentationCombo")
# Set enable mode change sound checkbox # Set enable mode change sound checkbox
enabled = prefs.get("enableModeChangeSound", settings.enableModeChangeSound) enabled = prefs.get("enableModeChangeSound", settings.enableModeChangeSound)
@@ -2035,6 +2036,25 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
else: else:
self.soundThemeCombo.set_active(0) 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 # Update sensitivity based on checkbox
self._updateSoundThemeWidgetSensitivity() self._updateSoundThemeWidgetSensitivity()
@@ -2054,6 +2074,14 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
if activeText: if activeText:
self.prefsDict["soundTheme"] = 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): def _updateCthulhuModifier(self):
combobox = self.get_widget("cthulhuModifierComboBox") combobox = self.get_widget("cthulhuModifierComboBox")
keystring = ", ".join(self.prefsDict["cthulhuModifierKeys"]) keystring = ", ".join(self.prefsDict["cthulhuModifierKeys"])
+18 -2
View File
@@ -43,6 +43,7 @@ from cthulhu import object_properties
from cthulhu import cthulhu_state from cthulhu import cthulhu_state
from cthulhu import settings from cthulhu import settings
from cthulhu import settings_manager from cthulhu import settings_manager
from cthulhu import sound_theme_manager
from cthulhu import speech_generator from cthulhu import speech_generator
from cthulhu.ax_object import AXObject from cthulhu.ax_object import AXObject
from cthulhu.ax_text import AXText from cthulhu.ax_text import AXText
@@ -550,7 +551,6 @@ class SpeechGenerator(speech_generator.SpeechGenerator):
if roledescription: if roledescription:
result = [roledescription] result = [roledescription]
result.extend(self.voice(speech_generator.SYSTEM, obj=obj, **args)) result.extend(self.voice(speech_generator.SYSTEM, obj=obj, **args))
return result
role = args.get('role', AXObject.get_role(obj)) role = args.get('role', AXObject.get_role(obj))
enabled, disabled = self._getEnabledAndDisabledContextRoles() 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)): and (index == total - 1 or AXObject.get_name(obj) == AXObject.get_name(ancestor)):
result.extend(self._generateRoleName(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): def _generatePageSummary(self, obj, **args):
if not self._script.utilities.inDocumentContent(obj): if not self._script.utilities.inDocumentContent(obj):
+6
View File
@@ -78,6 +78,7 @@ userCustomizableSettings = [
"playSoundForState", "playSoundForState",
"playSoundForPositionInSet", "playSoundForPositionInSet",
"playSoundForValue", "playSoundForValue",
"roleSoundPresentation",
"soundTheme", "soundTheme",
"enableModeChangeSound", "enableModeChangeSound",
"verbalizePunctuationStyle", "verbalizePunctuationStyle",
@@ -309,12 +310,17 @@ textAttributesBrailleIndicator = BRAILLE_UNDERLINE_NONE
brailleVerbosityLevel = VERBOSITY_LEVEL_VERBOSE brailleVerbosityLevel = VERBOSITY_LEVEL_VERBOSE
# Sound # 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 enableSound = True
soundVolume = 0.5 soundVolume = 0.5
playSoundForRole = False playSoundForRole = False
playSoundForState = False playSoundForState = False
playSoundForPositionInSet = False playSoundForPositionInSet = False
playSoundForValue = False playSoundForValue = False
roleSoundPresentation = ROLE_SOUND_PRESENTATION_SOUND_AND_SPEECH
soundTheme = "default" soundTheme = "default"
enableModeChangeSound = True enableModeChangeSound = True
+77
View File
@@ -38,6 +38,7 @@ __license__ = "LGPL"
import os import os
from gi.repository import GLib from gi.repository import GLib
from gi.repository import Atspi
from . import debug from . import debug
from . import settings_manager from . import settings_manager
@@ -53,6 +54,28 @@ SOUND_BUTTON = "button"
SOUND_START = "start" SOUND_START = "start"
SOUND_STOP = "stop" 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 # Special theme name for no sounds
THEME_NONE = "none" THEME_NONE = "none"
@@ -146,6 +169,60 @@ class SoundThemeManager:
return None 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, def _playThemeSound(self, soundName, interrupt=True, wait=False,
requireModeChangeSetting=False, requireSoundSetting=False): requireModeChangeSetting=False, requireSoundSetting=False):
"""Play a themed sound with optional gating and blocking. """Play a themed sound with optional gating and blocking.
+11 -1
View File
@@ -39,6 +39,8 @@ from . import debug
from . import logger from . import logger
from . import settings from . import settings
from . import speech_generator from . import speech_generator
from . import sound
from .sound_generator import Icon
from .speechserver import VoiceFamily from .speechserver import VoiceFamily
from .acss import ACSS from .acss import ACSS
@@ -240,7 +242,7 @@ def speak(content, acss=None, interrupt=True):
return return
validTypes = (str, list, speech_generator.Pause, validTypes = (str, list, speech_generator.Pause,
speech_generator.LineBreak, ACSS) speech_generator.LineBreak, ACSS, Icon)
error = "SPEECH: bad content sent to speak(): '%s'" error = "SPEECH: bad content sent to speak(): '%s'"
if not isinstance(content, validTypes): if not isinstance(content, validTypes):
debug.printMessage(debug.LEVEL_INFO, error % content, True) debug.printMessage(debug.LEVEL_INFO, error % content, True)
@@ -277,6 +279,14 @@ def speak(content, acss=None, interrupt=True):
elif isinstance(element, str): elif isinstance(element, str):
if len(element): if len(element):
toSpeak.append(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: elif toSpeak:
newVoice = ACSS(acss) newVoice = ACSS(acss)
newItemsToSpeak = [] newItemsToSpeak = []
+56
View File
@@ -49,6 +49,7 @@ from . import messages
from . import object_properties from . import object_properties
from . import settings from . import settings
from . import settings_manager from . import settings_manager
from . import sound_theme_manager
from . import speech from . import speech
from . import text_attribute_names from . import text_attribute_names
from .ax_object import AXObject from .ax_object import AXObject
@@ -579,6 +580,11 @@ class SpeechGenerator(generator.Generator):
result = [] result = []
role = args.get('role', AXObject.get_role(obj)) 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, doNotPresent = [Atspi.Role.UNKNOWN,
Atspi.Role.REDUNDANT_OBJECT, Atspi.Role.REDUNDANT_OBJECT,
@@ -620,6 +626,13 @@ class SpeechGenerator(generator.Generator):
if role not in doNotPresent and not result: if role not in doNotPresent and not result:
result.append(self.getLocalizedRoleName(obj, **args)) result.append(self.getLocalizedRoleName(obj, **args))
result.extend(self.voice(SYSTEM, obj=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 return result
def getRoleName(self, obj, **args): 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): def _generateCheckedState(self, obj, **args):
"""Returns an array of strings for use by speech and braille that """Returns an array of strings for use by speech and braille that
represent the checked state of the object. This is typically 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) result = generator.Generator._generateCheckedState(self, obj, **args)
if result: if result:
result.extend(self.voice(STATE, obj=obj, **args)) 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 return result
def _generateExpandableState(self, obj, **args): def _generateExpandableState(self, obj, **args):
@@ -742,6 +782,11 @@ class SpeechGenerator(generator.Generator):
_generateMenuItemCheckedState(self, obj, **args) _generateMenuItemCheckedState(self, obj, **args)
if result: if result:
result.extend(self.voice(STATE, obj=obj, **args)) result.extend(self.voice(STATE, obj=obj, **args))
result = self._applyStateSound(
result,
Atspi.Role.CHECK_MENU_ITEM,
"checked"
)
return result return result
def _generateMultiselectableState(self, obj, **args): def _generateMultiselectableState(self, obj, **args):
@@ -770,6 +815,9 @@ class SpeechGenerator(generator.Generator):
result = generator.Generator._generateRadioState(self, obj, **args) result = generator.Generator._generateRadioState(self, obj, **args)
if result: if result:
result.extend(self.voice(STATE, obj=obj, **args)) 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 return result
def _generateSwitchState(self, obj, **args): def _generateSwitchState(self, obj, **args):
@@ -780,6 +828,10 @@ class SpeechGenerator(generator.Generator):
result = generator.Generator._generateSwitchState(self, obj, **args) result = generator.Generator._generateSwitchState(self, obj, **args)
if result: if result:
result.extend(self.voice(STATE, obj=obj, **args)) 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 return result
def _generateToggleState(self, obj, **args): def _generateToggleState(self, obj, **args):
@@ -794,6 +846,10 @@ class SpeechGenerator(generator.Generator):
result = generator.Generator._generateToggleState(self, obj, **args) result = generator.Generator._generateToggleState(self, obj, **args)
if result: if result:
result.extend(self.voice(STATE, obj=obj, **args)) 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 return result
##################################################################### #####################################################################