From f09437ea60baad941ed50c7efbfa6b9beee44c15 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Tue, 19 May 2026 01:54:09 -0400 Subject: [PATCH] Speculative fix for a sometimes speech crash bug. --- src/fenrirscreenreader/core/outputManager.py | 249 ++++++++++--------- src/fenrirscreenreader/fenrirVersion.py | 2 +- tests/unit/test_output_manager.py | 42 ++++ tests/unit/test_remote_instance_registry.py | 35 +++ 4 files changed, 215 insertions(+), 113 deletions(-) create mode 100644 tests/unit/test_remote_instance_registry.py diff --git a/src/fenrirscreenreader/core/outputManager.py b/src/fenrirscreenreader/core/outputManager.py index 1b4a66c8..8aa2af4c 100644 --- a/src/fenrirscreenreader/core/outputManager.py +++ b/src/fenrirscreenreader/core/outputManager.py @@ -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 == "": diff --git a/src/fenrirscreenreader/fenrirVersion.py b/src/fenrirscreenreader/fenrirVersion.py index 2f6dffbc..95722b55 100644 --- a/src/fenrirscreenreader/fenrirVersion.py +++ b/src/fenrirscreenreader/fenrirVersion.py @@ -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" diff --git a/tests/unit/test_output_manager.py b/tests/unit/test_output_manager.py index 6d6b21d5..a1660cb8 100644 --- a/tests/unit/test_output_manager.py +++ b/tests/unit/test_output_manager.py @@ -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() diff --git a/tests/unit/test_remote_instance_registry.py b/tests/unit/test_remote_instance_registry.py new file mode 100644 index 00000000..7955665a --- /dev/null +++ b/tests/unit/test_remote_instance_registry.py @@ -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()