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>
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"
+1 -1
View File
@@ -1,5 +1,5 @@
project('cthulhu',
version: '2025.12.28-testing',
version: '2025.12.29-testing',
meson_version: '>= 1.0.0',
)
Binary file not shown.
+45
View File
@@ -1097,6 +1097,51 @@
<property name="position">1</property>
</packing>
</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>
</child>
</object>
+1 -1
View File
@@ -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"
+28
View File
@@ -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"])
+18 -2
View File
@@ -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):
+6
View File
@@ -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
+77
View File
@@ -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.
+11 -1
View File
@@ -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 = []
+56
View File
@@ -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
#####################################################################