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