From 009938c49594050b2d41ea1ef3460c437b0108a1 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Tue, 19 May 2026 15:26:44 -0400 Subject: [PATCH] Changed how speech interruptions are handled hopefully improved things being interrupted when they shouldn't be. --- src/cthulhu/scripts/default.py | 8 +- src/cthulhu/scripts/web/script.py | 19 ++-- src/cthulhu/speech.py | 5 +- src/cthulhu/speechdispatcherfactory.py | 2 +- src/cthulhu/structural_navigation.py | 7 +- .../test_speech_default_policy_regressions.py | 91 +++++++++++++++++++ ..._speechdispatcher_interrupt_regressions.py | 8 ++ ...structural_navigation_table_regressions.py | 5 +- tests/test_web_input_regressions.py | 10 +- 9 files changed, 130 insertions(+), 25 deletions(-) create mode 100644 tests/test_speech_default_policy_regressions.py diff --git a/src/cthulhu/scripts/default.py b/src/cthulhu/scripts/default.py index b665f4a..911d2e9 100644 --- a/src/cthulhu/scripts/default.py +++ b/src/cthulhu/scripts/default.py @@ -3470,7 +3470,7 @@ class Script(script.Script): return True def presentMessage(self, fullMessage, briefMessage=None, voice=None, resetStyles=True, - force=False, interrupt=True): + force=False, interrupt=False): """Convenience method to speak a message and 'flash' it in braille. Arguments: @@ -3484,6 +3484,8 @@ class Script(script.Script): the briefMessage should set briefMessage to an empty string. - voice: The voice to use when speaking this message. By default, the "system" voice will be used. + - interrupt: If True, any current speech should be interrupted + prior to speaking the new text. The default queues the message. """ if not fullMessage: @@ -3959,7 +3961,7 @@ class Script(script.Script): voice = self.speechGenerator.voice(string=character) speech.speakCharacter(character, voice) - def speakMessage(self, string, voice=None, interrupt=True, resetStyles=True, force=False): + def speakMessage(self, string, voice=None, interrupt=False, resetStyles=True, force=False): """Method to speak a single string. Scripts should use this method rather than calling speech.speak directly. @@ -3967,7 +3969,7 @@ class Script(script.Script): - voice: The voice to use. By default, the "system" voice will be used. - interrupt: If True, any current speech should be interrupted - prior to speaking the new text. + prior to speaking the new text. The default queues the message. """ if not cthulhu.cthulhuApp.settingsManager.getSetting('enableSpeech') \ diff --git a/src/cthulhu/scripts/web/script.py b/src/cthulhu/scripts/web/script.py index caf3e1e..1dde9f4 100644 --- a/src/cthulhu/scripts/web/script.py +++ b/src/cthulhu/scripts/web/script.py @@ -215,7 +215,7 @@ class Script(default.Script): self._clearSyntheticWebSelection() if oldContents: self.speakContents(oldContents) - self.speakMessage(messages.TEXT_UNSELECTED, interrupt=False) + self.speakMessage(messages.TEXT_UNSELECTED) return True self.pointOfReference["syntheticWebSelection"] = { @@ -247,7 +247,7 @@ class Script(default.Script): if deltaContents: self.speakContents(deltaContents) - self.speakMessage(message, interrupt=False) + self.speakMessage(message) return True @@ -1083,7 +1083,7 @@ class Script(default.Script): """Speaks the specified contents.""" utterances = self.speechGenerator.generateContents(contents, **args) - speech.speak(utterances, interrupt=args.get("interrupt", True)) + speech.speak(utterances, interrupt=args.get("interrupt", False)) def sayCharacter(self, obj): """Speaks the character at the current caret position.""" @@ -1504,7 +1504,6 @@ class Script(default.Script): def togglePresentationMode(self, inputEvent, documentFrame=None): [obj, characterOffset] = self.utilities.getCaretContext(documentFrame) - interrupt = inputEvent is not None if self._inFocusMode: parent = AXObject.get_parent(obj) if AXUtilities.is_list_box(parent): @@ -1512,7 +1511,10 @@ class Script(default.Script): elif AXUtilities.is_menu(parent): self.utilities.setCaretContext(AXObject.get_parent(parent), -1) if not self._loadingDocumentContent: - self.presentMessage(messages.MODE_BROWSE, interrupt=interrupt) + if inputEvent is not None: + self.presentMessage(messages.MODE_BROWSE, interrupt=True) + else: + self.presentMessage(messages.MODE_BROWSE) if not self._shouldSuppressBrowseModeSound(obj, inputEvent): sound_theme_manager.getManager().playBrowseModeSound() else: @@ -1522,7 +1524,10 @@ class Script(default.Script): or inputEvent): self.utilities.grabFocus(obj) - self.presentMessage(messages.MODE_FOCUS, interrupt=interrupt) + if inputEvent is not None: + self.presentMessage(messages.MODE_FOCUS, interrupt=True) + else: + self.presentMessage(messages.MODE_FOCUS) sound_theme_manager.getManager().playFocusModeSound() self._inFocusMode = not self._inFocusMode self._focusModeIsSticky = False @@ -2689,7 +2694,7 @@ class Script(default.Script): debug.printTokens(debug.LEVEL_INFO, tokens, True) return True - self.presentMessage(event.any_data, interrupt=False) + self.presentMessage(event.any_data) return True def onNameChanged(self, event): diff --git a/src/cthulhu/speech.py b/src/cthulhu/speech.py index 604aeeb..3c02a7b 100644 --- a/src/cthulhu/speech.py +++ b/src/cthulhu/speech.py @@ -425,10 +425,11 @@ def _speak(text: str, acss: Optional[Any], interrupt: bool) -> None: debug.printMessage(debug.LEVEL_INFO, msg, True) _speechserver.speak(text, resolvedVoice, interrupt) # type: ignore -def speak(content: Union[str, List[Any]], acss: Optional[Any] = None, interrupt: bool = True) -> None: +def speak(content: Union[str, List[Any]], acss: Optional[Any] = None, interrupt: bool = False) -> None: """Speaks the given content. The content can be either a simple string or an array of arrays of objects returned by a speech - generator.""" + generator. Speech queues by default; callers that need to cancel + current output should pass interrupt=True or call presentationInterrupt().""" if settings.silenceSpeech: return diff --git a/src/cthulhu/speechdispatcherfactory.py b/src/cthulhu/speechdispatcherfactory.py index 3896994..01a9e89 100644 --- a/src/cthulhu/speechdispatcherfactory.py +++ b/src/cthulhu/speechdispatcherfactory.py @@ -632,7 +632,7 @@ class SpeechServer(speechserver.SpeechServer): return families - def speak(self, text=None, acss=None, interrupt=True): + def speak(self, text=None, acss=None, interrupt=False): if not text: return diff --git a/src/cthulhu/structural_navigation.py b/src/cthulhu/structural_navigation.py index 67ce85b..b595da3 100644 --- a/src/cthulhu/structural_navigation.py +++ b/src/cthulhu/structural_navigation.py @@ -946,7 +946,7 @@ class StructuralNavigation: for match in matches: if _isValidMatch(match): structuralNavigationObject.present(match, arg) - self._script.presentMessage(wrapMessage, interrupt=False) + self._script.presentMessage(wrapMessage) return structuralNavigationObject.present(None, arg) @@ -2219,14 +2219,13 @@ class StructuralNavigation: if settings.speakCellCoordinates: [row, col] = self.getCellCoordinates(cell) self._script.presentMessage( - messages.TABLE_CELL_COORDINATES % {"row": row + 1, "column": col + 1}, - interrupt=False, + messages.TABLE_CELL_COORDINATES % {"row": row + 1, "column": col + 1} ) rowspan, colspan = self._script.utilities.rowAndColumnSpan(cell) spanString = messages.cellSpan(rowspan, colspan) if spanString and settings.speakCellSpan: - self._script.presentMessage(spanString, interrupt=False) + self._script.presentMessage(spanString) ######################## # # diff --git a/tests/test_speech_default_policy_regressions.py b/tests/test_speech_default_policy_regressions.py new file mode 100644 index 0000000..debb79b --- /dev/null +++ b/tests/test_speech_default_policy_regressions.py @@ -0,0 +1,91 @@ +import sys +import unittest +from pathlib import Path +from unittest import mock + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from cthulhu import settings +from cthulhu import speech +from cthulhu import cthulhu_state +from cthulhu.scripts import default + + +class SpeechDefaultPolicyRegressionTests(unittest.TestCase): + def _make_settings_manager(self): + values = { + "enableSpeech": True, + "onlySpeakDisplayedText": False, + "messagesAreDetailed": True, + "enableBraille": False, + "enableBrailleMonitor": False, + "enableFlashMessages": False, + "voices": {settings.SYSTEM_VOICE: "system"}, + "capitalizationStyle": settings.CAPITALIZATION_STYLE_NONE, + "verbalizePunctuationStyle": settings.PUNCTUATION_STYLE_NONE, + } + + manager = mock.Mock() + manager.getSetting.side_effect = values.get + return manager + + def _make_script(self): + testScript = default.Script.__new__(default.Script) + testScript.speechAndVerbosityManager = mock.Mock() + return testScript + + def test_speech_speak_queues_by_default(self): + server = mock.Mock() + + with ( + mock.patch.object(speech, "_speechserver", server), + mock.patch.object(speech, "_write_to_monitor"), + mock.patch.object(speech.speech_history, "add"), + mock.patch.object(cthulhu_state, "activeScript", None), + ): + speech.speak("status") + + server.speak.assert_called_once() + self.assertFalse(server.speak.call_args.args[2]) + + def test_speech_speak_explicit_interrupt_is_preserved(self): + server = mock.Mock() + + with ( + mock.patch.object(speech, "_speechserver", server), + mock.patch.object(speech, "_write_to_monitor"), + mock.patch.object(speech.speech_history, "add"), + mock.patch.object(cthulhu_state, "activeScript", None), + ): + speech.speak("status", interrupt=True) + + server.speak.assert_called_once() + self.assertTrue(server.speak.call_args.args[2]) + + def test_present_message_queues_by_default(self): + testScript = self._make_script() + manager = self._make_settings_manager() + + with ( + mock.patch.object(default.cthulhu.cthulhuApp, "settingsManager", manager), + mock.patch.object(default.speech, "speak") as speak, + ): + default.Script.presentMessage(testScript, "Status") + + speak.assert_called_once_with("Status", "system", False) + + def test_present_message_explicit_interrupt_is_preserved(self): + testScript = self._make_script() + manager = self._make_settings_manager() + + with ( + mock.patch.object(default.cthulhu.cthulhuApp, "settingsManager", manager), + mock.patch.object(default.speech, "speak") as speak, + ): + default.Script.presentMessage(testScript, "Status", interrupt=True) + + speak.assert_called_once_with("Status", "system", True) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_speechdispatcher_interrupt_regressions.py b/tests/test_speechdispatcher_interrupt_regressions.py index 786a6a1..0f23a45 100644 --- a/tests/test_speechdispatcher_interrupt_regressions.py +++ b/tests/test_speechdispatcher_interrupt_regressions.py @@ -30,6 +30,14 @@ class SpeechDispatcherInterruptRegressionTests(unittest.TestCase): server._cancel.assert_called_once_with() server._speak.assert_called_once_with("long utterance", None) + def test_string_speech_queues_by_default(self): + server = self._make_server() + + server.speak("next") + + server._cancel.assert_not_called() + server._speak.assert_called_once_with("next", None) + def test_recent_key_echo_suppresses_backend_cancel(self): server = self._make_server() server._lastKeyEchoTime = time.time() diff --git a/tests/test_structural_navigation_table_regressions.py b/tests/test_structural_navigation_table_regressions.py index 2487378..de49dd3 100644 --- a/tests/test_structural_navigation_table_regressions.py +++ b/tests/test_structural_navigation_table_regressions.py @@ -38,8 +38,7 @@ class StructuralNavigationTableRegressionTests(unittest.TestCase): navigator._presentObject.assert_called_once_with("cell", 0) navigator._script.presentMessage.assert_called_once_with( - messages.TABLE_CELL_COORDINATES % {"row": 2, "column": 3}, - interrupt=False, + messages.TABLE_CELL_COORDINATES % {"row": 2, "column": 3} ) def test_wrapping_announcement_does_not_interrupt_wrapped_object(self): @@ -78,7 +77,7 @@ class StructuralNavigationTableRegressionTests(unittest.TestCase): events, [ ("present", first, "arg"), - ("message", messages.WRAPPING_TO_TOP, {"interrupt": False}), + ("message", messages.WRAPPING_TO_TOP, {}), ], ) diff --git a/tests/test_web_input_regressions.py b/tests/test_web_input_regressions.py index 287f0cc..00bbf84 100644 --- a/tests/test_web_input_regressions.py +++ b/tests/test_web_input_regressions.py @@ -182,7 +182,7 @@ class WebPresentationModeSpeechRegressionTests(unittest.TestCase): ): web_script.Script.togglePresentationMode(testScript, None, "document") - testScript.presentMessage.assert_called_once_with(messages.MODE_BROWSE, interrupt=False) + testScript.presentMessage.assert_called_once_with(messages.MODE_BROWSE) soundManager.playBrowseModeSound.assert_called_once_with() self.assertFalse(testScript._inFocusMode) self.assertFalse(testScript._focusModeIsSticky) @@ -200,7 +200,7 @@ class WebPresentationModeSpeechRegressionTests(unittest.TestCase): ): web_script.Script.togglePresentationMode(testScript, None, "document") - testScript.presentMessage.assert_called_once_with(messages.MODE_FOCUS, interrupt=False) + testScript.presentMessage.assert_called_once_with(messages.MODE_FOCUS) soundManager.playFocusModeSound.assert_called_once_with() self.assertTrue(testScript._inFocusMode) self.assertFalse(testScript._focusModeIsSticky) @@ -311,7 +311,7 @@ class WebDescriptionChangeRegressionTests(unittest.TestCase): result = web_script.Script.onDescriptionChanged(testScript, event) self.assertTrue(result) - testScript.presentMessage.assert_called_once_with("Helpful tooltip", interrupt=False) + testScript.presentMessage.assert_called_once_with("Helpful tooltip") if __name__ == "__main__": @@ -378,7 +378,7 @@ class WebSelectionRegressionTests(unittest.TestCase): testScript.utilities.setCaretContext.assert_called_once_with(section, 1, document) setLocusOfFocus.assert_called_once_with(event, section, False, True) testScript.speakContents.assert_called_once_with([[section, 0, 1, "D"]]) - testScript.speakMessage.assert_called_once_with(messages.TEXT_SELECTED, interrupt=False) + testScript.speakMessage.assert_called_once_with(messages.TEXT_SELECTED) self.assertEqual( testScript.pointOfReference["syntheticWebSelection"]["string"], "D", @@ -414,7 +414,7 @@ class WebSelectionRegressionTests(unittest.TestCase): testScript.utilities.setCaretContext.assert_called_once_with(link, 0, document) setLocusOfFocus.assert_called_once_with(event, link, False, True) testScript.speakContents.assert_called_once_with([[link, 0, 1, "S"]]) - testScript.speakMessage.assert_called_once_with(messages.TEXT_SELECTED, interrupt=False) + testScript.speakMessage.assert_called_once_with(messages.TEXT_SELECTED) self.assertEqual( testScript.pointOfReference["syntheticWebSelection"]["string"], "S",