Speculative fix for a sometimes speech crash bug.
This commit is contained in:
@@ -22,6 +22,8 @@ class OutputManager:
|
|||||||
self.interrupt_thread = None
|
self.interrupt_thread = None
|
||||||
self.interrupt_done = None
|
self.interrupt_done = None
|
||||||
self.interrupt_wait_timeout = 0.1
|
self.interrupt_wait_timeout = 0.1
|
||||||
|
self.speech_driver_lock = threading.Lock()
|
||||||
|
self.speech_driver_lock_timeout = 0.05
|
||||||
|
|
||||||
def initialize(self, environment):
|
def initialize(self, environment):
|
||||||
self.env = environment
|
self.env = environment
|
||||||
@@ -159,6 +161,15 @@ class OutputManager:
|
|||||||
return
|
return
|
||||||
if interrupt or flush:
|
if interrupt or flush:
|
||||||
self.interrupt_output()
|
self.interrupt_output()
|
||||||
|
if not self.speech_driver_lock.acquire(
|
||||||
|
timeout=self.speech_driver_lock_timeout
|
||||||
|
):
|
||||||
|
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||||
|
"OutputManager.speak_text: Speech driver busy, dropping speech",
|
||||||
|
debug.DebugLevel.WARNING,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
try:
|
||||||
try:
|
try:
|
||||||
self.env["runtime"]["SpeechDriver"].set_language(
|
self.env["runtime"]["SpeechDriver"].set_language(
|
||||||
self.env["runtime"]["SettingsManager"].get_setting(
|
self.env["runtime"]["SettingsManager"].get_setting(
|
||||||
@@ -213,9 +224,9 @@ class OutputManager:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
self.env["runtime"]["SpeechDriver"].set_rate(
|
self.env["runtime"]["SpeechDriver"].set_rate(
|
||||||
self.env["runtime"]["SettingsManager"].get_setting_as_float(
|
self.env["runtime"][
|
||||||
"speech", "rate"
|
"SettingsManager"
|
||||||
)
|
].get_setting_as_float("speech", "rate")
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||||
@@ -243,9 +254,9 @@ class OutputManager:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
self.env["runtime"]["SpeechDriver"].set_volume(
|
self.env["runtime"]["SpeechDriver"].set_volume(
|
||||||
self.env["runtime"]["SettingsManager"].get_setting_as_float(
|
self.env["runtime"][
|
||||||
"speech", "volume"
|
"SettingsManager"
|
||||||
)
|
].get_setting_as_float("speech", "volume")
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||||
@@ -264,9 +275,9 @@ class OutputManager:
|
|||||||
else:
|
else:
|
||||||
clean_text = text.replace("\n", " ")
|
clean_text = text.replace("\n", " ")
|
||||||
|
|
||||||
clean_text = self.env["runtime"]["TextManager"].replace_head_lines(
|
clean_text = self.env["runtime"][
|
||||||
clean_text
|
"TextManager"
|
||||||
)
|
].replace_head_lines(clean_text)
|
||||||
clean_text = self.process_mid_word_punctuation(clean_text)
|
clean_text = self.process_mid_word_punctuation(clean_text)
|
||||||
clean_text = self.env["runtime"][
|
clean_text = self.env["runtime"][
|
||||||
"PunctuationManager"
|
"PunctuationManager"
|
||||||
@@ -280,11 +291,14 @@ class OutputManager:
|
|||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||||
'"speak" in OutputManager.speak_text ', debug.DebugLevel.ERROR
|
'"speak" in OutputManager.speak_text ',
|
||||||
|
debug.DebugLevel.ERROR,
|
||||||
)
|
)
|
||||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||||
str(e), debug.DebugLevel.ERROR
|
str(e), debug.DebugLevel.ERROR
|
||||||
)
|
)
|
||||||
|
finally:
|
||||||
|
self.speech_driver_lock.release()
|
||||||
|
|
||||||
def interrupt_output(self, wait=True):
|
def interrupt_output(self, wait=True):
|
||||||
interrupt_done, started = self.start_interrupt_output()
|
interrupt_done, started = self.start_interrupt_output()
|
||||||
@@ -320,6 +334,15 @@ class OutputManager:
|
|||||||
self.interrupt_running = False
|
self.interrupt_running = False
|
||||||
|
|
||||||
def cancel_speech(self):
|
def cancel_speech(self):
|
||||||
|
if not self.speech_driver_lock.acquire(
|
||||||
|
timeout=self.speech_driver_lock_timeout
|
||||||
|
):
|
||||||
|
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||||
|
"OutputManager interrupt_output: Speech driver busy, "
|
||||||
|
"skipping interrupt",
|
||||||
|
debug.DebugLevel.WARNING,
|
||||||
|
)
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
self.env["runtime"]["SpeechDriver"].cancel()
|
self.env["runtime"]["SpeechDriver"].cancel()
|
||||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||||
@@ -331,6 +354,8 @@ class OutputManager:
|
|||||||
+ str(e),
|
+ str(e),
|
||||||
debug.DebugLevel.ERROR,
|
debug.DebugLevel.ERROR,
|
||||||
)
|
)
|
||||||
|
finally:
|
||||||
|
self.speech_driver_lock.release()
|
||||||
|
|
||||||
def play_sound_icon(self, sound_icon="", interrupt=True):
|
def play_sound_icon(self, sound_icon="", interrupt=True):
|
||||||
if sound_icon == "":
|
if sound_icon == "":
|
||||||
|
|||||||
@@ -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.05.18"
|
version = "2026.05.19"
|
||||||
code_name = "testing"
|
code_name = "testing"
|
||||||
|
|||||||
@@ -26,6 +26,14 @@ def build_output_manager():
|
|||||||
"SoundDriver": sound_driver,
|
"SoundDriver": sound_driver,
|
||||||
"SpeechDriver": speech_driver,
|
"SpeechDriver": speech_driver,
|
||||||
"DebugManager": Mock(write_debug_out=Mock()),
|
"DebugManager": Mock(write_debug_out=Mock()),
|
||||||
|
"TextManager": Mock(
|
||||||
|
replace_head_lines=Mock(side_effect=lambda text: text)
|
||||||
|
),
|
||||||
|
"PunctuationManager": Mock(
|
||||||
|
proceed_punctuation=Mock(
|
||||||
|
side_effect=lambda text, _ignore_punctuation: text
|
||||||
|
)
|
||||||
|
),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
return output_manager, sound_driver, speech_driver
|
return output_manager, sound_driver, speech_driver
|
||||||
@@ -122,6 +130,40 @@ def test_interrupt_output_waits_only_briefly_for_slow_cancel():
|
|||||||
output_manager.interrupt_thread.join(timeout=1.0)
|
output_manager.interrupt_thread.join(timeout=1.0)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_cancel_speech_skips_when_speech_driver_is_busy():
|
||||||
|
output_manager, _sound_driver, speech_driver = build_output_manager()
|
||||||
|
output_manager.speech_driver_lock_timeout = 0.01
|
||||||
|
output_manager.speech_driver_lock.acquire()
|
||||||
|
|
||||||
|
try:
|
||||||
|
start_time = time.monotonic()
|
||||||
|
output_manager.cancel_speech()
|
||||||
|
elapsed = time.monotonic() - start_time
|
||||||
|
finally:
|
||||||
|
output_manager.speech_driver_lock.release()
|
||||||
|
|
||||||
|
assert elapsed < 0.2
|
||||||
|
speech_driver.cancel.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_speak_text_drops_speech_when_cancel_holds_driver_lock():
|
||||||
|
output_manager, _sound_driver, speech_driver = build_output_manager()
|
||||||
|
output_manager.speech_driver_lock_timeout = 0.01
|
||||||
|
output_manager.speech_driver_lock.acquire()
|
||||||
|
|
||||||
|
try:
|
||||||
|
start_time = time.monotonic()
|
||||||
|
output_manager.speak_text("hello", interrupt=False, flush=False)
|
||||||
|
elapsed = time.monotonic() - start_time
|
||||||
|
finally:
|
||||||
|
output_manager.speech_driver_lock.release()
|
||||||
|
|
||||||
|
assert elapsed < 0.2
|
||||||
|
speech_driver.speak.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
def test_key_interrupt_command_uses_nonblocking_interrupt():
|
def test_key_interrupt_command_uses_nonblocking_interrupt():
|
||||||
module = load_key_interrupt_module()
|
module = load_key_interrupt_module()
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
|
from fenrirscreenreader.core import remoteInstanceRegistry
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_instance_prunes_stale_registry_files(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
remoteInstanceRegistry, "get_registry_dir", lambda: str(tmp_path)
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
remoteInstanceRegistry,
|
||||||
|
"process_exists",
|
||||||
|
lambda pid: pid == 456,
|
||||||
|
)
|
||||||
|
|
||||||
|
stale_instance = tmp_path / "123.json"
|
||||||
|
stale_instance.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"pid": 123,
|
||||||
|
"updated_at": time.time(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
+ "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
invalid_instance = tmp_path / "invalid.json"
|
||||||
|
invalid_instance.write_text("not json\n", encoding="utf-8")
|
||||||
|
|
||||||
|
remoteInstanceRegistry.write_instance({"pid": 456})
|
||||||
|
|
||||||
|
assert not stale_instance.exists()
|
||||||
|
assert not invalid_instance.exists()
|
||||||
|
assert (tmp_path / "456.json").exists()
|
||||||
Reference in New Issue
Block a user