diff --git a/src/fenrirscreenreader/core/quickMenuManager.py b/src/fenrirscreenreader/core/quickMenuManager.py index 2d96f85e..4fda9f64 100644 --- a/src/fenrirscreenreader/core/quickMenuManager.py +++ b/src/fenrirscreenreader/core/quickMenuManager.py @@ -48,7 +48,8 @@ class SpeechHelperMixin: if result.returncode == 0: lines = result.stdout.strip().split("\n") self._modules_cache = [ - line.strip() for line in lines[1:] if line.strip() + line.strip() for line in lines[1:] + if line.strip() and line.strip().lower() != "dummy" ] self._cache_timestamp = now return self._modules_cache @@ -92,8 +93,13 @@ class SpeechHelperMixin: voice = self._process_espeak_voice(line) if voice: voices.append(voice) + elif module.lower() == "voxin": + # For Voxin, store voice name with language + voice_data = self._process_voxin_voice(line) + if voice_data: + voices.append(voice_data) else: - # For non-espeak modules, extract first field (voice name) + # For other modules, extract first field (voice name) parts = line.strip().split() if parts: voices.append(parts[0]) @@ -126,6 +132,91 @@ class SpeechHelperMixin: return (f"{lang_code}+{variant}" if variant and variant != "none" else lang_code) + def _process_voxin_voice(self, voice_line): + """Process Voxin voice format with language information. + + Args: + voice_line (str): Raw line from spd-say -o voxin -L output + Format: NAME LANGUAGE VARIANT + + Returns: + str: Voice name with language encoded (e.g., 'daniel-embedded-high|en-GB') + """ + parts = [p for p in voice_line.split() if p] + if len(parts) < 2: + return None + voice_name = parts[0] + language = parts[1] + # Encode language with voice for later extraction + return f"{voice_name}|{language}" + + def _select_default_voice(self, voices): + """Select a sensible default voice from list, preferring user's + language. + + Args: + voices (list): List of available voice names + + Returns: + str: Selected default voice (matches user language if possible) + """ + if not voices: + return "" + + # Get current voice to preserve language preference + current_voice = self.env["runtime"]["SettingsManager"].get_setting( + "speech", "voice" + ) + + # Get configured language from settings + configured_lang = self.env["runtime"]["SettingsManager"].get_setting( + "speech", "language" + ) + + # Extract language code from current voice if available + current_lang = None + if current_voice: + # Extract language code (e.g., 'en-gb' from 'en-gb+male') + current_lang = current_voice.split('+')[0].lower() + + # Build preference list: current language, configured language, English + preferences = [] + if current_lang: + preferences.append(current_lang) + if configured_lang: + preferences.append(configured_lang.lower()) + preferences.extend(['en-gb', 'en-us', 'en']) + + # Remove duplicates while preserving order + seen = set() + preferences = [x for x in preferences + if not (x in seen or seen.add(x))] + + # Try exact matches for preferred languages + for pref in preferences: + for voice in voices: + # Extract language if voice is in "name|lang" format + voice_to_check = voice + if "|" in voice: + _, voice_lang = voice.split("|", 1) + voice_to_check = voice_lang + if voice_to_check.lower() == pref: + return voice + + # Try voices starting with preferred language codes + for pref in preferences: + for voice in voices: + # Extract language if voice is in "name|lang" format + voice_to_check = voice + if "|" in voice: + _, voice_lang = voice.split("|", 1) + voice_to_check = voice_lang + if voice_to_check.lower().startswith(pref): + return voice + + # Fall back to first available voice + return voices[0] + def invalidate_speech_cache(self): """Clear cached module and voice data.""" self._modules_cache = None @@ -387,13 +478,43 @@ class QuickMenuManager(SpeechHelperMixin): "speech", "module", new_module ) - # Reset voice to first available for new module + # Select sensible default voice for new module voices = self.get_module_voices(new_module) if voices: + default_voice = self._select_default_voice(voices) + + # Parse voice name and language for modules like Voxin + voice_name = default_voice + voice_lang = None + if "|" in default_voice: + voice_name, voice_lang = default_voice.split("|", 1) + self.env["runtime"]["SettingsManager"].set_setting( - "speech", "voice", voices[0] + "speech", "voice", voice_name ) + # Apply voice to speech driver immediately + if "SpeechDriver" in self.env["runtime"]: + try: + self.env["runtime"]["SpeechDriver"].set_module( + new_module + ) + # Set language first if available + if voice_lang: + self.env["runtime"]["SpeechDriver"].set_language( + voice_lang + ) + # Then set voice + self.env["runtime"]["SpeechDriver"].set_voice( + voice_name + ) + except Exception as e: + self.env["runtime"]["DebugManager"].write_debug_out( + (f"QuickMenuManager cycle_speech_module: " + f"Error applying voice: {e}"), + debug.DebugLevel.ERROR + ) + # Announce new module self.env["runtime"]["OutputManager"].present_text( new_module, interrupt=True @@ -442,12 +563,19 @@ class QuickMenuManager(SpeechHelperMixin): "speech", "voice" ) - # Find current index - try: - current_index = (voices.index(current_voice) - if current_voice else 0) - except ValueError: - current_index = 0 + # Find current index (handle Voxin voice|language format) + current_index = 0 + if current_voice: + try: + # Try exact match first + current_index = voices.index(current_voice) + except ValueError: + # For Voxin, compare just the voice name part + for i, voice in enumerate(voices): + voice_name = voice.split("|")[0] if "|" in voice else voice + if voice_name == current_voice: + current_index = i + break # Cycle to next/previous if direction == "next": @@ -457,14 +585,38 @@ class QuickMenuManager(SpeechHelperMixin): new_voice = voices[new_index] - # Update setting (runtime only) + # Parse voice name and language for modules like Voxin + voice_name = new_voice + voice_lang = None + if "|" in new_voice: + # Format: "voicename|language" (e.g., "daniel-embedded-high|en-GB") + voice_name, voice_lang = new_voice.split("|", 1) + + # Update setting (runtime only) - store the voice name only self.env["runtime"]["SettingsManager"].set_setting( - "speech", "voice", new_voice + "speech", "voice", voice_name ) - # Announce new voice + # Apply voice to speech driver immediately + if "SpeechDriver" in self.env["runtime"]: + try: + # Set language first if available + if voice_lang: + self.env["runtime"]["SpeechDriver"].set_language( + voice_lang + ) + # Then set voice + self.env["runtime"]["SpeechDriver"].set_voice(voice_name) + except Exception as e: + self.env["runtime"]["DebugManager"].write_debug_out( + (f"QuickMenuManager cycle_speech_voice: " + f"Error applying voice: {e}"), + debug.DebugLevel.ERROR + ) + + # Announce new voice (voice name only, not language) self.env["runtime"]["OutputManager"].present_text( - new_voice, interrupt=True + voice_name, interrupt=True ) return True diff --git a/src/fenrirscreenreader/fenrirVersion.py b/src/fenrirscreenreader/fenrirVersion.py index c3eb812f..894b8fed 100644 --- a/src/fenrirscreenreader/fenrirVersion.py +++ b/src/fenrirscreenreader/fenrirVersion.py @@ -5,4 +5,4 @@ # By Chrys, Storm Dragon, and contributors. version = "2025.12.02" -code_name = "master" +code_name = "testing"