From 337b5d42731a42c5f82aa5b3e3edbb7078483c7d Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Sat, 25 Apr 2026 19:03:24 -0400 Subject: [PATCH] Hopefully fixed a weird speech bug where some games could make it suddenly stop speaking. --- src/cthulhu/speechdispatcherfactory.py | 50 +++++++++++++----- ..._speechdispatcher_interrupt_regressions.py | 51 +++++++++++++++++++ 2 files changed, 87 insertions(+), 14 deletions(-) diff --git a/src/cthulhu/speechdispatcherfactory.py b/src/cthulhu/speechdispatcherfactory.py index 6802cf1..3896994 100644 --- a/src/cthulhu/speechdispatcherfactory.py +++ b/src/cthulhu/speechdispatcherfactory.py @@ -201,16 +201,38 @@ class SpeechServer(speechserver.SpeechServer): mode = self._PUNCTUATION_MODE_MAP[settings.verbalizePunctuationStyle] self._client.set_punctuation(mode) - def _send_command(self, command, *args, **kwargs): - try: - return command(*args, **kwargs) - except speechd.SSIPCommunicationError: - msg = "SPEECH DISPATCHER: Connection lost. Trying to reconnect." - debug.printMessage(debug.LEVEL_INFO, msg, True) - self.reset() - return command(*args, **kwargs) - except Exception: - pass + def _log_command_failure(self, command_name, error, will_retry=False): + error_type = type(error).__name__ + action = " Resetting backend and retrying." if will_retry else "" + msg = f"SPEECH DISPATCHER: {command_name} failed with {error_type}: {error!s}.{action}" + debug.printMessage(debug.LEVEL_WARNING, msg, True) + + def _send_command(self, command, *args, treat_none_as_error=False, **kwargs): + command_name = getattr(command, "__name__", repr(command)) + + for attempt in range(2): + try: + result = command(*args, **kwargs) + if treat_none_as_error and result is None: + raise RuntimeError(f"{command_name} returned None") + return result + except speechd.SSIPCommunicationError as error: + self._log_command_failure(command_name, error, will_retry=attempt == 0) + except speechd.SSIPCommandError as error: + self._log_command_failure(command_name, error, will_retry=attempt == 0) + except RuntimeError as error: + if not treat_none_as_error: + raise + self._log_command_failure(command_name, error, will_retry=attempt == 0) + except Exception as error: + self._log_command_failure(command_name, error, will_retry=False) + return None + + if attempt == 0: + self.reset() + continue + + return None def _set_rate(self, acss_rate): rate = int(2 * max(0, min(99, acss_rate)) - 98) @@ -267,10 +289,10 @@ class SpeechServer(speechserver.SpeechServer): return try: - sd_rate = self._send_command(self._client.get_rate) - sd_pitch = self._send_command(self._client.get_pitch) - sd_volume = self._send_command(self._client.get_volume) - sd_language = self._send_command(self._client.get_language) + sd_rate = self._send_command(self._client.get_rate, treat_none_as_error=True) + sd_pitch = self._send_command(self._client.get_pitch, treat_none_as_error=True) + sd_volume = self._send_command(self._client.get_volume, treat_none_as_error=True) + sd_language = self._send_command(self._client.get_language, treat_none_as_error=True) except Exception: sd_rate = sd_pitch = sd_volume = sd_language = "(exception occurred)" diff --git a/tests/test_speechdispatcher_interrupt_regressions.py b/tests/test_speechdispatcher_interrupt_regressions.py index 674b966..786a6a1 100644 --- a/tests/test_speechdispatcher_interrupt_regressions.py +++ b/tests/test_speechdispatcher_interrupt_regressions.py @@ -7,6 +7,7 @@ from unittest import mock sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) from cthulhu import speechdispatcherfactory +import speechd class SpeechDispatcherInterruptRegressionTests(unittest.TestCase): @@ -38,6 +39,56 @@ class SpeechDispatcherInterruptRegressionTests(unittest.TestCase): server._cancel.assert_not_called() server._speak.assert_called_once_with("next", None) + def test_send_command_logs_and_recovers_from_command_errors(self): + server = self._make_server() + server.reset = mock.Mock() + command = mock.Mock( + side_effect=[ + speechd.SSIPCommandError(500, "bad ssml", "bad ssml"), + "recovered", + ] + ) + + with mock.patch.object(speechdispatcherfactory.debug, "printMessage") as print_message: + result = speechdispatcherfactory.SpeechServer._send_command(server, command, "payload") + + self.assertEqual("recovered", result) + server.reset.assert_called_once_with() + self.assertEqual(2, command.call_count) + logged_messages = [call.args[1] for call in print_message.call_args_list] + self.assertTrue(any("SSIPCommandError" in message for message in logged_messages)) + + def test_debug_sd_values_logs_when_backend_state_queries_return_none(self): + server = self._make_server() + server._current_voice_properties = {} + server._id = "default" + server.reset = mock.Mock() + server._send_command = speechdispatcherfactory.SpeechServer._send_command.__get__( + server, speechdispatcherfactory.SpeechServer + ) + + fake_app = mock.Mock() + fake_app.settingsManager.getSetting.return_value = speechdispatcherfactory.settings.PUNCTUATION_STYLE_MOST + fake_script = mock.Mock() + fake_script.utilities.adjustForDigits.side_effect = lambda text: text + fake_state = mock.Mock(activeScript=fake_script) + + server._client.get_rate.return_value = None + server._client.get_pitch.return_value = None + server._client.get_volume.return_value = None + server._client.get_language.return_value = None + + with mock.patch.object(speechdispatcherfactory, "cthulhu") as fake_cthulhu, \ + mock.patch.object(speechdispatcherfactory, "cthulhu_state", fake_state), \ + mock.patch.object(speechdispatcherfactory.debug, "debugLevel", speechdispatcherfactory.debug.LEVEL_INFO), \ + mock.patch.object(speechdispatcherfactory.debug, "printMessage") as print_message: + fake_cthulhu.cthulhuApp = fake_app + speechdispatcherfactory.SpeechServer._debug_sd_values(server, "prefix") + + logged_messages = [str(call.args[1]) for call in print_message.call_args_list] + self.assertTrue(any("returned None" in message for message in logged_messages)) + self.assertEqual(4, server.reset.call_count) + if __name__ == "__main__": unittest.main()