diff --git a/config/settings/settings.conf b/config/settings/settings.conf index 92c9c7a8..c4793155 100644 --- a/config/settings/settings.conf +++ b/config/settings/settings.conf @@ -217,6 +217,13 @@ list= [menu] vmenuPath= +# quickMenu: Semicolon-separated list of settings for quick adjustment +# Format: section#setting;section#setting;... +# Supported settings: +# - speech#rate, speech#pitch, speech#volume (0.0-1.0) +# - speech#module, speech#voice (speechdDriver only, auto-added) +# Note: speech#module and speech#voice are automatically added when +# speechdDriver is active. Do not add them manually. quickMenu=speech#rate;speech#pitch;speech#volume [prompt] diff --git a/src/fenrirscreenreader/core/quickMenuManager.py b/src/fenrirscreenreader/core/quickMenuManager.py index f29b53d3..2d96f85e 100644 --- a/src/fenrirscreenreader/core/quickMenuManager.py +++ b/src/fenrirscreenreader/core/quickMenuManager.py @@ -7,25 +7,194 @@ from fenrirscreenreader.core import debug from fenrirscreenreader.core.i18n import _ from fenrirscreenreader.core.settingsData import settings_data +import subprocess +import time -class QuickMenuManager: +class SpeechHelperMixin: + """Helper methods for querying speech-dispatcher modules and voices. + + Provides caching and query functionality for speech-dispatcher module + and voice enumeration, reusing proven logic from voice_browser.py. + """ + def __init__(self): + self._modules_cache = None + self._voices_cache = {} # {module_name: [voice_list]} + self._cache_timestamp = 0 + self._cache_timeout = 300 # 5 minutes + + def get_speechd_modules(self): + """Get available speech-dispatcher modules (cached). + + Returns: + list: Available module names (e.g., ['espeak-ng', 'festival']) + """ + now = time.time() + + # Return cached if valid + if (self._modules_cache and + (now - self._cache_timestamp) < self._cache_timeout): + return self._modules_cache + + # Query spd-say + try: + result = subprocess.run( + ["spd-say", "-O"], + capture_output=True, + text=True, + timeout=8 + ) + if result.returncode == 0: + lines = result.stdout.strip().split("\n") + self._modules_cache = [ + line.strip() for line in lines[1:] if line.strip() + ] + self._cache_timestamp = now + return self._modules_cache + except Exception as e: + self.env["runtime"]["DebugManager"].write_debug_out( + (f"QuickMenuManager get_speechd_modules: " + f"Error querying modules: {e}"), + debug.DebugLevel.ERROR + ) + + return [] + + def get_module_voices(self, module): + """Get voices for a specific module (cached per-module). + + Args: + module (str): Module name (e.g., 'espeak-ng') + + Returns: + list: Available voice names for this module + """ + # Return cached if available + if module in self._voices_cache: + return self._voices_cache[module] + + # Query spd-say + try: + result = subprocess.run( + ["spd-say", "-o", module, "-L"], + capture_output=True, + text=True, + timeout=8 + ) + if result.returncode == 0: + lines = result.stdout.strip().split("\n") + voices = [] + for line in lines[1:]: + if not line.strip(): + continue + if module.lower() == "espeak-ng": + voice = self._process_espeak_voice(line) + if voice: + voices.append(voice) + else: + # For non-espeak modules, extract first field (voice name) + parts = line.strip().split() + if parts: + voices.append(parts[0]) + + self._voices_cache[module] = voices + return voices + except Exception as e: + self.env["runtime"]["DebugManager"].write_debug_out( + (f"QuickMenuManager get_module_voices: " + f"Error querying voices for {module}: {e}"), + debug.DebugLevel.ERROR + ) + + return [] + + def _process_espeak_voice(self, voice_line): + """Process espeak-ng voice format into usable voice name. + + Args: + voice_line (str): Raw line from spd-say -L output + + Returns: + str: Processed voice name (e.g., 'en-us' or 'en-us+f3') + """ + parts = [p for p in voice_line.split() if p] + if len(parts) < 2: + return None + lang_code = parts[-2].lower() + variant = parts[-1].lower() + return (f"{lang_code}+{variant}" + if variant and variant != "none" else lang_code) + + def invalidate_speech_cache(self): + """Clear cached module and voice data.""" + self._modules_cache = None + self._voices_cache = {} + self._cache_timestamp = 0 + + +class QuickMenuManager(SpeechHelperMixin): + def __init__(self): + SpeechHelperMixin.__init__(self) self.position = 0 self.quickMenu = [] self.settings = settings_data def initialize(self, environment): self.env = environment - self.load_menu( - self.env["runtime"]["SettingsManager"].get_setting( - "menu", "quickMenu" - ) + + # Load base menu from config + menu_string = self.env["runtime"]["SettingsManager"].get_setting( + "menu", "quickMenu" ) + # Dynamically add speech-dispatcher specific items + if self._is_speechd_driver_active(): + menu_string = self._add_speechd_menu_items(menu_string) + + self.load_menu(menu_string) + def shutdown(self): pass + def _is_speechd_driver_active(self): + """Check if speechdDriver is currently active. + + Returns: + bool: True if speech driver is speechdDriver + """ + try: + driver = self.env["runtime"]["SettingsManager"].get_setting( + "speech", "driver" + ) + return driver == "speechdDriver" + except Exception as e: + self.env["runtime"]["DebugManager"].write_debug_out( + (f"QuickMenuManager _is_speechd_driver_active: " + f"Error checking driver: {e}"), + debug.DebugLevel.ERROR + ) + return False + + def _add_speechd_menu_items(self, menu_string): + """Add speech-dispatcher module and voice to quick menu. + + Args: + menu_string (str): Existing menu string from config + + Returns: + str: Updated menu string with module and voice added + """ + if not menu_string: + return "speech#module;speech#voice" + + # Check if already present (user manually added) + if "speech#module" in menu_string or "speech#voice" in menu_string: + return menu_string + + # Add module and voice after existing items + return f"{menu_string};speech#module;speech#voice" + def load_menu(self, menuString): self.position = 0 self.quickMenu = [] @@ -86,8 +255,15 @@ class QuickMenuManager: try: if isinstance(self.settings[section][setting], str): - value = str(value_string) - return False + # Check for special string cycling cases + if section == "speech" and setting == "module": + return self.cycle_speech_module("next") + elif section == "speech" and setting == "voice": + return self.cycle_speech_voice("next") + else: + # Generic strings not supported for cycling + value = str(value_string) + return False elif isinstance(self.settings[section][setting], bool): if value_string not in ["True", "False"]: return False @@ -132,8 +308,15 @@ class QuickMenuManager: return False try: if isinstance(self.settings[section][setting], str): - value = str(value_string) - return False + # Check for special string cycling cases + if section == "speech" and setting == "module": + return self.cycle_speech_module("prev") + elif section == "speech" and setting == "voice": + return self.cycle_speech_voice("prev") + else: + # Generic strings not supported for cycling + value = str(value_string) + return False elif isinstance(self.settings[section][setting], bool): if value_string not in ["True", "False"]: return False @@ -161,6 +344,138 @@ class QuickMenuManager: return False return True + def cycle_speech_module(self, direction): + """Cycle to next/previous speech-dispatcher module. + + Args: + direction (str): 'next' or 'prev' + + Returns: + bool: True if successful, False otherwise + """ + try: + # Get available modules + modules = self.get_speechd_modules() + if not modules: + self.env["runtime"]["OutputManager"].present_text( + "No modules available", interrupt=True + ) + return False + + # Get current module + current_module = self.env["runtime"]["SettingsManager"].get_setting( + "speech", "module" + ) + + # Find current index + try: + current_index = (modules.index(current_module) + if current_module else 0) + except ValueError: + current_index = 0 + + # Cycle to next/previous + if direction == "next": + new_index = (current_index + 1) % len(modules) + else: # prev + new_index = (current_index - 1) % len(modules) + + new_module = modules[new_index] + + # Update setting (runtime only) + self.env["runtime"]["SettingsManager"].set_setting( + "speech", "module", new_module + ) + + # Reset voice to first available for new module + voices = self.get_module_voices(new_module) + if voices: + self.env["runtime"]["SettingsManager"].set_setting( + "speech", "voice", voices[0] + ) + + # Announce new module + self.env["runtime"]["OutputManager"].present_text( + new_module, interrupt=True + ) + + return True + + except Exception as e: + self.env["runtime"]["DebugManager"].write_debug_out( + f"QuickMenuManager cycle_speech_module: Error: {e}", + debug.DebugLevel.ERROR + ) + return False + + def cycle_speech_voice(self, direction): + """Cycle to next/previous voice for current module. + + Args: + direction (str): 'next' or 'prev' + + Returns: + bool: True if successful, False otherwise + """ + try: + # Get current module + current_module = self.env["runtime"]["SettingsManager"].get_setting( + "speech", "module" + ) + + if not current_module: + self.env["runtime"]["OutputManager"].present_text( + "No module selected", interrupt=True + ) + return False + + # Get available voices for this module + voices = self.get_module_voices(current_module) + if not voices: + self.env["runtime"]["OutputManager"].present_text( + f"No voices for module {current_module}", interrupt=True + ) + return False + + # Get current voice + current_voice = self.env["runtime"]["SettingsManager"].get_setting( + "speech", "voice" + ) + + # Find current index + try: + current_index = (voices.index(current_voice) + if current_voice else 0) + except ValueError: + current_index = 0 + + # Cycle to next/previous + if direction == "next": + new_index = (current_index + 1) % len(voices) + else: # prev + new_index = (current_index - 1) % len(voices) + + new_voice = voices[new_index] + + # Update setting (runtime only) + self.env["runtime"]["SettingsManager"].set_setting( + "speech", "voice", new_voice + ) + + # Announce new voice + self.env["runtime"]["OutputManager"].present_text( + new_voice, interrupt=True + ) + + return True + + except Exception as e: + self.env["runtime"]["DebugManager"].write_debug_out( + f"QuickMenuManager cycle_speech_voice: Error: {e}", + debug.DebugLevel.ERROR + ) + return False + def get_current_entry(self): if len(self.quickMenu) == 0: return "" diff --git a/src/fenrirscreenreader/fenrirVersion.py b/src/fenrirscreenreader/fenrirVersion.py index e9253681..894b8fed 100644 --- a/src/fenrirscreenreader/fenrirVersion.py +++ b/src/fenrirscreenreader/fenrirVersion.py @@ -4,5 +4,5 @@ # Fenrir TTY screen reader # By Chrys, Storm Dragon, and contributors. -version = "2025.11.27" +version = "2025.12.02" code_name = "testing"