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 + + + True + False + 0 + none + + + True + False + 12 + + + True + False + vertical + 6 + + + Play sounds when switching between _browse and focus modes + True + True + False + True + 0 + True + + + + False + True + 0 + + + + + True + False + 12 + + + True + False + 0 + Sound _theme: + True + soundThemeCombo + + + + + + False + True + 0 + + + + + True + False + + + + + + + False + True + 1 + + + + + False + True + 1 + + + + + + + + + True + False + Sound Theme + + + + + + + + 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