Initial sound support added.

This commit is contained in:
Storm Dragon
2025-12-26 23:49:48 -05:00
parent 56a8d80edb
commit 66ece62423
10 changed files with 386 additions and 0 deletions
+1
View File
@@ -119,6 +119,7 @@ summary += {'Install dir': python.find_installation('python3').get_install_dir()
subdir('docs')
subdir('icons')
subdir('po')
subdir('sounds')
subdir('src')
summary(summary)
Binary file not shown.
Binary file not shown.
+99
View File
@@ -1018,6 +1018,105 @@
<property name="height">2</property>
</packing>
</child>
<child>
<object class="GtkFrame" id="soundThemeFrame">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="label_xalign">0</property>
<property name="shadow_type">none</property>
<child>
<object class="GtkAlignment" id="soundThemeAlignment">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="left_padding">12</property>
<child>
<object class="GtkBox" id="soundThemeVBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="orientation">vertical</property>
<property name="spacing">6</property>
<child>
<object class="GtkCheckButton" id="enableModeChangeSoundCheckButton">
<property name="label" translatable="yes" comments="Translators: If this checkbox is checked, Cthulhu will play sounds when switching between browse mode and focus mode in web content.">Play sounds when switching between _browse and focus modes</property>
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">False</property>
<property name="use_underline">True</property>
<property name="xalign">0</property>
<property name="draw_indicator">True</property>
<signal name="toggled" handler="enableModeChangeSoundCheckButtonToggled" swapped="no"/>
</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>
<property name="can_focus">False</property>
<property name="spacing">12</property>
<child>
<object class="GtkLabel" id="soundThemeLabel">
<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 select a sound theme.">Sound _theme:</property>
<property name="use_underline">True</property>
<property name="mnemonic_widget">soundThemeCombo</property>
<accessibility>
<relation type="label-for" target="soundThemeCombo"/>
</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="soundThemeCombo">
<property name="visible">True</property>
<property name="can_focus">False</property>
<signal name="changed" handler="soundThemeComboChanged" swapped="no"/>
<accessibility>
<relation type="labelled-by" target="soundThemeLabel"/>
</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">1</property>
</packing>
</child>
</object>
</child>
</object>
</child>
<child type="label">
<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>
<attributes>
<attribute name="weight" value="bold"/>
</attributes>
</object>
</child>
</object>
<packing>
<property name="left_attach">1</property>
<property name="top_attach">4</property>
</packing>
</child>
</object>
</child>
<child type="tab">
+57
View File
@@ -63,6 +63,7 @@ from . import braille
from . import speech
from . import speechserver
from . import text_attribute_names
from . import sound_theme_manager
from .ax_object import AXObject
_settingsManager = settings_manager.getManager()
@@ -368,6 +369,7 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
self.get_widget("notebook").append_page(appPage, label)
self._initGUIState()
self._initSoundThemeState()
def _getACSSForVoiceType(self, voiceType):
"""Return the ACSS value for the given voice type.
@@ -1991,6 +1993,61 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
copyToClipboard = prefs.get("ocrCopyToClipboard", settings.ocrCopyToClipboard)
self.ocrCopyToClipboardCheckButton.set_active(copyToClipboard)
def _initSoundThemeState(self):
"""Initialize Sound Theme widgets with current settings."""
prefs = self.prefsDict
# Get widget references
self.enableModeChangeSoundCheckButton = self.get_widget(
"enableModeChangeSoundCheckButton")
self.soundThemeCombo = self.get_widget("soundThemeCombo")
# Set enable mode change sound checkbox
enabled = prefs.get("enableModeChangeSound", settings.enableModeChangeSound)
self.enableModeChangeSoundCheckButton.set_active(enabled)
# Populate sound theme combo box
themeManager = sound_theme_manager.getManager()
availableThemes = themeManager.getAvailableThemes()
# Clear and populate combo - add "None" as first option
self.soundThemeCombo.remove_all()
self.soundThemeCombo.append_text(sound_theme_manager.THEME_NONE)
for theme in availableThemes:
self.soundThemeCombo.append_text(theme)
# Build the full list for index lookup
allThemes = [sound_theme_manager.THEME_NONE] + availableThemes
# Set active theme
currentTheme = prefs.get("soundTheme", settings.soundTheme)
if currentTheme in allThemes:
self.soundThemeCombo.set_active(allThemes.index(currentTheme))
elif len(allThemes) > 1:
# Default to first actual theme (skip "none")
self.soundThemeCombo.set_active(1)
else:
self.soundThemeCombo.set_active(0)
# Update sensitivity based on checkbox
self._updateSoundThemeWidgetSensitivity()
def _updateSoundThemeWidgetSensitivity(self):
"""Update sound theme combo sensitivity based on enable checkbox."""
enabled = self.enableModeChangeSoundCheckButton.get_active()
self.soundThemeCombo.set_sensitive(enabled)
def enableModeChangeSoundCheckButtonToggled(self, widget):
"""Signal handler for the enable mode change sound checkbox."""
self.prefsDict["enableModeChangeSound"] = widget.get_active()
self._updateSoundThemeWidgetSensitivity()
def soundThemeComboChanged(self, widget):
"""Signal handler for the sound theme combo box."""
activeText = widget.get_active_text()
if activeText:
self.prefsDict["soundTheme"] = activeText
def _updateCthulhuModifier(self):
combobox = self.get_widget("cthulhuModifierComboBox")
keystring = ", ".join(self.prefsDict["cthulhuModifierKeys"])
+14
View File
@@ -929,6 +929,20 @@ USE_CARET_NAVIGATION = _("Control caret navigation")
# of a checkbox in which users can indicate their default preference.
USE_STRUCTURAL_NAVIGATION = _("Enable _structural navigation")
# Translators: This is the label for a checkbox in the preferences dialog.
# When enabled, Cthulhu will play sounds when switching between browse mode
# and focus mode in web content.
ENABLE_MODE_CHANGE_SOUND = _("Play sounds when switching between _browse and focus modes")
# Translators: This is the label for a combo box in the preferences dialog
# where users can select a sound theme. A sound theme is a collection of
# audio files that Cthulhu plays for various events.
SOUND_THEME = _("Sound _theme:")
# Translators: This is the title of a frame in the preferences dialog
# containing sound theme options.
SOUND_THEME_TITLE = _("Sound Theme")
# Translators: This refers to the amount of information Cthulhu provides about a
# particular object that receives focus.
VERBOSITY_LEVEL_BRIEF = _("Brie_f")
+1
View File
@@ -85,6 +85,7 @@ cthulhu_python_sources = files([
'sleep_mode_manager.py',
'sound.py',
'sound_generator.py',
'sound_theme_manager.py',
'speech_and_verbosity_manager.py',
'speech_history.py',
'speech.py',
+3
View File
@@ -54,6 +54,7 @@ from cthulhu import settings_manager
from cthulhu import speech
from cthulhu import speechserver
from cthulhu import structural_navigation
from cthulhu import sound_theme_manager
from cthulhu.acss import ACSS
from cthulhu.scripts import default
from cthulhu.ax_object import AXObject
@@ -1376,6 +1377,7 @@ class Script(default.Script):
self.utilities.setCaretContext(AXObject.get_parent(parent), -1)
if not self._loadingDocumentContent:
self.presentMessage(messages.MODE_BROWSE)
sound_theme_manager.getManager().playBrowseModeSound()
else:
if not self.utilities.grabFocusWhenSettingCaret(obj) \
and (self._lastCommandWasCaretNav \
@@ -1384,6 +1386,7 @@ class Script(default.Script):
self.utilities.grabFocus(obj)
self.presentMessage(messages.MODE_FOCUS)
sound_theme_manager.getManager().playFocusModeSound()
self._inFocusMode = not self._inFocusMode
self._focusModeIsSticky = False
self._browseModeIsSticky = False
+4
View File
@@ -78,6 +78,8 @@ userCustomizableSettings = [
"playSoundForState",
"playSoundForPositionInSet",
"playSoundForValue",
"soundTheme",
"enableModeChangeSound",
"verbalizePunctuationStyle",
"presentToolTips",
"sayAllStyle",
@@ -312,6 +314,8 @@ playSoundForRole = False
playSoundForState = False
playSoundForPositionInSet = False
playSoundForValue = False
soundTheme = "default"
enableModeChangeSound = True
# Keyboard and Echo
keyboardLayout = GENERAL_KEYBOARD_LAYOUT_DESKTOP
+207
View File
@@ -0,0 +1,207 @@
#!/usr/bin/env python3
#
# Copyright (c) 2024 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.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the
# Free Software Foundation, Inc., Franklin Street, Fifth Floor,
# Boston MA 02110-1301 USA.
#
# Cthulhu project: https://git.stormux.org/storm/cthulhu
"""Sound Theme Manager for Cthulhu screen reader.
Handles discovery and playback of sound theme files from:
- System: /usr/share/cthulhu/sounds/{theme_name}/
- Local: ~/.local/share/cthulhu/sounds/{theme_name}/
- User themes: Same as local, user can add custom folders
Themes are simply folders containing sound files. The folder name is the theme name.
"""
__id__ = "$Id$"
__version__ = "$Revision$"
__date__ = "$Date$"
__copyright__ = "Copyright (c) 2024 Stormux"
__license__ = "LGPL"
import os
from gi.repository import GLib
from . import debug
from . import settings_manager
from . import sound
from .sound_generator import Icon
_settingsManager = settings_manager.getManager()
# Sound event constants - add new events here for easy extensibility
SOUND_FOCUS_MODE = "editbox"
SOUND_BROWSE_MODE = "browse_mode"
SOUND_BUTTON = "button"
# Special theme name for no sounds
THEME_NONE = "none"
class SoundThemeManager:
"""Manages sound themes for Cthulhu."""
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._initialized = False
return cls._instance
def __init__(self):
if self._initialized:
return
self._initialized = True
self._systemSoundsDir = None
self._userSoundsDir = None
def getSystemSoundsDir(self):
"""Get system sounds directory from platform settings."""
if self._systemSoundsDir is None:
try:
from . import cthulhu_platform
datadir = getattr(cthulhu_platform, 'datadir', '/usr/share')
except ImportError:
datadir = '/usr/share'
self._systemSoundsDir = os.path.join(datadir, 'cthulhu', 'sounds')
return self._systemSoundsDir
def getUserSoundsDir(self):
"""Get user sounds directory (XDG_DATA_HOME/cthulhu/sounds)."""
if self._userSoundsDir is None:
self._userSoundsDir = os.path.join(
GLib.get_user_data_dir(), 'cthulhu', 'sounds'
)
return self._userSoundsDir
def getAvailableThemes(self):
"""Discover all available sound themes.
Returns list of theme names (folder names) from both system and user dirs.
User themes with same name as system themes will override system themes.
"""
themes = set()
for baseDir in [self.getSystemSoundsDir(), self.getUserSoundsDir()]:
if os.path.isdir(baseDir):
try:
for entry in os.listdir(baseDir):
entryPath = os.path.join(baseDir, entry)
if os.path.isdir(entryPath):
themes.add(entry)
except OSError as e:
tokens = ["SOUND THEME: Error listing directory:", baseDir, str(e)]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
return sorted(list(themes))
def getThemePath(self, themeName):
"""Get the path to a theme directory.
User themes take precedence over system themes.
"""
userPath = os.path.join(self.getUserSoundsDir(), themeName)
if os.path.isdir(userPath):
return userPath
systemPath = os.path.join(self.getSystemSoundsDir(), themeName)
if os.path.isdir(systemPath):
return systemPath
return None
def getSoundPath(self, themeName, soundName):
"""Get path to a specific sound file.
Checks for common audio file extensions: .wav, .ogg, .mp3, .flac
"""
themePath = self.getThemePath(themeName)
if not themePath:
return None
for ext in ['.wav', '.ogg', '.mp3', '.flac']:
soundPath = os.path.join(themePath, soundName + ext)
if os.path.isfile(soundPath):
return soundPath
return None
def playSound(self, soundName, interrupt=True):
"""Play a sound from the current theme if enabled.
Args:
soundName: The name of the sound file (without extension)
interrupt: Whether to interrupt currently playing sounds
Returns:
True if sound was played, False otherwise
"""
if not _settingsManager.getSetting('enableModeChangeSound'):
return False
themeName = _settingsManager.getSetting('soundTheme')
if not themeName:
themeName = 'default'
# "none" theme means no sounds
if themeName == THEME_NONE:
return False
soundPath = self.getSoundPath(themeName, soundName)
if not soundPath:
tokens = ["SOUND THEME: Sound not found:", soundName, "in theme:", themeName]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
return False
try:
icon = Icon(os.path.dirname(soundPath), os.path.basename(soundPath))
if icon.isValid():
player = sound.getPlayer()
player.play(icon, interrupt=interrupt)
return True
except Exception as e:
tokens = ["SOUND THEME: Error playing sound:", str(e)]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
return False
def playFocusModeSound(self):
"""Play sound for entering focus mode."""
return self.playSound(SOUND_FOCUS_MODE)
def playBrowseModeSound(self):
"""Play sound for entering browse mode."""
return self.playSound(SOUND_BROWSE_MODE)
def playButtonSound(self):
"""Play sound for button focus (future use)."""
return self.playSound(SOUND_BUTTON)
_manager = None
def getManager():
"""Get the singleton SoundThemeManager instance."""
global _manager
if _manager is None:
_manager = SoundThemeManager()
return _manager