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:
@@ -34,6 +34,7 @@ class command:
|
||||
|
||||
module = self.env["commandBuffer"]["lastTestedModule"]
|
||||
voice = self.env["commandBuffer"]["lastTestedVoice"]
|
||||
language = self.env["commandBuffer"].get("lastTestedLanguage")
|
||||
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
f"Applying {voice} from {module}", interrupt=True
|
||||
@@ -46,6 +47,7 @@ class command:
|
||||
old_driver = SettingsManager.get_setting("speech", "driver")
|
||||
old_module = SettingsManager.get_setting("speech", "module")
|
||||
old_voice = SettingsManager.get_setting("speech", "voice")
|
||||
old_language = SettingsManager.get_setting("speech", "language")
|
||||
|
||||
try:
|
||||
# Apply new settings to runtime only (use set_setting to update
|
||||
@@ -55,6 +57,10 @@ class command:
|
||||
)
|
||||
SettingsManager.set_setting("speech", "module", module)
|
||||
SettingsManager.set_setting("speech", "voice", voice)
|
||||
if language:
|
||||
SettingsManager.set_setting(
|
||||
"speech", "language", language
|
||||
)
|
||||
|
||||
# Apply to speech driver instance directly
|
||||
if "SpeechDriver" in self.env["runtime"]:
|
||||
@@ -62,6 +68,8 @@ class command:
|
||||
|
||||
# Set the module and voice on the driver instance
|
||||
SpeechDriver.set_module(module)
|
||||
if language:
|
||||
SpeechDriver.set_language(language)
|
||||
SpeechDriver.set_voice(voice)
|
||||
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
@@ -77,6 +85,9 @@ class command:
|
||||
SettingsManager.set_setting("speech", "driver", old_driver)
|
||||
SettingsManager.set_setting("speech", "module", old_module)
|
||||
SettingsManager.set_setting("speech", "voice", old_voice)
|
||||
SettingsManager.set_setting(
|
||||
"speech", "language", old_language
|
||||
)
|
||||
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
f"Failed to apply voice, reverted: {str(e)}",
|
||||
|
||||
@@ -4,30 +4,35 @@
|
||||
# Fenrir TTY screen reader
|
||||
# By Chrys, Storm Dragon, and contributors.
|
||||
|
||||
import _thread
|
||||
|
||||
from fenrirscreenreader.core import debug
|
||||
from fenrirscreenreader.core.i18n import _
|
||||
from fenrirscreenreader.utils import x_clipboard
|
||||
|
||||
|
||||
def write_clipboard(clipboard):
|
||||
"""Write the graphical clipboard without accessing Fenrir state."""
|
||||
return x_clipboard.write_text(clipboard, scan_displays=True)
|
||||
|
||||
|
||||
class command:
|
||||
def __init__(self):
|
||||
pass
|
||||
self._task_ids = set()
|
||||
|
||||
def initialize(self, environment, script_path=""):
|
||||
self.env = environment
|
||||
self.script_path = script_path
|
||||
|
||||
def shutdown(self):
|
||||
pass
|
||||
task_manager = self.env["runtime"].get("BackgroundTaskManager")
|
||||
if task_manager:
|
||||
for task_id in self._task_ids:
|
||||
task_manager.cancel_task(task_id)
|
||||
self._task_ids.clear()
|
||||
|
||||
def get_description(self):
|
||||
return _("Export current fenrir clipboard to X or GUI clipboard")
|
||||
|
||||
def run(self):
|
||||
_thread.start_new_thread(self._thread_run, ())
|
||||
|
||||
def _thread_run(self):
|
||||
try:
|
||||
# Check if clipboard is empty
|
||||
if self.env["runtime"]["MemoryManager"].is_index_list_empty(
|
||||
@@ -43,36 +48,43 @@ class command:
|
||||
"MemoryManager"
|
||||
].get_index_list_element("clipboardHistory")
|
||||
|
||||
try:
|
||||
success = x_clipboard.write_text(
|
||||
clipboard, scan_displays=True
|
||||
)
|
||||
except Exception:
|
||||
success = False
|
||||
|
||||
# Notify the user of the result
|
||||
if success:
|
||||
sync_manager = self.env["runtime"].get(
|
||||
"ClipboardSyncManager"
|
||||
)
|
||||
if sync_manager:
|
||||
sync_manager.mark_written_to_x(clipboard)
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
_("exported to the X session."), interrupt=True
|
||||
)
|
||||
else:
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
_(
|
||||
"failed to export to X clipboard. No available display "
|
||||
"found."
|
||||
),
|
||||
interrupt=True,
|
||||
)
|
||||
task_id = self.env["runtime"][
|
||||
"BackgroundTaskManager"
|
||||
].submit_task(
|
||||
write_clipboard,
|
||||
lambda result: self._handle_result(clipboard, result),
|
||||
clipboard,
|
||||
)
|
||||
if task_id is not None:
|
||||
self._task_ids.add(task_id)
|
||||
|
||||
except Exception as e:
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
str(e), sound_icon="", interrupt=False
|
||||
)
|
||||
|
||||
def _handle_result(self, clipboard, result):
|
||||
self._task_ids.discard(result.get("task_id"))
|
||||
if result.get("succeeded") and result.get("value"):
|
||||
sync_manager = self.env["runtime"].get("ClipboardSyncManager")
|
||||
if sync_manager:
|
||||
sync_manager.mark_written_to_x(clipboard)
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
_("exported to the X session."), interrupt=True
|
||||
)
|
||||
return
|
||||
|
||||
if not result.get("succeeded") and result.get("error"):
|
||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||
"Clipboard export failed: " + result["error"],
|
||||
debug.DebugLevel.ERROR,
|
||||
)
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
_(
|
||||
"failed to export to X clipboard. No available display found."
|
||||
),
|
||||
interrupt=True,
|
||||
)
|
||||
|
||||
def set_callback(self, callback):
|
||||
pass
|
||||
|
||||
@@ -4,60 +4,68 @@
|
||||
# Fenrir TTY screen reader
|
||||
# By Chrys, Storm Dragon, and contributors.
|
||||
|
||||
import _thread
|
||||
|
||||
from fenrirscreenreader.core.i18n import _
|
||||
from fenrirscreenreader.utils import x_clipboard
|
||||
|
||||
|
||||
def read_clipboard():
|
||||
"""Read the graphical clipboard without accessing Fenrir state."""
|
||||
return x_clipboard.read_text(scan_displays=True)
|
||||
|
||||
|
||||
class command:
|
||||
def __init__(self):
|
||||
pass
|
||||
self._task_ids = set()
|
||||
|
||||
def initialize(self, environment, script_path=""):
|
||||
self.env = environment
|
||||
self.script_path = script_path
|
||||
|
||||
def shutdown(self):
|
||||
pass
|
||||
task_manager = self.env["runtime"].get("BackgroundTaskManager")
|
||||
if task_manager:
|
||||
for task_id in self._task_ids:
|
||||
task_manager.cancel_task(task_id)
|
||||
self._task_ids.clear()
|
||||
|
||||
def get_description(self):
|
||||
return _("imports the graphical clipboard to Fenrir's clipboard")
|
||||
|
||||
def run(self):
|
||||
_thread.start_new_thread(self._thread_run, ())
|
||||
task_id = self.env["runtime"]["BackgroundTaskManager"].submit_task(
|
||||
read_clipboard, self._handle_result
|
||||
)
|
||||
if task_id is not None:
|
||||
self._task_ids.add(task_id)
|
||||
|
||||
def _thread_run(self):
|
||||
try:
|
||||
try:
|
||||
clipboard_content = x_clipboard.read_text(
|
||||
scan_displays=True
|
||||
)
|
||||
except Exception:
|
||||
clipboard_content = None
|
||||
|
||||
# Process the clipboard content if we found any
|
||||
if clipboard_content and isinstance(clipboard_content, str):
|
||||
self.env["runtime"]["MemoryManager"].add_value_to_first_index(
|
||||
"clipboardHistory", clipboard_content
|
||||
)
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
"Import to Clipboard",
|
||||
sound_icon="CopyToClipboard",
|
||||
interrupt=True,
|
||||
)
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
clipboard_content, sound_icon="", interrupt=False
|
||||
)
|
||||
else:
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
"No text found in clipboard or no accessible display",
|
||||
interrupt=True,
|
||||
)
|
||||
except Exception as e:
|
||||
def _handle_result(self, result):
|
||||
self._task_ids.discard(result.get("task_id"))
|
||||
clipboard_content = result.get("value")
|
||||
if not result.get("succeeded"):
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
str(e), sound_icon="", interrupt=False
|
||||
result.get("error", "Clipboard import failed"),
|
||||
sound_icon="",
|
||||
interrupt=False,
|
||||
)
|
||||
return
|
||||
if not clipboard_content or not isinstance(clipboard_content, str):
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
"No text found in clipboard or no accessible display",
|
||||
interrupt=True,
|
||||
)
|
||||
return
|
||||
|
||||
self.env["runtime"]["MemoryManager"].add_value_to_first_index(
|
||||
"clipboardHistory", clipboard_content
|
||||
)
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
"Import to Clipboard",
|
||||
sound_icon="CopyToClipboard",
|
||||
interrupt=True,
|
||||
)
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
clipboard_content, sound_icon="", interrupt=False
|
||||
)
|
||||
|
||||
def set_callback(self, callback):
|
||||
pass
|
||||
|
||||
@@ -4,24 +4,42 @@
|
||||
# Fenrir TTY screen reader
|
||||
# By Chrys, Storm Dragon, and contributors.
|
||||
|
||||
import _thread
|
||||
import os
|
||||
from subprocess import PIPE
|
||||
from subprocess import Popen
|
||||
import subprocess
|
||||
|
||||
from fenrirscreenreader.core.i18n import _
|
||||
|
||||
|
||||
def run_script(script_path, current_user):
|
||||
"""Run an external command without accessing Fenrir's shared state."""
|
||||
process = subprocess.Popen(
|
||||
[script_path, current_user],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
stdout, stderr = process.communicate()
|
||||
return {
|
||||
"return_code": process.returncode,
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
}
|
||||
|
||||
|
||||
class command:
|
||||
def __init__(self):
|
||||
pass
|
||||
self._task_ids = set()
|
||||
|
||||
def initialize(self, environment, script_path=""):
|
||||
self.env = environment
|
||||
self.script_path = script_path
|
||||
|
||||
def shutdown(self):
|
||||
pass
|
||||
task_manager = self.env["runtime"].get("BackgroundTaskManager")
|
||||
if task_manager:
|
||||
for task_id in self._task_ids:
|
||||
task_manager.cancel_task(task_id)
|
||||
self._task_ids.clear()
|
||||
|
||||
def get_description(self):
|
||||
return _("script: {0} fullpath: {1}").format(
|
||||
@@ -48,30 +66,39 @@ class command:
|
||||
interrupt=False,
|
||||
)
|
||||
return
|
||||
_thread.start_new_thread(self._thread_run, ())
|
||||
|
||||
def _thread_run(self):
|
||||
try:
|
||||
p = Popen(
|
||||
[self.script_path, self.env["general"]["curr_user"]],
|
||||
stdout=PIPE,
|
||||
stderr=PIPE,
|
||||
)
|
||||
stdout, stderr = p.communicate()
|
||||
stdout = stdout.decode("utf-8")
|
||||
stderr = stderr.decode("utf-8")
|
||||
self.env["runtime"]["OutputManager"].interrupt_output()
|
||||
if stderr != "":
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
str(stderr), sound_icon="", interrupt=False
|
||||
)
|
||||
if stdout != "":
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
str(stdout), sound_icon="", interrupt=False
|
||||
)
|
||||
except Exception as e:
|
||||
task_id = self.env["runtime"][
|
||||
"BackgroundTaskManager"
|
||||
].submit_external_task(
|
||||
run_script,
|
||||
self._handle_result,
|
||||
self.script_path,
|
||||
self.env["general"]["curr_user"],
|
||||
)
|
||||
if task_id is not None:
|
||||
self._task_ids.add(task_id)
|
||||
|
||||
def _handle_result(self, result):
|
||||
self._task_ids.discard(result.get("task_id"))
|
||||
if not result.get("succeeded"):
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
str(e), sound_icon="", interrupt=False
|
||||
result.get("error", _("Script failed")),
|
||||
sound_icon="",
|
||||
interrupt=False,
|
||||
)
|
||||
return
|
||||
|
||||
output = result.get("value") or {}
|
||||
stderr = output.get("stderr", "")
|
||||
stdout = output.get("stdout", "")
|
||||
self.env["runtime"]["OutputManager"].interrupt_output()
|
||||
if stderr:
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
stderr, sound_icon="", interrupt=False
|
||||
)
|
||||
if stdout:
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
stdout, sound_icon="", interrupt=False
|
||||
)
|
||||
|
||||
def set_callback(self, callback):
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
from fenrirscreenreader.core.i18n import _
|
||||
from fenrirscreenreader.utils.speechd_utils import get_synthesis_voice_name
|
||||
|
||||
|
||||
class command:
|
||||
def __init__(self):
|
||||
pass
|
||||
self._request_generation = 0
|
||||
self._loading = False
|
||||
|
||||
def initialize(self, environment):
|
||||
self.env = environment
|
||||
self.testMessage = "This is a voice test. The quick brown fox jumps over the lazy dog."
|
||||
self.test_message = (
|
||||
"This is a voice test. The quick brown fox jumps over the lazy dog."
|
||||
)
|
||||
self.modules = []
|
||||
self.voices = []
|
||||
self.module_index = 0
|
||||
@@ -23,7 +24,9 @@ class command:
|
||||
self.lastAnnounceTime = 0
|
||||
|
||||
def shutdown(self):
|
||||
pass
|
||||
self._request_generation += 1
|
||||
self._loading = False
|
||||
self._leave_voice_browser(False)
|
||||
|
||||
def get_description(self):
|
||||
return "Interactive voice browser with arrow key navigation"
|
||||
@@ -37,34 +40,59 @@ class command:
|
||||
"Starting voice browser", interrupt=True
|
||||
)
|
||||
|
||||
# Load modules
|
||||
self.modules = self.get_speechd_modules()
|
||||
if not self.modules:
|
||||
if self._loading:
|
||||
return
|
||||
self._loading = True
|
||||
self._request_generation += 1
|
||||
generation = self._request_generation
|
||||
self.env["runtime"]["SpeechDiscoveryManager"].request_modules(
|
||||
lambda modules, error: self._modules_ready(
|
||||
generation, modules, error
|
||||
)
|
||||
)
|
||||
|
||||
def _modules_ready(self, generation, modules, error):
|
||||
if generation != self._request_generation:
|
||||
return
|
||||
if error or not modules:
|
||||
self._loading = False
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
"No speech modules found", interrupt=True
|
||||
)
|
||||
self.env["runtime"]["OutputManager"].play_sound("Error")
|
||||
return
|
||||
|
||||
# Set current module
|
||||
self.modules = modules
|
||||
current_module = self.env["runtime"]["SettingsManager"].get_setting(
|
||||
"speech", "module"
|
||||
)
|
||||
if current_module and current_module in self.modules:
|
||||
self.module_index = self.modules.index(current_module)
|
||||
self._request_current_module_voices(generation, True)
|
||||
|
||||
# Load voices
|
||||
self.load_voices_for_current_module()
|
||||
def _request_current_module_voices(self, generation, enter_browser=False):
|
||||
module = self.modules[self.module_index]
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
f"Loading voices for {module}", interrupt=True
|
||||
)
|
||||
self.env["runtime"]["SpeechDiscoveryManager"].request_voices(
|
||||
module,
|
||||
lambda _module, voices, error: self._voices_ready(
|
||||
generation, voices, error, enter_browser
|
||||
),
|
||||
)
|
||||
|
||||
# Set current voice
|
||||
def _voices_ready(self, generation, voices, error, enter_browser):
|
||||
if generation != self._request_generation:
|
||||
return
|
||||
self._loading = False
|
||||
self.voices = voices if not error else []
|
||||
self.voice_index = 0
|
||||
current_voice = self.env["runtime"]["SettingsManager"].get_setting(
|
||||
"speech", "voice"
|
||||
)
|
||||
if current_voice and current_voice in self.voices:
|
||||
self.voice_index = self.voices.index(current_voice)
|
||||
|
||||
# Enter browser mode
|
||||
self.enter_voice_browser()
|
||||
if enter_browser:
|
||||
self.enter_voice_browser()
|
||||
self.announce_current_selection()
|
||||
|
||||
def enter_voice_browser(self):
|
||||
@@ -113,6 +141,9 @@ class command:
|
||||
|
||||
def exit_voice_browser(self):
|
||||
"""Exit voice browser and restore normal key bindings"""
|
||||
self._leave_voice_browser(True)
|
||||
|
||||
def _leave_voice_browser(self, announce):
|
||||
if not self.browserActive:
|
||||
return
|
||||
|
||||
@@ -125,16 +156,10 @@ class command:
|
||||
if "voiceBrowserInstance" in self.env["runtime"]:
|
||||
del self.env["runtime"]["voiceBrowserInstance"]
|
||||
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
"Voice browser exited", interrupt=True
|
||||
)
|
||||
|
||||
def load_voices_for_current_module(self):
|
||||
"""Load voices for current module"""
|
||||
if self.module_index < len(self.modules):
|
||||
module = self.modules[self.module_index]
|
||||
self.voices = self.get_module_voices(module)
|
||||
self.voice_index = 0 # Reset to first voice when changing modules
|
||||
if announce:
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
"Voice browser exited", interrupt=True
|
||||
)
|
||||
|
||||
def announce_current_selection(self):
|
||||
"""Announce current module and voice"""
|
||||
@@ -149,7 +174,7 @@ class command:
|
||||
|
||||
module = self.modules[self.module_index]
|
||||
if self.voices and self.voice_index < len(self.voices):
|
||||
voice = self.voices[self.voice_index]
|
||||
voice = self.voices[self.voice_index].split("|", 1)[0]
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
f"{module}: {voice} ({self.voice_index + 1}/{len(self.voices)})",
|
||||
interrupt=True,
|
||||
@@ -174,16 +199,19 @@ class command:
|
||||
self.announce_current_selection()
|
||||
|
||||
def next_module(self):
|
||||
"""Move to next module"""
|
||||
self.module_index = (self.module_index + 1) % len(self.modules)
|
||||
self.load_voices_for_current_module()
|
||||
self.announce_current_selection()
|
||||
self._change_module(1)
|
||||
|
||||
def prev_module(self):
|
||||
"""Move to previous module"""
|
||||
self.module_index = (self.module_index - 1) % len(self.modules)
|
||||
self.load_voices_for_current_module()
|
||||
self.announce_current_selection()
|
||||
self._change_module(-1)
|
||||
|
||||
def _change_module(self, offset):
|
||||
if self._loading or not self.modules:
|
||||
return
|
||||
self._loading = True
|
||||
self._request_generation += 1
|
||||
generation = self._request_generation
|
||||
self.module_index = (self.module_index + offset) % len(self.modules)
|
||||
self._request_current_module_voices(generation)
|
||||
|
||||
def test_voice(self):
|
||||
"""Test current voice"""
|
||||
@@ -194,15 +222,38 @@ class command:
|
||||
return
|
||||
|
||||
module = self.modules[self.module_index]
|
||||
voice = self.voices[self.voice_index]
|
||||
encoded_voice = self.voices[self.voice_index]
|
||||
voice_name = encoded_voice.split("|", 1)[0]
|
||||
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
"Testing...", interrupt=True
|
||||
)
|
||||
if self.preview_voice(module, voice):
|
||||
# Store for apply command
|
||||
self._request_generation += 1
|
||||
generation = self._request_generation
|
||||
self._loading = True
|
||||
self.env["runtime"]["SpeechDiscoveryManager"].request_voice_test(
|
||||
module,
|
||||
voice_name,
|
||||
self.test_message,
|
||||
lambda succeeded, error: self._voice_test_ready(
|
||||
generation, module, encoded_voice, succeeded, error
|
||||
),
|
||||
)
|
||||
|
||||
def _voice_test_ready(
|
||||
self, generation, module, voice, succeeded, _error
|
||||
):
|
||||
if generation != self._request_generation:
|
||||
return
|
||||
self._loading = False
|
||||
if succeeded:
|
||||
self.env["commandBuffer"]["lastTestedModule"] = module
|
||||
self.env["commandBuffer"]["lastTestedVoice"] = voice
|
||||
voice_name, separator, language = voice.partition("|")
|
||||
self.env["commandBuffer"]["lastTestedVoice"] = voice_name
|
||||
if separator:
|
||||
self.env["commandBuffer"]["lastTestedLanguage"] = language
|
||||
else:
|
||||
self.env["commandBuffer"].pop("lastTestedLanguage", None)
|
||||
self.env["runtime"]["OutputManager"].play_sound("Accept")
|
||||
else:
|
||||
self.env["runtime"]["OutputManager"].play_sound("Error")
|
||||
@@ -213,13 +264,17 @@ class command:
|
||||
return
|
||||
|
||||
module = self.modules[self.module_index]
|
||||
voice = self.voices[self.voice_index]
|
||||
voice, separator, language = self.voices[self.voice_index].partition(
|
||||
"|"
|
||||
)
|
||||
|
||||
try:
|
||||
SettingsManager = self.env["runtime"]["SettingsManager"]
|
||||
SettingsManager.settings["speech"]["driver"] = "speechdDriver"
|
||||
SettingsManager.settings["speech"]["module"] = module
|
||||
SettingsManager.settings["speech"]["voice"] = voice
|
||||
if separator:
|
||||
SettingsManager.settings["speech"]["language"] = language
|
||||
|
||||
if "SpeechDriver" in self.env["runtime"]:
|
||||
SpeechDriver = self.env["runtime"]["SpeechDriver"]
|
||||
@@ -237,50 +292,5 @@ class command:
|
||||
)
|
||||
self.env["runtime"]["OutputManager"].play_sound("Error")
|
||||
|
||||
def preview_voice(self, module, voice):
|
||||
"""Test voice with spd-say"""
|
||||
try:
|
||||
cmd = ["spd-say", "-o", module, "-y", voice, self.testMessage]
|
||||
result = subprocess.run(cmd, timeout=10)
|
||||
return result.returncode == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def get_speechd_modules(self):
|
||||
"""Get available speech modules"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["spd-say", "-O"], capture_output=True, text=True, timeout=10
|
||||
)
|
||||
if result.returncode == 0:
|
||||
lines = result.stdout.strip().split("\n")
|
||||
return [line.strip() for line in lines[1:] if line.strip()]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
def get_module_voices(self, module):
|
||||
"""Get voices for module"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["spd-say", "-o", module, "-L"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
lines = result.stdout.strip().split("\n")
|
||||
voices = []
|
||||
for line in lines[1:]:
|
||||
if not line.strip():
|
||||
continue
|
||||
voice = get_synthesis_voice_name(module, line)
|
||||
if voice:
|
||||
voices.append(voice)
|
||||
return voices
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
def set_callback(self, callback):
|
||||
pass
|
||||
|
||||
@@ -1,207 +1,160 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
|
||||
from fenrirscreenreader.core import debug
|
||||
from fenrirscreenreader.core.i18n import _
|
||||
from fenrirscreenreader.utils.speechd_utils import get_synthesis_voice_name
|
||||
|
||||
|
||||
class command:
|
||||
def __init__(self):
|
||||
pass
|
||||
self._request_generation = 0
|
||||
self._loading = False
|
||||
|
||||
def initialize(self, environment):
|
||||
self.env = environment
|
||||
self.testMessage = (
|
||||
self.test_message = (
|
||||
"Voice test: The quick brown fox jumps over the lazy dog."
|
||||
)
|
||||
|
||||
def shutdown(self):
|
||||
pass
|
||||
self._request_generation += 1
|
||||
self._loading = False
|
||||
|
||||
def get_description(self):
|
||||
return "Safe voice browser - cycles through voices without hanging"
|
||||
return _("browse and test Speech Dispatcher voices")
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
if self._loading:
|
||||
return
|
||||
self._loading = True
|
||||
self._request_generation += 1
|
||||
generation = self._request_generation
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
_("Loading speech modules"), interrupt=True
|
||||
)
|
||||
self.env["runtime"]["SpeechDiscoveryManager"].request_modules(
|
||||
lambda modules, error: self._modules_ready(
|
||||
generation, modules, error
|
||||
)
|
||||
)
|
||||
|
||||
def _modules_ready(self, generation, modules, error):
|
||||
if generation != self._request_generation:
|
||||
return
|
||||
if error or not modules:
|
||||
self._loading = False
|
||||
self._report_error(_("No speech modules found"), error)
|
||||
return
|
||||
|
||||
module_index = self.env["commandBuffer"].get(
|
||||
"safeBrowserModuleIndex", 0
|
||||
)
|
||||
if module_index >= len(modules):
|
||||
module_index = 0
|
||||
module = modules[module_index]
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
_("Loading voices for {module}").format(module=module),
|
||||
interrupt=True,
|
||||
)
|
||||
self.env["runtime"]["SpeechDiscoveryManager"].request_voices(
|
||||
module,
|
||||
lambda _module, voices, voice_error: self._voices_ready(
|
||||
generation,
|
||||
modules,
|
||||
module_index,
|
||||
voices,
|
||||
voice_error,
|
||||
),
|
||||
)
|
||||
|
||||
def _voices_ready(
|
||||
self, generation, modules, module_index, voices, error
|
||||
):
|
||||
if generation != self._request_generation:
|
||||
return
|
||||
if error or not voices:
|
||||
self._loading = False
|
||||
next_module = (module_index + 1) % len(modules)
|
||||
self.env["commandBuffer"]["safeBrowserModuleIndex"] = next_module
|
||||
self.env["commandBuffer"]["safeBrowserVoiceIndex"] = 0
|
||||
self._report_error(_("No voices found"), error)
|
||||
return
|
||||
|
||||
voice_index = self.env["commandBuffer"].get(
|
||||
"safeBrowserVoiceIndex", 0
|
||||
)
|
||||
if voice_index >= len(voices):
|
||||
voice_index = 0
|
||||
module = modules[module_index]
|
||||
voice = voices[voice_index]
|
||||
voice_name = voice.split("|", 1)[0]
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
_("Module: {module}, voice: {voice}").format(
|
||||
module=module, voice=voice_name
|
||||
),
|
||||
interrupt=True,
|
||||
)
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
_("Testing voice"), interrupt=True
|
||||
)
|
||||
self.env["runtime"]["SpeechDiscoveryManager"].request_voice_test(
|
||||
module,
|
||||
voice_name,
|
||||
self.test_message,
|
||||
lambda succeeded, test_error: self._test_ready(
|
||||
generation,
|
||||
modules,
|
||||
module_index,
|
||||
voices,
|
||||
voice_index,
|
||||
succeeded,
|
||||
test_error,
|
||||
),
|
||||
)
|
||||
|
||||
def _test_ready(
|
||||
self,
|
||||
generation,
|
||||
modules,
|
||||
module_index,
|
||||
voices,
|
||||
voice_index,
|
||||
succeeded,
|
||||
error,
|
||||
):
|
||||
if generation != self._request_generation:
|
||||
return
|
||||
self._loading = False
|
||||
module = modules[module_index]
|
||||
voice_name, separator, language = voices[voice_index].partition("|")
|
||||
if succeeded:
|
||||
self.env["commandBuffer"]["lastTestedModule"] = module
|
||||
self.env["commandBuffer"]["lastTestedVoice"] = voice_name
|
||||
if separator:
|
||||
self.env["commandBuffer"]["lastTestedLanguage"] = language
|
||||
else:
|
||||
self.env["commandBuffer"].pop("lastTestedLanguage", None)
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
"Starting safe voice browser", interrupt=True
|
||||
_("Voice test completed successfully"), interrupt=True
|
||||
)
|
||||
else:
|
||||
self._report_error(_("Voice test failed"), error)
|
||||
|
||||
# Get modules with timeout protection
|
||||
modules = self.get_speechd_modules_with_timeout()
|
||||
if not modules:
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
"No speech modules found", interrupt=True
|
||||
)
|
||||
return
|
||||
voice_index += 1
|
||||
if voice_index >= len(voices):
|
||||
voice_index = 0
|
||||
module_index = (module_index + 1) % len(modules)
|
||||
self.env["commandBuffer"]["safeBrowserModuleIndex"] = module_index
|
||||
self.env["commandBuffer"]["safeBrowserVoiceIndex"] = voice_index
|
||||
|
||||
# Get current position from commandBuffer or start fresh
|
||||
module_index = self.env["commandBuffer"].get(
|
||||
"safeBrowserModuleIndex", 0
|
||||
def _report_error(self, message, detail):
|
||||
if detail:
|
||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||
f"voice_browser_safe: {detail}",
|
||||
debug.DebugLevel.ERROR,
|
||||
)
|
||||
voice_index = self.env["commandBuffer"].get(
|
||||
"safeBrowserVoiceIndex", 0
|
||||
)
|
||||
|
||||
# Ensure valid module index
|
||||
if module_index >= len(modules):
|
||||
module_index = 0
|
||||
|
||||
current_module = modules[module_index]
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
f"Loading voices for {current_module}...", interrupt=True
|
||||
)
|
||||
|
||||
# Get voices with timeout protection
|
||||
voices = self.get_module_voices_with_timeout(current_module)
|
||||
if not voices:
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
f"No voices in {current_module}, trying next module",
|
||||
interrupt=True,
|
||||
)
|
||||
module_index = (module_index + 1) % len(modules)
|
||||
self.env["commandBuffer"][
|
||||
"safeBrowserModuleIndex"
|
||||
] = module_index
|
||||
self.env["commandBuffer"]["safeBrowserVoiceIndex"] = 0
|
||||
return
|
||||
|
||||
# Ensure valid voice index
|
||||
if voice_index >= len(voices):
|
||||
voice_index = 0
|
||||
|
||||
current_voice = voices[voice_index]
|
||||
|
||||
# Announce current selection
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
f"Module: {current_module} ({module_index + 1}/{len(modules)})",
|
||||
interrupt=True,
|
||||
)
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
f"Voice: {current_voice} ({voice_index + 1}/{len(voices)})",
|
||||
interrupt=True,
|
||||
)
|
||||
|
||||
# Test voice in background thread to avoid blocking
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
"Testing voice...", interrupt=True
|
||||
)
|
||||
|
||||
# Use threading to prevent freezing
|
||||
test_thread = threading.Thread(
|
||||
target=self.test_voice_async,
|
||||
args=(current_module, current_voice),
|
||||
)
|
||||
test_thread.daemon = True
|
||||
test_thread.start()
|
||||
|
||||
# Store tested voice for apply command
|
||||
self.env["commandBuffer"]["lastTestedModule"] = current_module
|
||||
self.env["commandBuffer"]["lastTestedVoice"] = current_voice
|
||||
|
||||
# Advance to next voice for next run
|
||||
voice_index += 1
|
||||
if voice_index >= len(voices):
|
||||
voice_index = 0
|
||||
module_index = (module_index + 1) % len(modules)
|
||||
|
||||
# Store position for next run
|
||||
self.env["commandBuffer"]["safeBrowserModuleIndex"] = module_index
|
||||
self.env["commandBuffer"]["safeBrowserVoiceIndex"] = voice_index
|
||||
|
||||
# Give instructions
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
"Run again for next voice, or use apply voice command",
|
||||
interrupt=True,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
f"Voice browser error: {str(e)}", interrupt=True
|
||||
)
|
||||
self.env["runtime"]["OutputManager"].play_sound("Error")
|
||||
|
||||
def test_voice_async(self, module, voice):
|
||||
"""Test voice in background thread to avoid blocking"""
|
||||
try:
|
||||
# Run with strict timeout
|
||||
cmd = ["spd-say", "-o", module, "-y", voice, self.testMessage]
|
||||
result = subprocess.run(cmd, timeout=5, capture_output=True)
|
||||
|
||||
# Schedule success sound for main thread
|
||||
if result.returncode == 0:
|
||||
# We can't call OutputManager from background thread safely
|
||||
# So we'll just let the main thread handle feedback
|
||||
pass
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
# Voice test timed out - this is okay, don't crash
|
||||
pass
|
||||
except Exception:
|
||||
# Any other error - also okay, don't crash
|
||||
pass
|
||||
|
||||
def get_speechd_modules_with_timeout(self):
|
||||
"""Get speech modules with timeout protection"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["spd-say", "-O"], capture_output=True, text=True, timeout=3
|
||||
)
|
||||
if result.returncode == 0:
|
||||
lines = result.stdout.strip().split("\n")
|
||||
modules = [line.strip() for line in lines[1:] if line.strip()]
|
||||
# Limit to first 10 modules to prevent overload
|
||||
return modules[:10]
|
||||
except subprocess.TimeoutExpired:
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
"Module detection timed out", interrupt=True
|
||||
)
|
||||
except Exception as e:
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
f"Module detection failed: {str(e)}", interrupt=True
|
||||
)
|
||||
return []
|
||||
|
||||
def get_module_voices_with_timeout(self, module):
|
||||
"""Get voices with timeout and limits"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["spd-say", "-o", module, "-L"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
lines = result.stdout.strip().split("\n")
|
||||
voices = []
|
||||
for line in lines[1:]:
|
||||
if not line.strip():
|
||||
continue
|
||||
voice = get_synthesis_voice_name(module, line)
|
||||
if voice:
|
||||
voices.append(voice)
|
||||
|
||||
# Limit voice count to prevent memory issues
|
||||
if len(voices) > 1000:
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
f"found {len(voices)} voices, limiting to first 1000",
|
||||
interrupt=True,
|
||||
)
|
||||
voices = voices[:1000]
|
||||
|
||||
return voices
|
||||
except subprocess.TimeoutExpired:
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
f"Voice detection for {module} timed out", interrupt=True
|
||||
)
|
||||
except Exception as e:
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
f"Voice detection failed: {str(e)}", interrupt=True
|
||||
)
|
||||
return []
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
message, interrupt=True
|
||||
)
|
||||
|
||||
def set_callback(self, callback):
|
||||
pass
|
||||
|
||||
@@ -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 threading
|
||||
import time
|
||||
|
||||
from fenrirscreenreader.core import debug
|
||||
from fenrirscreenreader.core.eventData import FenrirEventType
|
||||
from fenrirscreenreader.utils import x_clipboard
|
||||
|
||||
|
||||
def synchronize_clipboards(
|
||||
display, fenrir_text, last_observed_fenrir, last_observed_x
|
||||
):
|
||||
"""Perform one clipboard I/O cycle without accessing Fenrir managers."""
|
||||
result = {}
|
||||
try:
|
||||
x_text = x_clipboard.read_text(display)
|
||||
if not isinstance(x_text, str) or not x_text:
|
||||
x_text = None
|
||||
except Exception as error:
|
||||
return {"error": "ClipboardSyncManager paste failed: " + str(error)}
|
||||
|
||||
if fenrir_text and x_text and fenrir_text == x_text:
|
||||
return {
|
||||
"last_observed_fenrir": fenrir_text,
|
||||
"last_observed_x": x_text,
|
||||
}
|
||||
|
||||
fenrir_changed = (
|
||||
fenrir_text and fenrir_text != last_observed_fenrir
|
||||
)
|
||||
x_changed = x_text and x_text != last_observed_x
|
||||
|
||||
if fenrir_changed:
|
||||
try:
|
||||
written = x_clipboard.write_text(fenrir_text, display)
|
||||
except Exception as error:
|
||||
written = False
|
||||
result["error"] = (
|
||||
"ClipboardSyncManager copy failed: " + str(error)
|
||||
)
|
||||
result["last_observed_fenrir"] = fenrir_text
|
||||
if written:
|
||||
result["last_written_to_x"] = fenrir_text
|
||||
result["last_observed_x"] = fenrir_text
|
||||
return result
|
||||
|
||||
if x_changed:
|
||||
return {
|
||||
"import_text": x_text,
|
||||
"last_imported_from_x": x_text,
|
||||
"last_observed_fenrir": x_text,
|
||||
"last_observed_x": x_text,
|
||||
}
|
||||
|
||||
if fenrir_text:
|
||||
result["last_observed_fenrir"] = fenrir_text
|
||||
if x_text:
|
||||
result["last_observed_x"] = x_text
|
||||
return result
|
||||
|
||||
|
||||
class ClipboardSyncManager:
|
||||
def __init__(self):
|
||||
self.env = None
|
||||
@@ -19,7 +71,10 @@ class ClipboardSyncManager:
|
||||
self.display = ""
|
||||
self.interval = 0.5
|
||||
self.running = False
|
||||
self.thread = None
|
||||
self._task_id = None
|
||||
self._scheduler_stop = threading.Event()
|
||||
self._sync_event_pending = threading.Event()
|
||||
self._scheduler_thread = None
|
||||
self.last_written_to_x = None
|
||||
self.last_imported_from_x = None
|
||||
self.last_observed_fenrir = None
|
||||
@@ -82,19 +137,85 @@ class ClipboardSyncManager:
|
||||
if self.running:
|
||||
return
|
||||
self.running = True
|
||||
self.thread = threading.Thread(target=self._run, daemon=True)
|
||||
self.thread.start()
|
||||
self._scheduler_stop.clear()
|
||||
self._scheduler_thread = threading.Thread(
|
||||
target=self._schedule_sync_events,
|
||||
name="fenrir-clipboard-scheduler",
|
||||
daemon=True,
|
||||
)
|
||||
self._scheduler_thread.start()
|
||||
|
||||
def stop(self):
|
||||
self.running = False
|
||||
if self.thread:
|
||||
self.thread.join(timeout=1.0)
|
||||
self.thread = None
|
||||
task_manager = self.env["runtime"].get("BackgroundTaskManager")
|
||||
if task_manager and self._task_id is not None:
|
||||
task_manager.cancel_task(self._task_id)
|
||||
self._task_id = None
|
||||
self._scheduler_stop.set()
|
||||
self._sync_event_pending.clear()
|
||||
if self._scheduler_thread:
|
||||
self._scheduler_thread.join(timeout=1.0)
|
||||
self._scheduler_thread = None
|
||||
|
||||
def _run(self):
|
||||
def _schedule_sync_events(self):
|
||||
while self.running:
|
||||
self.poll_once()
|
||||
time.sleep(self.interval)
|
||||
if not self._sync_event_pending.is_set():
|
||||
self._sync_event_pending.set()
|
||||
self.env["runtime"]["EventManager"].put_to_event_queue(
|
||||
FenrirEventType.clipboard_sync, None
|
||||
)
|
||||
if self._scheduler_stop.wait(self.interval):
|
||||
return
|
||||
|
||||
def handle_sync_event(self):
|
||||
"""Schedule clipboard I/O while keeping manager state on the loop."""
|
||||
if not self.running or self._task_id is not None:
|
||||
return
|
||||
|
||||
fenrir_text = self._get_fenrir_clipboard_text()
|
||||
self._task_id = self.env["runtime"][
|
||||
"BackgroundTaskManager"
|
||||
].submit_task(
|
||||
synchronize_clipboards,
|
||||
lambda result: self._handle_poll_result(fenrir_text, result),
|
||||
self.display,
|
||||
fenrir_text,
|
||||
self.last_observed_fenrir,
|
||||
self.last_observed_x,
|
||||
)
|
||||
if self._task_id is None:
|
||||
self._sync_event_pending.clear()
|
||||
|
||||
def _handle_poll_result(self, fenrir_snapshot, task_result):
|
||||
self._task_id = None
|
||||
self._sync_event_pending.clear()
|
||||
if not self.running:
|
||||
return
|
||||
if not task_result.get("succeeded"):
|
||||
self._debug(
|
||||
"ClipboardSyncManager poll failed: "
|
||||
+ task_result.get("error", "unknown error")
|
||||
)
|
||||
return
|
||||
|
||||
result = task_result.get("value") or {}
|
||||
if result.get("error"):
|
||||
self._debug(result["error"])
|
||||
import_text = result.get("import_text")
|
||||
if import_text:
|
||||
if self._get_fenrir_clipboard_text() != fenrir_snapshot:
|
||||
return
|
||||
self.env["runtime"]["MemoryManager"].add_value_to_first_index(
|
||||
"clipboardHistory", import_text
|
||||
)
|
||||
for attribute in (
|
||||
"last_written_to_x",
|
||||
"last_imported_from_x",
|
||||
"last_observed_fenrir",
|
||||
"last_observed_x",
|
||||
):
|
||||
if attribute in result:
|
||||
setattr(self, attribute, result[attribute])
|
||||
|
||||
def poll_once(self):
|
||||
fenrir_text = self._get_fenrir_clipboard_text()
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
from fenrirscreenreader.core import debug
|
||||
from fenrirscreenreader.utils.speechd_utils import get_synthesis_voice_name
|
||||
|
||||
|
||||
class DynamicVoiceCommand:
|
||||
@@ -14,51 +8,40 @@ class DynamicVoiceCommand:
|
||||
|
||||
def __init__(self, module, voice, env):
|
||||
self.module = module
|
||||
self.voice = voice
|
||||
self.voice, separator, self.language = voice.partition("|")
|
||||
if not separator:
|
||||
self.language = ""
|
||||
self.env = env
|
||||
self.testMessage = "This is a voice test. The quick brown fox jumps over the lazy dog."
|
||||
self.test_message = (
|
||||
"This is a voice test. The quick brown fox jumps over the lazy dog."
|
||||
)
|
||||
self._test_generation = 0
|
||||
|
||||
def initialize(self, environment):
|
||||
self.env = environment
|
||||
|
||||
def shutdown(self):
|
||||
pass
|
||||
self._test_generation += 1
|
||||
|
||||
def get_description(self):
|
||||
return f"Select voice: {self.voice}"
|
||||
|
||||
def run(self):
|
||||
self._test_generation += 1
|
||||
generation = self._test_generation
|
||||
try:
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
f"Testing voice {self.voice} from {self.module}. Please wait.",
|
||||
interrupt=True,
|
||||
)
|
||||
|
||||
# Brief pause before testing to avoid speech overlap
|
||||
time.sleep(0.5)
|
||||
|
||||
# Test voice
|
||||
testResult, errorMsg = self.test_voice()
|
||||
if testResult:
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
"Voice test completed successfully. Navigate to Apply Tested Voice to use this voice.",
|
||||
interrupt=False,
|
||||
flush=False,
|
||||
)
|
||||
|
||||
# Store for confirmation (use same variables as
|
||||
# apply_tested_voice.py)
|
||||
self.env["commandBuffer"]["lastTestedModule"] = self.module
|
||||
self.env["commandBuffer"]["lastTestedVoice"] = self.voice
|
||||
self.env["commandBuffer"]["pendingVoiceModule"] = self.module
|
||||
self.env["commandBuffer"]["pendingVoiceVoice"] = self.voice
|
||||
self.env["commandBuffer"]["voiceTestCompleted"] = True
|
||||
else:
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
f"Voice test failed: {errorMsg}",
|
||||
interrupt=False,
|
||||
flush=False,
|
||||
)
|
||||
self.env["runtime"]["SpeechDiscoveryManager"].request_voice_test(
|
||||
self.module,
|
||||
self.voice,
|
||||
self.test_message,
|
||||
lambda succeeded, error: self._finish_voice_test(
|
||||
generation, succeeded, error
|
||||
),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
@@ -67,35 +50,32 @@ class DynamicVoiceCommand:
|
||||
flush=False,
|
||||
)
|
||||
|
||||
def test_voice(self):
|
||||
"""Test voice with spd-say"""
|
||||
try:
|
||||
cmd = [
|
||||
"spd-say",
|
||||
"-C",
|
||||
"-w",
|
||||
"-o",
|
||||
self.module,
|
||||
"-y",
|
||||
self.voice,
|
||||
self.testMessage,
|
||||
]
|
||||
result = subprocess.run(
|
||||
cmd, timeout=8, capture_output=True, text=True
|
||||
def _finish_voice_test(self, generation, succeeded, error):
|
||||
if generation != self._test_generation:
|
||||
return
|
||||
if not succeeded:
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
f"Voice test failed: {error}",
|
||||
interrupt=False,
|
||||
flush=False,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return True, "Voice test successful"
|
||||
else:
|
||||
error_msg = (
|
||||
result.stderr.strip()
|
||||
if result.stderr
|
||||
else f"Command failed with return code {result.returncode}"
|
||||
)
|
||||
return False, error_msg
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "Voice test timed out"
|
||||
except Exception as e:
|
||||
return False, f"Error running voice test: {str(e)}"
|
||||
return
|
||||
|
||||
self.env["commandBuffer"]["lastTestedModule"] = self.module
|
||||
self.env["commandBuffer"]["lastTestedVoice"] = self.voice
|
||||
self.env["commandBuffer"]["pendingVoiceModule"] = self.module
|
||||
self.env["commandBuffer"]["pendingVoiceVoice"] = self.voice
|
||||
if self.language:
|
||||
self.env["commandBuffer"]["pendingVoiceLanguage"] = self.language
|
||||
else:
|
||||
self.env["commandBuffer"].pop("pendingVoiceLanguage", None)
|
||||
self.env["commandBuffer"]["voiceTestCompleted"] = True
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
"Voice test completed successfully. "
|
||||
"Navigate to Apply Tested Voice to use this voice.",
|
||||
interrupt=False,
|
||||
flush=False,
|
||||
)
|
||||
|
||||
def set_callback(self, callback):
|
||||
pass
|
||||
@@ -126,6 +106,7 @@ class DynamicApplyVoiceCommand:
|
||||
|
||||
module = self.env["commandBuffer"]["pendingVoiceModule"]
|
||||
voice = self.env["commandBuffer"]["pendingVoiceVoice"]
|
||||
language = self.env["commandBuffer"].get("pendingVoiceLanguage")
|
||||
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
f"Applying {voice} from {module}", interrupt=True
|
||||
@@ -148,6 +129,7 @@ class DynamicApplyVoiceCommand:
|
||||
old_driver = settings_manager.get_setting("speech", "driver")
|
||||
old_module = settings_manager.get_setting("speech", "module")
|
||||
old_voice = settings_manager.get_setting("speech", "voice")
|
||||
old_language = settings_manager.get_setting("speech", "language")
|
||||
|
||||
try:
|
||||
# Apply new settings to runtime only (use set_setting to update
|
||||
@@ -157,51 +139,36 @@ class DynamicApplyVoiceCommand:
|
||||
)
|
||||
settings_manager.set_setting("speech", "module", module)
|
||||
settings_manager.set_setting("speech", "voice", voice)
|
||||
if language:
|
||||
settings_manager.set_setting(
|
||||
"speech", "language", language
|
||||
)
|
||||
|
||||
# Apply settings to speech driver directly
|
||||
if "SpeechDriver" in self.env["runtime"]:
|
||||
SpeechDriver = self.env["runtime"]["SpeechDriver"]
|
||||
speech_driver = self.env["runtime"]["SpeechDriver"]
|
||||
|
||||
# Get current module to see if we're changing modules
|
||||
current_module = settings_manager.get_setting(
|
||||
"speech", "module"
|
||||
)
|
||||
module_changing = current_module != module
|
||||
|
||||
# Set module and voice on driver instance first
|
||||
SpeechDriver.set_module(module)
|
||||
SpeechDriver.set_voice(voice)
|
||||
|
||||
if module_changing:
|
||||
# Module change requires reinitializing the speech
|
||||
# driver
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
f"Switching from {current_module} to {module} module",
|
||||
interrupt=True,
|
||||
)
|
||||
SpeechDriver.shutdown()
|
||||
SpeechDriver.initialize(self.env)
|
||||
# Re-set after initialization
|
||||
SpeechDriver.set_module(module)
|
||||
SpeechDriver.set_voice(voice)
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
"Speech driver reinitialized", interrupt=True
|
||||
)
|
||||
# Set module and voice on the driver instance.
|
||||
speech_driver.set_module(module)
|
||||
if language:
|
||||
speech_driver.set_language(language)
|
||||
speech_driver.set_voice(voice)
|
||||
|
||||
# Debug: verify what was actually set
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
f"Speech driver now has module: {SpeechDriver.module}, voice: {SpeechDriver.voice}",
|
||||
"Speech driver now has module: "
|
||||
f"{speech_driver.module}, voice: "
|
||||
f"{speech_driver.voice}",
|
||||
interrupt=True,
|
||||
)
|
||||
|
||||
# Force application by speaking a test message
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
"Voice applied successfully! You should hear this in the new voice.",
|
||||
"Voice applied successfully! You should hear this in "
|
||||
"the new voice.",
|
||||
interrupt=True,
|
||||
)
|
||||
|
||||
# Brief pause then more speech to test
|
||||
time.sleep(1)
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
"Use save settings to make permanent", interrupt=True
|
||||
)
|
||||
@@ -214,16 +181,19 @@ class DynamicApplyVoiceCommand:
|
||||
|
||||
except Exception as e:
|
||||
# Revert on failure
|
||||
settings_manager.settings["speech"]["driver"] = old_driver
|
||||
settings_manager.settings["speech"]["module"] = old_module
|
||||
settings_manager.settings["speech"]["voice"] = old_voice
|
||||
settings_manager.set_setting("speech", "driver", old_driver)
|
||||
settings_manager.set_setting("speech", "module", old_module)
|
||||
settings_manager.set_setting("speech", "voice", old_voice)
|
||||
settings_manager.set_setting(
|
||||
"speech", "language", old_language
|
||||
)
|
||||
|
||||
# Try to reinitialize with old settings
|
||||
if "SpeechDriver" in self.env["runtime"]:
|
||||
try:
|
||||
SpeechDriver = self.env["runtime"]["SpeechDriver"]
|
||||
SpeechDriver.shutdown()
|
||||
SpeechDriver.initialize(self.env)
|
||||
speech_driver = self.env["runtime"]["SpeechDriver"]
|
||||
speech_driver.shutdown()
|
||||
speech_driver.initialize(self.env)
|
||||
except Exception as e:
|
||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||
"dynamicVoiceMenu: Error reinitializing speech driver: "
|
||||
@@ -246,56 +216,139 @@ class DynamicApplyVoiceCommand:
|
||||
pass
|
||||
|
||||
|
||||
def add_dynamic_voice_menus(VmenuManager):
|
||||
"""Add dynamic voice menus to vmenu system"""
|
||||
def add_dynamic_voice_menus(vmenu_manager):
|
||||
"""Populate cached voice menus and start non-blocking discovery if needed."""
|
||||
try:
|
||||
env = VmenuManager.env
|
||||
|
||||
# Get speech modules
|
||||
modules = get_speechd_modules()
|
||||
if not modules:
|
||||
return
|
||||
|
||||
# Create voice browser submenu
|
||||
voice_browser_menu = {}
|
||||
|
||||
# Add apply voice command
|
||||
apply_command = DynamicApplyVoiceCommand(env)
|
||||
voice_browser_menu["Apply Tested Voice Action"] = apply_command
|
||||
|
||||
# Add modules as submenus
|
||||
for module in modules[
|
||||
:8
|
||||
]: # Limit to 8 modules to keep menu manageable
|
||||
module_menu = {}
|
||||
|
||||
# Get voices for this module
|
||||
voices = get_module_voices(module)
|
||||
if voices:
|
||||
|
||||
# Add voice commands
|
||||
for voice in voices:
|
||||
voice_command = DynamicVoiceCommand(module, voice, env)
|
||||
module_menu[f"{voice} Action"] = voice_command
|
||||
else:
|
||||
module_menu["No voices available Action"] = (
|
||||
create_info_command(f"No voices found for {module}", env)
|
||||
env = vmenu_manager.env
|
||||
discovery = env["runtime"]["SpeechDiscoveryManager"]
|
||||
generation = getattr(vmenu_manager, "_voice_menu_generation", 0) + 1
|
||||
vmenu_manager._voice_menu_generation = generation
|
||||
modules = discovery.get_cached_modules()
|
||||
if modules is None:
|
||||
_install_loading_menu(vmenu_manager)
|
||||
discovery.request_modules(
|
||||
lambda found, error: _request_dynamic_voice_lists(
|
||||
vmenu_manager, generation, found, error, False
|
||||
)
|
||||
|
||||
voice_browser_menu[f"{module} Menu"] = module_menu
|
||||
|
||||
# Add to main menu dict
|
||||
VmenuManager.menuDict["Voice Browser Menu"] = voice_browser_menu
|
||||
|
||||
except Exception as e:
|
||||
# Use debug manager instead of print for error logging
|
||||
)
|
||||
return
|
||||
_request_dynamic_voice_lists(
|
||||
vmenu_manager, generation, modules, "", True
|
||||
)
|
||||
except Exception as error:
|
||||
if "DebugManager" in env["runtime"]:
|
||||
env["runtime"]["DebugManager"].write_debug_out(
|
||||
f"Error creating dynamic voice menus: {e}",
|
||||
f"Error creating dynamic voice menus: {error}",
|
||||
debug.DebugLevel.ERROR,
|
||||
)
|
||||
else:
|
||||
print(f"Error creating dynamic voice menus: {e}")
|
||||
print(f"Error creating dynamic voice menus: {error}")
|
||||
|
||||
|
||||
def _request_dynamic_voice_lists(
|
||||
vmenu_manager, generation, modules, error, allow_active
|
||||
):
|
||||
if generation != getattr(vmenu_manager, "_voice_menu_generation", 0):
|
||||
return
|
||||
if error or not modules:
|
||||
if vmenu_manager.get_active() and not allow_active:
|
||||
vmenu_manager._voice_menu_refresh_pending = True
|
||||
return
|
||||
_install_discovery_error_menu(vmenu_manager)
|
||||
return
|
||||
|
||||
discovery = vmenu_manager.env["runtime"]["SpeechDiscoveryManager"]
|
||||
selected_modules = modules[:8]
|
||||
voices_by_module = {}
|
||||
missing_modules = []
|
||||
for module in selected_modules:
|
||||
voices = discovery.get_cached_voices(module)
|
||||
if voices is None:
|
||||
missing_modules.append(module)
|
||||
else:
|
||||
voices_by_module[module] = voices
|
||||
|
||||
if not missing_modules:
|
||||
_install_dynamic_voice_menu(
|
||||
vmenu_manager,
|
||||
generation,
|
||||
selected_modules,
|
||||
voices_by_module,
|
||||
allow_active,
|
||||
)
|
||||
return
|
||||
|
||||
_install_loading_menu(vmenu_manager)
|
||||
remaining_modules = set(missing_modules)
|
||||
|
||||
def voices_ready(module, voices, _voice_error):
|
||||
if generation != getattr(vmenu_manager, "_voice_menu_generation", 0):
|
||||
return
|
||||
voices_by_module[module] = voices
|
||||
remaining_modules.discard(module)
|
||||
if not remaining_modules:
|
||||
_install_dynamic_voice_menu(
|
||||
vmenu_manager,
|
||||
generation,
|
||||
selected_modules,
|
||||
voices_by_module,
|
||||
False,
|
||||
)
|
||||
|
||||
for module in missing_modules:
|
||||
discovery.request_voices(module, voices_ready)
|
||||
|
||||
|
||||
def _install_dynamic_voice_menu(
|
||||
vmenu_manager,
|
||||
generation,
|
||||
modules,
|
||||
voices_by_module,
|
||||
allow_active,
|
||||
):
|
||||
if generation != getattr(vmenu_manager, "_voice_menu_generation", 0):
|
||||
return
|
||||
if vmenu_manager.get_active() and not allow_active:
|
||||
vmenu_manager._voice_menu_refresh_pending = True
|
||||
return
|
||||
|
||||
env = vmenu_manager.env
|
||||
voice_browser_menu = {
|
||||
"Apply Tested Voice Action": DynamicApplyVoiceCommand(env)
|
||||
}
|
||||
for module in modules:
|
||||
module_menu = {}
|
||||
voices = voices_by_module.get(module, [])
|
||||
for voice in voices:
|
||||
voice_name = voice.split("|", 1)[0]
|
||||
module_menu[f"{voice_name} Action"] = DynamicVoiceCommand(
|
||||
module, voice, env
|
||||
)
|
||||
if not module_menu:
|
||||
module_menu["No voices available Action"] = create_info_command(
|
||||
f"No voices found for {module}", env
|
||||
)
|
||||
voice_browser_menu[f"{module} Menu"] = module_menu
|
||||
vmenu_manager.menuDict["Voice Browser Menu"] = voice_browser_menu
|
||||
vmenu_manager._voice_menu_refresh_pending = False
|
||||
|
||||
|
||||
def _install_loading_menu(vmenu_manager):
|
||||
env = vmenu_manager.env
|
||||
vmenu_manager.menuDict["Voice Browser Menu"] = {
|
||||
"Loading voices Action": create_info_command(
|
||||
"Voice information is still loading", env
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def _install_discovery_error_menu(vmenu_manager):
|
||||
env = vmenu_manager.env
|
||||
vmenu_manager.menuDict["Voice Browser Menu"] = {
|
||||
"Voice discovery failed Action": create_info_command(
|
||||
"Voice discovery failed", env
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def create_info_command(message, env):
|
||||
@@ -324,41 +377,3 @@ def create_info_command(message, env):
|
||||
pass
|
||||
|
||||
return InfoCommand(message, env)
|
||||
|
||||
|
||||
def get_speechd_modules():
|
||||
"""Get available speech modules"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["spd-say", "-O"], capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if result.returncode == 0:
|
||||
lines = result.stdout.strip().split("\n")
|
||||
return [line.strip() for line in lines[1:] if line.strip()]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
|
||||
def get_module_voices(module):
|
||||
"""Get voices for a module"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["spd-say", "-o", module, "-L"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=8,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
lines = result.stdout.strip().split("\n")
|
||||
voices = []
|
||||
for line in lines[1:]:
|
||||
if not line.strip():
|
||||
continue
|
||||
voice = get_synthesis_voice_name(module, line)
|
||||
if voice:
|
||||
voices.append(voice)
|
||||
return voices
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
@@ -19,6 +19,8 @@ class FenrirEventType(Enum):
|
||||
heart_beat = 6
|
||||
execute_command = 7
|
||||
remote_incomming = 8
|
||||
background_task_result = 9
|
||||
clipboard_sync = 10
|
||||
|
||||
def __int__(self):
|
||||
return self.value
|
||||
|
||||
@@ -63,6 +63,12 @@ class EventManager:
|
||||
self.env["runtime"]["FenrirManager"].handle_execute_command(event)
|
||||
elif event["Type"] == FenrirEventType.remote_incomming:
|
||||
self.env["runtime"]["FenrirManager"].handle_remote_incomming(event)
|
||||
elif event["Type"] == FenrirEventType.background_task_result:
|
||||
self.env["runtime"]["FenrirManager"].handle_background_task_result(
|
||||
event
|
||||
)
|
||||
elif event["Type"] == FenrirEventType.clipboard_sync:
|
||||
self.env["runtime"]["FenrirManager"].handle_clipboard_sync(event)
|
||||
|
||||
def is_main_event_loop_running(self):
|
||||
return self.running.value == 1
|
||||
|
||||
@@ -221,6 +221,11 @@ class FenrirManager:
|
||||
event["data"]
|
||||
)
|
||||
|
||||
def handle_background_task_result(self, event):
|
||||
self.environment["runtime"]["BackgroundTaskManager"].handle_result(
|
||||
event["data"]
|
||||
)
|
||||
|
||||
def handle_screen_change(self, event):
|
||||
self.environment["runtime"]["ScreenManager"].handle_screen_change(
|
||||
event["data"]
|
||||
@@ -287,6 +292,13 @@ class FenrirManager:
|
||||
"onHeartBeat", force=True
|
||||
)
|
||||
|
||||
def handle_clipboard_sync(self, event):
|
||||
clipboard_sync_manager = self.environment["runtime"].get(
|
||||
"ClipboardSyncManager"
|
||||
)
|
||||
if clipboard_sync_manager:
|
||||
clipboard_sync_manager.handle_sync_event()
|
||||
|
||||
def detect_shortcut_command(self):
|
||||
if self.environment["input"]["key_forward"] != 0:
|
||||
return
|
||||
|
||||
@@ -24,6 +24,8 @@ general_data = {
|
||||
"SpeechHistoryManager",
|
||||
"HelpManager",
|
||||
"MemoryManager",
|
||||
"SpeechDiscoveryManager",
|
||||
"BackgroundTaskManager",
|
||||
"EventManager",
|
||||
"ProcessManager",
|
||||
"VmenuManager",
|
||||
|
||||
@@ -4,135 +4,19 @@
|
||||
# Fenrir TTY screen reader
|
||||
# By Chrys, Storm Dragon, and contributors.
|
||||
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
from fenrirscreenreader.core import debug
|
||||
from fenrirscreenreader.core.i18n import _
|
||||
from fenrirscreenreader.core.settingsData import settings_data
|
||||
from fenrirscreenreader.utils.speechd_utils import (
|
||||
get_synthesis_voice_name,
|
||||
parse_synthesis_voice_line,
|
||||
)
|
||||
|
||||
|
||||
class SpeechHelperMixin:
|
||||
"""Helper methods for querying speech-dispatcher modules and voices.
|
||||
|
||||
Provides caching and query functionality for speech-dispatcher module
|
||||
and voice enumeration, reusing proven logic from voice_browser.py.
|
||||
"""
|
||||
class QuickMenuManager:
|
||||
|
||||
def __init__(self):
|
||||
self._modules_cache = None
|
||||
self._voices_cache = {} # {module_name: [voice_list]}
|
||||
self._cache_timestamp = 0
|
||||
self._cache_timeout = 300 # 5 minutes
|
||||
|
||||
def get_speechd_modules(self):
|
||||
"""Get available speech-dispatcher modules (cached).
|
||||
|
||||
Returns:
|
||||
list: Available module names (e.g., ['espeak-ng', 'festival'])
|
||||
"""
|
||||
now = time.time()
|
||||
|
||||
# Return cached if valid
|
||||
if (self._modules_cache and
|
||||
(now - self._cache_timestamp) < self._cache_timeout):
|
||||
return self._modules_cache
|
||||
|
||||
# Query spd-say
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["spd-say", "-O"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=8
|
||||
)
|
||||
if result.returncode == 0:
|
||||
lines = result.stdout.strip().split("\n")
|
||||
self._modules_cache = [
|
||||
line.strip() for line in lines[1:]
|
||||
if line.strip() and line.strip().lower() != "dummy"
|
||||
]
|
||||
self._cache_timestamp = now
|
||||
return self._modules_cache
|
||||
except Exception as e:
|
||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||
(f"QuickMenuManager get_speechd_modules: "
|
||||
f"Error querying modules: {e}"),
|
||||
debug.DebugLevel.ERROR
|
||||
)
|
||||
|
||||
return []
|
||||
|
||||
def get_module_voices(self, module):
|
||||
"""Get voices for a specific module (cached per-module).
|
||||
|
||||
Args:
|
||||
module (str): Module name (e.g., 'espeak-ng')
|
||||
|
||||
Returns:
|
||||
list: Available voice names for this module
|
||||
"""
|
||||
# Return cached if available
|
||||
if module in self._voices_cache:
|
||||
return self._voices_cache[module]
|
||||
|
||||
# Query spd-say
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["spd-say", "-o", module, "-L"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=8
|
||||
)
|
||||
if result.returncode == 0:
|
||||
lines = result.stdout.strip().split("\n")
|
||||
voices = []
|
||||
for line in lines[1:]:
|
||||
if not line.strip():
|
||||
continue
|
||||
if module.lower() == "voxin":
|
||||
# For Voxin, store voice name with language
|
||||
voice_data = self._process_voxin_voice(line)
|
||||
if voice_data:
|
||||
voices.append(voice_data)
|
||||
else:
|
||||
voice = get_synthesis_voice_name(module, line)
|
||||
if voice:
|
||||
voices.append(voice)
|
||||
|
||||
self._voices_cache[module] = voices
|
||||
return voices
|
||||
except Exception as e:
|
||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||
(f"QuickMenuManager get_module_voices: "
|
||||
f"Error querying voices for {module}: {e}"),
|
||||
debug.DebugLevel.ERROR
|
||||
)
|
||||
|
||||
return []
|
||||
|
||||
def _process_voxin_voice(self, voice_line):
|
||||
"""Process Voxin voice format with language information.
|
||||
|
||||
Args:
|
||||
voice_line (str): Raw line from spd-say -o voxin -L output
|
||||
Format: NAME LANGUAGE VARIANT
|
||||
|
||||
Returns:
|
||||
str: Voice name with language encoded (e.g., 'daniel-embedded-high|en-GB')
|
||||
"""
|
||||
voice_data = parse_synthesis_voice_line(voice_line)
|
||||
if voice_data is None:
|
||||
return None
|
||||
voice_name, language, _variant = voice_data
|
||||
if not language:
|
||||
return None
|
||||
# Encode language with voice for later extraction
|
||||
return f"{voice_name}|{language}"
|
||||
self.position = 0
|
||||
self.quickMenu = []
|
||||
self.settings = settings_data
|
||||
self._module_request_pending = False
|
||||
self._voice_requests_pending = set()
|
||||
|
||||
def _select_default_voice(self, voices):
|
||||
"""Select a sensible default voice from list, preferring user's
|
||||
@@ -202,18 +86,7 @@ class SpeechHelperMixin:
|
||||
return voices[0]
|
||||
|
||||
def invalidate_speech_cache(self):
|
||||
"""Clear cached module and voice data."""
|
||||
self._modules_cache = None
|
||||
self._voices_cache = {}
|
||||
self._cache_timestamp = 0
|
||||
|
||||
|
||||
class QuickMenuManager(SpeechHelperMixin):
|
||||
def __init__(self):
|
||||
SpeechHelperMixin.__init__(self)
|
||||
self.position = 0
|
||||
self.quickMenu = []
|
||||
self.settings = settings_data
|
||||
self.env["runtime"]["SpeechDiscoveryManager"].invalidate_cache()
|
||||
|
||||
def initialize(self, environment):
|
||||
self.env = environment
|
||||
@@ -420,197 +293,202 @@ class QuickMenuManager(SpeechHelperMixin):
|
||||
return True
|
||||
|
||||
def cycle_speech_module(self, direction):
|
||||
"""Cycle to next/previous speech-dispatcher module.
|
||||
if self._module_request_pending:
|
||||
return False
|
||||
discovery = self.env["runtime"]["SpeechDiscoveryManager"]
|
||||
modules = discovery.get_cached_modules()
|
||||
if modules is not None:
|
||||
return self._continue_module_cycle(direction, modules, False)
|
||||
|
||||
Args:
|
||||
direction (str): 'next' or 'prev'
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Get available modules
|
||||
modules = self.get_speechd_modules()
|
||||
if not modules:
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
"No modules available", interrupt=True
|
||||
)
|
||||
return False
|
||||
|
||||
# Get current module
|
||||
current_module = self.env["runtime"]["SettingsManager"].get_setting(
|
||||
"speech", "module"
|
||||
self._module_request_pending = True
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
"Loading speech modules", interrupt=True
|
||||
)
|
||||
discovery.request_modules(
|
||||
lambda found, error: self._continue_module_cycle(
|
||||
direction, found, True, error
|
||||
)
|
||||
)
|
||||
return False
|
||||
|
||||
# Find current index
|
||||
try:
|
||||
current_index = (modules.index(current_module)
|
||||
if current_module else 0)
|
||||
except ValueError:
|
||||
current_index = 0
|
||||
|
||||
# Cycle to next/previous
|
||||
if direction == "next":
|
||||
new_index = (current_index + 1) % len(modules)
|
||||
else: # prev
|
||||
new_index = (current_index - 1) % len(modules)
|
||||
|
||||
new_module = modules[new_index]
|
||||
|
||||
# Update setting (runtime only)
|
||||
self.env["runtime"]["SettingsManager"].set_setting(
|
||||
"speech", "module", new_module
|
||||
)
|
||||
|
||||
# Select sensible default voice for new module
|
||||
voices = self.get_module_voices(new_module)
|
||||
if voices:
|
||||
default_voice = self._select_default_voice(voices)
|
||||
|
||||
# Parse voice name and language for modules like Voxin
|
||||
voice_name = default_voice
|
||||
voice_lang = None
|
||||
if "|" in default_voice:
|
||||
voice_name, voice_lang = default_voice.split("|", 1)
|
||||
|
||||
self.env["runtime"]["SettingsManager"].set_setting(
|
||||
"speech", "voice", voice_name
|
||||
)
|
||||
|
||||
# Apply voice to speech driver immediately
|
||||
if "SpeechDriver" in self.env["runtime"]:
|
||||
try:
|
||||
self.env["runtime"]["SpeechDriver"].set_module(
|
||||
new_module
|
||||
)
|
||||
# Set language first if available
|
||||
if voice_lang:
|
||||
self.env["runtime"]["SpeechDriver"].set_language(
|
||||
voice_lang
|
||||
)
|
||||
# Then set voice
|
||||
self.env["runtime"]["SpeechDriver"].set_voice(
|
||||
voice_name
|
||||
)
|
||||
except Exception as e:
|
||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||
(f"QuickMenuManager cycle_speech_module: "
|
||||
f"Error applying voice: {e}"),
|
||||
debug.DebugLevel.ERROR
|
||||
)
|
||||
|
||||
# Announce new module
|
||||
def _continue_module_cycle(
|
||||
self, direction, modules, announce_result, error=""
|
||||
):
|
||||
if error:
|
||||
self._module_request_pending = False
|
||||
self._report_discovery_error("module", error)
|
||||
return False
|
||||
if not modules:
|
||||
self._module_request_pending = False
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
new_module, interrupt=True
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||
f"QuickMenuManager cycle_speech_module: Error: {e}",
|
||||
debug.DebugLevel.ERROR
|
||||
"No modules available", interrupt=True
|
||||
)
|
||||
return False
|
||||
|
||||
def cycle_speech_voice(self, direction):
|
||||
"""Cycle to next/previous voice for current module.
|
||||
|
||||
Args:
|
||||
direction (str): 'next' or 'prev'
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Get current module
|
||||
current_module = self.env["runtime"]["SettingsManager"].get_setting(
|
||||
"speech", "module"
|
||||
)
|
||||
try:
|
||||
current_index = (
|
||||
modules.index(current_module) if current_module else 0
|
||||
)
|
||||
except ValueError:
|
||||
current_index = 0
|
||||
if direction == "next":
|
||||
new_index = (current_index + 1) % len(modules)
|
||||
else:
|
||||
new_index = (current_index - 1) % len(modules)
|
||||
new_module = modules[new_index]
|
||||
discovery = self.env["runtime"]["SpeechDiscoveryManager"]
|
||||
voices = discovery.get_cached_voices(new_module)
|
||||
if voices is not None:
|
||||
self._module_request_pending = False
|
||||
return self._apply_module(
|
||||
new_module, voices, announce_result
|
||||
)
|
||||
|
||||
self._module_request_pending = True
|
||||
discovery.request_voices(
|
||||
new_module,
|
||||
lambda _module, found, voice_error: self._finish_module_cycle(
|
||||
new_module, found, voice_error
|
||||
),
|
||||
)
|
||||
return False
|
||||
except Exception as error:
|
||||
self._module_request_pending = False
|
||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||
f"QuickMenuManager cycle_speech_module: Error: {error}",
|
||||
debug.DebugLevel.ERROR,
|
||||
)
|
||||
return False
|
||||
|
||||
def _finish_module_cycle(self, module, voices, error):
|
||||
self._module_request_pending = False
|
||||
if error:
|
||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||
f"QuickMenuManager voice discovery failed: {error}",
|
||||
debug.DebugLevel.ERROR,
|
||||
)
|
||||
self._apply_module(module, voices, False)
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
f"{module}; voice discovery failed", interrupt=True
|
||||
)
|
||||
return
|
||||
self._apply_module(module, voices, True)
|
||||
|
||||
def _apply_module(self, module, voices, announce_result):
|
||||
settings_manager = self.env["runtime"]["SettingsManager"]
|
||||
settings_manager.set_setting("speech", "module", module)
|
||||
speech_driver = self.env["runtime"].get("SpeechDriver")
|
||||
if speech_driver:
|
||||
speech_driver.set_module(module)
|
||||
|
||||
if voices:
|
||||
default_voice = self._select_default_voice(voices)
|
||||
voice_name, voice_language = self._split_voice(default_voice)
|
||||
settings_manager.set_setting("speech", "voice", voice_name)
|
||||
if speech_driver:
|
||||
if voice_language:
|
||||
speech_driver.set_language(voice_language)
|
||||
speech_driver.set_voice(voice_name)
|
||||
|
||||
if announce_result:
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
module, interrupt=True
|
||||
)
|
||||
return True
|
||||
|
||||
def cycle_speech_voice(self, direction):
|
||||
try:
|
||||
current_module = self.env["runtime"]["SettingsManager"].get_setting(
|
||||
"speech", "module"
|
||||
)
|
||||
if not current_module:
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
"No module selected", interrupt=True
|
||||
)
|
||||
return False
|
||||
|
||||
# Get available voices for this module
|
||||
voices = self.get_module_voices(current_module)
|
||||
if not voices:
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
f"No voices for module {current_module}", interrupt=True
|
||||
)
|
||||
discovery = self.env["runtime"]["SpeechDiscoveryManager"]
|
||||
voices = discovery.get_cached_voices(current_module)
|
||||
if voices is not None:
|
||||
return self._apply_voice(direction, voices, False)
|
||||
if current_module in self._voice_requests_pending:
|
||||
return False
|
||||
|
||||
# Get current voice
|
||||
current_voice = self.env["runtime"]["SettingsManager"].get_setting(
|
||||
"speech", "voice"
|
||||
self._voice_requests_pending.add(current_module)
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
f"Loading voices for {current_module}", interrupt=True
|
||||
)
|
||||
|
||||
# Find current index (handle Voxin voice|language format)
|
||||
current_index = 0
|
||||
if current_voice:
|
||||
try:
|
||||
# Try exact match first
|
||||
current_index = voices.index(current_voice)
|
||||
except ValueError:
|
||||
# For Voxin, compare just the voice name part
|
||||
for i, voice in enumerate(voices):
|
||||
voice_name = voice.split("|")[0] if "|" in voice else voice
|
||||
if voice_name == current_voice:
|
||||
current_index = i
|
||||
break
|
||||
|
||||
# Cycle to next/previous
|
||||
if direction == "next":
|
||||
new_index = (current_index + 1) % len(voices)
|
||||
else: # prev
|
||||
new_index = (current_index - 1) % len(voices)
|
||||
|
||||
new_voice = voices[new_index]
|
||||
|
||||
# Parse voice name and language for modules like Voxin
|
||||
voice_name = new_voice
|
||||
voice_lang = None
|
||||
if "|" in new_voice:
|
||||
# Format: "voicename|language" (e.g., "daniel-embedded-high|en-GB")
|
||||
voice_name, voice_lang = new_voice.split("|", 1)
|
||||
|
||||
# Update setting (runtime only) - store the voice name only
|
||||
self.env["runtime"]["SettingsManager"].set_setting(
|
||||
"speech", "voice", voice_name
|
||||
discovery.request_voices(
|
||||
current_module,
|
||||
lambda module, found, error: self._finish_voice_cycle(
|
||||
direction, module, found, error
|
||||
),
|
||||
)
|
||||
return False
|
||||
except Exception as error:
|
||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||
f"QuickMenuManager cycle_speech_voice: Error: {error}",
|
||||
debug.DebugLevel.ERROR,
|
||||
)
|
||||
return False
|
||||
|
||||
# Apply voice to speech driver immediately
|
||||
if "SpeechDriver" in self.env["runtime"]:
|
||||
try:
|
||||
# Set language first if available
|
||||
if voice_lang:
|
||||
self.env["runtime"]["SpeechDriver"].set_language(
|
||||
voice_lang
|
||||
)
|
||||
# Then set voice
|
||||
self.env["runtime"]["SpeechDriver"].set_voice(voice_name)
|
||||
except Exception as e:
|
||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||
(f"QuickMenuManager cycle_speech_voice: "
|
||||
f"Error applying voice: {e}"),
|
||||
debug.DebugLevel.ERROR
|
||||
)
|
||||
def _finish_voice_cycle(self, direction, module, voices, error):
|
||||
self._voice_requests_pending.discard(module)
|
||||
if error:
|
||||
self._report_discovery_error("voice", error)
|
||||
return
|
||||
self._apply_voice(direction, voices, True)
|
||||
|
||||
# Announce new voice (voice name only, not language)
|
||||
def _apply_voice(self, direction, voices, announce_result):
|
||||
if not voices:
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
"No voices available", interrupt=True
|
||||
)
|
||||
return False
|
||||
|
||||
settings_manager = self.env["runtime"]["SettingsManager"]
|
||||
current_voice = settings_manager.get_setting("speech", "voice")
|
||||
current_index = 0
|
||||
for voice_index, voice in enumerate(voices):
|
||||
voice_name, _language = self._split_voice(voice)
|
||||
if voice == current_voice or voice_name == current_voice:
|
||||
current_index = voice_index
|
||||
break
|
||||
if direction == "next":
|
||||
new_index = (current_index + 1) % len(voices)
|
||||
else:
|
||||
new_index = (current_index - 1) % len(voices)
|
||||
|
||||
voice_name, voice_language = self._split_voice(voices[new_index])
|
||||
settings_manager.set_setting("speech", "voice", voice_name)
|
||||
speech_driver = self.env["runtime"].get("SpeechDriver")
|
||||
if speech_driver:
|
||||
if voice_language:
|
||||
speech_driver.set_language(voice_language)
|
||||
speech_driver.set_voice(voice_name)
|
||||
if announce_result:
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
voice_name, interrupt=True
|
||||
)
|
||||
return True
|
||||
|
||||
return True
|
||||
@staticmethod
|
||||
def _split_voice(voice):
|
||||
if "|" in voice:
|
||||
return tuple(voice.split("|", 1))
|
||||
return voice, None
|
||||
|
||||
except Exception as e:
|
||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||
f"QuickMenuManager cycle_speech_voice: Error: {e}",
|
||||
debug.DebugLevel.ERROR
|
||||
)
|
||||
return False
|
||||
def _report_discovery_error(self, kind, error):
|
||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||
f"QuickMenuManager {kind} discovery failed: {error}",
|
||||
debug.DebugLevel.ERROR,
|
||||
)
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
f"{kind.capitalize()} discovery failed", interrupt=True
|
||||
)
|
||||
|
||||
def get_current_entry(self):
|
||||
if len(self.quickMenu) == 0:
|
||||
|
||||
@@ -22,6 +22,8 @@ runtime_data = {
|
||||
"SettingsManager": None,
|
||||
"FenrirManager": None,
|
||||
"EventManager": None,
|
||||
"BackgroundTaskManager": None,
|
||||
"ProcessManager": None,
|
||||
"SpeechDiscoveryManager": None,
|
||||
"DiffReviewManager": None,
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ from configparser import ConfigParser
|
||||
from fenrirscreenreader.core import applicationManager
|
||||
from fenrirscreenreader.core import attributeManager
|
||||
from fenrirscreenreader.core import barrierManager
|
||||
from fenrirscreenreader.core import backgroundTaskManager
|
||||
from fenrirscreenreader.core import clipboardSyncManager
|
||||
from fenrirscreenreader.core import commandManager
|
||||
from fenrirscreenreader.core import cursorManager
|
||||
@@ -32,6 +33,7 @@ from fenrirscreenreader.core import remoteManager
|
||||
from fenrirscreenreader.core import sayAllManager
|
||||
from fenrirscreenreader.core import screenManager
|
||||
from fenrirscreenreader.core import speechHistoryManager
|
||||
from fenrirscreenreader.core import speechDiscoveryManager
|
||||
from fenrirscreenreader.core import tableManager
|
||||
from fenrirscreenreader.core import textManager
|
||||
from fenrirscreenreader.core import vmenuManager
|
||||
@@ -753,6 +755,11 @@ class SettingsManager:
|
||||
] = processManager.ProcessManager()
|
||||
environment["runtime"]["ProcessManager"].initialize(environment)
|
||||
|
||||
environment["runtime"][
|
||||
"BackgroundTaskManager"
|
||||
] = backgroundTaskManager.BackgroundTaskManager()
|
||||
environment["runtime"]["BackgroundTaskManager"].initialize(environment)
|
||||
|
||||
environment["runtime"]["OutputManager"] = outputManager.OutputManager()
|
||||
environment["runtime"]["OutputManager"].initialize(environment)
|
||||
|
||||
@@ -807,6 +814,10 @@ class SettingsManager:
|
||||
environment["runtime"]["BarrierManager"].initialize(environment)
|
||||
environment["runtime"]["SayAllManager"] = sayAllManager.SayAllManager()
|
||||
environment["runtime"]["SayAllManager"].initialize(environment)
|
||||
environment["runtime"][
|
||||
"SpeechDiscoveryManager"
|
||||
] = speechDiscoveryManager.SpeechDiscoveryManager()
|
||||
environment["runtime"]["SpeechDiscoveryManager"].initialize(environment)
|
||||
environment["runtime"]["VmenuManager"] = vmenuManager.VmenuManager()
|
||||
environment["runtime"]["VmenuManager"].initialize(environment)
|
||||
environment["runtime"][
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -4,5 +4,5 @@
|
||||
# Fenrir TTY screen reader
|
||||
# By Chrys, Storm Dragon, and contributors.
|
||||
|
||||
version = "2026.08.13"
|
||||
version = "2026.08.14"
|
||||
code_name = "testing"
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -3,6 +3,7 @@ from unittest.mock import Mock
|
||||
import pytest
|
||||
|
||||
from fenrirscreenreader.core.clipboardSyncManager import ClipboardSyncManager
|
||||
from fenrirscreenreader.core.clipboardSyncManager import synchronize_clipboards
|
||||
|
||||
|
||||
def build_env(
|
||||
@@ -270,3 +271,87 @@ def test_x_clipboard_paste_exception_is_ignored(monkeypatch):
|
||||
|
||||
env["runtime"]["MemoryManager"].add_value_to_first_index.assert_not_called()
|
||||
env["runtime"]["DebugManager"].write_debug_out.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_runtime_sync_applies_worker_result_on_main_loop():
|
||||
env = build_env(fenrir_text=None)
|
||||
task_manager = Mock()
|
||||
task_manager.submit_task.return_value = 12
|
||||
env["runtime"]["BackgroundTaskManager"] = task_manager
|
||||
manager = ClipboardSyncManager()
|
||||
manager.env = env
|
||||
manager.enabled = True
|
||||
manager.display = ":1"
|
||||
manager.running = True
|
||||
|
||||
manager.handle_sync_event()
|
||||
env["runtime"]["MemoryManager"].add_value_to_first_index.assert_not_called()
|
||||
|
||||
callback = task_manager.submit_task.call_args.args[1]
|
||||
callback(
|
||||
{
|
||||
"task_id": 12,
|
||||
"succeeded": True,
|
||||
"value": {
|
||||
"import_text": "from x",
|
||||
"last_imported_from_x": "from x",
|
||||
"last_observed_fenrir": "from x",
|
||||
"last_observed_x": "from x",
|
||||
},
|
||||
"error": "",
|
||||
}
|
||||
)
|
||||
|
||||
env["runtime"]["MemoryManager"].add_value_to_first_index.assert_called_once_with(
|
||||
"clipboardHistory", "from x"
|
||||
)
|
||||
assert manager.last_imported_from_x == "from x"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_runtime_sync_discards_import_if_fenrir_clipboard_changed():
|
||||
env = build_env(fenrir_text=None)
|
||||
task_manager = Mock()
|
||||
task_manager.submit_task.return_value = 12
|
||||
env["runtime"]["BackgroundTaskManager"] = task_manager
|
||||
manager = ClipboardSyncManager()
|
||||
manager.env = env
|
||||
manager.display = ":1"
|
||||
manager.running = True
|
||||
|
||||
manager.handle_sync_event()
|
||||
env["runtime"]["MemoryManager"].is_index_list_empty.return_value = False
|
||||
env["runtime"]["MemoryManager"].get_index_list_element.return_value = (
|
||||
"new fenrir copy"
|
||||
)
|
||||
callback = task_manager.submit_task.call_args.args[1]
|
||||
callback(
|
||||
{
|
||||
"task_id": 12,
|
||||
"succeeded": True,
|
||||
"value": {
|
||||
"import_text": "stale x text",
|
||||
"last_imported_from_x": "stale x text",
|
||||
"last_observed_fenrir": "stale x text",
|
||||
"last_observed_x": "stale x text",
|
||||
},
|
||||
"error": "",
|
||||
}
|
||||
)
|
||||
|
||||
env["runtime"]["MemoryManager"].add_value_to_first_index.assert_not_called()
|
||||
assert manager.last_imported_from_x is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_sync_worker_returns_changes_without_manager_access(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"fenrirscreenreader.core.clipboardSyncManager.x_clipboard.read_text",
|
||||
Mock(return_value="from x"),
|
||||
)
|
||||
|
||||
result = synchronize_clipboards(":1", None, None, None)
|
||||
|
||||
assert result["import_text"] == "from x"
|
||||
assert result["last_observed_x"] == "from x"
|
||||
|
||||
@@ -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"]
|
||||
@@ -2,90 +2,11 @@ import sys
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
from fenrirscreenreader.commands.commands import voice_browser
|
||||
from fenrirscreenreader.commands.commands import voice_browser_safe
|
||||
from fenrirscreenreader.core import dynamicVoiceMenu
|
||||
from fenrirscreenreader.core.quickMenuManager import (
|
||||
QuickMenuManager,
|
||||
SpeechHelperMixin,
|
||||
)
|
||||
from fenrirscreenreader.core.quickMenuManager import QuickMenuManager
|
||||
from fenrirscreenreader.speechDriver import speechdDriver
|
||||
from fenrirscreenreader.utils.speechd_utils import get_synthesis_voice_name
|
||||
|
||||
|
||||
VOICE_LIST = (
|
||||
" NAME LANGUAGE VARIANT\n"
|
||||
" Perfect Paul en-US none\n"
|
||||
" Big Bob en-US none\n"
|
||||
)
|
||||
|
||||
|
||||
def completed_voice_list():
|
||||
return SimpleNamespace(returncode=0, stdout=VOICE_LIST)
|
||||
|
||||
|
||||
def test_quick_menu_preserves_multiword_synthesis_voice_names(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"fenrirscreenreader.core.quickMenuManager.subprocess.run",
|
||||
Mock(return_value=completed_voice_list()),
|
||||
)
|
||||
helper = SpeechHelperMixin()
|
||||
helper.env = {"runtime": {"DebugManager": Mock()}}
|
||||
|
||||
assert helper.get_module_voices("doubletalk") == [
|
||||
"Perfect Paul",
|
||||
"Big Bob",
|
||||
]
|
||||
|
||||
|
||||
def test_safe_voice_browser_preserves_multiword_synthesis_voice_names(
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setattr(
|
||||
voice_browser_safe.subprocess,
|
||||
"run",
|
||||
Mock(return_value=completed_voice_list()),
|
||||
)
|
||||
browser = voice_browser_safe.command()
|
||||
browser.initialize({"runtime": {}})
|
||||
|
||||
assert browser.get_module_voices_with_timeout("doubletalk") == [
|
||||
"Perfect Paul",
|
||||
"Big Bob",
|
||||
]
|
||||
|
||||
|
||||
def test_interactive_voice_browser_preserves_multiword_synthesis_voice_names(
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setattr(
|
||||
voice_browser.subprocess,
|
||||
"run",
|
||||
Mock(return_value=completed_voice_list()),
|
||||
)
|
||||
browser = voice_browser.command()
|
||||
|
||||
assert browser.get_module_voices("doubletalk") == [
|
||||
"Perfect Paul",
|
||||
"Big Bob",
|
||||
]
|
||||
|
||||
|
||||
def test_dynamic_voice_menu_preserves_multiword_synthesis_voice_names(
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setattr(
|
||||
dynamicVoiceMenu.subprocess,
|
||||
"run",
|
||||
Mock(return_value=completed_voice_list()),
|
||||
)
|
||||
|
||||
assert dynamicVoiceMenu.get_module_voices("doubletalk") == [
|
||||
"Perfect Paul",
|
||||
"Big Bob",
|
||||
]
|
||||
|
||||
|
||||
def test_espeak_voice_selection_keeps_language_and_variant_behavior():
|
||||
voice = get_synthesis_voice_name(
|
||||
"espeak-ng",
|
||||
@@ -160,6 +81,12 @@ def test_speechd_driver_keeps_running_when_default_module_query_fails(
|
||||
|
||||
def test_quick_menu_espeak_voice_can_move_away_and_return():
|
||||
settings = RuntimeSettings(module="rhvoice")
|
||||
discovery = Mock()
|
||||
discovery.get_cached_modules.return_value = ["rhvoice", "espeak-ng"]
|
||||
discovery.get_cached_voices.side_effect = lambda module: {
|
||||
"rhvoice": ["alan"],
|
||||
"espeak-ng": ["en-gb", "en-us", "en-us+female2"],
|
||||
}[module]
|
||||
manager = QuickMenuManager()
|
||||
manager.env = {
|
||||
"runtime": {
|
||||
@@ -167,15 +94,9 @@ def test_quick_menu_espeak_voice_can_move_away_and_return():
|
||||
"OutputManager": Mock(),
|
||||
"SettingsManager": settings,
|
||||
"SpeechDriver": Mock(),
|
||||
"SpeechDiscoveryManager": discovery,
|
||||
}
|
||||
}
|
||||
manager._modules_cache = ["rhvoice", "espeak-ng"]
|
||||
manager._cache_timestamp = float("inf")
|
||||
manager._voices_cache["espeak-ng"] = [
|
||||
"en-gb",
|
||||
"en-us",
|
||||
"en-us+female2",
|
||||
]
|
||||
|
||||
assert manager.cycle_speech_module("next") is True
|
||||
assert settings.get_setting("speech", "module") == "espeak-ng"
|
||||
|
||||
@@ -8,26 +8,19 @@ from fenrirscreenreader.commands.commands import subprocess as subprocess_comman
|
||||
@pytest.mark.unit
|
||||
def test_script_command_executes_without_shell(monkeypatch):
|
||||
process = Mock()
|
||||
process.communicate.return_value = (b"done", b"")
|
||||
process.returncode = 0
|
||||
process.communicate.return_value = ("done", "")
|
||||
popen = Mock(return_value=process)
|
||||
monkeypatch.setattr(subprocess_command, "Popen", popen)
|
||||
output_manager = Mock()
|
||||
command = subprocess_command.command()
|
||||
command.initialize(
|
||||
{
|
||||
"general": {"curr_user": "Username"},
|
||||
"runtime": {"OutputManager": output_manager},
|
||||
},
|
||||
"/tmp/script with spaces",
|
||||
)
|
||||
monkeypatch.setattr(subprocess_command.subprocess, "Popen", popen)
|
||||
|
||||
command._thread_run()
|
||||
result = subprocess_command.run_script(
|
||||
"/tmp/script with spaces", "Username"
|
||||
)
|
||||
|
||||
popen.assert_called_once_with(
|
||||
["/tmp/script with spaces", "Username"],
|
||||
stdout=subprocess_command.PIPE,
|
||||
stderr=subprocess_command.PIPE,
|
||||
)
|
||||
output_manager.present_text.assert_called_once_with(
|
||||
"done", sound_icon="", interrupt=False
|
||||
stdout=subprocess_command.subprocess.PIPE,
|
||||
stderr=subprocess_command.subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
assert result == {"return_code": 0, "stdout": "done", "stderr": ""}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
Reference in New Issue
Block a user