diff --git a/meson.build b/meson.build
index c3aeef7..431fe31 100644
--- a/meson.build
+++ b/meson.build
@@ -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)
diff --git a/sounds/default/browse_mode.wav b/sounds/default/browse_mode.wav
new file mode 100644
index 0000000..c26e051
Binary files /dev/null and b/sounds/default/browse_mode.wav differ
diff --git a/sounds/default/editbox.wav b/sounds/default/editbox.wav
new file mode 100644
index 0000000..7e8268d
Binary files /dev/null and b/sounds/default/editbox.wav differ
diff --git a/src/cthulhu/cthulhu-setup.ui b/src/cthulhu/cthulhu-setup.ui
index bac0ecf..337935f 100644
--- a/src/cthulhu/cthulhu-setup.ui
+++ b/src/cthulhu/cthulhu-setup.ui
@@ -1018,6 +1018,105 @@
2
+
+
+
+ 1
+ 4
+
+
diff --git a/src/cthulhu/cthulhu_gui_prefs.py b/src/cthulhu/cthulhu_gui_prefs.py
index 8073622..be79be9 100644
--- a/src/cthulhu/cthulhu_gui_prefs.py
+++ b/src/cthulhu/cthulhu_gui_prefs.py
@@ -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"])
diff --git a/src/cthulhu/guilabels.py b/src/cthulhu/guilabels.py
index 81eadbb..730e0fb 100644
--- a/src/cthulhu/guilabels.py
+++ b/src/cthulhu/guilabels.py
@@ -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")
diff --git a/src/cthulhu/meson.build b/src/cthulhu/meson.build
index 94400f8..f7ed6a1 100644
--- a/src/cthulhu/meson.build
+++ b/src/cthulhu/meson.build
@@ -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',
diff --git a/src/cthulhu/scripts/web/script.py b/src/cthulhu/scripts/web/script.py
index 80dadbd..e876679 100644
--- a/src/cthulhu/scripts/web/script.py
+++ b/src/cthulhu/scripts/web/script.py
@@ -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
diff --git a/src/cthulhu/settings.py b/src/cthulhu/settings.py
index 79cd8b9..5c7d32b 100644
--- a/src/cthulhu/settings.py
+++ b/src/cthulhu/settings.py
@@ -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
diff --git a/src/cthulhu/sound_theme_manager.py b/src/cthulhu/sound_theme_manager.py
new file mode 100644
index 0000000..3fe5b32
--- /dev/null
+++ b/src/cthulhu/sound_theme_manager.py
@@ -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