Speculative fix for a sometimes speech crash bug.

This commit is contained in:
Storm Dragon
2026-05-19 01:54:09 -04:00
parent 19194e73fc
commit f09437ea60
4 changed files with 215 additions and 113 deletions
+137 -112
View File
@@ -22,6 +22,8 @@ class OutputManager:
self.interrupt_thread = None
self.interrupt_done = None
self.interrupt_wait_timeout = 0.1
self.speech_driver_lock = threading.Lock()
self.speech_driver_lock_timeout = 0.05
def initialize(self, environment):
self.env = environment
@@ -159,132 +161,144 @@ class OutputManager:
return
if interrupt or flush:
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:
self.env["runtime"]["SpeechDriver"].set_language(
self.env["runtime"]["SettingsManager"].get_setting(
"speech", "language"
try:
self.env["runtime"]["SpeechDriver"].set_language(
self.env["runtime"]["SettingsManager"].get_setting(
"speech", "language"
)
)
)
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
"setting speech language in OutputManager.speak_text",
debug.DebugLevel.ERROR,
)
self.env["runtime"]["DebugManager"].write_debug_out(
str(e), debug.DebugLevel.ERROR
)
try:
self.env["runtime"]["SpeechDriver"].set_voice(
self.env["runtime"]["SettingsManager"].get_setting(
"speech", "voice"
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
"setting speech language in OutputManager.speak_text",
debug.DebugLevel.ERROR,
)
self.env["runtime"]["DebugManager"].write_debug_out(
str(e), debug.DebugLevel.ERROR
)
)
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
"Error while setting speech voice in OutputManager.speak_text",
debug.DebugLevel.ERROR,
)
self.env["runtime"]["DebugManager"].write_debug_out(
str(e), debug.DebugLevel.ERROR
)
try:
if announce_capital:
self.env["runtime"]["SpeechDriver"].set_pitch(
try:
self.env["runtime"]["SpeechDriver"].set_voice(
self.env["runtime"]["SettingsManager"].get_setting(
"speech", "voice"
)
)
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
"Error while setting speech voice in OutputManager.speak_text",
debug.DebugLevel.ERROR,
)
self.env["runtime"]["DebugManager"].write_debug_out(
str(e), debug.DebugLevel.ERROR
)
try:
if announce_capital:
self.env["runtime"]["SpeechDriver"].set_pitch(
self.env["runtime"][
"SettingsManager"
].get_setting_as_float("speech", "capital_pitch")
)
else:
self.env["runtime"]["SpeechDriver"].set_pitch(
self.env["runtime"][
"SettingsManager"
].get_setting_as_float("speech", "pitch")
)
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
"setting speech pitch in OutputManager.speak_text",
debug.DebugLevel.ERROR,
)
self.env["runtime"]["DebugManager"].write_debug_out(
str(e), debug.DebugLevel.ERROR
)
try:
self.env["runtime"]["SpeechDriver"].set_rate(
self.env["runtime"][
"SettingsManager"
].get_setting_as_float("speech", "capital_pitch")
].get_setting_as_float("speech", "rate")
)
else:
self.env["runtime"]["SpeechDriver"].set_pitch(
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
"setting speech rate in OutputManager.speak_text",
debug.DebugLevel.ERROR,
)
self.env["runtime"]["DebugManager"].write_debug_out(
str(e), debug.DebugLevel.ERROR
)
try:
self.env["runtime"]["SpeechDriver"].set_module(
self.env["runtime"]["SettingsManager"].get_setting(
"speech", "module"
)
)
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
"setting speech module in OutputManager.speak_text",
debug.DebugLevel.ERROR,
)
self.env["runtime"]["DebugManager"].write_debug_out(
str(e), debug.DebugLevel.ERROR
)
try:
self.env["runtime"]["SpeechDriver"].set_volume(
self.env["runtime"][
"SettingsManager"
].get_setting_as_float("speech", "pitch")
].get_setting_as_float("speech", "volume")
)
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
"setting speech pitch in OutputManager.speak_text",
debug.DebugLevel.ERROR,
)
self.env["runtime"]["DebugManager"].write_debug_out(
str(e), debug.DebugLevel.ERROR
)
try:
self.env["runtime"]["SpeechDriver"].set_rate(
self.env["runtime"]["SettingsManager"].get_setting_as_float(
"speech", "rate"
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
"setting speech volume in OutputManager.speak_text ",
debug.DebugLevel.ERROR,
)
)
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
"setting speech rate in OutputManager.speak_text",
debug.DebugLevel.ERROR,
)
self.env["runtime"]["DebugManager"].write_debug_out(
str(e), debug.DebugLevel.ERROR
)
try:
self.env["runtime"]["SpeechDriver"].set_module(
self.env["runtime"]["SettingsManager"].get_setting(
"speech", "module"
self.env["runtime"]["DebugManager"].write_debug_out(
str(e), debug.DebugLevel.ERROR
)
)
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
"setting speech module in OutputManager.speak_text",
debug.DebugLevel.ERROR,
)
self.env["runtime"]["DebugManager"].write_debug_out(
str(e), debug.DebugLevel.ERROR
)
try:
self.env["runtime"]["SpeechDriver"].set_volume(
self.env["runtime"]["SettingsManager"].get_setting_as_float(
"speech", "volume"
try:
if self.env["runtime"]["SettingsManager"].get_setting_as_bool(
"general", "new_line_pause"
):
clean_text = text.replace("\n", " , ")
else:
clean_text = text.replace("\n", " ")
clean_text = self.env["runtime"][
"TextManager"
].replace_head_lines(clean_text)
clean_text = self.process_mid_word_punctuation(clean_text)
clean_text = self.env["runtime"][
"PunctuationManager"
].proceed_punctuation(clean_text, ignore_punctuation)
clean_text = re.sub(" +$", " ", clean_text)
self.env["runtime"]["SpeechDriver"].speak(
clean_text, True, ignore_punctuation
)
)
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
"setting speech volume in OutputManager.speak_text ",
debug.DebugLevel.ERROR,
)
self.env["runtime"]["DebugManager"].write_debug_out(
str(e), debug.DebugLevel.ERROR
)
try:
if self.env["runtime"]["SettingsManager"].get_setting_as_bool(
"general", "new_line_pause"
):
clean_text = text.replace("\n", " , ")
else:
clean_text = text.replace("\n", " ")
clean_text = self.env["runtime"]["TextManager"].replace_head_lines(
clean_text
)
clean_text = self.process_mid_word_punctuation(clean_text)
clean_text = self.env["runtime"][
"PunctuationManager"
].proceed_punctuation(clean_text, ignore_punctuation)
clean_text = re.sub(" +$", " ", clean_text)
self.env["runtime"]["SpeechDriver"].speak(
clean_text, True, ignore_punctuation
)
self.env["runtime"]["DebugManager"].write_debug_out(
"Speak: " + clean_text, debug.DebugLevel.INFO
)
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
'"speak" in OutputManager.speak_text ', debug.DebugLevel.ERROR
)
self.env["runtime"]["DebugManager"].write_debug_out(
str(e), debug.DebugLevel.ERROR
)
self.env["runtime"]["DebugManager"].write_debug_out(
"Speak: " + clean_text, debug.DebugLevel.INFO
)
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
'"speak" in OutputManager.speak_text ',
debug.DebugLevel.ERROR,
)
self.env["runtime"]["DebugManager"].write_debug_out(
str(e), debug.DebugLevel.ERROR
)
finally:
self.speech_driver_lock.release()
def interrupt_output(self, wait=True):
interrupt_done, started = self.start_interrupt_output()
@@ -320,6 +334,15 @@ class OutputManager:
self.interrupt_running = False
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:
self.env["runtime"]["SpeechDriver"].cancel()
self.env["runtime"]["DebugManager"].write_debug_out(
@@ -331,6 +354,8 @@ class OutputManager:
+ str(e),
debug.DebugLevel.ERROR,
)
finally:
self.speech_driver_lock.release()
def play_sound_icon(self, sound_icon="", interrupt=True):
if sound_icon == "":
+1 -1
View File
@@ -4,5 +4,5 @@
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
version = "2026.05.18"
version = "2026.05.19"
code_name = "testing"
+42
View File
@@ -26,6 +26,14 @@ def build_output_manager():
"SoundDriver": sound_driver,
"SpeechDriver": speech_driver,
"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
@@ -122,6 +130,40 @@ def test_interrupt_output_waits_only_briefly_for_slow_cancel():
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
def test_key_interrupt_command_uses_nonblocking_interrupt():
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()