diff --git a/src/fenrirscreenreader/commands/commands/apply_tested_voice.py b/src/fenrirscreenreader/commands/commands/apply_tested_voice.py index a1fe209b..a228452b 100644 --- a/src/fenrirscreenreader/commands/commands/apply_tested_voice.py +++ b/src/fenrirscreenreader/commands/commands/apply_tested_voice.py @@ -34,6 +34,7 @@ class command: module = self.env["commandBuffer"]["lastTestedModule"] voice = self.env["commandBuffer"]["lastTestedVoice"] + language = self.env["commandBuffer"].get("lastTestedLanguage") self.env["runtime"]["OutputManager"].present_text( f"Applying {voice} from {module}", interrupt=True @@ -46,6 +47,7 @@ class command: old_driver = SettingsManager.get_setting("speech", "driver") old_module = SettingsManager.get_setting("speech", "module") old_voice = SettingsManager.get_setting("speech", "voice") + old_language = SettingsManager.get_setting("speech", "language") try: # Apply new settings to runtime only (use set_setting to update @@ -55,6 +57,10 @@ class command: ) SettingsManager.set_setting("speech", "module", module) SettingsManager.set_setting("speech", "voice", voice) + if language: + SettingsManager.set_setting( + "speech", "language", language + ) # Apply to speech driver instance directly if "SpeechDriver" in self.env["runtime"]: @@ -62,6 +68,8 @@ class command: # Set the module and voice on the driver instance SpeechDriver.set_module(module) + if language: + SpeechDriver.set_language(language) SpeechDriver.set_voice(voice) self.env["runtime"]["OutputManager"].present_text( @@ -77,6 +85,9 @@ class command: SettingsManager.set_setting("speech", "driver", old_driver) SettingsManager.set_setting("speech", "module", old_module) SettingsManager.set_setting("speech", "voice", old_voice) + SettingsManager.set_setting( + "speech", "language", old_language + ) self.env["runtime"]["OutputManager"].present_text( f"Failed to apply voice, reverted: {str(e)}", diff --git a/src/fenrirscreenreader/commands/commands/export_clipboard_to_x.py b/src/fenrirscreenreader/commands/commands/export_clipboard_to_x.py index e30dfd0b..01a4ad3c 100644 --- a/src/fenrirscreenreader/commands/commands/export_clipboard_to_x.py +++ b/src/fenrirscreenreader/commands/commands/export_clipboard_to_x.py @@ -4,30 +4,35 @@ # Fenrir TTY screen reader # By Chrys, Storm Dragon, and contributors. -import _thread - +from fenrirscreenreader.core import debug from fenrirscreenreader.core.i18n import _ from fenrirscreenreader.utils import x_clipboard +def write_clipboard(clipboard): + """Write the graphical clipboard without accessing Fenrir state.""" + return x_clipboard.write_text(clipboard, scan_displays=True) + + class command: def __init__(self): - pass + self._task_ids = set() def initialize(self, environment, script_path=""): self.env = environment self.script_path = script_path def shutdown(self): - pass + task_manager = self.env["runtime"].get("BackgroundTaskManager") + if task_manager: + for task_id in self._task_ids: + task_manager.cancel_task(task_id) + self._task_ids.clear() def get_description(self): return _("Export current fenrir clipboard to X or GUI clipboard") def run(self): - _thread.start_new_thread(self._thread_run, ()) - - def _thread_run(self): try: # Check if clipboard is empty if self.env["runtime"]["MemoryManager"].is_index_list_empty( @@ -43,36 +48,43 @@ class command: "MemoryManager" ].get_index_list_element("clipboardHistory") - try: - success = x_clipboard.write_text( - clipboard, scan_displays=True - ) - except Exception: - success = False - - # Notify the user of the result - if success: - sync_manager = self.env["runtime"].get( - "ClipboardSyncManager" - ) - if sync_manager: - sync_manager.mark_written_to_x(clipboard) - self.env["runtime"]["OutputManager"].present_text( - _("exported to the X session."), interrupt=True - ) - else: - self.env["runtime"]["OutputManager"].present_text( - _( - "failed to export to X clipboard. No available display " - "found." - ), - interrupt=True, - ) + task_id = self.env["runtime"][ + "BackgroundTaskManager" + ].submit_task( + write_clipboard, + lambda result: self._handle_result(clipboard, result), + clipboard, + ) + if task_id is not None: + self._task_ids.add(task_id) except Exception as e: self.env["runtime"]["OutputManager"].present_text( str(e), sound_icon="", interrupt=False ) + def _handle_result(self, clipboard, result): + self._task_ids.discard(result.get("task_id")) + if result.get("succeeded") and result.get("value"): + sync_manager = self.env["runtime"].get("ClipboardSyncManager") + if sync_manager: + sync_manager.mark_written_to_x(clipboard) + self.env["runtime"]["OutputManager"].present_text( + _("exported to the X session."), interrupt=True + ) + return + + if not result.get("succeeded") and result.get("error"): + self.env["runtime"]["DebugManager"].write_debug_out( + "Clipboard export failed: " + result["error"], + debug.DebugLevel.ERROR, + ) + self.env["runtime"]["OutputManager"].present_text( + _( + "failed to export to X clipboard. No available display found." + ), + interrupt=True, + ) + def set_callback(self, callback): pass diff --git a/src/fenrirscreenreader/commands/commands/import_clipboard_from_x.py b/src/fenrirscreenreader/commands/commands/import_clipboard_from_x.py index 1c61a523..6bcd48a8 100644 --- a/src/fenrirscreenreader/commands/commands/import_clipboard_from_x.py +++ b/src/fenrirscreenreader/commands/commands/import_clipboard_from_x.py @@ -4,60 +4,68 @@ # Fenrir TTY screen reader # By Chrys, Storm Dragon, and contributors. -import _thread - from fenrirscreenreader.core.i18n import _ from fenrirscreenreader.utils import x_clipboard +def read_clipboard(): + """Read the graphical clipboard without accessing Fenrir state.""" + return x_clipboard.read_text(scan_displays=True) + + class command: def __init__(self): - pass + self._task_ids = set() def initialize(self, environment, script_path=""): self.env = environment self.script_path = script_path def shutdown(self): - pass + task_manager = self.env["runtime"].get("BackgroundTaskManager") + if task_manager: + for task_id in self._task_ids: + task_manager.cancel_task(task_id) + self._task_ids.clear() def get_description(self): return _("imports the graphical clipboard to Fenrir's clipboard") def run(self): - _thread.start_new_thread(self._thread_run, ()) + task_id = self.env["runtime"]["BackgroundTaskManager"].submit_task( + read_clipboard, self._handle_result + ) + if task_id is not None: + self._task_ids.add(task_id) - def _thread_run(self): - try: - try: - clipboard_content = x_clipboard.read_text( - scan_displays=True - ) - except Exception: - clipboard_content = None - - # Process the clipboard content if we found any - if clipboard_content and isinstance(clipboard_content, str): - self.env["runtime"]["MemoryManager"].add_value_to_first_index( - "clipboardHistory", clipboard_content - ) - self.env["runtime"]["OutputManager"].present_text( - "Import to Clipboard", - sound_icon="CopyToClipboard", - interrupt=True, - ) - self.env["runtime"]["OutputManager"].present_text( - clipboard_content, sound_icon="", interrupt=False - ) - else: - self.env["runtime"]["OutputManager"].present_text( - "No text found in clipboard or no accessible display", - interrupt=True, - ) - except Exception as e: + def _handle_result(self, result): + self._task_ids.discard(result.get("task_id")) + clipboard_content = result.get("value") + if not result.get("succeeded"): self.env["runtime"]["OutputManager"].present_text( - str(e), sound_icon="", interrupt=False + result.get("error", "Clipboard import failed"), + sound_icon="", + interrupt=False, ) + return + if not clipboard_content or not isinstance(clipboard_content, str): + self.env["runtime"]["OutputManager"].present_text( + "No text found in clipboard or no accessible display", + interrupt=True, + ) + return + + self.env["runtime"]["MemoryManager"].add_value_to_first_index( + "clipboardHistory", clipboard_content + ) + self.env["runtime"]["OutputManager"].present_text( + "Import to Clipboard", + sound_icon="CopyToClipboard", + interrupt=True, + ) + self.env["runtime"]["OutputManager"].present_text( + clipboard_content, sound_icon="", interrupt=False + ) def set_callback(self, callback): pass diff --git a/src/fenrirscreenreader/commands/commands/subprocess.py b/src/fenrirscreenreader/commands/commands/subprocess.py index 450fb1be..73f4084b 100644 --- a/src/fenrirscreenreader/commands/commands/subprocess.py +++ b/src/fenrirscreenreader/commands/commands/subprocess.py @@ -4,24 +4,42 @@ # Fenrir TTY screen reader # By Chrys, Storm Dragon, and contributors. -import _thread import os -from subprocess import PIPE -from subprocess import Popen +import subprocess from fenrirscreenreader.core.i18n import _ +def run_script(script_path, current_user): + """Run an external command without accessing Fenrir's shared state.""" + process = subprocess.Popen( + [script_path, current_user], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + stdout, stderr = process.communicate() + return { + "return_code": process.returncode, + "stdout": stdout, + "stderr": stderr, + } + + class command: def __init__(self): - pass + self._task_ids = set() def initialize(self, environment, script_path=""): self.env = environment self.script_path = script_path def shutdown(self): - pass + task_manager = self.env["runtime"].get("BackgroundTaskManager") + if task_manager: + for task_id in self._task_ids: + task_manager.cancel_task(task_id) + self._task_ids.clear() def get_description(self): return _("script: {0} fullpath: {1}").format( @@ -48,30 +66,39 @@ class command: interrupt=False, ) return - _thread.start_new_thread(self._thread_run, ()) - def _thread_run(self): - try: - p = Popen( - [self.script_path, self.env["general"]["curr_user"]], - stdout=PIPE, - stderr=PIPE, - ) - stdout, stderr = p.communicate() - stdout = stdout.decode("utf-8") - stderr = stderr.decode("utf-8") - self.env["runtime"]["OutputManager"].interrupt_output() - if stderr != "": - self.env["runtime"]["OutputManager"].present_text( - str(stderr), sound_icon="", interrupt=False - ) - if stdout != "": - self.env["runtime"]["OutputManager"].present_text( - str(stdout), sound_icon="", interrupt=False - ) - except Exception as e: + task_id = self.env["runtime"][ + "BackgroundTaskManager" + ].submit_external_task( + run_script, + self._handle_result, + self.script_path, + self.env["general"]["curr_user"], + ) + if task_id is not None: + self._task_ids.add(task_id) + + def _handle_result(self, result): + self._task_ids.discard(result.get("task_id")) + if not result.get("succeeded"): self.env["runtime"]["OutputManager"].present_text( - str(e), sound_icon="", interrupt=False + result.get("error", _("Script failed")), + sound_icon="", + interrupt=False, + ) + return + + output = result.get("value") or {} + stderr = output.get("stderr", "") + stdout = output.get("stdout", "") + self.env["runtime"]["OutputManager"].interrupt_output() + if stderr: + self.env["runtime"]["OutputManager"].present_text( + stderr, sound_icon="", interrupt=False + ) + if stdout: + self.env["runtime"]["OutputManager"].present_text( + stdout, sound_icon="", interrupt=False ) def set_callback(self, callback): diff --git a/src/fenrirscreenreader/commands/commands/voice_browser.py b/src/fenrirscreenreader/commands/commands/voice_browser.py index 7d201e75..b881b222 100644 --- a/src/fenrirscreenreader/commands/commands/voice_browser.py +++ b/src/fenrirscreenreader/commands/commands/voice_browser.py @@ -1,19 +1,20 @@ #!/usr/bin/env python3 -import subprocess import time from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.utils.speechd_utils import get_synthesis_voice_name class command: def __init__(self): - pass + self._request_generation = 0 + self._loading = False def initialize(self, environment): self.env = environment - self.testMessage = "This is a voice test. The quick brown fox jumps over the lazy dog." + self.test_message = ( + "This is a voice test. The quick brown fox jumps over the lazy dog." + ) self.modules = [] self.voices = [] self.module_index = 0 @@ -23,7 +24,9 @@ class command: self.lastAnnounceTime = 0 def shutdown(self): - pass + self._request_generation += 1 + self._loading = False + self._leave_voice_browser(False) def get_description(self): return "Interactive voice browser with arrow key navigation" @@ -37,34 +40,59 @@ class command: "Starting voice browser", interrupt=True ) - # Load modules - self.modules = self.get_speechd_modules() - if not self.modules: + if self._loading: + return + self._loading = True + self._request_generation += 1 + generation = self._request_generation + self.env["runtime"]["SpeechDiscoveryManager"].request_modules( + lambda modules, error: self._modules_ready( + generation, modules, error + ) + ) + + def _modules_ready(self, generation, modules, error): + if generation != self._request_generation: + return + if error or not modules: + self._loading = False self.env["runtime"]["OutputManager"].present_text( "No speech modules found", interrupt=True ) - self.env["runtime"]["OutputManager"].play_sound("Error") return - - # Set current module + self.modules = modules current_module = self.env["runtime"]["SettingsManager"].get_setting( "speech", "module" ) if current_module and current_module in self.modules: self.module_index = self.modules.index(current_module) + self._request_current_module_voices(generation, True) - # Load voices - self.load_voices_for_current_module() + def _request_current_module_voices(self, generation, enter_browser=False): + module = self.modules[self.module_index] + self.env["runtime"]["OutputManager"].present_text( + f"Loading voices for {module}", interrupt=True + ) + self.env["runtime"]["SpeechDiscoveryManager"].request_voices( + module, + lambda _module, voices, error: self._voices_ready( + generation, voices, error, enter_browser + ), + ) - # Set current voice + def _voices_ready(self, generation, voices, error, enter_browser): + if generation != self._request_generation: + return + self._loading = False + self.voices = voices if not error else [] + self.voice_index = 0 current_voice = self.env["runtime"]["SettingsManager"].get_setting( "speech", "voice" ) if current_voice and current_voice in self.voices: self.voice_index = self.voices.index(current_voice) - - # Enter browser mode - self.enter_voice_browser() + if enter_browser: + self.enter_voice_browser() self.announce_current_selection() def enter_voice_browser(self): @@ -113,6 +141,9 @@ class command: def exit_voice_browser(self): """Exit voice browser and restore normal key bindings""" + self._leave_voice_browser(True) + + def _leave_voice_browser(self, announce): if not self.browserActive: return @@ -125,16 +156,10 @@ class command: if "voiceBrowserInstance" in self.env["runtime"]: del self.env["runtime"]["voiceBrowserInstance"] - self.env["runtime"]["OutputManager"].present_text( - "Voice browser exited", interrupt=True - ) - - def load_voices_for_current_module(self): - """Load voices for current module""" - if self.module_index < len(self.modules): - module = self.modules[self.module_index] - self.voices = self.get_module_voices(module) - self.voice_index = 0 # Reset to first voice when changing modules + if announce: + self.env["runtime"]["OutputManager"].present_text( + "Voice browser exited", interrupt=True + ) def announce_current_selection(self): """Announce current module and voice""" @@ -149,7 +174,7 @@ class command: module = self.modules[self.module_index] if self.voices and self.voice_index < len(self.voices): - voice = self.voices[self.voice_index] + voice = self.voices[self.voice_index].split("|", 1)[0] self.env["runtime"]["OutputManager"].present_text( f"{module}: {voice} ({self.voice_index + 1}/{len(self.voices)})", interrupt=True, @@ -174,16 +199,19 @@ class command: self.announce_current_selection() def next_module(self): - """Move to next module""" - self.module_index = (self.module_index + 1) % len(self.modules) - self.load_voices_for_current_module() - self.announce_current_selection() + self._change_module(1) def prev_module(self): - """Move to previous module""" - self.module_index = (self.module_index - 1) % len(self.modules) - self.load_voices_for_current_module() - self.announce_current_selection() + self._change_module(-1) + + def _change_module(self, offset): + if self._loading or not self.modules: + return + self._loading = True + self._request_generation += 1 + generation = self._request_generation + self.module_index = (self.module_index + offset) % len(self.modules) + self._request_current_module_voices(generation) def test_voice(self): """Test current voice""" @@ -194,15 +222,38 @@ class command: return module = self.modules[self.module_index] - voice = self.voices[self.voice_index] + encoded_voice = self.voices[self.voice_index] + voice_name = encoded_voice.split("|", 1)[0] self.env["runtime"]["OutputManager"].present_text( "Testing...", interrupt=True ) - if self.preview_voice(module, voice): - # Store for apply command + self._request_generation += 1 + generation = self._request_generation + self._loading = True + self.env["runtime"]["SpeechDiscoveryManager"].request_voice_test( + module, + voice_name, + self.test_message, + lambda succeeded, error: self._voice_test_ready( + generation, module, encoded_voice, succeeded, error + ), + ) + + def _voice_test_ready( + self, generation, module, voice, succeeded, _error + ): + if generation != self._request_generation: + return + self._loading = False + if succeeded: self.env["commandBuffer"]["lastTestedModule"] = module - self.env["commandBuffer"]["lastTestedVoice"] = voice + voice_name, separator, language = voice.partition("|") + self.env["commandBuffer"]["lastTestedVoice"] = voice_name + if separator: + self.env["commandBuffer"]["lastTestedLanguage"] = language + else: + self.env["commandBuffer"].pop("lastTestedLanguage", None) self.env["runtime"]["OutputManager"].play_sound("Accept") else: self.env["runtime"]["OutputManager"].play_sound("Error") @@ -213,13 +264,17 @@ class command: return module = self.modules[self.module_index] - voice = self.voices[self.voice_index] + voice, separator, language = self.voices[self.voice_index].partition( + "|" + ) try: SettingsManager = self.env["runtime"]["SettingsManager"] SettingsManager.settings["speech"]["driver"] = "speechdDriver" SettingsManager.settings["speech"]["module"] = module SettingsManager.settings["speech"]["voice"] = voice + if separator: + SettingsManager.settings["speech"]["language"] = language if "SpeechDriver" in self.env["runtime"]: SpeechDriver = self.env["runtime"]["SpeechDriver"] @@ -237,50 +292,5 @@ class command: ) self.env["runtime"]["OutputManager"].play_sound("Error") - def preview_voice(self, module, voice): - """Test voice with spd-say""" - try: - cmd = ["spd-say", "-o", module, "-y", voice, self.testMessage] - result = subprocess.run(cmd, timeout=10) - return result.returncode == 0 - except Exception: - return False - - def get_speechd_modules(self): - """Get available speech modules""" - try: - result = subprocess.run( - ["spd-say", "-O"], capture_output=True, text=True, timeout=10 - ) - if result.returncode == 0: - lines = result.stdout.strip().split("\n") - return [line.strip() for line in lines[1:] if line.strip()] - except Exception: - pass - return [] - - def get_module_voices(self, module): - """Get voices for module""" - try: - result = subprocess.run( - ["spd-say", "-o", module, "-L"], - capture_output=True, - text=True, - timeout=10, - ) - if result.returncode == 0: - lines = result.stdout.strip().split("\n") - voices = [] - for line in lines[1:]: - if not line.strip(): - continue - voice = get_synthesis_voice_name(module, line) - if voice: - voices.append(voice) - return voices - except Exception: - pass - return [] - def set_callback(self, callback): pass diff --git a/src/fenrirscreenreader/commands/commands/voice_browser_safe.py b/src/fenrirscreenreader/commands/commands/voice_browser_safe.py index 0e76e8e8..71a71763 100644 --- a/src/fenrirscreenreader/commands/commands/voice_browser_safe.py +++ b/src/fenrirscreenreader/commands/commands/voice_browser_safe.py @@ -1,207 +1,160 @@ #!/usr/bin/env python3 -import subprocess -import threading -import time - +from fenrirscreenreader.core import debug from fenrirscreenreader.core.i18n import _ -from fenrirscreenreader.utils.speechd_utils import get_synthesis_voice_name class command: def __init__(self): - pass + self._request_generation = 0 + self._loading = False def initialize(self, environment): self.env = environment - self.testMessage = ( + self.test_message = ( "Voice test: The quick brown fox jumps over the lazy dog." ) def shutdown(self): - pass + self._request_generation += 1 + self._loading = False def get_description(self): - return "Safe voice browser - cycles through voices without hanging" + return _("browse and test Speech Dispatcher voices") def run(self): - try: + if self._loading: + return + self._loading = True + self._request_generation += 1 + generation = self._request_generation + self.env["runtime"]["OutputManager"].present_text( + _("Loading speech modules"), interrupt=True + ) + self.env["runtime"]["SpeechDiscoveryManager"].request_modules( + lambda modules, error: self._modules_ready( + generation, modules, error + ) + ) + + def _modules_ready(self, generation, modules, error): + if generation != self._request_generation: + return + if error or not modules: + self._loading = False + self._report_error(_("No speech modules found"), error) + return + + module_index = self.env["commandBuffer"].get( + "safeBrowserModuleIndex", 0 + ) + if module_index >= len(modules): + module_index = 0 + module = modules[module_index] + self.env["runtime"]["OutputManager"].present_text( + _("Loading voices for {module}").format(module=module), + interrupt=True, + ) + self.env["runtime"]["SpeechDiscoveryManager"].request_voices( + module, + lambda _module, voices, voice_error: self._voices_ready( + generation, + modules, + module_index, + voices, + voice_error, + ), + ) + + def _voices_ready( + self, generation, modules, module_index, voices, error + ): + if generation != self._request_generation: + return + if error or not voices: + self._loading = False + next_module = (module_index + 1) % len(modules) + self.env["commandBuffer"]["safeBrowserModuleIndex"] = next_module + self.env["commandBuffer"]["safeBrowserVoiceIndex"] = 0 + self._report_error(_("No voices found"), error) + return + + voice_index = self.env["commandBuffer"].get( + "safeBrowserVoiceIndex", 0 + ) + if voice_index >= len(voices): + voice_index = 0 + module = modules[module_index] + voice = voices[voice_index] + voice_name = voice.split("|", 1)[0] + self.env["runtime"]["OutputManager"].present_text( + _("Module: {module}, voice: {voice}").format( + module=module, voice=voice_name + ), + interrupt=True, + ) + self.env["runtime"]["OutputManager"].present_text( + _("Testing voice"), interrupt=True + ) + self.env["runtime"]["SpeechDiscoveryManager"].request_voice_test( + module, + voice_name, + self.test_message, + lambda succeeded, test_error: self._test_ready( + generation, + modules, + module_index, + voices, + voice_index, + succeeded, + test_error, + ), + ) + + def _test_ready( + self, + generation, + modules, + module_index, + voices, + voice_index, + succeeded, + error, + ): + if generation != self._request_generation: + return + self._loading = False + module = modules[module_index] + voice_name, separator, language = voices[voice_index].partition("|") + if succeeded: + self.env["commandBuffer"]["lastTestedModule"] = module + self.env["commandBuffer"]["lastTestedVoice"] = voice_name + if separator: + self.env["commandBuffer"]["lastTestedLanguage"] = language + else: + self.env["commandBuffer"].pop("lastTestedLanguage", None) self.env["runtime"]["OutputManager"].present_text( - "Starting safe voice browser", interrupt=True + _("Voice test completed successfully"), interrupt=True ) + else: + self._report_error(_("Voice test failed"), error) - # Get modules with timeout protection - modules = self.get_speechd_modules_with_timeout() - if not modules: - self.env["runtime"]["OutputManager"].present_text( - "No speech modules found", interrupt=True - ) - return + voice_index += 1 + if voice_index >= len(voices): + voice_index = 0 + module_index = (module_index + 1) % len(modules) + self.env["commandBuffer"]["safeBrowserModuleIndex"] = module_index + self.env["commandBuffer"]["safeBrowserVoiceIndex"] = voice_index - # Get current position from commandBuffer or start fresh - module_index = self.env["commandBuffer"].get( - "safeBrowserModuleIndex", 0 + def _report_error(self, message, detail): + if detail: + self.env["runtime"]["DebugManager"].write_debug_out( + f"voice_browser_safe: {detail}", + debug.DebugLevel.ERROR, ) - voice_index = self.env["commandBuffer"].get( - "safeBrowserVoiceIndex", 0 - ) - - # Ensure valid module index - if module_index >= len(modules): - module_index = 0 - - current_module = modules[module_index] - self.env["runtime"]["OutputManager"].present_text( - f"Loading voices for {current_module}...", interrupt=True - ) - - # Get voices with timeout protection - voices = self.get_module_voices_with_timeout(current_module) - if not voices: - self.env["runtime"]["OutputManager"].present_text( - f"No voices in {current_module}, trying next module", - interrupt=True, - ) - module_index = (module_index + 1) % len(modules) - self.env["commandBuffer"][ - "safeBrowserModuleIndex" - ] = module_index - self.env["commandBuffer"]["safeBrowserVoiceIndex"] = 0 - return - - # Ensure valid voice index - if voice_index >= len(voices): - voice_index = 0 - - current_voice = voices[voice_index] - - # Announce current selection - self.env["runtime"]["OutputManager"].present_text( - f"Module: {current_module} ({module_index + 1}/{len(modules)})", - interrupt=True, - ) - self.env["runtime"]["OutputManager"].present_text( - f"Voice: {current_voice} ({voice_index + 1}/{len(voices)})", - interrupt=True, - ) - - # Test voice in background thread to avoid blocking - self.env["runtime"]["OutputManager"].present_text( - "Testing voice...", interrupt=True - ) - - # Use threading to prevent freezing - test_thread = threading.Thread( - target=self.test_voice_async, - args=(current_module, current_voice), - ) - test_thread.daemon = True - test_thread.start() - - # Store tested voice for apply command - self.env["commandBuffer"]["lastTestedModule"] = current_module - self.env["commandBuffer"]["lastTestedVoice"] = current_voice - - # Advance to next voice for next run - voice_index += 1 - if voice_index >= len(voices): - voice_index = 0 - module_index = (module_index + 1) % len(modules) - - # Store position for next run - self.env["commandBuffer"]["safeBrowserModuleIndex"] = module_index - self.env["commandBuffer"]["safeBrowserVoiceIndex"] = voice_index - - # Give instructions - self.env["runtime"]["OutputManager"].present_text( - "Run again for next voice, or use apply voice command", - interrupt=True, - ) - - except Exception as e: - self.env["runtime"]["OutputManager"].present_text( - f"Voice browser error: {str(e)}", interrupt=True - ) - self.env["runtime"]["OutputManager"].play_sound("Error") - - def test_voice_async(self, module, voice): - """Test voice in background thread to avoid blocking""" - try: - # Run with strict timeout - cmd = ["spd-say", "-o", module, "-y", voice, self.testMessage] - result = subprocess.run(cmd, timeout=5, capture_output=True) - - # Schedule success sound for main thread - if result.returncode == 0: - # We can't call OutputManager from background thread safely - # So we'll just let the main thread handle feedback - pass - - except subprocess.TimeoutExpired: - # Voice test timed out - this is okay, don't crash - pass - except Exception: - # Any other error - also okay, don't crash - pass - - def get_speechd_modules_with_timeout(self): - """Get speech modules with timeout protection""" - try: - result = subprocess.run( - ["spd-say", "-O"], capture_output=True, text=True, timeout=3 - ) - if result.returncode == 0: - lines = result.stdout.strip().split("\n") - modules = [line.strip() for line in lines[1:] if line.strip()] - # Limit to first 10 modules to prevent overload - return modules[:10] - except subprocess.TimeoutExpired: - self.env["runtime"]["OutputManager"].present_text( - "Module detection timed out", interrupt=True - ) - except Exception as e: - self.env["runtime"]["OutputManager"].present_text( - f"Module detection failed: {str(e)}", interrupt=True - ) - return [] - - def get_module_voices_with_timeout(self, module): - """Get voices with timeout and limits""" - try: - result = subprocess.run( - ["spd-say", "-o", module, "-L"], - capture_output=True, - text=True, - timeout=5, - ) - if result.returncode == 0: - lines = result.stdout.strip().split("\n") - voices = [] - for line in lines[1:]: - if not line.strip(): - continue - voice = get_synthesis_voice_name(module, line) - if voice: - voices.append(voice) - - # Limit voice count to prevent memory issues - if len(voices) > 1000: - self.env["runtime"]["OutputManager"].present_text( - f"found {len(voices)} voices, limiting to first 1000", - interrupt=True, - ) - voices = voices[:1000] - - return voices - except subprocess.TimeoutExpired: - self.env["runtime"]["OutputManager"].present_text( - f"Voice detection for {module} timed out", interrupt=True - ) - except Exception as e: - self.env["runtime"]["OutputManager"].present_text( - f"Voice detection failed: {str(e)}", interrupt=True - ) - return [] + self.env["runtime"]["OutputManager"].present_text( + message, interrupt=True + ) def set_callback(self, callback): pass diff --git a/src/fenrirscreenreader/core/backgroundTaskManager.py b/src/fenrirscreenreader/core/backgroundTaskManager.py new file mode 100644 index 00000000..a91e54b5 --- /dev/null +++ b/src/fenrirscreenreader/core/backgroundTaskManager.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# Fenrir TTY screen reader +# By Chrys, Storm Dragon, and contributors. + +import itertools +import queue +import threading + +from fenrirscreenreader.core import debug +from fenrirscreenreader.core.eventData import FenrirEventType + + +class BackgroundTaskManager: + """Run bounded auxiliary work without exposing Fenrir state to workers.""" + + def __init__(self, worker_count=4, external_worker_count=2): + self.worker_count = worker_count + self.external_worker_count = external_worker_count + self.env = None + self._accepting_tasks = False + self._callbacks = {} + self._callback_lock = threading.Lock() + self._task_ids = itertools.count(1) + self._task_queues = { + "default": queue.Queue(), + "external": queue.Queue(), + } + self._workers = [] + + def initialize(self, environment): + self.env = environment + self._accepting_tasks = True + self._start_workers("default", self.worker_count) + self._start_workers("external", self.external_worker_count) + + def _start_workers(self, lane, worker_count): + task_queue = self._task_queues[lane] + for worker_index in range(worker_count): + worker = threading.Thread( + target=self._run_worker, + args=(task_queue,), + name=f"fenrir-{lane}-{worker_index + 1}", + daemon=True, + ) + self._workers.append(worker) + worker.start() + + def shutdown(self): + self._accepting_tasks = False + with self._callback_lock: + self._callbacks.clear() + + for task_queue in self._task_queues.values(): + try: + while True: + task_queue.get_nowait() + task_queue.task_done() + except queue.Empty: + pass + + for _worker_index in range(self.worker_count): + self._task_queues["default"].put(None) + for _worker_index in range(self.external_worker_count): + self._task_queues["external"].put(None) + for worker in self._workers: + worker.join(timeout=0.25) + self._workers = [] + + def submit_task(self, function, callback, *args, **kwargs): + """Schedule a pure worker function and return its task identifier.""" + return self._submit_to_lane( + "default", function, callback, args, kwargs + ) + + def submit_external_task(self, function, callback, *args, **kwargs): + """Schedule potentially long-running user scripts on an isolated lane.""" + return self._submit_to_lane( + "external", function, callback, args, kwargs + ) + + def _submit_to_lane(self, lane, function, callback, args, kwargs): + if not self._accepting_tasks or not callable(function): + return None + if not callable(callback): + return None + + task_id = next(self._task_ids) + with self._callback_lock: + self._callbacks[task_id] = callback + self._task_queues[lane].put((task_id, function, args, kwargs)) + return task_id + + def cancel_task(self, task_id): + """Discard delivery for a task that is no longer relevant.""" + if task_id is None: + return + with self._callback_lock: + self._callbacks.pop(task_id, None) + + def handle_result(self, result): + """Deliver a completed task on Fenrir's main event-loop thread.""" + if not isinstance(result, dict): + return + task_id = result.get("task_id") + with self._callback_lock: + callback = self._callbacks.pop(task_id, None) + if callback is None: + return + try: + callback(result) + except Exception as error: + self.env["runtime"]["DebugManager"].write_debug_out( + "BackgroundTaskManager handle_result: " + str(error), + debug.DebugLevel.ERROR, + ) + + def _run_worker(self, task_queue): + while True: + task = task_queue.get() + try: + if task is None: + return + task_id, function, args, kwargs = task + try: + value = function(*args, **kwargs) + result = { + "task_id": task_id, + "succeeded": True, + "value": value, + "error": "", + } + except Exception as error: + result = { + "task_id": task_id, + "succeeded": False, + "value": None, + "error": str(error), + } + + if self._accepting_tasks: + self.env["runtime"]["EventManager"].put_to_event_queue( + FenrirEventType.background_task_result, + result, + ) + finally: + task_queue.task_done() diff --git a/src/fenrirscreenreader/core/clipboardSyncManager.py b/src/fenrirscreenreader/core/clipboardSyncManager.py index d6512d9d..af09b499 100644 --- a/src/fenrirscreenreader/core/clipboardSyncManager.py +++ b/src/fenrirscreenreader/core/clipboardSyncManager.py @@ -6,12 +6,64 @@ import os import threading -import time from fenrirscreenreader.core import debug +from fenrirscreenreader.core.eventData import FenrirEventType from fenrirscreenreader.utils import x_clipboard +def synchronize_clipboards( + display, fenrir_text, last_observed_fenrir, last_observed_x +): + """Perform one clipboard I/O cycle without accessing Fenrir managers.""" + result = {} + try: + x_text = x_clipboard.read_text(display) + if not isinstance(x_text, str) or not x_text: + x_text = None + except Exception as error: + return {"error": "ClipboardSyncManager paste failed: " + str(error)} + + if fenrir_text and x_text and fenrir_text == x_text: + return { + "last_observed_fenrir": fenrir_text, + "last_observed_x": x_text, + } + + fenrir_changed = ( + fenrir_text and fenrir_text != last_observed_fenrir + ) + x_changed = x_text and x_text != last_observed_x + + if fenrir_changed: + try: + written = x_clipboard.write_text(fenrir_text, display) + except Exception as error: + written = False + result["error"] = ( + "ClipboardSyncManager copy failed: " + str(error) + ) + result["last_observed_fenrir"] = fenrir_text + if written: + result["last_written_to_x"] = fenrir_text + result["last_observed_x"] = fenrir_text + return result + + if x_changed: + return { + "import_text": x_text, + "last_imported_from_x": x_text, + "last_observed_fenrir": x_text, + "last_observed_x": x_text, + } + + if fenrir_text: + result["last_observed_fenrir"] = fenrir_text + if x_text: + result["last_observed_x"] = x_text + return result + + class ClipboardSyncManager: def __init__(self): self.env = None @@ -19,7 +71,10 @@ class ClipboardSyncManager: self.display = "" self.interval = 0.5 self.running = False - self.thread = None + self._task_id = None + self._scheduler_stop = threading.Event() + self._sync_event_pending = threading.Event() + self._scheduler_thread = None self.last_written_to_x = None self.last_imported_from_x = None self.last_observed_fenrir = None @@ -82,19 +137,85 @@ class ClipboardSyncManager: if self.running: return self.running = True - self.thread = threading.Thread(target=self._run, daemon=True) - self.thread.start() + self._scheduler_stop.clear() + self._scheduler_thread = threading.Thread( + target=self._schedule_sync_events, + name="fenrir-clipboard-scheduler", + daemon=True, + ) + self._scheduler_thread.start() def stop(self): self.running = False - if self.thread: - self.thread.join(timeout=1.0) - self.thread = None + task_manager = self.env["runtime"].get("BackgroundTaskManager") + if task_manager and self._task_id is not None: + task_manager.cancel_task(self._task_id) + self._task_id = None + self._scheduler_stop.set() + self._sync_event_pending.clear() + if self._scheduler_thread: + self._scheduler_thread.join(timeout=1.0) + self._scheduler_thread = None - def _run(self): + def _schedule_sync_events(self): while self.running: - self.poll_once() - time.sleep(self.interval) + if not self._sync_event_pending.is_set(): + self._sync_event_pending.set() + self.env["runtime"]["EventManager"].put_to_event_queue( + FenrirEventType.clipboard_sync, None + ) + if self._scheduler_stop.wait(self.interval): + return + + def handle_sync_event(self): + """Schedule clipboard I/O while keeping manager state on the loop.""" + if not self.running or self._task_id is not None: + return + + fenrir_text = self._get_fenrir_clipboard_text() + self._task_id = self.env["runtime"][ + "BackgroundTaskManager" + ].submit_task( + synchronize_clipboards, + lambda result: self._handle_poll_result(fenrir_text, result), + self.display, + fenrir_text, + self.last_observed_fenrir, + self.last_observed_x, + ) + if self._task_id is None: + self._sync_event_pending.clear() + + def _handle_poll_result(self, fenrir_snapshot, task_result): + self._task_id = None + self._sync_event_pending.clear() + if not self.running: + return + if not task_result.get("succeeded"): + self._debug( + "ClipboardSyncManager poll failed: " + + task_result.get("error", "unknown error") + ) + return + + result = task_result.get("value") or {} + if result.get("error"): + self._debug(result["error"]) + import_text = result.get("import_text") + if import_text: + if self._get_fenrir_clipboard_text() != fenrir_snapshot: + return + self.env["runtime"]["MemoryManager"].add_value_to_first_index( + "clipboardHistory", import_text + ) + for attribute in ( + "last_written_to_x", + "last_imported_from_x", + "last_observed_fenrir", + "last_observed_x", + ): + if attribute in result: + setattr(self, attribute, result[attribute]) def poll_once(self): fenrir_text = self._get_fenrir_clipboard_text() diff --git a/src/fenrirscreenreader/core/dynamicVoiceMenu.py b/src/fenrirscreenreader/core/dynamicVoiceMenu.py index 3d91d994..6a153aed 100644 --- a/src/fenrirscreenreader/core/dynamicVoiceMenu.py +++ b/src/fenrirscreenreader/core/dynamicVoiceMenu.py @@ -1,12 +1,6 @@ #!/usr/bin/env python3 -import importlib.util -import os -import subprocess -import time - from fenrirscreenreader.core import debug -from fenrirscreenreader.utils.speechd_utils import get_synthesis_voice_name class DynamicVoiceCommand: @@ -14,51 +8,40 @@ class DynamicVoiceCommand: def __init__(self, module, voice, env): self.module = module - self.voice = voice + self.voice, separator, self.language = voice.partition("|") + if not separator: + self.language = "" self.env = env - self.testMessage = "This is a voice test. The quick brown fox jumps over the lazy dog." + self.test_message = ( + "This is a voice test. The quick brown fox jumps over the lazy dog." + ) + self._test_generation = 0 def initialize(self, environment): self.env = environment def shutdown(self): - pass + self._test_generation += 1 def get_description(self): return f"Select voice: {self.voice}" def run(self): + self._test_generation += 1 + generation = self._test_generation try: self.env["runtime"]["OutputManager"].present_text( f"Testing voice {self.voice} from {self.module}. Please wait.", interrupt=True, ) - - # Brief pause before testing to avoid speech overlap - time.sleep(0.5) - - # Test voice - testResult, errorMsg = self.test_voice() - if testResult: - self.env["runtime"]["OutputManager"].present_text( - "Voice test completed successfully. Navigate to Apply Tested Voice to use this voice.", - interrupt=False, - flush=False, - ) - - # Store for confirmation (use same variables as - # apply_tested_voice.py) - self.env["commandBuffer"]["lastTestedModule"] = self.module - self.env["commandBuffer"]["lastTestedVoice"] = self.voice - self.env["commandBuffer"]["pendingVoiceModule"] = self.module - self.env["commandBuffer"]["pendingVoiceVoice"] = self.voice - self.env["commandBuffer"]["voiceTestCompleted"] = True - else: - self.env["runtime"]["OutputManager"].present_text( - f"Voice test failed: {errorMsg}", - interrupt=False, - flush=False, - ) + self.env["runtime"]["SpeechDiscoveryManager"].request_voice_test( + self.module, + self.voice, + self.test_message, + lambda succeeded, error: self._finish_voice_test( + generation, succeeded, error + ), + ) except Exception as e: self.env["runtime"]["OutputManager"].present_text( @@ -67,35 +50,32 @@ class DynamicVoiceCommand: flush=False, ) - def test_voice(self): - """Test voice with spd-say""" - try: - cmd = [ - "spd-say", - "-C", - "-w", - "-o", - self.module, - "-y", - self.voice, - self.testMessage, - ] - result = subprocess.run( - cmd, timeout=8, capture_output=True, text=True + def _finish_voice_test(self, generation, succeeded, error): + if generation != self._test_generation: + return + if not succeeded: + self.env["runtime"]["OutputManager"].present_text( + f"Voice test failed: {error}", + interrupt=False, + flush=False, ) - if result.returncode == 0: - return True, "Voice test successful" - else: - error_msg = ( - result.stderr.strip() - if result.stderr - else f"Command failed with return code {result.returncode}" - ) - return False, error_msg - except subprocess.TimeoutExpired: - return False, "Voice test timed out" - except Exception as e: - return False, f"Error running voice test: {str(e)}" + return + + self.env["commandBuffer"]["lastTestedModule"] = self.module + self.env["commandBuffer"]["lastTestedVoice"] = self.voice + self.env["commandBuffer"]["pendingVoiceModule"] = self.module + self.env["commandBuffer"]["pendingVoiceVoice"] = self.voice + if self.language: + self.env["commandBuffer"]["pendingVoiceLanguage"] = self.language + else: + self.env["commandBuffer"].pop("pendingVoiceLanguage", None) + self.env["commandBuffer"]["voiceTestCompleted"] = True + self.env["runtime"]["OutputManager"].present_text( + "Voice test completed successfully. " + "Navigate to Apply Tested Voice to use this voice.", + interrupt=False, + flush=False, + ) def set_callback(self, callback): pass @@ -126,6 +106,7 @@ class DynamicApplyVoiceCommand: module = self.env["commandBuffer"]["pendingVoiceModule"] voice = self.env["commandBuffer"]["pendingVoiceVoice"] + language = self.env["commandBuffer"].get("pendingVoiceLanguage") self.env["runtime"]["OutputManager"].present_text( f"Applying {voice} from {module}", interrupt=True @@ -148,6 +129,7 @@ class DynamicApplyVoiceCommand: old_driver = settings_manager.get_setting("speech", "driver") old_module = settings_manager.get_setting("speech", "module") old_voice = settings_manager.get_setting("speech", "voice") + old_language = settings_manager.get_setting("speech", "language") try: # Apply new settings to runtime only (use set_setting to update @@ -157,51 +139,36 @@ class DynamicApplyVoiceCommand: ) settings_manager.set_setting("speech", "module", module) settings_manager.set_setting("speech", "voice", voice) + if language: + settings_manager.set_setting( + "speech", "language", language + ) # Apply settings to speech driver directly if "SpeechDriver" in self.env["runtime"]: - SpeechDriver = self.env["runtime"]["SpeechDriver"] + speech_driver = self.env["runtime"]["SpeechDriver"] - # Get current module to see if we're changing modules - current_module = settings_manager.get_setting( - "speech", "module" - ) - module_changing = current_module != module - - # Set module and voice on driver instance first - SpeechDriver.set_module(module) - SpeechDriver.set_voice(voice) - - if module_changing: - # Module change requires reinitializing the speech - # driver - self.env["runtime"]["OutputManager"].present_text( - f"Switching from {current_module} to {module} module", - interrupt=True, - ) - SpeechDriver.shutdown() - SpeechDriver.initialize(self.env) - # Re-set after initialization - SpeechDriver.set_module(module) - SpeechDriver.set_voice(voice) - self.env["runtime"]["OutputManager"].present_text( - "Speech driver reinitialized", interrupt=True - ) + # Set module and voice on the driver instance. + speech_driver.set_module(module) + if language: + speech_driver.set_language(language) + speech_driver.set_voice(voice) # Debug: verify what was actually set self.env["runtime"]["OutputManager"].present_text( - f"Speech driver now has module: {SpeechDriver.module}, voice: {SpeechDriver.voice}", + "Speech driver now has module: " + f"{speech_driver.module}, voice: " + f"{speech_driver.voice}", interrupt=True, ) # Force application by speaking a test message self.env["runtime"]["OutputManager"].present_text( - "Voice applied successfully! You should hear this in the new voice.", + "Voice applied successfully! You should hear this in " + "the new voice.", interrupt=True, ) - # Brief pause then more speech to test - time.sleep(1) self.env["runtime"]["OutputManager"].present_text( "Use save settings to make permanent", interrupt=True ) @@ -214,16 +181,19 @@ class DynamicApplyVoiceCommand: except Exception as e: # Revert on failure - settings_manager.settings["speech"]["driver"] = old_driver - settings_manager.settings["speech"]["module"] = old_module - settings_manager.settings["speech"]["voice"] = old_voice + settings_manager.set_setting("speech", "driver", old_driver) + settings_manager.set_setting("speech", "module", old_module) + settings_manager.set_setting("speech", "voice", old_voice) + settings_manager.set_setting( + "speech", "language", old_language + ) # Try to reinitialize with old settings if "SpeechDriver" in self.env["runtime"]: try: - SpeechDriver = self.env["runtime"]["SpeechDriver"] - SpeechDriver.shutdown() - SpeechDriver.initialize(self.env) + speech_driver = self.env["runtime"]["SpeechDriver"] + speech_driver.shutdown() + speech_driver.initialize(self.env) except Exception as e: self.env["runtime"]["DebugManager"].write_debug_out( "dynamicVoiceMenu: Error reinitializing speech driver: " @@ -246,56 +216,139 @@ class DynamicApplyVoiceCommand: pass -def add_dynamic_voice_menus(VmenuManager): - """Add dynamic voice menus to vmenu system""" +def add_dynamic_voice_menus(vmenu_manager): + """Populate cached voice menus and start non-blocking discovery if needed.""" try: - env = VmenuManager.env - - # Get speech modules - modules = get_speechd_modules() - if not modules: - return - - # Create voice browser submenu - voice_browser_menu = {} - - # Add apply voice command - apply_command = DynamicApplyVoiceCommand(env) - voice_browser_menu["Apply Tested Voice Action"] = apply_command - - # Add modules as submenus - for module in modules[ - :8 - ]: # Limit to 8 modules to keep menu manageable - module_menu = {} - - # Get voices for this module - voices = get_module_voices(module) - if voices: - - # Add voice commands - for voice in voices: - voice_command = DynamicVoiceCommand(module, voice, env) - module_menu[f"{voice} Action"] = voice_command - else: - module_menu["No voices available Action"] = ( - create_info_command(f"No voices found for {module}", env) + env = vmenu_manager.env + discovery = env["runtime"]["SpeechDiscoveryManager"] + generation = getattr(vmenu_manager, "_voice_menu_generation", 0) + 1 + vmenu_manager._voice_menu_generation = generation + modules = discovery.get_cached_modules() + if modules is None: + _install_loading_menu(vmenu_manager) + discovery.request_modules( + lambda found, error: _request_dynamic_voice_lists( + vmenu_manager, generation, found, error, False ) - - voice_browser_menu[f"{module} Menu"] = module_menu - - # Add to main menu dict - VmenuManager.menuDict["Voice Browser Menu"] = voice_browser_menu - - except Exception as e: - # Use debug manager instead of print for error logging + ) + return + _request_dynamic_voice_lists( + vmenu_manager, generation, modules, "", True + ) + except Exception as error: if "DebugManager" in env["runtime"]: env["runtime"]["DebugManager"].write_debug_out( - f"Error creating dynamic voice menus: {e}", + f"Error creating dynamic voice menus: {error}", debug.DebugLevel.ERROR, ) else: - print(f"Error creating dynamic voice menus: {e}") + print(f"Error creating dynamic voice menus: {error}") + + +def _request_dynamic_voice_lists( + vmenu_manager, generation, modules, error, allow_active +): + if generation != getattr(vmenu_manager, "_voice_menu_generation", 0): + return + if error or not modules: + if vmenu_manager.get_active() and not allow_active: + vmenu_manager._voice_menu_refresh_pending = True + return + _install_discovery_error_menu(vmenu_manager) + return + + discovery = vmenu_manager.env["runtime"]["SpeechDiscoveryManager"] + selected_modules = modules[:8] + voices_by_module = {} + missing_modules = [] + for module in selected_modules: + voices = discovery.get_cached_voices(module) + if voices is None: + missing_modules.append(module) + else: + voices_by_module[module] = voices + + if not missing_modules: + _install_dynamic_voice_menu( + vmenu_manager, + generation, + selected_modules, + voices_by_module, + allow_active, + ) + return + + _install_loading_menu(vmenu_manager) + remaining_modules = set(missing_modules) + + def voices_ready(module, voices, _voice_error): + if generation != getattr(vmenu_manager, "_voice_menu_generation", 0): + return + voices_by_module[module] = voices + remaining_modules.discard(module) + if not remaining_modules: + _install_dynamic_voice_menu( + vmenu_manager, + generation, + selected_modules, + voices_by_module, + False, + ) + + for module in missing_modules: + discovery.request_voices(module, voices_ready) + + +def _install_dynamic_voice_menu( + vmenu_manager, + generation, + modules, + voices_by_module, + allow_active, +): + if generation != getattr(vmenu_manager, "_voice_menu_generation", 0): + return + if vmenu_manager.get_active() and not allow_active: + vmenu_manager._voice_menu_refresh_pending = True + return + + env = vmenu_manager.env + voice_browser_menu = { + "Apply Tested Voice Action": DynamicApplyVoiceCommand(env) + } + for module in modules: + module_menu = {} + voices = voices_by_module.get(module, []) + for voice in voices: + voice_name = voice.split("|", 1)[0] + module_menu[f"{voice_name} Action"] = DynamicVoiceCommand( + module, voice, env + ) + if not module_menu: + module_menu["No voices available Action"] = create_info_command( + f"No voices found for {module}", env + ) + voice_browser_menu[f"{module} Menu"] = module_menu + vmenu_manager.menuDict["Voice Browser Menu"] = voice_browser_menu + vmenu_manager._voice_menu_refresh_pending = False + + +def _install_loading_menu(vmenu_manager): + env = vmenu_manager.env + vmenu_manager.menuDict["Voice Browser Menu"] = { + "Loading voices Action": create_info_command( + "Voice information is still loading", env + ) + } + + +def _install_discovery_error_menu(vmenu_manager): + env = vmenu_manager.env + vmenu_manager.menuDict["Voice Browser Menu"] = { + "Voice discovery failed Action": create_info_command( + "Voice discovery failed", env + ) + } def create_info_command(message, env): @@ -324,41 +377,3 @@ def create_info_command(message, env): pass return InfoCommand(message, env) - - -def get_speechd_modules(): - """Get available speech modules""" - try: - result = subprocess.run( - ["spd-say", "-O"], capture_output=True, text=True, timeout=5 - ) - if result.returncode == 0: - lines = result.stdout.strip().split("\n") - return [line.strip() for line in lines[1:] if line.strip()] - except Exception: - pass - return [] - - -def get_module_voices(module): - """Get voices for a module""" - 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 - voice = get_synthesis_voice_name(module, line) - if voice: - voices.append(voice) - return voices - except Exception: - pass - return [] diff --git a/src/fenrirscreenreader/core/eventData.py b/src/fenrirscreenreader/core/eventData.py index 1880b67d..0a812d1f 100644 --- a/src/fenrirscreenreader/core/eventData.py +++ b/src/fenrirscreenreader/core/eventData.py @@ -19,6 +19,8 @@ class FenrirEventType(Enum): heart_beat = 6 execute_command = 7 remote_incomming = 8 + background_task_result = 9 + clipboard_sync = 10 def __int__(self): return self.value diff --git a/src/fenrirscreenreader/core/eventManager.py b/src/fenrirscreenreader/core/eventManager.py index 88d81f9e..cacb97bf 100644 --- a/src/fenrirscreenreader/core/eventManager.py +++ b/src/fenrirscreenreader/core/eventManager.py @@ -63,6 +63,12 @@ class EventManager: self.env["runtime"]["FenrirManager"].handle_execute_command(event) elif event["Type"] == FenrirEventType.remote_incomming: self.env["runtime"]["FenrirManager"].handle_remote_incomming(event) + elif event["Type"] == FenrirEventType.background_task_result: + self.env["runtime"]["FenrirManager"].handle_background_task_result( + event + ) + elif event["Type"] == FenrirEventType.clipboard_sync: + self.env["runtime"]["FenrirManager"].handle_clipboard_sync(event) def is_main_event_loop_running(self): return self.running.value == 1 diff --git a/src/fenrirscreenreader/core/fenrirManager.py b/src/fenrirscreenreader/core/fenrirManager.py index cc7ef237..65ca30ff 100644 --- a/src/fenrirscreenreader/core/fenrirManager.py +++ b/src/fenrirscreenreader/core/fenrirManager.py @@ -221,6 +221,11 @@ class FenrirManager: event["data"] ) + def handle_background_task_result(self, event): + self.environment["runtime"]["BackgroundTaskManager"].handle_result( + event["data"] + ) + def handle_screen_change(self, event): self.environment["runtime"]["ScreenManager"].handle_screen_change( event["data"] @@ -287,6 +292,13 @@ class FenrirManager: "onHeartBeat", force=True ) + def handle_clipboard_sync(self, event): + clipboard_sync_manager = self.environment["runtime"].get( + "ClipboardSyncManager" + ) + if clipboard_sync_manager: + clipboard_sync_manager.handle_sync_event() + def detect_shortcut_command(self): if self.environment["input"]["key_forward"] != 0: return diff --git a/src/fenrirscreenreader/core/generalData.py b/src/fenrirscreenreader/core/generalData.py index c85234d5..dcf9721d 100644 --- a/src/fenrirscreenreader/core/generalData.py +++ b/src/fenrirscreenreader/core/generalData.py @@ -24,6 +24,8 @@ general_data = { "SpeechHistoryManager", "HelpManager", "MemoryManager", + "SpeechDiscoveryManager", + "BackgroundTaskManager", "EventManager", "ProcessManager", "VmenuManager", diff --git a/src/fenrirscreenreader/core/quickMenuManager.py b/src/fenrirscreenreader/core/quickMenuManager.py index b4f6eaed..1b1312fe 100644 --- a/src/fenrirscreenreader/core/quickMenuManager.py +++ b/src/fenrirscreenreader/core/quickMenuManager.py @@ -4,135 +4,19 @@ # Fenrir TTY screen reader # By Chrys, Storm Dragon, and contributors. -import subprocess -import time - from fenrirscreenreader.core import debug from fenrirscreenreader.core.i18n import _ from fenrirscreenreader.core.settingsData import settings_data -from fenrirscreenreader.utils.speechd_utils import ( - get_synthesis_voice_name, - parse_synthesis_voice_line, -) -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. - """ +class QuickMenuManager: 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() and line.strip().lower() != "dummy" - ] - 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() == "voxin": - # For Voxin, store voice name with language - voice_data = self._process_voxin_voice(line) - if voice_data: - voices.append(voice_data) - else: - voice = get_synthesis_voice_name(module, line) - if voice: - voices.append(voice) - - 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_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') - """ - voice_data = parse_synthesis_voice_line(voice_line) - if voice_data is None: - return None - voice_name, language, _variant = voice_data - if not language: - return None - # Encode language with voice for later extraction - return f"{voice_name}|{language}" + self.position = 0 + self.quickMenu = [] + self.settings = settings_data + self._module_request_pending = False + self._voice_requests_pending = set() def _select_default_voice(self, voices): """Select a sensible default voice from list, preferring user's @@ -202,18 +86,7 @@ class SpeechHelperMixin: return voices[0] 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 + self.env["runtime"]["SpeechDiscoveryManager"].invalidate_cache() def initialize(self, environment): self.env = environment @@ -420,197 +293,202 @@ class QuickMenuManager(SpeechHelperMixin): return True def cycle_speech_module(self, direction): - """Cycle to next/previous speech-dispatcher module. + if self._module_request_pending: + return False + discovery = self.env["runtime"]["SpeechDiscoveryManager"] + modules = discovery.get_cached_modules() + if modules is not None: + return self._continue_module_cycle(direction, modules, False) - 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" + self._module_request_pending = True + self.env["runtime"]["OutputManager"].present_text( + "Loading speech modules", interrupt=True + ) + discovery.request_modules( + lambda found, error: self._continue_module_cycle( + direction, found, True, error ) + ) + return False - # 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 - ) - - # 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", 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 + def _continue_module_cycle( + self, direction, modules, announce_result, error="" + ): + if error: + self._module_request_pending = False + self._report_discovery_error("module", error) + return False + if not modules: + self._module_request_pending = False 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 + "No modules available", interrupt=True ) 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" ) + try: + current_index = ( + modules.index(current_module) if current_module else 0 + ) + except ValueError: + current_index = 0 + if direction == "next": + new_index = (current_index + 1) % len(modules) + else: + new_index = (current_index - 1) % len(modules) + new_module = modules[new_index] + discovery = self.env["runtime"]["SpeechDiscoveryManager"] + voices = discovery.get_cached_voices(new_module) + if voices is not None: + self._module_request_pending = False + return self._apply_module( + new_module, voices, announce_result + ) + self._module_request_pending = True + discovery.request_voices( + new_module, + lambda _module, found, voice_error: self._finish_module_cycle( + new_module, found, voice_error + ), + ) + return False + except Exception as error: + self._module_request_pending = False + self.env["runtime"]["DebugManager"].write_debug_out( + f"QuickMenuManager cycle_speech_module: Error: {error}", + debug.DebugLevel.ERROR, + ) + return False + + def _finish_module_cycle(self, module, voices, error): + self._module_request_pending = False + if error: + self.env["runtime"]["DebugManager"].write_debug_out( + f"QuickMenuManager voice discovery failed: {error}", + debug.DebugLevel.ERROR, + ) + self._apply_module(module, voices, False) + self.env["runtime"]["OutputManager"].present_text( + f"{module}; voice discovery failed", interrupt=True + ) + return + self._apply_module(module, voices, True) + + def _apply_module(self, module, voices, announce_result): + settings_manager = self.env["runtime"]["SettingsManager"] + settings_manager.set_setting("speech", "module", module) + speech_driver = self.env["runtime"].get("SpeechDriver") + if speech_driver: + speech_driver.set_module(module) + + if voices: + default_voice = self._select_default_voice(voices) + voice_name, voice_language = self._split_voice(default_voice) + settings_manager.set_setting("speech", "voice", voice_name) + if speech_driver: + if voice_language: + speech_driver.set_language(voice_language) + speech_driver.set_voice(voice_name) + + if announce_result: + self.env["runtime"]["OutputManager"].present_text( + module, interrupt=True + ) + return True + + def cycle_speech_voice(self, direction): + try: + 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 - ) + discovery = self.env["runtime"]["SpeechDiscoveryManager"] + voices = discovery.get_cached_voices(current_module) + if voices is not None: + return self._apply_voice(direction, voices, False) + if current_module in self._voice_requests_pending: return False - # Get current voice - current_voice = self.env["runtime"]["SettingsManager"].get_setting( - "speech", "voice" + self._voice_requests_pending.add(current_module) + self.env["runtime"]["OutputManager"].present_text( + f"Loading voices for {current_module}", interrupt=True ) - - # 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": - new_index = (current_index + 1) % len(voices) - else: # prev - new_index = (current_index - 1) % len(voices) - - new_voice = voices[new_index] - - # 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", voice_name + discovery.request_voices( + current_module, + lambda module, found, error: self._finish_voice_cycle( + direction, module, found, error + ), ) + return False + except Exception as error: + self.env["runtime"]["DebugManager"].write_debug_out( + f"QuickMenuManager cycle_speech_voice: Error: {error}", + debug.DebugLevel.ERROR, + ) + return False - # 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 - ) + def _finish_voice_cycle(self, direction, module, voices, error): + self._voice_requests_pending.discard(module) + if error: + self._report_discovery_error("voice", error) + return + self._apply_voice(direction, voices, True) - # Announce new voice (voice name only, not language) + def _apply_voice(self, direction, voices, announce_result): + if not voices: + self.env["runtime"]["OutputManager"].present_text( + "No voices available", interrupt=True + ) + return False + + settings_manager = self.env["runtime"]["SettingsManager"] + current_voice = settings_manager.get_setting("speech", "voice") + current_index = 0 + for voice_index, voice in enumerate(voices): + voice_name, _language = self._split_voice(voice) + if voice == current_voice or voice_name == current_voice: + current_index = voice_index + break + if direction == "next": + new_index = (current_index + 1) % len(voices) + else: + new_index = (current_index - 1) % len(voices) + + voice_name, voice_language = self._split_voice(voices[new_index]) + settings_manager.set_setting("speech", "voice", voice_name) + speech_driver = self.env["runtime"].get("SpeechDriver") + if speech_driver: + if voice_language: + speech_driver.set_language(voice_language) + speech_driver.set_voice(voice_name) + if announce_result: self.env["runtime"]["OutputManager"].present_text( voice_name, interrupt=True ) + return True - return True + @staticmethod + def _split_voice(voice): + if "|" in voice: + return tuple(voice.split("|", 1)) + return voice, None - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - f"QuickMenuManager cycle_speech_voice: Error: {e}", - debug.DebugLevel.ERROR - ) - return False + def _report_discovery_error(self, kind, error): + self.env["runtime"]["DebugManager"].write_debug_out( + f"QuickMenuManager {kind} discovery failed: {error}", + debug.DebugLevel.ERROR, + ) + self.env["runtime"]["OutputManager"].present_text( + f"{kind.capitalize()} discovery failed", interrupt=True + ) def get_current_entry(self): if len(self.quickMenu) == 0: diff --git a/src/fenrirscreenreader/core/runtimeData.py b/src/fenrirscreenreader/core/runtimeData.py index 3f255a43..7c9e1d12 100644 --- a/src/fenrirscreenreader/core/runtimeData.py +++ b/src/fenrirscreenreader/core/runtimeData.py @@ -22,6 +22,8 @@ runtime_data = { "SettingsManager": None, "FenrirManager": None, "EventManager": None, + "BackgroundTaskManager": None, "ProcessManager": None, + "SpeechDiscoveryManager": None, "DiffReviewManager": None, } diff --git a/src/fenrirscreenreader/core/settingsManager.py b/src/fenrirscreenreader/core/settingsManager.py index 5a613bde..f044b26e 100644 --- a/src/fenrirscreenreader/core/settingsManager.py +++ b/src/fenrirscreenreader/core/settingsManager.py @@ -12,6 +12,7 @@ from configparser import ConfigParser from fenrirscreenreader.core import applicationManager from fenrirscreenreader.core import attributeManager from fenrirscreenreader.core import barrierManager +from fenrirscreenreader.core import backgroundTaskManager from fenrirscreenreader.core import clipboardSyncManager from fenrirscreenreader.core import commandManager from fenrirscreenreader.core import cursorManager @@ -32,6 +33,7 @@ from fenrirscreenreader.core import remoteManager from fenrirscreenreader.core import sayAllManager from fenrirscreenreader.core import screenManager from fenrirscreenreader.core import speechHistoryManager +from fenrirscreenreader.core import speechDiscoveryManager from fenrirscreenreader.core import tableManager from fenrirscreenreader.core import textManager from fenrirscreenreader.core import vmenuManager @@ -753,6 +755,11 @@ class SettingsManager: ] = processManager.ProcessManager() environment["runtime"]["ProcessManager"].initialize(environment) + environment["runtime"][ + "BackgroundTaskManager" + ] = backgroundTaskManager.BackgroundTaskManager() + environment["runtime"]["BackgroundTaskManager"].initialize(environment) + environment["runtime"]["OutputManager"] = outputManager.OutputManager() environment["runtime"]["OutputManager"].initialize(environment) @@ -807,6 +814,10 @@ class SettingsManager: environment["runtime"]["BarrierManager"].initialize(environment) environment["runtime"]["SayAllManager"] = sayAllManager.SayAllManager() environment["runtime"]["SayAllManager"].initialize(environment) + environment["runtime"][ + "SpeechDiscoveryManager" + ] = speechDiscoveryManager.SpeechDiscoveryManager() + environment["runtime"]["SpeechDiscoveryManager"].initialize(environment) environment["runtime"]["VmenuManager"] = vmenuManager.VmenuManager() environment["runtime"]["VmenuManager"].initialize(environment) environment["runtime"][ diff --git a/src/fenrirscreenreader/core/speechDiscoveryManager.py b/src/fenrirscreenreader/core/speechDiscoveryManager.py new file mode 100644 index 00000000..434bc3ae --- /dev/null +++ b/src/fenrirscreenreader/core/speechDiscoveryManager.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# Fenrir TTY screen reader +# By Chrys, Storm Dragon, and contributors. + +import subprocess +import time + +from fenrirscreenreader.core import debug +from fenrirscreenreader.utils.speechd_utils import ( + get_synthesis_voice_name, + parse_synthesis_voice_line, +) + + +MODULE_QUERY_TIMEOUT = 5 +VOICE_QUERY_TIMEOUT = 8 +VOICE_TEST_TIMEOUT = 8 + + +def query_speechd_modules(timeout=MODULE_QUERY_TIMEOUT): + """Return available Speech Dispatcher output modules.""" + result = subprocess.run( + ["spd-say", "-O"], + capture_output=True, + text=True, + timeout=timeout, + check=False, + ) + if result.returncode != 0: + raise RuntimeError(result.stderr.strip() or "module discovery failed") + return [ + line.strip() + for line in result.stdout.splitlines()[1:] + if line.strip() and line.strip().lower() != "dummy" + ] + + +def query_speechd_voices(module, timeout=VOICE_QUERY_TIMEOUT): + """Return the synthesis voices exposed by one output module.""" + result = subprocess.run( + ["spd-say", "-o", module, "-L"], + capture_output=True, + text=True, + timeout=timeout, + check=False, + ) + if result.returncode != 0: + raise RuntimeError( + result.stderr.strip() or f"voice discovery failed for {module}" + ) + + voices = [] + for line in result.stdout.splitlines()[1:]: + if not line.strip(): + continue + voice = get_synthesis_voice_name(module, line) + if module.lower() == "voxin": + voice_data = parse_synthesis_voice_line(line) + if voice_data is not None: + voice_name, language, _variant = voice_data + voice = f"{voice_name}|{language}" if language else None + if voice: + voices.append(voice) + return voices + + +def test_speechd_voice(module, voice, message, timeout=VOICE_TEST_TIMEOUT): + """Play one bounded voice test and return when Speech Dispatcher finishes.""" + result = subprocess.run( + ["spd-say", "-C", "-w", "-o", module, "-y", voice, message], + capture_output=True, + text=True, + timeout=timeout, + check=False, + ) + if result.returncode != 0: + raise RuntimeError(result.stderr.strip() or "voice test failed") + return True + + +class SpeechDiscoveryManager: + """Cache and coordinate asynchronous Speech Dispatcher discovery.""" + + def __init__(self): + self.env = None + self.cache_timeout = 300 + self._modules = None + self._modules_timestamp = 0 + self._voices = {} + self._voice_timestamps = {} + self._module_callbacks = [] + self._voice_callbacks = {} + self._task_ids = set() + self._module_task_id = None + self._voice_task_ids = {} + + def initialize(self, environment): + self.env = environment + + def shutdown(self): + task_manager = self.env["runtime"].get("BackgroundTaskManager") + if task_manager: + for task_id in self._task_ids: + task_manager.cancel_task(task_id) + self._task_ids.clear() + self._module_callbacks = [] + self._voice_callbacks = {} + + def get_cached_modules(self): + if not self._cache_is_current(self._modules_timestamp): + return None + return list(self._modules) if self._modules is not None else None + + def get_cached_voices(self, module): + if not self._cache_is_current(self._voice_timestamps.get(module, 0)): + return None + voices = self._voices.get(module) + return list(voices) if voices is not None else None + + def request_modules(self, callback, refresh=False): + modules = None if refresh else self.get_cached_modules() + if modules is not None: + callback(modules, "") + return None + + self._module_callbacks.append(callback) + if self._module_task_id is not None: + return self._module_task_id + + task_id = self._submit_task(query_speechd_modules, self._finish_modules) + if task_id is None: + self._finish_modules(self._submission_failure()) + return None + self._module_task_id = task_id + return task_id + + def request_voices(self, module, callback, refresh=False): + voices = None if refresh else self.get_cached_voices(module) + if voices is not None: + callback(module, voices, "") + return None + + callbacks = self._voice_callbacks.setdefault(module, []) + callbacks.append(callback) + if module in self._voice_task_ids: + return self._voice_task_ids[module] + + task_id = self._submit_task( + query_speechd_voices, + lambda result: self._finish_voices(module, result), + module, + ) + if task_id is None: + self._finish_voices(module, self._submission_failure()) + return None + self._voice_task_ids[module] = task_id + return task_id + + def request_voice_test(self, module, voice, message, callback): + task_id = self._submit_task( + test_speechd_voice, + lambda result: callback( + bool(result.get("succeeded")), result.get("error", "") + ), + module, + voice, + message, + ) + if task_id is None: + callback(False, "background task manager is unavailable") + return task_id + + def invalidate_cache(self): + self._modules = None + self._modules_timestamp = 0 + self._voices = {} + self._voice_timestamps = {} + + def _cache_is_current(self, timestamp): + return timestamp > 0 and time.time() - timestamp < self.cache_timeout + + def _submit_task(self, function, callback, *args): + task_manager = self.env["runtime"]["BackgroundTaskManager"] + + def finish(result): + self._task_ids.discard(result.get("task_id")) + callback(result) + + task_id = task_manager.submit_task(function, finish, *args) + if task_id is None: + self.env["runtime"]["DebugManager"].write_debug_out( + "SpeechDiscoveryManager could not submit background task", + debug.DebugLevel.ERROR, + ) + return None + self._task_ids.add(task_id) + return task_id + + @staticmethod + def _submission_failure(): + return { + "task_id": None, + "succeeded": False, + "value": None, + "error": "background task manager is unavailable", + } + + def _finish_modules(self, result): + self._module_task_id = None + callbacks = self._module_callbacks + self._module_callbacks = [] + error = result.get("error", "") + modules = [] + if result.get("succeeded"): + modules = list(result.get("value") or []) + self._modules = modules + self._modules_timestamp = time.time() + self._run_callbacks(callbacks, list(modules), error) + + def _finish_voices(self, module, result): + self._voice_task_ids.pop(module, None) + callbacks = self._voice_callbacks.pop(module, []) + error = result.get("error", "") + voices = [] + if result.get("succeeded"): + voices = list(result.get("value") or []) + self._voices[module] = voices + self._voice_timestamps[module] = time.time() + self._run_callbacks(callbacks, module, list(voices), error) + + def _run_callbacks(self, callbacks, *args): + for callback in callbacks: + try: + callback(*args) + except Exception as error: + self.env["runtime"]["DebugManager"].write_debug_out( + "SpeechDiscoveryManager callback failed: " + str(error), + debug.DebugLevel.ERROR, + ) diff --git a/src/fenrirscreenreader/fenrirVersion.py b/src/fenrirscreenreader/fenrirVersion.py index 8c276282..75a6ecff 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 = "2026.08.13" +version = "2026.08.14" code_name = "testing" diff --git a/tests/unit/test_async_voice_workflows.py b/tests/unit/test_async_voice_workflows.py new file mode 100644 index 00000000..5f0d75fe --- /dev/null +++ b/tests/unit/test_async_voice_workflows.py @@ -0,0 +1,303 @@ +from unittest.mock import Mock + +from fenrirscreenreader.commands.commands import apply_tested_voice +from fenrirscreenreader.commands.commands import voice_browser +from fenrirscreenreader.commands.commands import voice_browser_safe +from fenrirscreenreader.core import dynamicVoiceMenu +from fenrirscreenreader.core.quickMenuManager import QuickMenuManager + + +class FakeDiscoveryManager: + def __init__(self): + self.cached_modules = None + self.cached_voices = {} + self.module_callbacks = [] + self.voice_callbacks = {} + self.test_callbacks = [] + + def get_cached_modules(self): + return self.cached_modules + + def get_cached_voices(self, module): + return self.cached_voices.get(module) + + def request_modules(self, callback, refresh=False): + self.module_callbacks.append(callback) + return len(self.module_callbacks) + + def request_voices(self, module, callback, refresh=False): + self.voice_callbacks.setdefault(module, []).append(callback) + return len(self.voice_callbacks[module]) + + def request_voice_test(self, module, voice, message, callback): + self.test_callbacks.append((module, voice, message, callback)) + return len(self.test_callbacks) + + def complete_modules(self, modules, error=""): + self.cached_modules = modules + callbacks = self.module_callbacks + self.module_callbacks = [] + for callback in callbacks: + callback(list(modules), error) + + def complete_voices(self, module, voices, error=""): + self.cached_voices[module] = voices + callbacks = self.voice_callbacks.pop(module, []) + for callback in callbacks: + callback(module, list(voices), error) + + def complete_test(self, succeeded, error=""): + _module, _voice, _message, callback = self.test_callbacks.pop(0) + callback(succeeded, error) + + +class RuntimeSettings: + def __init__( + self, + module="rhvoice", + voice="alan", + language="en-us", + driver="speechdDriver", + ): + self.values = { + ("speech", "driver"): driver, + ("speech", "module"): module, + ("speech", "voice"): voice, + ("speech", "language"): language, + } + + def get_setting(self, section, setting): + return self.values[(section, setting)] + + def set_setting(self, section, setting, value): + self.values[(section, setting)] = value + + +def create_voice_environment(discovery): + return { + "runtime": { + "DebugManager": Mock(), + "OutputManager": Mock(), + "SpeechDiscoveryManager": discovery, + }, + "commandBuffer": {}, + } + + +def test_safe_browser_records_voice_only_after_successful_test(): + discovery = FakeDiscoveryManager() + environment = create_voice_environment(discovery) + browser = voice_browser_safe.command() + browser.initialize(environment) + + browser.run() + assert "lastTestedVoice" not in environment["commandBuffer"] + + discovery.complete_modules(["rhvoice"]) + discovery.complete_voices("rhvoice", ["alan", "slt"]) + assert "lastTestedVoice" not in environment["commandBuffer"] + + discovery.complete_test(True) + assert environment["commandBuffer"]["lastTestedModule"] == "rhvoice" + assert environment["commandBuffer"]["lastTestedVoice"] == "alan" + + +def test_safe_browser_does_not_record_failed_test(): + discovery = FakeDiscoveryManager() + environment = create_voice_environment(discovery) + browser = voice_browser_safe.command() + browser.initialize(environment) + + browser.run() + discovery.complete_modules(["rhvoice"]) + discovery.complete_voices("rhvoice", ["alan"]) + discovery.complete_test(False, "test timed out") + + assert "lastTestedVoice" not in environment["commandBuffer"] + + +def test_safe_browser_keeps_voxin_language_separate(): + discovery = FakeDiscoveryManager() + environment = create_voice_environment(discovery) + browser = voice_browser_safe.command() + browser.initialize(environment) + + browser.run() + discovery.complete_modules(["voxin"]) + discovery.complete_voices("voxin", ["Nathan|en-US"]) + discovery.complete_test(True) + + assert environment["commandBuffer"]["lastTestedVoice"] == "Nathan" + assert environment["commandBuffer"]["lastTestedLanguage"] == "en-US" + + +def test_interactive_browser_keeps_voxin_language_through_test_and_apply(): + discovery = FakeDiscoveryManager() + environment = create_voice_environment(discovery) + environment["runtime"]["SettingsManager"] = Mock( + settings={"speech": {}}, + ) + browser = voice_browser.command() + browser.initialize(environment) + browser.modules = ["voxin"] + browser.voices = ["Nathan|en-US"] + + browser.test_voice() + assert discovery.test_callbacks[0][1] == "Nathan" + discovery.complete_test(True) + assert environment["commandBuffer"]["lastTestedVoice"] == "Nathan" + assert environment["commandBuffer"]["lastTestedLanguage"] == "en-US" + + browser.apply_voice() + speech_settings = environment["runtime"]["SettingsManager"].settings[ + "speech" + ] + assert speech_settings["voice"] == "Nathan" + assert speech_settings["language"] == "en-US" + + +def test_apply_tested_voice_applies_voxin_language(): + settings = RuntimeSettings() + speech_driver = Mock() + environment = { + "runtime": { + "OutputManager": Mock(), + "SettingsManager": settings, + "SpeechDriver": speech_driver, + }, + "commandBuffer": { + "lastTestedModule": "voxin", + "lastTestedVoice": "Nathan", + "lastTestedLanguage": "en-US", + }, + } + command = apply_tested_voice.command() + command.initialize(environment) + + command.run() + + assert settings.get_setting("speech", "language") == "en-US" + speech_driver.set_language.assert_called_once_with("en-US") + + +def test_dynamic_voice_command_commits_result_on_main_loop_callback(): + discovery = FakeDiscoveryManager() + environment = create_voice_environment(discovery) + voice_command = dynamicVoiceMenu.DynamicVoiceCommand( + "rhvoice", "alan", environment + ) + + voice_command.run() + assert environment["commandBuffer"] == {} + + discovery.complete_test(True) + assert environment["commandBuffer"]["pendingVoiceModule"] == "rhvoice" + assert environment["commandBuffer"]["pendingVoiceVoice"] == "alan" + assert environment["commandBuffer"]["voiceTestCompleted"] is True + + +def test_dynamic_voxin_voice_keeps_language_out_of_voice_name(): + discovery = FakeDiscoveryManager() + environment = create_voice_environment(discovery) + voice_command = dynamicVoiceMenu.DynamicVoiceCommand( + "voxin", "Nathan|en-US", environment + ) + + voice_command.run() + assert discovery.test_callbacks[0][1] == "Nathan" + + discovery.complete_test(True) + assert environment["commandBuffer"]["pendingVoiceVoice"] == "Nathan" + assert environment["commandBuffer"]["pendingVoiceLanguage"] == "en-US" + + +def test_quick_menu_waits_for_discovery_then_applies_module(): + discovery = FakeDiscoveryManager() + settings = RuntimeSettings() + output_manager = Mock() + manager = QuickMenuManager() + manager.env = { + "runtime": { + "DebugManager": Mock(), + "OutputManager": output_manager, + "SettingsManager": settings, + "SpeechDriver": Mock(), + "SpeechDiscoveryManager": discovery, + } + } + + assert manager.cycle_speech_module("next") is False + assert settings.get_setting("speech", "module") == "rhvoice" + + discovery.complete_modules(["rhvoice", "espeak-ng"]) + discovery.complete_voices("espeak-ng", ["en-us"]) + + assert settings.get_setting("speech", "module") == "espeak-ng" + assert settings.get_setting("speech", "voice") == "en-us" + output_manager.present_text.assert_any_call("espeak-ng", interrupt=True) + + +def test_quick_menu_deduplicates_voice_request_for_module_cycle(): + discovery = FakeDiscoveryManager() + discovery.cached_modules = ["rhvoice", "espeak-ng"] + settings = RuntimeSettings() + manager = QuickMenuManager() + manager.env = { + "runtime": { + "DebugManager": Mock(), + "OutputManager": Mock(), + "SettingsManager": settings, + "SpeechDriver": Mock(), + "SpeechDiscoveryManager": discovery, + } + } + + assert manager.cycle_speech_module("next") is False + assert manager.cycle_speech_module("next") is False + assert len(discovery.voice_callbacks["espeak-ng"]) == 1 + + +def test_dynamic_vmenu_keeps_loading_entry_until_all_results_arrive(): + discovery = FakeDiscoveryManager() + environment = create_voice_environment(discovery) + vmenu_manager = Mock() + vmenu_manager.env = environment + vmenu_manager.menuDict = {} + vmenu_manager._voice_menu_generation = 0 + vmenu_manager.get_active.return_value = False + + dynamicVoiceMenu.add_dynamic_voice_menus(vmenu_manager) + assert "Loading voices Action" in vmenu_manager.menuDict[ + "Voice Browser Menu" + ] + + discovery.complete_modules(["rhvoice", "espeak-ng"]) + discovery.complete_voices("rhvoice", ["alan"]) + assert "Loading voices Action" in vmenu_manager.menuDict[ + "Voice Browser Menu" + ] + + discovery.complete_voices("espeak-ng", ["en-us"]) + voice_menu = vmenu_manager.menuDict["Voice Browser Menu"] + assert "rhvoice Menu" in voice_menu + assert "espeak-ng Menu" in voice_menu + assert "alan Action" in voice_menu["rhvoice Menu"] + + +def test_dynamic_vmenu_does_not_replace_active_loading_menu(): + discovery = FakeDiscoveryManager() + environment = create_voice_environment(discovery) + vmenu_manager = Mock() + vmenu_manager.env = environment + vmenu_manager.menuDict = {} + vmenu_manager._voice_menu_generation = 0 + vmenu_manager.get_active.return_value = False + + dynamicVoiceMenu.add_dynamic_voice_menus(vmenu_manager) + vmenu_manager.get_active.return_value = True + discovery.complete_modules(["voxin"]) + discovery.complete_voices("voxin", ["Nathan|en-US"]) + + voice_menu = vmenu_manager.menuDict["Voice Browser Menu"] + assert "Loading voices Action" in voice_menu + assert vmenu_manager._voice_menu_refresh_pending is True diff --git a/tests/unit/test_background_task_manager.py b/tests/unit/test_background_task_manager.py new file mode 100644 index 00000000..e8f43edf --- /dev/null +++ b/tests/unit/test_background_task_manager.py @@ -0,0 +1,109 @@ +import threading +import time +from unittest.mock import Mock + +from fenrirscreenreader.core.backgroundTaskManager import BackgroundTaskManager +from fenrirscreenreader.core.eventData import FenrirEventType + + +def wait_for_call(mock, timeout=1.0): + deadline = time.monotonic() + timeout + while not mock.called and time.monotonic() < deadline: + time.sleep(0.01) + assert mock.called + + +def test_worker_returns_result_through_event_queue_before_callback_runs(): + event_manager = Mock() + environment = { + "runtime": { + "DebugManager": Mock(), + "EventManager": event_manager, + } + } + manager = BackgroundTaskManager(worker_count=1) + manager.initialize(environment) + callback = Mock() + main_thread_id = threading.get_ident() + + try: + task_id = manager.submit_task(lambda value: value * 2, callback, 21) + wait_for_call(event_manager.put_to_event_queue) + + callback.assert_not_called() + event_type, result = event_manager.put_to_event_queue.call_args.args + assert event_type == FenrirEventType.background_task_result + assert result == { + "task_id": task_id, + "succeeded": True, + "value": 42, + "error": "", + } + + callback.side_effect = lambda _result: setattr( + callback, "thread_id", threading.get_ident() + ) + manager.handle_result(result) + callback.assert_called_once_with(result) + assert callback.thread_id == main_thread_id + finally: + manager.shutdown() + + +def test_cancelled_task_result_is_not_delivered(): + environment = { + "runtime": { + "DebugManager": Mock(), + "EventManager": Mock(), + } + } + manager = BackgroundTaskManager(worker_count=1) + manager.initialize(environment) + callback = Mock() + + try: + task_id = manager.submit_task(lambda: "late", callback) + manager.cancel_task(task_id) + manager.handle_result( + { + "task_id": task_id, + "succeeded": True, + "value": "late", + "error": "", + } + ) + callback.assert_not_called() + finally: + manager.shutdown() + + +def test_external_tasks_cannot_starve_default_tasks(): + event_manager = Mock() + environment = { + "runtime": { + "DebugManager": Mock(), + "EventManager": event_manager, + } + } + manager = BackgroundTaskManager(worker_count=1, external_worker_count=2) + manager.initialize(environment) + release_external = threading.Event() + external_started = [threading.Event(), threading.Event()] + + def block_external(started): + started.set() + release_external.wait(timeout=2) + + try: + for started in external_started: + manager.submit_external_task(block_external, Mock(), started) + for started in external_started: + assert started.wait(timeout=1) + + manager.submit_task(lambda: "voice result", Mock()) + wait_for_call(event_manager.put_to_event_queue) + result = event_manager.put_to_event_queue.call_args.args[1] + assert result["value"] == "voice result" + finally: + release_external.set() + manager.shutdown() diff --git a/tests/unit/test_clipboard_commands_background_result.py b/tests/unit/test_clipboard_commands_background_result.py new file mode 100644 index 00000000..97289ee7 --- /dev/null +++ b/tests/unit/test_clipboard_commands_background_result.py @@ -0,0 +1,72 @@ +from unittest.mock import Mock + +from fenrirscreenreader.commands.commands import export_clipboard_to_x +from fenrirscreenreader.commands.commands import import_clipboard_from_x + + +class FakeTaskManager: + def __init__(self): + self.callback = None + self.function = None + self.args = None + + def submit_task(self, function, callback, *args): + self.function = function + self.callback = callback + self.args = args + return 7 + + def cancel_task(self, _task_id): + pass + + +def test_clipboard_import_changes_history_only_after_result_event(): + task_manager = FakeTaskManager() + memory_manager = Mock() + environment = { + "runtime": { + "BackgroundTaskManager": task_manager, + "MemoryManager": memory_manager, + "OutputManager": Mock(), + } + } + command = import_clipboard_from_x.command() + command.initialize(environment) + + command.run() + memory_manager.add_value_to_first_index.assert_not_called() + + task_manager.callback( + {"task_id": 7, "succeeded": True, "value": "copied text"} + ) + memory_manager.add_value_to_first_index.assert_called_once_with( + "clipboardHistory", "copied text" + ) + + +def test_clipboard_export_updates_sync_state_only_after_result_event(): + task_manager = FakeTaskManager() + memory_manager = Mock( + is_index_list_empty=Mock(return_value=False), + get_index_list_element=Mock(return_value="copied text"), + ) + sync_manager = Mock() + environment = { + "runtime": { + "BackgroundTaskManager": task_manager, + "ClipboardSyncManager": sync_manager, + "DebugManager": Mock(), + "MemoryManager": memory_manager, + "OutputManager": Mock(), + } + } + command = export_clipboard_to_x.command() + command.initialize(environment) + + command.run() + sync_manager.mark_written_to_x.assert_not_called() + + task_manager.callback( + {"task_id": 7, "succeeded": True, "value": True} + ) + sync_manager.mark_written_to_x.assert_called_once_with("copied text") diff --git a/tests/unit/test_clipboard_sync_manager.py b/tests/unit/test_clipboard_sync_manager.py index 756efab5..481f2f00 100644 --- a/tests/unit/test_clipboard_sync_manager.py +++ b/tests/unit/test_clipboard_sync_manager.py @@ -3,6 +3,7 @@ from unittest.mock import Mock import pytest from fenrirscreenreader.core.clipboardSyncManager import ClipboardSyncManager +from fenrirscreenreader.core.clipboardSyncManager import synchronize_clipboards def build_env( @@ -270,3 +271,87 @@ def test_x_clipboard_paste_exception_is_ignored(monkeypatch): env["runtime"]["MemoryManager"].add_value_to_first_index.assert_not_called() env["runtime"]["DebugManager"].write_debug_out.assert_called_once() + + +@pytest.mark.unit +def test_runtime_sync_applies_worker_result_on_main_loop(): + env = build_env(fenrir_text=None) + task_manager = Mock() + task_manager.submit_task.return_value = 12 + env["runtime"]["BackgroundTaskManager"] = task_manager + manager = ClipboardSyncManager() + manager.env = env + manager.enabled = True + manager.display = ":1" + manager.running = True + + manager.handle_sync_event() + env["runtime"]["MemoryManager"].add_value_to_first_index.assert_not_called() + + callback = task_manager.submit_task.call_args.args[1] + callback( + { + "task_id": 12, + "succeeded": True, + "value": { + "import_text": "from x", + "last_imported_from_x": "from x", + "last_observed_fenrir": "from x", + "last_observed_x": "from x", + }, + "error": "", + } + ) + + env["runtime"]["MemoryManager"].add_value_to_first_index.assert_called_once_with( + "clipboardHistory", "from x" + ) + assert manager.last_imported_from_x == "from x" + + +@pytest.mark.unit +def test_runtime_sync_discards_import_if_fenrir_clipboard_changed(): + env = build_env(fenrir_text=None) + task_manager = Mock() + task_manager.submit_task.return_value = 12 + env["runtime"]["BackgroundTaskManager"] = task_manager + manager = ClipboardSyncManager() + manager.env = env + manager.display = ":1" + manager.running = True + + manager.handle_sync_event() + env["runtime"]["MemoryManager"].is_index_list_empty.return_value = False + env["runtime"]["MemoryManager"].get_index_list_element.return_value = ( + "new fenrir copy" + ) + callback = task_manager.submit_task.call_args.args[1] + callback( + { + "task_id": 12, + "succeeded": True, + "value": { + "import_text": "stale x text", + "last_imported_from_x": "stale x text", + "last_observed_fenrir": "stale x text", + "last_observed_x": "stale x text", + }, + "error": "", + } + ) + + env["runtime"]["MemoryManager"].add_value_to_first_index.assert_not_called() + assert manager.last_imported_from_x is None + + +@pytest.mark.unit +def test_sync_worker_returns_changes_without_manager_access(monkeypatch): + monkeypatch.setattr( + "fenrirscreenreader.core.clipboardSyncManager.x_clipboard.read_text", + Mock(return_value="from x"), + ) + + result = synchronize_clipboards(":1", None, None, None) + + assert result["import_text"] == "from x" + assert result["last_observed_x"] == "from x" diff --git a/tests/unit/test_speech_discovery_manager.py b/tests/unit/test_speech_discovery_manager.py new file mode 100644 index 00000000..38a16fc0 --- /dev/null +++ b/tests/unit/test_speech_discovery_manager.py @@ -0,0 +1,116 @@ +from unittest.mock import Mock + +from fenrirscreenreader.core.speechDiscoveryManager import ( + SpeechDiscoveryManager, + query_speechd_voices, +) + + +class FakeBackgroundTaskManager: + def __init__(self): + self.tasks = [] + + def submit_task(self, function, callback, *args, **kwargs): + task_id = len(self.tasks) + 1 + self.tasks.append((task_id, function, callback, args, kwargs)) + return task_id + + def cancel_task(self, task_id): + pass + + def complete(self, task_id, value=None, error=""): + _, _function, callback, _args, _kwargs = self.tasks[task_id - 1] + callback( + { + "task_id": task_id, + "succeeded": not error, + "value": value, + "error": error, + } + ) + + +def create_manager(): + task_manager = FakeBackgroundTaskManager() + manager = SpeechDiscoveryManager() + manager.initialize( + { + "runtime": { + "BackgroundTaskManager": task_manager, + "DebugManager": Mock(), + } + } + ) + return manager, task_manager + + +def test_module_requests_share_one_worker_and_cache_main_thread_result(): + manager, task_manager = create_manager() + first_callback = Mock() + second_callback = Mock() + + first_task_id = manager.request_modules(first_callback) + second_task_id = manager.request_modules(second_callback) + + assert first_task_id == second_task_id + assert len(task_manager.tasks) == 1 + + task_manager.complete(first_task_id, ["rhvoice", "espeak-ng"]) + + first_callback.assert_called_once_with(["rhvoice", "espeak-ng"], "") + second_callback.assert_called_once_with(["rhvoice", "espeak-ng"], "") + + cached_callback = Mock() + assert manager.request_modules(cached_callback) is None + cached_callback.assert_called_once_with(["rhvoice", "espeak-ng"], "") + assert len(task_manager.tasks) == 1 + + +def test_voice_requests_are_deduplicated_per_module(): + manager, task_manager = create_manager() + rhvoice_callback = Mock() + repeated_callback = Mock() + espeak_callback = Mock() + + rhvoice_task = manager.request_voices("rhvoice", rhvoice_callback) + repeated_task = manager.request_voices("rhvoice", repeated_callback) + espeak_task = manager.request_voices("espeak-ng", espeak_callback) + + assert rhvoice_task == repeated_task + assert espeak_task != rhvoice_task + assert len(task_manager.tasks) == 2 + + task_manager.complete(rhvoice_task, ["alan", "slt"]) + rhvoice_callback.assert_called_once_with("rhvoice", ["alan", "slt"], "") + repeated_callback.assert_called_once_with( + "rhvoice", ["alan", "slt"], "" + ) + espeak_callback.assert_not_called() + + +def test_failing_discovery_callback_does_not_hide_result_from_others(): + manager, task_manager = create_manager() + failing_callback = Mock(side_effect=RuntimeError("consumer failed")) + second_callback = Mock() + + task_id = manager.request_modules(failing_callback) + manager.request_modules(second_callback) + task_manager.complete(task_id, ["rhvoice"]) + + failing_callback.assert_called_once_with(["rhvoice"], "") + second_callback.assert_called_once_with(["rhvoice"], "") + manager.env["runtime"]["DebugManager"].write_debug_out.assert_called_once() + + +def test_voice_query_preserves_multiword_synthesis_voice_names(monkeypatch): + voice_list = ( + " NAME LANGUAGE VARIANT\n" + " Perfect Paul en-US none\n" + " Big Bob en-US none\n" + ) + monkeypatch.setattr( + "fenrirscreenreader.core.speechDiscoveryManager.subprocess.run", + Mock(return_value=Mock(returncode=0, stdout=voice_list, stderr="")), + ) + + assert query_speechd_voices("doubletalk") == ["Perfect Paul", "Big Bob"] diff --git a/tests/unit/test_speechd_voice_selection.py b/tests/unit/test_speechd_voice_selection.py index 9bb15151..92f7c3a0 100644 --- a/tests/unit/test_speechd_voice_selection.py +++ b/tests/unit/test_speechd_voice_selection.py @@ -2,90 +2,11 @@ import sys from types import SimpleNamespace from unittest.mock import Mock -from fenrirscreenreader.commands.commands import voice_browser -from fenrirscreenreader.commands.commands import voice_browser_safe -from fenrirscreenreader.core import dynamicVoiceMenu -from fenrirscreenreader.core.quickMenuManager import ( - QuickMenuManager, - SpeechHelperMixin, -) +from fenrirscreenreader.core.quickMenuManager import QuickMenuManager from fenrirscreenreader.speechDriver import speechdDriver from fenrirscreenreader.utils.speechd_utils import get_synthesis_voice_name -VOICE_LIST = ( - " NAME LANGUAGE VARIANT\n" - " Perfect Paul en-US none\n" - " Big Bob en-US none\n" -) - - -def completed_voice_list(): - return SimpleNamespace(returncode=0, stdout=VOICE_LIST) - - -def test_quick_menu_preserves_multiword_synthesis_voice_names(monkeypatch): - monkeypatch.setattr( - "fenrirscreenreader.core.quickMenuManager.subprocess.run", - Mock(return_value=completed_voice_list()), - ) - helper = SpeechHelperMixin() - helper.env = {"runtime": {"DebugManager": Mock()}} - - assert helper.get_module_voices("doubletalk") == [ - "Perfect Paul", - "Big Bob", - ] - - -def test_safe_voice_browser_preserves_multiword_synthesis_voice_names( - monkeypatch, -): - monkeypatch.setattr( - voice_browser_safe.subprocess, - "run", - Mock(return_value=completed_voice_list()), - ) - browser = voice_browser_safe.command() - browser.initialize({"runtime": {}}) - - assert browser.get_module_voices_with_timeout("doubletalk") == [ - "Perfect Paul", - "Big Bob", - ] - - -def test_interactive_voice_browser_preserves_multiword_synthesis_voice_names( - monkeypatch, -): - monkeypatch.setattr( - voice_browser.subprocess, - "run", - Mock(return_value=completed_voice_list()), - ) - browser = voice_browser.command() - - assert browser.get_module_voices("doubletalk") == [ - "Perfect Paul", - "Big Bob", - ] - - -def test_dynamic_voice_menu_preserves_multiword_synthesis_voice_names( - monkeypatch, -): - monkeypatch.setattr( - dynamicVoiceMenu.subprocess, - "run", - Mock(return_value=completed_voice_list()), - ) - - assert dynamicVoiceMenu.get_module_voices("doubletalk") == [ - "Perfect Paul", - "Big Bob", - ] - - def test_espeak_voice_selection_keeps_language_and_variant_behavior(): voice = get_synthesis_voice_name( "espeak-ng", @@ -160,6 +81,12 @@ def test_speechd_driver_keeps_running_when_default_module_query_fails( def test_quick_menu_espeak_voice_can_move_away_and_return(): settings = RuntimeSettings(module="rhvoice") + discovery = Mock() + discovery.get_cached_modules.return_value = ["rhvoice", "espeak-ng"] + discovery.get_cached_voices.side_effect = lambda module: { + "rhvoice": ["alan"], + "espeak-ng": ["en-gb", "en-us", "en-us+female2"], + }[module] manager = QuickMenuManager() manager.env = { "runtime": { @@ -167,15 +94,9 @@ def test_quick_menu_espeak_voice_can_move_away_and_return(): "OutputManager": Mock(), "SettingsManager": settings, "SpeechDriver": Mock(), + "SpeechDiscoveryManager": discovery, } } - manager._modules_cache = ["rhvoice", "espeak-ng"] - manager._cache_timestamp = float("inf") - manager._voices_cache["espeak-ng"] = [ - "en-gb", - "en-us", - "en-us+female2", - ] assert manager.cycle_speech_module("next") is True assert settings.get_setting("speech", "module") == "espeak-ng" diff --git a/tests/unit/test_subprocess_command.py b/tests/unit/test_subprocess_command.py index 777c3064..c2b81244 100644 --- a/tests/unit/test_subprocess_command.py +++ b/tests/unit/test_subprocess_command.py @@ -8,26 +8,19 @@ from fenrirscreenreader.commands.commands import subprocess as subprocess_comman @pytest.mark.unit def test_script_command_executes_without_shell(monkeypatch): process = Mock() - process.communicate.return_value = (b"done", b"") + process.returncode = 0 + process.communicate.return_value = ("done", "") popen = Mock(return_value=process) - monkeypatch.setattr(subprocess_command, "Popen", popen) - output_manager = Mock() - command = subprocess_command.command() - command.initialize( - { - "general": {"curr_user": "Username"}, - "runtime": {"OutputManager": output_manager}, - }, - "/tmp/script with spaces", - ) + monkeypatch.setattr(subprocess_command.subprocess, "Popen", popen) - command._thread_run() + result = subprocess_command.run_script( + "/tmp/script with spaces", "Username" + ) popen.assert_called_once_with( ["/tmp/script with spaces", "Username"], - stdout=subprocess_command.PIPE, - stderr=subprocess_command.PIPE, - ) - output_manager.present_text.assert_called_once_with( - "done", sound_icon="", interrupt=False + stdout=subprocess_command.subprocess.PIPE, + stderr=subprocess_command.subprocess.PIPE, + text=True, ) + assert result == {"return_code": 0, "stdout": "done", "stderr": ""} diff --git a/tests/unit/test_subprocess_command_background_result.py b/tests/unit/test_subprocess_command_background_result.py new file mode 100644 index 00000000..761df604 --- /dev/null +++ b/tests/unit/test_subprocess_command_background_result.py @@ -0,0 +1,47 @@ +import importlib +from unittest.mock import Mock + + +subprocess_command = importlib.import_module( + "fenrirscreenreader.commands.commands.subprocess" +) + + +def test_external_script_output_is_delivered_by_main_loop_callback(tmp_path): + script_path = tmp_path / "helper.sh" + script_path.write_text("#!/bin/sh\necho ready\n", encoding="utf-8") + script_path.chmod(0o755) + task_manager = Mock() + task_manager.submit_external_task.return_value = 7 + output_manager = Mock() + environment = { + "runtime": { + "BackgroundTaskManager": task_manager, + "OutputManager": output_manager, + }, + "general": {"curr_user": "Username"}, + } + script_command = subprocess_command.command() + script_command.initialize(environment, str(script_path)) + + script_command.run() + + output_manager.present_text.assert_not_called() + function, callback, path, current_user = ( + task_manager.submit_external_task.call_args.args + ) + assert function is subprocess_command.run_script + assert path == str(script_path) + assert current_user == "Username" + + callback( + { + "task_id": 7, + "succeeded": True, + "value": {"return_code": 0, "stdout": "ready\n", "stderr": ""}, + "error": "", + } + ) + output_manager.present_text.assert_called_once_with( + "ready\n", sound_icon="", interrupt=False + )