Move auxiliary work off the main event loop

Run voice discovery, voice testing, clipboard I/O, and external scripts in isolated background worker lanes. Deliver results through Fenrir's event queue so manager state remains owned by the main loop.
This commit is contained in:
Storm Dragon
2026-08-14 23:29:15 -04:00
parent 4ed431640e
commit 93a2745065
26 changed files with 2057 additions and 952 deletions
@@ -34,6 +34,7 @@ class command:
module = self.env["commandBuffer"]["lastTestedModule"] module = self.env["commandBuffer"]["lastTestedModule"]
voice = self.env["commandBuffer"]["lastTestedVoice"] voice = self.env["commandBuffer"]["lastTestedVoice"]
language = self.env["commandBuffer"].get("lastTestedLanguage")
self.env["runtime"]["OutputManager"].present_text( self.env["runtime"]["OutputManager"].present_text(
f"Applying {voice} from {module}", interrupt=True f"Applying {voice} from {module}", interrupt=True
@@ -46,6 +47,7 @@ class command:
old_driver = SettingsManager.get_setting("speech", "driver") old_driver = SettingsManager.get_setting("speech", "driver")
old_module = SettingsManager.get_setting("speech", "module") old_module = SettingsManager.get_setting("speech", "module")
old_voice = SettingsManager.get_setting("speech", "voice") old_voice = SettingsManager.get_setting("speech", "voice")
old_language = SettingsManager.get_setting("speech", "language")
try: try:
# Apply new settings to runtime only (use set_setting to update # 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", "module", module)
SettingsManager.set_setting("speech", "voice", voice) SettingsManager.set_setting("speech", "voice", voice)
if language:
SettingsManager.set_setting(
"speech", "language", language
)
# Apply to speech driver instance directly # Apply to speech driver instance directly
if "SpeechDriver" in self.env["runtime"]: if "SpeechDriver" in self.env["runtime"]:
@@ -62,6 +68,8 @@ class command:
# Set the module and voice on the driver instance # Set the module and voice on the driver instance
SpeechDriver.set_module(module) SpeechDriver.set_module(module)
if language:
SpeechDriver.set_language(language)
SpeechDriver.set_voice(voice) SpeechDriver.set_voice(voice)
self.env["runtime"]["OutputManager"].present_text( self.env["runtime"]["OutputManager"].present_text(
@@ -77,6 +85,9 @@ class command:
SettingsManager.set_setting("speech", "driver", old_driver) SettingsManager.set_setting("speech", "driver", old_driver)
SettingsManager.set_setting("speech", "module", old_module) SettingsManager.set_setting("speech", "module", old_module)
SettingsManager.set_setting("speech", "voice", old_voice) SettingsManager.set_setting("speech", "voice", old_voice)
SettingsManager.set_setting(
"speech", "language", old_language
)
self.env["runtime"]["OutputManager"].present_text( self.env["runtime"]["OutputManager"].present_text(
f"Failed to apply voice, reverted: {str(e)}", f"Failed to apply voice, reverted: {str(e)}",
@@ -4,30 +4,35 @@
# Fenrir TTY screen reader # Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors. # By Chrys, Storm Dragon, and contributors.
import _thread from fenrirscreenreader.core import debug
from fenrirscreenreader.core.i18n import _ from fenrirscreenreader.core.i18n import _
from fenrirscreenreader.utils import x_clipboard 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: class command:
def __init__(self): def __init__(self):
pass self._task_ids = set()
def initialize(self, environment, script_path=""): def initialize(self, environment, script_path=""):
self.env = environment self.env = environment
self.script_path = script_path self.script_path = script_path
def shutdown(self): 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): def get_description(self):
return _("Export current fenrir clipboard to X or GUI clipboard") return _("Export current fenrir clipboard to X or GUI clipboard")
def run(self): def run(self):
_thread.start_new_thread(self._thread_run, ())
def _thread_run(self):
try: try:
# Check if clipboard is empty # Check if clipboard is empty
if self.env["runtime"]["MemoryManager"].is_index_list_empty( if self.env["runtime"]["MemoryManager"].is_index_list_empty(
@@ -43,36 +48,43 @@ class command:
"MemoryManager" "MemoryManager"
].get_index_list_element("clipboardHistory") ].get_index_list_element("clipboardHistory")
try: task_id = self.env["runtime"][
success = x_clipboard.write_text( "BackgroundTaskManager"
clipboard, scan_displays=True ].submit_task(
) write_clipboard,
except Exception: lambda result: self._handle_result(clipboard, result),
success = False clipboard,
)
# Notify the user of the result if task_id is not None:
if success: self._task_ids.add(task_id)
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,
)
except Exception as e: except Exception as e:
self.env["runtime"]["OutputManager"].present_text( self.env["runtime"]["OutputManager"].present_text(
str(e), sound_icon="", interrupt=False 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): def set_callback(self, callback):
pass pass
@@ -4,60 +4,68 @@
# Fenrir TTY screen reader # Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors. # By Chrys, Storm Dragon, and contributors.
import _thread
from fenrirscreenreader.core.i18n import _ from fenrirscreenreader.core.i18n import _
from fenrirscreenreader.utils import x_clipboard 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: class command:
def __init__(self): def __init__(self):
pass self._task_ids = set()
def initialize(self, environment, script_path=""): def initialize(self, environment, script_path=""):
self.env = environment self.env = environment
self.script_path = script_path self.script_path = script_path
def shutdown(self): 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): def get_description(self):
return _("imports the graphical clipboard to Fenrir's clipboard") return _("imports the graphical clipboard to Fenrir's clipboard")
def run(self): 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): def _handle_result(self, result):
try: self._task_ids.discard(result.get("task_id"))
try: clipboard_content = result.get("value")
clipboard_content = x_clipboard.read_text( if not result.get("succeeded"):
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:
self.env["runtime"]["OutputManager"].present_text( 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): def set_callback(self, callback):
pass pass
@@ -4,24 +4,42 @@
# Fenrir TTY screen reader # Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors. # By Chrys, Storm Dragon, and contributors.
import _thread
import os import os
from subprocess import PIPE import subprocess
from subprocess import Popen
from fenrirscreenreader.core.i18n import _ 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: class command:
def __init__(self): def __init__(self):
pass self._task_ids = set()
def initialize(self, environment, script_path=""): def initialize(self, environment, script_path=""):
self.env = environment self.env = environment
self.script_path = script_path self.script_path = script_path
def shutdown(self): 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): def get_description(self):
return _("script: {0} fullpath: {1}").format( return _("script: {0} fullpath: {1}").format(
@@ -48,30 +66,39 @@ class command:
interrupt=False, interrupt=False,
) )
return return
_thread.start_new_thread(self._thread_run, ())
def _thread_run(self): task_id = self.env["runtime"][
try: "BackgroundTaskManager"
p = Popen( ].submit_external_task(
[self.script_path, self.env["general"]["curr_user"]], run_script,
stdout=PIPE, self._handle_result,
stderr=PIPE, self.script_path,
) self.env["general"]["curr_user"],
stdout, stderr = p.communicate() )
stdout = stdout.decode("utf-8") if task_id is not None:
stderr = stderr.decode("utf-8") self._task_ids.add(task_id)
self.env["runtime"]["OutputManager"].interrupt_output()
if stderr != "": def _handle_result(self, result):
self.env["runtime"]["OutputManager"].present_text( self._task_ids.discard(result.get("task_id"))
str(stderr), sound_icon="", interrupt=False if not result.get("succeeded"):
)
if stdout != "":
self.env["runtime"]["OutputManager"].present_text(
str(stdout), sound_icon="", interrupt=False
)
except Exception as e:
self.env["runtime"]["OutputManager"].present_text( 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): def set_callback(self, callback):
@@ -1,19 +1,20 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import subprocess
import time import time
from fenrirscreenreader.core.i18n import _ from fenrirscreenreader.core.i18n import _
from fenrirscreenreader.utils.speechd_utils import get_synthesis_voice_name
class command: class command:
def __init__(self): def __init__(self):
pass self._request_generation = 0
self._loading = False
def initialize(self, environment): def initialize(self, environment):
self.env = 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.modules = []
self.voices = [] self.voices = []
self.module_index = 0 self.module_index = 0
@@ -23,7 +24,9 @@ class command:
self.lastAnnounceTime = 0 self.lastAnnounceTime = 0
def shutdown(self): def shutdown(self):
pass self._request_generation += 1
self._loading = False
self._leave_voice_browser(False)
def get_description(self): def get_description(self):
return "Interactive voice browser with arrow key navigation" return "Interactive voice browser with arrow key navigation"
@@ -37,34 +40,59 @@ class command:
"Starting voice browser", interrupt=True "Starting voice browser", interrupt=True
) )
# Load modules if self._loading:
self.modules = self.get_speechd_modules() return
if not self.modules: 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( self.env["runtime"]["OutputManager"].present_text(
"No speech modules found", interrupt=True "No speech modules found", interrupt=True
) )
self.env["runtime"]["OutputManager"].play_sound("Error")
return return
self.modules = modules
# Set current module
current_module = self.env["runtime"]["SettingsManager"].get_setting( current_module = self.env["runtime"]["SettingsManager"].get_setting(
"speech", "module" "speech", "module"
) )
if current_module and current_module in self.modules: if current_module and current_module in self.modules:
self.module_index = self.modules.index(current_module) self.module_index = self.modules.index(current_module)
self._request_current_module_voices(generation, True)
# Load voices def _request_current_module_voices(self, generation, enter_browser=False):
self.load_voices_for_current_module() 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( current_voice = self.env["runtime"]["SettingsManager"].get_setting(
"speech", "voice" "speech", "voice"
) )
if current_voice and current_voice in self.voices: if current_voice and current_voice in self.voices:
self.voice_index = self.voices.index(current_voice) self.voice_index = self.voices.index(current_voice)
if enter_browser:
# Enter browser mode self.enter_voice_browser()
self.enter_voice_browser()
self.announce_current_selection() self.announce_current_selection()
def enter_voice_browser(self): def enter_voice_browser(self):
@@ -113,6 +141,9 @@ class command:
def exit_voice_browser(self): def exit_voice_browser(self):
"""Exit voice browser and restore normal key bindings""" """Exit voice browser and restore normal key bindings"""
self._leave_voice_browser(True)
def _leave_voice_browser(self, announce):
if not self.browserActive: if not self.browserActive:
return return
@@ -125,16 +156,10 @@ class command:
if "voiceBrowserInstance" in self.env["runtime"]: if "voiceBrowserInstance" in self.env["runtime"]:
del self.env["runtime"]["voiceBrowserInstance"] del self.env["runtime"]["voiceBrowserInstance"]
self.env["runtime"]["OutputManager"].present_text( if announce:
"Voice browser exited", interrupt=True 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
def announce_current_selection(self): def announce_current_selection(self):
"""Announce current module and voice""" """Announce current module and voice"""
@@ -149,7 +174,7 @@ class command:
module = self.modules[self.module_index] module = self.modules[self.module_index]
if self.voices and self.voice_index < len(self.voices): 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( self.env["runtime"]["OutputManager"].present_text(
f"{module}: {voice} ({self.voice_index + 1}/{len(self.voices)})", f"{module}: {voice} ({self.voice_index + 1}/{len(self.voices)})",
interrupt=True, interrupt=True,
@@ -174,16 +199,19 @@ class command:
self.announce_current_selection() self.announce_current_selection()
def next_module(self): def next_module(self):
"""Move to next module""" self._change_module(1)
self.module_index = (self.module_index + 1) % len(self.modules)
self.load_voices_for_current_module()
self.announce_current_selection()
def prev_module(self): def prev_module(self):
"""Move to previous module""" self._change_module(-1)
self.module_index = (self.module_index - 1) % len(self.modules)
self.load_voices_for_current_module() def _change_module(self, offset):
self.announce_current_selection() 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): def test_voice(self):
"""Test current voice""" """Test current voice"""
@@ -194,15 +222,38 @@ class command:
return return
module = self.modules[self.module_index] 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( self.env["runtime"]["OutputManager"].present_text(
"Testing...", interrupt=True "Testing...", interrupt=True
) )
if self.preview_voice(module, voice): self._request_generation += 1
# Store for apply command 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"]["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") self.env["runtime"]["OutputManager"].play_sound("Accept")
else: else:
self.env["runtime"]["OutputManager"].play_sound("Error") self.env["runtime"]["OutputManager"].play_sound("Error")
@@ -213,13 +264,17 @@ class command:
return return
module = self.modules[self.module_index] module = self.modules[self.module_index]
voice = self.voices[self.voice_index] voice, separator, language = self.voices[self.voice_index].partition(
"|"
)
try: try:
SettingsManager = self.env["runtime"]["SettingsManager"] SettingsManager = self.env["runtime"]["SettingsManager"]
SettingsManager.settings["speech"]["driver"] = "speechdDriver" SettingsManager.settings["speech"]["driver"] = "speechdDriver"
SettingsManager.settings["speech"]["module"] = module SettingsManager.settings["speech"]["module"] = module
SettingsManager.settings["speech"]["voice"] = voice SettingsManager.settings["speech"]["voice"] = voice
if separator:
SettingsManager.settings["speech"]["language"] = language
if "SpeechDriver" in self.env["runtime"]: if "SpeechDriver" in self.env["runtime"]:
SpeechDriver = self.env["runtime"]["SpeechDriver"] SpeechDriver = self.env["runtime"]["SpeechDriver"]
@@ -237,50 +292,5 @@ class command:
) )
self.env["runtime"]["OutputManager"].play_sound("Error") 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): def set_callback(self, callback):
pass pass
@@ -1,207 +1,160 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import subprocess from fenrirscreenreader.core import debug
import threading
import time
from fenrirscreenreader.core.i18n import _ from fenrirscreenreader.core.i18n import _
from fenrirscreenreader.utils.speechd_utils import get_synthesis_voice_name
class command: class command:
def __init__(self): def __init__(self):
pass self._request_generation = 0
self._loading = False
def initialize(self, environment): def initialize(self, environment):
self.env = environment self.env = environment
self.testMessage = ( self.test_message = (
"Voice test: The quick brown fox jumps over the lazy dog." "Voice test: The quick brown fox jumps over the lazy dog."
) )
def shutdown(self): def shutdown(self):
pass self._request_generation += 1
self._loading = False
def get_description(self): def get_description(self):
return "Safe voice browser - cycles through voices without hanging" return _("browse and test Speech Dispatcher voices")
def run(self): 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( 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 voice_index += 1
modules = self.get_speechd_modules_with_timeout() if voice_index >= len(voices):
if not modules: voice_index = 0
self.env["runtime"]["OutputManager"].present_text( module_index = (module_index + 1) % len(modules)
"No speech modules found", interrupt=True self.env["commandBuffer"]["safeBrowserModuleIndex"] = module_index
) self.env["commandBuffer"]["safeBrowserVoiceIndex"] = voice_index
return
# Get current position from commandBuffer or start fresh def _report_error(self, message, detail):
module_index = self.env["commandBuffer"].get( if detail:
"safeBrowserModuleIndex", 0 self.env["runtime"]["DebugManager"].write_debug_out(
f"voice_browser_safe: {detail}",
debug.DebugLevel.ERROR,
) )
voice_index = self.env["commandBuffer"].get( self.env["runtime"]["OutputManager"].present_text(
"safeBrowserVoiceIndex", 0 message, interrupt=True
) )
# 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 []
def set_callback(self, callback): def set_callback(self, callback):
pass pass
@@ -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()
@@ -6,12 +6,64 @@
import os import os
import threading import threading
import time
from fenrirscreenreader.core import debug from fenrirscreenreader.core import debug
from fenrirscreenreader.core.eventData import FenrirEventType
from fenrirscreenreader.utils import x_clipboard 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: class ClipboardSyncManager:
def __init__(self): def __init__(self):
self.env = None self.env = None
@@ -19,7 +71,10 @@ class ClipboardSyncManager:
self.display = "" self.display = ""
self.interval = 0.5 self.interval = 0.5
self.running = False 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_written_to_x = None
self.last_imported_from_x = None self.last_imported_from_x = None
self.last_observed_fenrir = None self.last_observed_fenrir = None
@@ -82,19 +137,85 @@ class ClipboardSyncManager:
if self.running: if self.running:
return return
self.running = True self.running = True
self.thread = threading.Thread(target=self._run, daemon=True) self._scheduler_stop.clear()
self.thread.start() self._scheduler_thread = threading.Thread(
target=self._schedule_sync_events,
name="fenrir-clipboard-scheduler",
daemon=True,
)
self._scheduler_thread.start()
def stop(self): def stop(self):
self.running = False self.running = False
if self.thread: task_manager = self.env["runtime"].get("BackgroundTaskManager")
self.thread.join(timeout=1.0) if task_manager and self._task_id is not None:
self.thread = 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: while self.running:
self.poll_once() if not self._sync_event_pending.is_set():
time.sleep(self.interval) 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): def poll_once(self):
fenrir_text = self._get_fenrir_clipboard_text() fenrir_text = self._get_fenrir_clipboard_text()
+195 -180
View File
@@ -1,12 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import importlib.util
import os
import subprocess
import time
from fenrirscreenreader.core import debug from fenrirscreenreader.core import debug
from fenrirscreenreader.utils.speechd_utils import get_synthesis_voice_name
class DynamicVoiceCommand: class DynamicVoiceCommand:
@@ -14,51 +8,40 @@ class DynamicVoiceCommand:
def __init__(self, module, voice, env): def __init__(self, module, voice, env):
self.module = module self.module = module
self.voice = voice self.voice, separator, self.language = voice.partition("|")
if not separator:
self.language = ""
self.env = env 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): def initialize(self, environment):
self.env = environment self.env = environment
def shutdown(self): def shutdown(self):
pass self._test_generation += 1
def get_description(self): def get_description(self):
return f"Select voice: {self.voice}" return f"Select voice: {self.voice}"
def run(self): def run(self):
self._test_generation += 1
generation = self._test_generation
try: try:
self.env["runtime"]["OutputManager"].present_text( self.env["runtime"]["OutputManager"].present_text(
f"Testing voice {self.voice} from {self.module}. Please wait.", f"Testing voice {self.voice} from {self.module}. Please wait.",
interrupt=True, interrupt=True,
) )
self.env["runtime"]["SpeechDiscoveryManager"].request_voice_test(
# Brief pause before testing to avoid speech overlap self.module,
time.sleep(0.5) self.voice,
self.test_message,
# Test voice lambda succeeded, error: self._finish_voice_test(
testResult, errorMsg = self.test_voice() generation, succeeded, error
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,
)
except Exception as e: except Exception as e:
self.env["runtime"]["OutputManager"].present_text( self.env["runtime"]["OutputManager"].present_text(
@@ -67,35 +50,32 @@ class DynamicVoiceCommand:
flush=False, flush=False,
) )
def test_voice(self): def _finish_voice_test(self, generation, succeeded, error):
"""Test voice with spd-say""" if generation != self._test_generation:
try: return
cmd = [ if not succeeded:
"spd-say", self.env["runtime"]["OutputManager"].present_text(
"-C", f"Voice test failed: {error}",
"-w", interrupt=False,
"-o", flush=False,
self.module,
"-y",
self.voice,
self.testMessage,
]
result = subprocess.run(
cmd, timeout=8, capture_output=True, text=True
) )
if result.returncode == 0: return
return True, "Voice test successful"
else: self.env["commandBuffer"]["lastTestedModule"] = self.module
error_msg = ( self.env["commandBuffer"]["lastTestedVoice"] = self.voice
result.stderr.strip() self.env["commandBuffer"]["pendingVoiceModule"] = self.module
if result.stderr self.env["commandBuffer"]["pendingVoiceVoice"] = self.voice
else f"Command failed with return code {result.returncode}" if self.language:
) self.env["commandBuffer"]["pendingVoiceLanguage"] = self.language
return False, error_msg else:
except subprocess.TimeoutExpired: self.env["commandBuffer"].pop("pendingVoiceLanguage", None)
return False, "Voice test timed out" self.env["commandBuffer"]["voiceTestCompleted"] = True
except Exception as e: self.env["runtime"]["OutputManager"].present_text(
return False, f"Error running voice test: {str(e)}" "Voice test completed successfully. "
"Navigate to Apply Tested Voice to use this voice.",
interrupt=False,
flush=False,
)
def set_callback(self, callback): def set_callback(self, callback):
pass pass
@@ -126,6 +106,7 @@ class DynamicApplyVoiceCommand:
module = self.env["commandBuffer"]["pendingVoiceModule"] module = self.env["commandBuffer"]["pendingVoiceModule"]
voice = self.env["commandBuffer"]["pendingVoiceVoice"] voice = self.env["commandBuffer"]["pendingVoiceVoice"]
language = self.env["commandBuffer"].get("pendingVoiceLanguage")
self.env["runtime"]["OutputManager"].present_text( self.env["runtime"]["OutputManager"].present_text(
f"Applying {voice} from {module}", interrupt=True f"Applying {voice} from {module}", interrupt=True
@@ -148,6 +129,7 @@ class DynamicApplyVoiceCommand:
old_driver = settings_manager.get_setting("speech", "driver") old_driver = settings_manager.get_setting("speech", "driver")
old_module = settings_manager.get_setting("speech", "module") old_module = settings_manager.get_setting("speech", "module")
old_voice = settings_manager.get_setting("speech", "voice") old_voice = settings_manager.get_setting("speech", "voice")
old_language = settings_manager.get_setting("speech", "language")
try: try:
# Apply new settings to runtime only (use set_setting to update # 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", "module", module)
settings_manager.set_setting("speech", "voice", voice) settings_manager.set_setting("speech", "voice", voice)
if language:
settings_manager.set_setting(
"speech", "language", language
)
# Apply settings to speech driver directly # Apply settings to speech driver directly
if "SpeechDriver" in self.env["runtime"]: 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 # Set module and voice on the driver instance.
current_module = settings_manager.get_setting( speech_driver.set_module(module)
"speech", "module" if language:
) speech_driver.set_language(language)
module_changing = current_module != module speech_driver.set_voice(voice)
# 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
)
# Debug: verify what was actually set # Debug: verify what was actually set
self.env["runtime"]["OutputManager"].present_text( 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, interrupt=True,
) )
# Force application by speaking a test message # Force application by speaking a test message
self.env["runtime"]["OutputManager"].present_text( 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, interrupt=True,
) )
# Brief pause then more speech to test
time.sleep(1)
self.env["runtime"]["OutputManager"].present_text( self.env["runtime"]["OutputManager"].present_text(
"Use save settings to make permanent", interrupt=True "Use save settings to make permanent", interrupt=True
) )
@@ -214,16 +181,19 @@ class DynamicApplyVoiceCommand:
except Exception as e: except Exception as e:
# Revert on failure # Revert on failure
settings_manager.settings["speech"]["driver"] = old_driver settings_manager.set_setting("speech", "driver", old_driver)
settings_manager.settings["speech"]["module"] = old_module settings_manager.set_setting("speech", "module", old_module)
settings_manager.settings["speech"]["voice"] = old_voice settings_manager.set_setting("speech", "voice", old_voice)
settings_manager.set_setting(
"speech", "language", old_language
)
# Try to reinitialize with old settings # Try to reinitialize with old settings
if "SpeechDriver" in self.env["runtime"]: if "SpeechDriver" in self.env["runtime"]:
try: try:
SpeechDriver = self.env["runtime"]["SpeechDriver"] speech_driver = self.env["runtime"]["SpeechDriver"]
SpeechDriver.shutdown() speech_driver.shutdown()
SpeechDriver.initialize(self.env) speech_driver.initialize(self.env)
except Exception as e: except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out( self.env["runtime"]["DebugManager"].write_debug_out(
"dynamicVoiceMenu: Error reinitializing speech driver: " "dynamicVoiceMenu: Error reinitializing speech driver: "
@@ -246,56 +216,139 @@ class DynamicApplyVoiceCommand:
pass pass
def add_dynamic_voice_menus(VmenuManager): def add_dynamic_voice_menus(vmenu_manager):
"""Add dynamic voice menus to vmenu system""" """Populate cached voice menus and start non-blocking discovery if needed."""
try: try:
env = VmenuManager.env env = vmenu_manager.env
discovery = env["runtime"]["SpeechDiscoveryManager"]
# Get speech modules generation = getattr(vmenu_manager, "_voice_menu_generation", 0) + 1
modules = get_speechd_modules() vmenu_manager._voice_menu_generation = generation
if not modules: modules = discovery.get_cached_modules()
return if modules is None:
_install_loading_menu(vmenu_manager)
# Create voice browser submenu discovery.request_modules(
voice_browser_menu = {} lambda found, error: _request_dynamic_voice_lists(
vmenu_manager, generation, found, error, False
# 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)
) )
)
voice_browser_menu[f"{module} Menu"] = module_menu return
_request_dynamic_voice_lists(
# Add to main menu dict vmenu_manager, generation, modules, "", True
VmenuManager.menuDict["Voice Browser Menu"] = voice_browser_menu )
except Exception as error:
except Exception as e:
# Use debug manager instead of print for error logging
if "DebugManager" in env["runtime"]: if "DebugManager" in env["runtime"]:
env["runtime"]["DebugManager"].write_debug_out( env["runtime"]["DebugManager"].write_debug_out(
f"Error creating dynamic voice menus: {e}", f"Error creating dynamic voice menus: {error}",
debug.DebugLevel.ERROR, debug.DebugLevel.ERROR,
) )
else: 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): def create_info_command(message, env):
@@ -324,41 +377,3 @@ def create_info_command(message, env):
pass pass
return InfoCommand(message, env) 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 []
+2
View File
@@ -19,6 +19,8 @@ class FenrirEventType(Enum):
heart_beat = 6 heart_beat = 6
execute_command = 7 execute_command = 7
remote_incomming = 8 remote_incomming = 8
background_task_result = 9
clipboard_sync = 10
def __int__(self): def __int__(self):
return self.value return self.value
@@ -63,6 +63,12 @@ class EventManager:
self.env["runtime"]["FenrirManager"].handle_execute_command(event) self.env["runtime"]["FenrirManager"].handle_execute_command(event)
elif event["Type"] == FenrirEventType.remote_incomming: elif event["Type"] == FenrirEventType.remote_incomming:
self.env["runtime"]["FenrirManager"].handle_remote_incomming(event) 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): def is_main_event_loop_running(self):
return self.running.value == 1 return self.running.value == 1
@@ -221,6 +221,11 @@ class FenrirManager:
event["data"] event["data"]
) )
def handle_background_task_result(self, event):
self.environment["runtime"]["BackgroundTaskManager"].handle_result(
event["data"]
)
def handle_screen_change(self, event): def handle_screen_change(self, event):
self.environment["runtime"]["ScreenManager"].handle_screen_change( self.environment["runtime"]["ScreenManager"].handle_screen_change(
event["data"] event["data"]
@@ -287,6 +292,13 @@ class FenrirManager:
"onHeartBeat", force=True "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): def detect_shortcut_command(self):
if self.environment["input"]["key_forward"] != 0: if self.environment["input"]["key_forward"] != 0:
return return
@@ -24,6 +24,8 @@ general_data = {
"SpeechHistoryManager", "SpeechHistoryManager",
"HelpManager", "HelpManager",
"MemoryManager", "MemoryManager",
"SpeechDiscoveryManager",
"BackgroundTaskManager",
"EventManager", "EventManager",
"ProcessManager", "ProcessManager",
"VmenuManager", "VmenuManager",
+175 -297
View File
@@ -4,135 +4,19 @@
# Fenrir TTY screen reader # Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors. # By Chrys, Storm Dragon, and contributors.
import subprocess
import time
from fenrirscreenreader.core import debug from fenrirscreenreader.core import debug
from fenrirscreenreader.core.i18n import _ from fenrirscreenreader.core.i18n import _
from fenrirscreenreader.core.settingsData import settings_data from fenrirscreenreader.core.settingsData import settings_data
from fenrirscreenreader.utils.speechd_utils import (
get_synthesis_voice_name,
parse_synthesis_voice_line,
)
class SpeechHelperMixin: class QuickMenuManager:
"""Helper methods for querying speech-dispatcher modules and voices.
Provides caching and query functionality for speech-dispatcher module
and voice enumeration, reusing proven logic from voice_browser.py.
"""
def __init__(self): def __init__(self):
self._modules_cache = None self.position = 0
self._voices_cache = {} # {module_name: [voice_list]} self.quickMenu = []
self._cache_timestamp = 0 self.settings = settings_data
self._cache_timeout = 300 # 5 minutes self._module_request_pending = False
self._voice_requests_pending = set()
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}"
def _select_default_voice(self, voices): def _select_default_voice(self, voices):
"""Select a sensible default voice from list, preferring user's """Select a sensible default voice from list, preferring user's
@@ -202,18 +86,7 @@ class SpeechHelperMixin:
return voices[0] return voices[0]
def invalidate_speech_cache(self): def invalidate_speech_cache(self):
"""Clear cached module and voice data.""" self.env["runtime"]["SpeechDiscoveryManager"].invalidate_cache()
self._modules_cache = None
self._voices_cache = {}
self._cache_timestamp = 0
class QuickMenuManager(SpeechHelperMixin):
def __init__(self):
SpeechHelperMixin.__init__(self)
self.position = 0
self.quickMenu = []
self.settings = settings_data
def initialize(self, environment): def initialize(self, environment):
self.env = environment self.env = environment
@@ -420,197 +293,202 @@ class QuickMenuManager(SpeechHelperMixin):
return True return True
def cycle_speech_module(self, direction): 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: self._module_request_pending = True
direction (str): 'next' or 'prev' self.env["runtime"]["OutputManager"].present_text(
"Loading speech modules", interrupt=True
Returns: )
bool: True if successful, False otherwise discovery.request_modules(
""" lambda found, error: self._continue_module_cycle(
try: direction, found, True, error
# 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"
) )
)
return False
# Find current index def _continue_module_cycle(
try: self, direction, modules, announce_result, error=""
current_index = (modules.index(current_module) ):
if current_module else 0) if error:
except ValueError: self._module_request_pending = False
current_index = 0 self._report_discovery_error("module", error)
return False
# Cycle to next/previous if not modules:
if direction == "next": self._module_request_pending = False
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
self.env["runtime"]["OutputManager"].present_text( self.env["runtime"]["OutputManager"].present_text(
new_module, interrupt=True "No modules available", interrupt=True
)
return True
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
f"QuickMenuManager cycle_speech_module: Error: {e}",
debug.DebugLevel.ERROR
) )
return False 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: try:
# Get current module
current_module = self.env["runtime"]["SettingsManager"].get_setting( current_module = self.env["runtime"]["SettingsManager"].get_setting(
"speech", "module" "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: if not current_module:
self.env["runtime"]["OutputManager"].present_text( self.env["runtime"]["OutputManager"].present_text(
"No module selected", interrupt=True "No module selected", interrupt=True
) )
return False return False
discovery = self.env["runtime"]["SpeechDiscoveryManager"]
# Get available voices for this module voices = discovery.get_cached_voices(current_module)
voices = self.get_module_voices(current_module) if voices is not None:
if not voices: return self._apply_voice(direction, voices, False)
self.env["runtime"]["OutputManager"].present_text( if current_module in self._voice_requests_pending:
f"No voices for module {current_module}", interrupt=True
)
return False return False
# Get current voice self._voice_requests_pending.add(current_module)
current_voice = self.env["runtime"]["SettingsManager"].get_setting( self.env["runtime"]["OutputManager"].present_text(
"speech", "voice" f"Loading voices for {current_module}", interrupt=True
) )
discovery.request_voices(
# Find current index (handle Voxin voice|language format) current_module,
current_index = 0 lambda module, found, error: self._finish_voice_cycle(
if current_voice: direction, module, found, error
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
) )
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 def _finish_voice_cycle(self, direction, module, voices, error):
if "SpeechDriver" in self.env["runtime"]: self._voice_requests_pending.discard(module)
try: if error:
# Set language first if available self._report_discovery_error("voice", error)
if voice_lang: return
self.env["runtime"]["SpeechDriver"].set_language( self._apply_voice(direction, voices, True)
voice_lang
)
# Then set voice
self.env["runtime"]["SpeechDriver"].set_voice(voice_name)
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
(f"QuickMenuManager cycle_speech_voice: "
f"Error applying voice: {e}"),
debug.DebugLevel.ERROR
)
# Announce new voice (voice name only, not language) 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( self.env["runtime"]["OutputManager"].present_text(
voice_name, interrupt=True 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: def _report_discovery_error(self, kind, error):
self.env["runtime"]["DebugManager"].write_debug_out( self.env["runtime"]["DebugManager"].write_debug_out(
f"QuickMenuManager cycle_speech_voice: Error: {e}", f"QuickMenuManager {kind} discovery failed: {error}",
debug.DebugLevel.ERROR debug.DebugLevel.ERROR,
) )
return False self.env["runtime"]["OutputManager"].present_text(
f"{kind.capitalize()} discovery failed", interrupt=True
)
def get_current_entry(self): def get_current_entry(self):
if len(self.quickMenu) == 0: if len(self.quickMenu) == 0:
@@ -22,6 +22,8 @@ runtime_data = {
"SettingsManager": None, "SettingsManager": None,
"FenrirManager": None, "FenrirManager": None,
"EventManager": None, "EventManager": None,
"BackgroundTaskManager": None,
"ProcessManager": None, "ProcessManager": None,
"SpeechDiscoveryManager": None,
"DiffReviewManager": None, "DiffReviewManager": None,
} }
@@ -12,6 +12,7 @@ from configparser import ConfigParser
from fenrirscreenreader.core import applicationManager from fenrirscreenreader.core import applicationManager
from fenrirscreenreader.core import attributeManager from fenrirscreenreader.core import attributeManager
from fenrirscreenreader.core import barrierManager from fenrirscreenreader.core import barrierManager
from fenrirscreenreader.core import backgroundTaskManager
from fenrirscreenreader.core import clipboardSyncManager from fenrirscreenreader.core import clipboardSyncManager
from fenrirscreenreader.core import commandManager from fenrirscreenreader.core import commandManager
from fenrirscreenreader.core import cursorManager from fenrirscreenreader.core import cursorManager
@@ -32,6 +33,7 @@ from fenrirscreenreader.core import remoteManager
from fenrirscreenreader.core import sayAllManager from fenrirscreenreader.core import sayAllManager
from fenrirscreenreader.core import screenManager from fenrirscreenreader.core import screenManager
from fenrirscreenreader.core import speechHistoryManager from fenrirscreenreader.core import speechHistoryManager
from fenrirscreenreader.core import speechDiscoveryManager
from fenrirscreenreader.core import tableManager from fenrirscreenreader.core import tableManager
from fenrirscreenreader.core import textManager from fenrirscreenreader.core import textManager
from fenrirscreenreader.core import vmenuManager from fenrirscreenreader.core import vmenuManager
@@ -753,6 +755,11 @@ class SettingsManager:
] = processManager.ProcessManager() ] = processManager.ProcessManager()
environment["runtime"]["ProcessManager"].initialize(environment) environment["runtime"]["ProcessManager"].initialize(environment)
environment["runtime"][
"BackgroundTaskManager"
] = backgroundTaskManager.BackgroundTaskManager()
environment["runtime"]["BackgroundTaskManager"].initialize(environment)
environment["runtime"]["OutputManager"] = outputManager.OutputManager() environment["runtime"]["OutputManager"] = outputManager.OutputManager()
environment["runtime"]["OutputManager"].initialize(environment) environment["runtime"]["OutputManager"].initialize(environment)
@@ -807,6 +814,10 @@ class SettingsManager:
environment["runtime"]["BarrierManager"].initialize(environment) environment["runtime"]["BarrierManager"].initialize(environment)
environment["runtime"]["SayAllManager"] = sayAllManager.SayAllManager() environment["runtime"]["SayAllManager"] = sayAllManager.SayAllManager()
environment["runtime"]["SayAllManager"].initialize(environment) environment["runtime"]["SayAllManager"].initialize(environment)
environment["runtime"][
"SpeechDiscoveryManager"
] = speechDiscoveryManager.SpeechDiscoveryManager()
environment["runtime"]["SpeechDiscoveryManager"].initialize(environment)
environment["runtime"]["VmenuManager"] = vmenuManager.VmenuManager() environment["runtime"]["VmenuManager"] = vmenuManager.VmenuManager()
environment["runtime"]["VmenuManager"].initialize(environment) environment["runtime"]["VmenuManager"].initialize(environment)
environment["runtime"][ environment["runtime"][
@@ -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,
)
+1 -1
View File
@@ -4,5 +4,5 @@
# Fenrir TTY screen reader # Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors. # By Chrys, Storm Dragon, and contributors.
version = "2026.08.13" version = "2026.08.14"
code_name = "testing" code_name = "testing"
+303
View File
@@ -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
+109
View File
@@ -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()
@@ -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")
+85
View File
@@ -3,6 +3,7 @@ from unittest.mock import Mock
import pytest import pytest
from fenrirscreenreader.core.clipboardSyncManager import ClipboardSyncManager from fenrirscreenreader.core.clipboardSyncManager import ClipboardSyncManager
from fenrirscreenreader.core.clipboardSyncManager import synchronize_clipboards
def build_env( 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"]["MemoryManager"].add_value_to_first_index.assert_not_called()
env["runtime"]["DebugManager"].write_debug_out.assert_called_once() 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"
+116
View File
@@ -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"]
+8 -87
View File
@@ -2,90 +2,11 @@ import sys
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import Mock from unittest.mock import Mock
from fenrirscreenreader.commands.commands import voice_browser from fenrirscreenreader.core.quickMenuManager import QuickMenuManager
from fenrirscreenreader.commands.commands import voice_browser_safe
from fenrirscreenreader.core import dynamicVoiceMenu
from fenrirscreenreader.core.quickMenuManager import (
QuickMenuManager,
SpeechHelperMixin,
)
from fenrirscreenreader.speechDriver import speechdDriver from fenrirscreenreader.speechDriver import speechdDriver
from fenrirscreenreader.utils.speechd_utils import get_synthesis_voice_name 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(): def test_espeak_voice_selection_keeps_language_and_variant_behavior():
voice = get_synthesis_voice_name( voice = get_synthesis_voice_name(
"espeak-ng", "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(): def test_quick_menu_espeak_voice_can_move_away_and_return():
settings = RuntimeSettings(module="rhvoice") 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 = QuickMenuManager()
manager.env = { manager.env = {
"runtime": { "runtime": {
@@ -167,15 +94,9 @@ def test_quick_menu_espeak_voice_can_move_away_and_return():
"OutputManager": Mock(), "OutputManager": Mock(),
"SettingsManager": settings, "SettingsManager": settings,
"SpeechDriver": Mock(), "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 manager.cycle_speech_module("next") is True
assert settings.get_setting("speech", "module") == "espeak-ng" assert settings.get_setting("speech", "module") == "espeak-ng"
+10 -17
View File
@@ -8,26 +8,19 @@ from fenrirscreenreader.commands.commands import subprocess as subprocess_comman
@pytest.mark.unit @pytest.mark.unit
def test_script_command_executes_without_shell(monkeypatch): def test_script_command_executes_without_shell(monkeypatch):
process = Mock() process = Mock()
process.communicate.return_value = (b"done", b"") process.returncode = 0
process.communicate.return_value = ("done", "")
popen = Mock(return_value=process) popen = Mock(return_value=process)
monkeypatch.setattr(subprocess_command, "Popen", popen) monkeypatch.setattr(subprocess_command.subprocess, "Popen", popen)
output_manager = Mock()
command = subprocess_command.command()
command.initialize(
{
"general": {"curr_user": "Username"},
"runtime": {"OutputManager": output_manager},
},
"/tmp/script with spaces",
)
command._thread_run() result = subprocess_command.run_script(
"/tmp/script with spaces", "Username"
)
popen.assert_called_once_with( popen.assert_called_once_with(
["/tmp/script with spaces", "Username"], ["/tmp/script with spaces", "Username"],
stdout=subprocess_command.PIPE, stdout=subprocess_command.subprocess.PIPE,
stderr=subprocess_command.PIPE, stderr=subprocess_command.subprocess.PIPE,
) text=True,
output_manager.present_text.assert_called_once_with(
"done", sound_icon="", interrupt=False
) )
assert result == {"return_code": 0, "stdout": "done", "stderr": ""}
@@ -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
)