diff --git a/src/cthulhu/cmdnames.py b/src/cthulhu/cmdnames.py index 7501304..c6ef3a0 100644 --- a/src/cthulhu/cmdnames.py +++ b/src/cthulhu/cmdnames.py @@ -450,6 +450,21 @@ INCREASE_SPEECH_VOLUME = _("Increase the speech volume") # speech synthesis engine will generate speech. DECREASE_SPEECH_VOLUME = _("Decrease the speech volume") +# Translators: This string describes the command to select the previous speech setting. +SPEECH_SETTING_PREVIOUS = _("Select the previous speech setting") + +# Translators: This string describes the command to select the next speech setting. +SPEECH_SETTING_NEXT = _("Select the next speech setting") + +# Translators: This string describes the command to decrease the current speech setting. +SPEECH_SETTING_DECREASE = _("Decrease the current speech setting") + +# Translators: This string describes the command to increase the current speech setting. +SPEECH_SETTING_INCREASE = _("Increase the current speech setting") + +# Translators: This string describes the command to save speech settings. +SPEECH_SETTINGS_SAVE = _("Save speech settings") + # Translators: Cthulhu allows the user to turn speech synthesis on or off. # We call it 'silencing'. TOGGLE_SPEECH = _("Toggle the silencing of speech") diff --git a/src/cthulhu/messages.py b/src/cthulhu/messages.py index 8fd9505..b7632ee 100644 --- a/src/cthulhu/messages.py +++ b/src/cthulhu/messages.py @@ -2275,6 +2275,33 @@ SPEECH_LOUDER = _("louder.") # Translators: This string announces speech volume change. SPEECH_SOFTER = _("softer.") +# Translators: This string announces the current speech rate value. +SPEECH_RATE_VALUE = _("Rate %d") + +# Translators: This string announces the current speech pitch value. +SPEECH_PITCH_VALUE = _("Pitch %s") + +# Translators: This string announces the current speech volume value. +SPEECH_VOLUME_VALUE = _("Volume %s") + +# Translators: This string announces the current speech-dispatcher module. +SPEECH_MODULE_VALUE = _("Speech-dispatcher module %s") + +# Translators: This string announces the current speech-dispatcher voice. +SPEECH_VOICE_VALUE = _("Speech-dispatcher voice %s") + +# Translators: This string is presented when speech-dispatcher modules are unavailable. +SPEECH_MODULES_UNAVAILABLE = _("No speech-dispatcher modules available") + +# Translators: This string is presented when speech-dispatcher voices are unavailable. +SPEECH_VOICES_UNAVAILABLE = _("No speech-dispatcher voices available") + +# Translators: This string confirms speech settings have been saved. +SPEECH_SETTINGS_SAVED = _("Speech settings saved") + +# Translators: This string is presented when speech settings cannot be saved. +SPEECH_SETTINGS_SAVE_FAILED = _("Speech settings could not be saved") + # Translators: Cthulhu's verbosity levels control how much (or how little) # Cthulhu will speak when presenting objects as the user navigates within # applications and reads content. The two levels are "brief" and "verbose". diff --git a/src/cthulhu/speech_and_verbosity_manager.py b/src/cthulhu/speech_and_verbosity_manager.py index c8cf216..ef9e602 100644 --- a/src/cthulhu/speech_and_verbosity_manager.py +++ b/src/cthulhu/speech_and_verbosity_manager.py @@ -39,6 +39,8 @@ from . import input_event from . import keybindings from . import messages from . import cthulhu_state +from . import script_manager +from . import speechserver from . import settings from . import settings_manager from . import speech @@ -49,6 +51,17 @@ class SpeechAndVerbosityManager: """Configures speech and verbosity settings.""" def __init__(self): + self._speech_settings_order = [ + "rate", + "pitch", + "volume", + "module", + "voice", + ] + self._current_speech_setting_index = 0 + self._rate_step = 1 + self._pitch_step = 0.1 + self._volume_step = 0.1 self._handlers = self._setup_handlers() self._bindings = self._setup_bindings() @@ -149,6 +162,31 @@ class SpeechAndVerbosityManager: self.increase_volume, cmdnames.INCREASE_SPEECH_VOLUME) + handlers["selectPreviousSpeechSettingHandler"] = \ + input_event.InputEventHandler( + self.select_previous_speech_setting, + cmdnames.SPEECH_SETTING_PREVIOUS) + + handlers["selectNextSpeechSettingHandler"] = \ + input_event.InputEventHandler( + self.select_next_speech_setting, + cmdnames.SPEECH_SETTING_NEXT) + + handlers["decreaseCurrentSpeechSettingHandler"] = \ + input_event.InputEventHandler( + self.decrease_current_speech_setting, + cmdnames.SPEECH_SETTING_DECREASE) + + handlers["increaseCurrentSpeechSettingHandler"] = \ + input_event.InputEventHandler( + self.increase_current_speech_setting, + cmdnames.SPEECH_SETTING_INCREASE) + + handlers["saveSpeechSettingsHandler"] = \ + input_event.InputEventHandler( + self.save_speech_settings, + cmdnames.SPEECH_SETTINGS_SAVE) + return handlers def _setup_bindings(self): @@ -233,6 +271,41 @@ class SpeechAndVerbosityManager: keybindings.NO_MODIFIER_MASK, self._handlers.get("increaseSpeechVolumeHandler"))) + bindings.add( + keybindings.KeyBinding( + "Left", + keybindings.defaultModifierMask, + keybindings.CTHULHU_MODIFIER_MASK, + self._handlers.get("selectPreviousSpeechSettingHandler"))) + + bindings.add( + keybindings.KeyBinding( + "Right", + keybindings.defaultModifierMask, + keybindings.CTHULHU_MODIFIER_MASK, + self._handlers.get("selectNextSpeechSettingHandler"))) + + bindings.add( + keybindings.KeyBinding( + "Down", + keybindings.defaultModifierMask, + keybindings.CTHULHU_MODIFIER_MASK, + self._handlers.get("decreaseCurrentSpeechSettingHandler"))) + + bindings.add( + keybindings.KeyBinding( + "Up", + keybindings.defaultModifierMask, + keybindings.CTHULHU_MODIFIER_MASK, + self._handlers.get("increaseCurrentSpeechSettingHandler"))) + + bindings.add( + keybindings.KeyBinding( + "s", + keybindings.defaultModifierMask, + keybindings.CTHULHU_CTRL_MODIFIER_MASK | keybindings.SHIFT_MODIFIER_MASK, + self._handlers.get("saveSpeechSettingsHandler"))) + bindings.add( keybindings.KeyBinding( "", @@ -266,6 +339,380 @@ class SpeechAndVerbosityManager: def _get_server(self): return speech.getSpeechServer() + def _format_number(self, value): + if isinstance(value, float): + if value.is_integer(): + return str(int(value)) + return f"{value:.1f}".rstrip("0").rstrip(".") + return str(value) + + def _present_message(self, script, message): + if script: + script.presentMessage(message) + else: + speech.speak(message) + + def _get_default_voice(self): + from . import acss + default_voice = settings.voices.get(settings.DEFAULT_VOICE) + if default_voice is None: + default_voice = acss.ACSS({}) + settings.voices[settings.DEFAULT_VOICE] = default_voice + return default_voice + + def _set_default_voice_family(self, family): + from . import acss + default_voice = self._get_default_voice() + if family is None: + default_voice.pop(acss.ACSS.FAMILY, None) + else: + default_voice[acss.ACSS.FAMILY] = dict(family) + default_voice['established'] = True + + def _get_current_speech_setting(self): + return self._speech_settings_order[self._current_speech_setting_index] + + def _get_rate_value(self): + from . import acss + default_voice = self._get_default_voice() + return int(default_voice.get(acss.ACSS.RATE, 50)) + + def _set_rate_value(self, value): + from . import acss + default_voice = self._get_default_voice() + default_voice[acss.ACSS.RATE] = int(value) + default_voice['established'] = True + + def _get_pitch_value(self): + from . import acss + default_voice = self._get_default_voice() + return float(default_voice.get(acss.ACSS.AVERAGE_PITCH, 5.0)) + + def _set_pitch_value(self, value): + from . import acss + default_voice = self._get_default_voice() + default_voice[acss.ACSS.AVERAGE_PITCH] = float(value) + default_voice['established'] = True + + def _get_volume_value(self): + from . import acss + default_voice = self._get_default_voice() + return float(default_voice.get(acss.ACSS.GAIN, 10.0)) + + def _set_volume_value(self, value): + from . import acss + default_voice = self._get_default_voice() + default_voice[acss.ACSS.GAIN] = float(value) + default_voice['established'] = True + + def _get_current_voice_name(self, server=None): + from . import acss + default_voice = self._get_default_voice() + family = default_voice.get(acss.ACSS.FAMILY, {}) or {} + name = family.get(speechserver.VoiceFamily.NAME) + if name: + return name + if server: + voices = self._get_available_voices(server) + if voices: + return voices[0].get(speechserver.VoiceFamily.NAME) + return "" + + def _get_available_modules(self, server): + if server is None or not hasattr(server, 'list_output_modules'): + return [] + try: + modules = list(server.list_output_modules() or []) + except Exception as e: + debug.printMessage(debug.LEVEL_WARNING, f"Error getting output modules: {e}", True) + return [] + return modules + + def _get_available_voices(self, server): + if server is None or not hasattr(server, 'getVoiceFamilies'): + return [] + try: + voices = server.getVoiceFamilies() or [] + except Exception as e: + debug.printMessage(debug.LEVEL_WARNING, f"Error getting voices: {e}", True) + return [] + filtered = [] + for voice in voices: + if voice.get(speechserver.VoiceFamily.NAME): + filtered.append(voice) + if not filtered: + return [] + + if not self._should_filter_voices_by_locale(server): + return filtered + + language, dialect = self._get_system_language_and_dialect() + if not language: + return filtered + + language_filtered = [] + for voice in filtered: + voice_lang = (voice.get(speechserver.VoiceFamily.LANG) or "").lower() + voice_dialect = (voice.get(speechserver.VoiceFamily.DIALECT) or "").lower() + if voice_lang != language: + continue + if dialect and voice_dialect and voice_dialect != dialect: + continue + language_filtered.append(voice) + + if language_filtered: + msg = ( + "SPEECH AND VERBOSITY MANAGER: " + f"Filtered voices to locale {language}" + f"{('-' + dialect) if dialect else ''}: " + f"{len(language_filtered)} of {len(filtered)}" + ) + debug.printMessage(debug.LEVEL_INFO, msg, True) + return language_filtered + + msg = ( + "SPEECH AND VERBOSITY MANAGER: " + f"No voices for locale {language}" + f"{('-' + dialect) if dialect else ''}; using all voices." + ) + debug.printMessage(debug.LEVEL_INFO, msg, True) + return filtered + + def _should_filter_voices_by_locale(self, server): + if server is None or not hasattr(server, "getOutputModule"): + return False + try: + module = (server.getOutputModule() or "").lower() + except Exception: + return False + return "espeak-ng" in module + + def _get_system_language_and_dialect(self): + import locale + locale_value = None + try: + locale_value = locale.getlocale(locale.LC_MESSAGES)[0] + except Exception: + locale_value = None + if not locale_value: + try: + locale_value = locale.getdefaultlocale()[0] + except Exception: + locale_value = None + + if not locale_value: + return "", "" + + if "_" in locale_value: + language, dialect = locale_value.split("_", 1) + elif "-" in locale_value: + language, dialect = locale_value.split("-", 1) + else: + language, dialect = locale_value, "" + + return language.lower(), dialect.lower() + + def _announce_current_speech_setting(self, script=None): + setting = self._get_current_speech_setting() + debug.printMessage(debug.LEVEL_INFO, + f"SPEECH AND VERBOSITY MANAGER: Current setting {setting}", + True) + if setting == "rate": + value = self._get_rate_value() + message = messages.SPEECH_RATE_VALUE % value + elif setting == "pitch": + value = self._format_number(self._get_pitch_value()) + message = messages.SPEECH_PITCH_VALUE % value + elif setting == "volume": + value = self._format_number(self._get_volume_value()) + message = messages.SPEECH_VOLUME_VALUE % value + elif setting == "module": + server = self._get_server() + if server is None or not hasattr(server, 'getOutputModule'): + message = messages.SPEECH_MODULES_UNAVAILABLE + else: + module = server.getOutputModule() or "" + if module: + message = messages.SPEECH_MODULE_VALUE % module + else: + message = messages.SPEECH_MODULES_UNAVAILABLE + elif setting == "voice": + server = self._get_server() + voices = self._get_available_voices(server) + if not voices: + message = messages.SPEECH_VOICES_UNAVAILABLE + else: + name = self._get_current_voice_name(server) + message = messages.SPEECH_VOICE_VALUE % name + else: + message = "" + + if message: + self._present_message(script, message) + + @dbus_service.command + def select_previous_speech_setting(self, script=None, event=None): + if self._current_speech_setting_index > 0: + self._current_speech_setting_index -= 1 + self._announce_current_speech_setting(script) + return True + + @dbus_service.command + def select_next_speech_setting(self, script=None, event=None): + if self._current_speech_setting_index < len(self._speech_settings_order) - 1: + self._current_speech_setting_index += 1 + self._announce_current_speech_setting(script) + return True + + @dbus_service.command + def decrease_current_speech_setting(self, script=None, event=None): + return self._adjust_current_speech_setting(script, decrease=True) + + @dbus_service.command + def increase_current_speech_setting(self, script=None, event=None): + return self._adjust_current_speech_setting(script, decrease=False) + + def _adjust_current_speech_setting(self, script, decrease=False): + setting = self._get_current_speech_setting() + if setting == "rate": + return self._adjust_rate(script, decrease) + if setting == "pitch": + return self._adjust_pitch(script, decrease) + if setting == "volume": + return self._adjust_volume(script, decrease) + if setting == "module": + return self._adjust_module(script, decrease) + if setting == "voice": + return self._adjust_voice(script, decrease) + return True + + def _adjust_rate(self, script, decrease): + delta = -self._rate_step if decrease else self._rate_step + value = self._get_rate_value() + delta + value = max(0, min(100, value)) + self._set_rate_value(value) + msg = f"SPEECH AND VERBOSITY MANAGER: Rate set to {value}" + debug.printMessage(debug.LEVEL_INFO, msg, True) + self._announce_current_speech_setting(script) + return True + + def _adjust_pitch(self, script, decrease): + delta = -self._pitch_step if decrease else self._pitch_step + value = round(self._get_pitch_value() + delta, 1) + value = max(0.0, min(10.0, value)) + self._set_pitch_value(value) + msg = f"SPEECH AND VERBOSITY MANAGER: Pitch set to {value}" + debug.printMessage(debug.LEVEL_INFO, msg, True) + self._announce_current_speech_setting(script) + return True + + def _adjust_volume(self, script, decrease): + delta = -self._volume_step if decrease else self._volume_step + value = round(self._get_volume_value() + delta, 1) + value = max(0.0, min(10.0, value)) + self._set_volume_value(value) + msg = f"SPEECH AND VERBOSITY MANAGER: Volume set to {value}" + debug.printMessage(debug.LEVEL_INFO, msg, True) + self._announce_current_speech_setting(script) + return True + + def _adjust_module(self, script, decrease): + server = self._get_server() + if server is None: + msg = "SPEECH AND VERBOSITY MANAGER: Cannot get speech server." + debug.printMessage(debug.LEVEL_INFO, msg, True) + return True + + modules = self._get_available_modules(server) + if not modules: + self._present_message(script, messages.SPEECH_MODULES_UNAVAILABLE) + return True + + current = server.getOutputModule() if hasattr(server, 'getOutputModule') else "" + try: + index = modules.index(current) + except ValueError: + index = 0 + + delta = -1 if decrease else 1 + new_index = max(0, min(len(modules) - 1, index + delta)) + new_module = modules[new_index] + + if new_module != current: + try: + server.setOutputModule(new_module) + msg = f"SPEECH AND VERBOSITY MANAGER: Output module set to {new_module}" + debug.printMessage(debug.LEVEL_INFO, msg, True) + except Exception as e: + debug.printMessage(debug.LEVEL_WARNING, f"Error setting output module: {e}", True) + return True + settings.speechServerInfo = [new_module, new_module] + msg = f"SPEECH AND VERBOSITY MANAGER: speechServerInfo set to {settings.speechServerInfo}" + debug.printMessage(debug.LEVEL_INFO, msg, True) + + voices = self._get_available_voices(server) + if voices: + self._set_default_voice_family(voices[0]) + else: + self._set_default_voice_family({}) + + self._present_message(script, messages.SPEECH_MODULE_VALUE % new_module) + return True + + def _adjust_voice(self, script, decrease): + server = self._get_server() + voices = self._get_available_voices(server) + if not voices: + self._present_message(script, messages.SPEECH_VOICES_UNAVAILABLE) + return True + + current_name = self._get_current_voice_name(server) + index = 0 + for idx, voice in enumerate(voices): + if voice.get(speechserver.VoiceFamily.NAME) == current_name: + index = idx + break + + delta = -1 if decrease else 1 + new_index = max(0, min(len(voices) - 1, index + delta)) + new_voice = voices[new_index] + self._set_default_voice_family(new_voice) + name = new_voice.get(speechserver.VoiceFamily.NAME, "") + msg = f"SPEECH AND VERBOSITY MANAGER: Voice set to {name}" + debug.printMessage(debug.LEVEL_INFO, msg, True) + self._present_message(script, messages.SPEECH_VOICE_VALUE % name) + return True + + @dbus_service.command + def save_speech_settings(self, script=None, event=None): + try: + server = self._get_server() + if server and hasattr(server, "getOutputModule"): + module = server.getOutputModule() or "" + if module: + settings.speechServerInfo = [module, module] + msg = f"SPEECH AND VERBOSITY MANAGER: Saving speechServerInfo {settings.speechServerInfo}" + debug.printMessage(debug.LEVEL_INFO, msg, True) + + general = {} + for key in settings.userCustomizableSettings: + general[key] = getattr(settings, key) + + current_profile = _settings_manager.profile + pronunciations = _settings_manager.getPronunciations(current_profile) + keybindings = _settings_manager.getKeybindings(current_profile) + + default_script = script_manager.get_manager().get_default_script() + _settings_manager.saveSettings(default_script, + general, + pronunciations, + keybindings) + self._present_message(script, messages.SPEECH_SETTINGS_SAVED) + except Exception as e: + debug.printMessage(debug.LEVEL_WARNING, f"Error saving speech settings: {e}", True) + self._present_message(script, messages.SPEECH_SETTINGS_SAVE_FAILED) + return True + # ======================================================================== # D-Bus Speech Server Controls # ======================================================================== diff --git a/src/cthulhu/speechdispatcherfactory.py b/src/cthulhu/speechdispatcherfactory.py index 5c88e2d..6317f48 100644 --- a/src/cthulhu/speechdispatcherfactory.py +++ b/src/cthulhu/speechdispatcherfactory.py @@ -589,7 +589,13 @@ class SpeechServer(speechserver.SpeechServer): if not default_lang: default_lang = locale_language - voices = ((self._default_voice_name, default_lang, None),) + voices + current_module = "" + try: + current_module = self.getOutputModule() or "" + except Exception: + current_module = "" + default_name = guilabels.SPEECH_DEFAULT_VOICE % (current_module or self._id) + voices = ((default_name, default_lang, None),) + voices families = [] for name, lang, variant in voices: @@ -798,4 +804,3 @@ class SpeechServer(speechserver.SpeechServer): return () except speechd.SSIPCommandError: return () -