More work on sound support.
This commit is contained in:
@@ -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
@@ -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.
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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 = []
|
||||
|
||||
@@ -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
|
||||
|
||||
#####################################################################
|
||||
|
||||
Reference in New Issue
Block a user