From 3350f62c6ec91454387b8582eab41635d41c938a Mon Sep 17 00:00:00 2001 From: Leonard de Ruijter Date: Tue, 28 Jul 2026 09:19:50 +0200 Subject: [PATCH 1/2] Route incoming remote speech through the local SpeechManager instead of offsetting indexes Remote IndexCommands are converted to callback commands (RemoteIndexCallbackCommand) that report the raw remote index when speech reaches them, with index 0 appended as a done speaking sentinel. The SpeechManager owns all synth index allocation, so the reserved offset range (which exceeded the MAX_INDEX synth contract, stacked in chained sessions and collided between concurrent sessions) is gone, along with the handler's synthIndexReached/synthDoneSpeaking registrations and index bookkeeping. Server CANCEL now cancels at manager level, and a speechCanceled handler sends a done speaking message when local cancellation drops pending callbacks. The wire format is unchanged. Unit tests now import the real speech.commands from the sibling NVDA checkout, and new integration tests drive the real SpeechManager with converted sequences. Co-Authored-By: Claude Fable 5 --- .../rdAccess/handlers/remoteSpeechHandler.py | 57 +++----- addon/lib/protocol/speech.py | 43 +++++- tests/_stubs.py | 127 ++++++++---------- tests/test_serializer.py | 4 +- tests/test_serializerConformance.py | 9 +- tests/test_speechIndexCallbacks.py | 69 ++++++++++ tests/test_speechManagerIntegration.py | 93 +++++++++++++ 7 files changed, 289 insertions(+), 113 deletions(-) create mode 100644 tests/test_speechIndexCallbacks.py create mode 100644 tests/test_speechManagerIntegration.py diff --git a/addon/globalPlugins/rdAccess/handlers/remoteSpeechHandler.py b/addon/globalPlugins/rdAccess/handlers/remoteSpeechHandler.py index cedf326..6d13e57 100644 --- a/addon/globalPlugins/rdAccess/handlers/remoteSpeechHandler.py +++ b/addon/globalPlugins/rdAccess/handlers/remoteSpeechHandler.py @@ -10,7 +10,9 @@ import tones from hwIo.ioThread import IoThread from logHandler import log -from speech.commands import IndexCommand, PitchCommand +from speech.commands import PitchCommand +from speech.extensions import speechCanceled +from speech.priorities import Spri from speech.types import SpeechSequence from ._remoteHandler import RemoteHandler @@ -29,16 +31,13 @@ class RemoteSpeechHandler(RemoteHandler[synthDriverHandler.SynthDriver]): driverType = protocol.DriverType.SPEECH def __init__(self, ioThread: IoThread, pipeName: str): - self._indexesSpeaking = [] super().__init__(ioThread, pipeName) - synthDriverHandler.synthIndexReached.register(self._onSynthIndexReached) - synthDriverHandler.synthDoneSpeaking.register(self._onSynthDoneSpeaking) + speechCanceled.register(self._onSpeechCanceled) synthDriverHandler.synthChanged.register(self._handleDriverChanged) def terminate(self): synthDriverHandler.synthChanged.unregister(self._handleDriverChanged) - synthDriverHandler.synthDoneSpeaking.unregister(self._onSynthDoneSpeaking) - synthDriverHandler.synthIndexReached.unregister(self._onSynthIndexReached) + speechCanceled.unregister(self._onSpeechCanceled) super().terminate() def _get__driver(self): @@ -66,24 +65,27 @@ def _speak(self, sequence: SpeechSequence): pitchChange = configuration.getIncomingSpeechPitchChange(fromCache=True) if pitchChange != 0 and PitchCommand in self._driver.supportedCommands: sequence = [PitchCommand(offset=pitchChange), *sequence, PitchCommand()] - for item in sequence: - if isinstance(item, IndexCommand): - item.index += protocol.speech.SPEECH_INDEX_OFFSET - self._indexesSpeaking.append(item.index) - # Send speech to the current synth directly because we don't want unnecessary processing to happen. - # We need to change speech state accordingly. + sequence = protocol.speech.remapIndexesToCallbacks(sequence, self._sendIndex) assert speech.speech._speechState is not None speech.speech._speechState.isPaused = False speech.speech._speechState.beenCanceled = False - self._driver.speak(sequence) + speech.speech._manager.speak(sequence, priority=Spri.NORMAL) + + def _sendIndex(self, index: int): + try: + self.sendMessage(protocol.RdMessageType.INDEX, index=index) + except OSError: + log.warning("Error sending index", exc_info=True) + + def _onSpeechCanceled(self): + self._sendIndex(0) @protocol.commandHandler(protocol.RdMessageType.CANCEL) def _command_cancel(self): - self._indexesSpeaking.clear() self._queueFunctionOnMainThread(self._cancel, _immediate=True) def _cancel(self): - self._driver.cancel() + speech.speech._manager.cancel() assert speech.speech._speechState is not None speech.speech._speechState.beenCanceled = True speech.speech._speechState.isPaused = False @@ -108,31 +110,8 @@ def _command_playWaveFile(self, **kwargs): kwargs["asynchronous"] = True nvwave.playWaveFile(**kwargs) - def _onSynthIndexReached( - self, - synth: synthDriverHandler.SynthDriver | None = None, - index: int | None = None, - ): - assert synth == self._driver - if index in self._indexesSpeaking: - subtractedIndex = index - protocol.speech.SPEECH_INDEX_OFFSET - try: - self.sendMessage(protocol.RdMessageType.INDEX, index=subtractedIndex) - except OSError: - log.warning("Error calling _onSynthIndexReached", exc_info=True) - self._indexesSpeaking.remove(index) - - def _onSynthDoneSpeaking(self, synth: synthDriverHandler.SynthDriver | None = None): - assert synth == self._driver - if len(self._indexesSpeaking) > 0: - self._indexesSpeaking.clear() - try: - self.sendMessage(protocol.RdMessageType.INDEX, index=0) - except OSError: - log.warning("Error calling _onSynthDoneSpeaking", exc_info=True) - def _handleDriverChanged(self, synth: synthDriverHandler.SynthDriver): - self._indexesSpeaking.clear() + self._sendIndex(0) super()._handleDriverChanged(synth) self._attributeSenderStore( protocol.SpeechAttribute.SUPPORTED_COMMANDS, diff --git a/addon/lib/protocol/speech.py b/addon/lib/protocol/speech.py index d33a2ec..e6c5e7b 100644 --- a/addon/lib/protocol/speech.py +++ b/addon/lib/protocol/speech.py @@ -2,11 +2,17 @@ # Copyright 2023 Leonard de Ruijter # License: GNU General Public License version 2.0 or later +from __future__ import annotations + +import typing from enum import IntEnum, StrEnum -import speech.manager +from speech.commands import BaseCallbackCommand, IndexCommand + +if typing.TYPE_CHECKING: + from collections.abc import Callable -SPEECH_INDEX_OFFSET = speech.manager.SpeechManager.MAX_INDEX + 1 + from speech.types import SpeechSequence class SpeechCommand(IntEnum): @@ -21,3 +27,36 @@ class SpeechCommand(IntEnum): class SpeechAttribute(StrEnum): SUPPORTED_COMMANDS = "supportedCommands" LANGUAGE = "language" + + +class RemoteIndexCallbackCommand(BaseCallbackCommand): + """Calls ``onIndexReached`` with a remote index when speech reaches this command. + + Index 0 doubles as the done speaking sentinel. + """ + + def __init__(self, index: int, onIndexReached: Callable[[int], None]): + self.index = index + self._onIndexReached = onIndexReached + + def run(self): + self._onIndexReached(self.index) + + def __repr__(self): + return f"RemoteIndexCallbackCommand({self.index!r})" + + +def remapIndexesToCallbacks( + sequence: SpeechSequence, + onIndexReached: Callable[[int], None], +) -> SpeechSequence: + """Return a new sequence with every IndexCommand replaced by a + :class:`RemoteIndexCallbackCommand` and a done speaking sentinel (index 0) appended. + """ + return [ + *( + RemoteIndexCallbackCommand(item.index, onIndexReached) if isinstance(item, IndexCommand) else item + for item in sequence + ), + RemoteIndexCallbackCommand(0, onIndexReached), + ] diff --git a/tests/_stubs.py b/tests/_stubs.py index 0252626..e7cbe29 100644 --- a/tests/_stubs.py +++ b/tests/_stubs.py @@ -4,9 +4,10 @@ """Stand-ins for NVDA runtime modules, installed into ``sys.modules``. -Only leaf modules are stubbed. ``baseObject``, ``extensionPoints``, ``winKernel`` and the -``hwIo`` submodules are imported for real from the sibling NVDA source checkout; their -dependencies (``logHandler``, ``garbageHandler``, ``NVDAState``, ``config``) are covered here. +Only leaf modules are stubbed. ``baseObject``, ``extensionPoints``, ``winKernel``, the +``hwIo`` submodules and ``speech.commands`` are imported for real from the sibling NVDA source +checkout; their dependencies (``logHandler``, ``garbageHandler``, ``NVDAState``, ``config``, +``synthDriverHandler.getSynth``) are covered here. """ from __future__ import annotations @@ -17,13 +18,21 @@ from pathlib import Path from typing import Any +_NVDA_SOURCE = Path(__file__).resolve().parent.parent.parent / "nvda" / "source" + class FakeLogger: """Collects log records so tests can optionally assert on them.""" + DEBUG = 10 + INFO = 20 + def __init__(self): self.records: list[tuple[str, str]] = [] + def isEnabledFor(self, level: int) -> bool: + return False + def _log(self, level: str, msg: Any, *args: Any, **kwargs: Any): self.records.append((level, str(msg))) @@ -69,7 +78,10 @@ def getFormattedStacksForAllThreads() -> str: logHandler.getFormattedStacksForAllThreads = getFormattedStacksForAllThreads config = _module("config") - config.conf = {"debugLog": {"hwIo": False}} + config.conf = { + "debugLog": {"hwIo": False, "speechManager": False}, + "featureFlag": {"cancelExpiredFocusSpeech": 0}, + } garbageHandler = _module("garbageHandler") @@ -165,16 +177,8 @@ class BrailleInputGesture: brailleInput.BrailleInputGesture = BrailleInputGesture speech = _module("speech") - speechManager = _module("speech.manager") - speech.manager = speechManager - - class SpeechManager: - MAX_INDEX = 9999 - - speechManager.SpeechManager = SpeechManager - - _installSpeechCommandsStub(speech) _installDriverSettingAndSynthVoiceStubs() + _installSpeechCommands(speech) _installInputCoreStub() @@ -186,9 +190,8 @@ def _installHwIo() -> None: directory. Submodule imports then resolve against the real sources without that import ever running. """ - hwIoPath = Path(__file__).resolve().parent.parent.parent / "nvda" / "source" / "hwIo" hwIo = _module("hwIo") - hwIo.__path__ = [str(hwIoPath)] + hwIo.__path__ = [str(_NVDA_SOURCE / "hwIo")] hwIo.base = importlib.import_module("hwIo.base") hwIo.ioThread = importlib.import_module("hwIo.ioThread") @@ -205,66 +208,35 @@ def _setStubIdentity(cls: type, module: str) -> None: cls.__qualname__ = cls.__name__ -def _installSpeechCommandsStub(speech: types.ModuleType) -> None: - speechCommands = _module("speech.commands") - speech.commands = speechCommands - - class SpeechCommand: - pass - - class SynthCommand(SpeechCommand): - pass +def _installSpeechCommands(speech: types.ModuleType) -> None: + """Make the real ``speech.commands`` (and ``speech.manager`` and its dependencies) importable + from the NVDA checkout. - class IndexCommand(SynthCommand): - def __init__(self, index: int): - self.index = index - - def __eq__(self, other: Any) -> bool: - return type(self) is type(other) and self.index == other.index - - class SynthParamCommand(SynthCommand): - pass - - class BaseProsodyCommand(SynthParamCommand): - pass + ``speech/__init__.py`` pulls in the full speech subsystem, so like ``hwIo`` the package is + registered with ``__path__`` pointing straight at the real sources. ``speech.commands`` + itself only needs ``config`` and ``synthDriverHandler.getSynth``, both installed above. + ``speech.languageHandling`` pulls in the full ``speech.speech`` module, so the one function + ``speech.manager`` needs from it is stubbed instead. + """ + speech.__path__ = [str(_NVDA_SOURCE / "speech")] + speechCommands = importlib.import_module("speech.commands") + speech.commands = speechCommands - class PitchCommand(BaseProsodyCommand): - def __init__(self, offset: int = 0): - self.offset = offset + class NotASpeechCommand: + """Injected into speech.commands without being a SpeechCommand subclass; used to test + that the dynamic find_class rule for speech.commands rejects it. + """ - def __eq__(self, other: Any) -> bool: - return type(self) is type(other) and self.offset == other.offset + _setStubIdentity(NotASpeechCommand, "speech.commands") + speechCommands.NotASpeechCommand = NotASpeechCommand - class BreakCommand(SynthCommand): - def __init__(self, time: int = 0): - self.time = time + speechLanguageHandling = _module("speech.languageHandling") - def __eq__(self, other: Any) -> bool: - return type(self) is type(other) and self.time == other.time + def shouldSwitchVoice() -> bool: + return True - class EndUtteranceCommand(SpeechCommand): - def __eq__(self, other: Any) -> bool: - return type(self) is type(other) - - class NotASpeechCommand: - """Exists in speech.commands but is not a SpeechCommand subclass; used to test that the - dynamic find_class rule for speech.commands rejects it. - """ - - stubClasses = ( - SpeechCommand, - SynthCommand, - IndexCommand, - SynthParamCommand, - BaseProsodyCommand, - PitchCommand, - BreakCommand, - EndUtteranceCommand, - NotASpeechCommand, - ) - for cls in stubClasses: - _setStubIdentity(cls, "speech.commands") - setattr(speechCommands, cls.__name__, cls) + speechLanguageHandling.shouldSwitchVoice = shouldSwitchVoice + speech.languageHandling = speechLanguageHandling def _installDriverSettingAndSynthVoiceStubs() -> None: @@ -316,7 +288,24 @@ class BooleanDriverSetting(DriverSetting): autoSettingsDriverSetting.NumericDriverSetting = NumericDriverSetting autoSettingsDriverSetting.BooleanDriverSetting = BooleanDriverSetting + from extensionPoints import Action + synthDriverHandler = _module("synthDriverHandler") + synthDriverHandler._currentSynth = None + + def getSynth() -> Any: + return synthDriverHandler._currentSynth + + synthDriverHandler.getSynth = getSynth + synthDriverHandler.synthIndexReached = Action() + synthDriverHandler.synthDoneSpeaking = Action() + synthDriverHandler.pre_synthSpeak = Action() + + class SynthDriver: + pass + + _setStubIdentity(SynthDriver, "synthDriverHandler") + synthDriverHandler.SynthDriver = SynthDriver class VoiceInfo(StringParameterInfo): def __init__(self, id: str, displayName: str, language: str | None = None): diff --git a/tests/test_serializer.py b/tests/test_serializer.py index cde13d2..4dba4a5 100644 --- a/tests/test_serializer.py +++ b/tests/test_serializer.py @@ -46,7 +46,9 @@ def test_roundTrip(self): speech.commands.EndUtteranceCommand(), ] obj = self.roundTrip(RdMessageType.SPEAK, sequence=sequence) - self.assertEqual(obj["sequence"], sequence) + # EndUtteranceCommand defines no __eq__, so compare it by type. + self.assertEqual(obj["sequence"][:-1], sequence[:-1]) + self.assertIs(type(obj["sequence"][-1]), speech.commands.EndUtteranceCommand) def test_unknownClassSkipped(self): data = b'{"sequence": ["hi", ["NoSuchCommand", {}]], "type": "speak"}\n' diff --git a/tests/test_serializerConformance.py b/tests/test_serializerConformance.py index 505be01..80f12eb 100644 --- a/tests/test_serializerConformance.py +++ b/tests/test_serializerConformance.py @@ -98,9 +98,14 @@ def test_serializeByteEquality(self): ), ) + def assertSequenceRoundTrips(self, decoded: list, sequence: list): + # EndUtteranceCommand defines no __eq__, so compare it by type. + self.assertEqual(decoded[:-1], sequence[:-1]) + self.assertIs(type(decoded[-1]), speech.commands.EndUtteranceCommand) + def test_crossDeserializeSpeak(self): sequence = self._sampleSequence() fromTheirs = self.ours.deserialize(self.theirs.serialize(type="speak", sequence=sequence)) - self.assertEqual(fromTheirs["sequence"], sequence) + self.assertSequenceRoundTrips(fromTheirs["sequence"], sequence) fromOurs = self.theirs.deserialize(self.ours.serialize(type="speak", sequence=sequence)) - self.assertEqual(fromOurs["sequence"], sequence) + self.assertSequenceRoundTrips(fromOurs["sequence"], sequence) diff --git a/tests/test_speechIndexCallbacks.py b/tests/test_speechIndexCallbacks.py new file mode 100644 index 0000000..cca6bf5 --- /dev/null +++ b/tests/test_speechIndexCallbacks.py @@ -0,0 +1,69 @@ +# RDAccess: Remote Desktop Accessibility for NVDA +# Copyright 2026 Leonard de Ruijter +# License: GNU General Public License version 2.0 or later + +"""Tests for the speech index callback conversion (``lib.protocol.speech``).""" + +from __future__ import annotations + +import unittest + +import speech.commands +from lib.protocol.speech import RemoteIndexCallbackCommand, remapIndexesToCallbacks + + +class RemapIndexesToCallbacksTests(unittest.TestCase): + def setUp(self): + self.reached: list[int] = [] + + def _onIndexReached(self, index: int): + self.reached.append(index) + + def test_indexCommandsReplacedInOrder(self): + sequence = [ + "Hello ", + speech.commands.IndexCommand(5), + "world", + speech.commands.IndexCommand(7), + ] + remapped = remapIndexesToCallbacks(sequence, self._onIndexReached) + self.assertIsInstance(remapped[1], RemoteIndexCallbackCommand) + self.assertEqual(remapped[1].index, 5) + self.assertIsInstance(remapped[3], RemoteIndexCallbackCommand) + self.assertEqual(remapped[3].index, 7) + + def test_nonIndexItemsPassThroughUntouched(self): + pitch = speech.commands.PitchCommand(offset=30) + sequence = ["Hello", pitch] + remapped = remapIndexesToCallbacks(sequence, self._onIndexReached) + self.assertIs(remapped[0], sequence[0]) + self.assertIs(remapped[1], pitch) + + def test_inputSequenceNotMutated(self): + index = speech.commands.IndexCommand(5) + sequence = ["Hello", index] + remapIndexesToCallbacks(sequence, self._onIndexReached) + self.assertEqual(len(sequence), 2) + self.assertIs(sequence[1], index) + self.assertEqual(index.index, 5) + + def test_doneSpeakingSentinelAppended(self): + remapped = remapIndexesToCallbacks(["Hello"], self._onIndexReached) + self.assertEqual(len(remapped), 2) + sentinel = remapped[-1] + self.assertIsInstance(sentinel, RemoteIndexCallbackCommand) + self.assertEqual(sentinel.index, 0) + + def test_runCallsOnIndexReachedWithOriginalIndex(self): + remapped = remapIndexesToCallbacks([speech.commands.IndexCommand(5)], self._onIndexReached) + for command in remapped: + command.run() + self.assertEqual(self.reached, [5, 0]) + + def test_isCallbackCommand(self): + command = RemoteIndexCallbackCommand(5, self._onIndexReached) + self.assertIsInstance(command, speech.commands.BaseCallbackCommand) + + def test_reprContainsIndex(self): + command = RemoteIndexCallbackCommand(5, self._onIndexReached) + self.assertIn("5", repr(command)) diff --git a/tests/test_speechManagerIntegration.py b/tests/test_speechManagerIntegration.py new file mode 100644 index 0000000..8b7848e --- /dev/null +++ b/tests/test_speechManagerIntegration.py @@ -0,0 +1,93 @@ +# RDAccess: Remote Desktop Accessibility for NVDA +# Copyright 2026 Leonard de Ruijter +# License: GNU General Public License version 2.0 or later + +"""Integration tests feeding converted remote sequences through the real NVDA SpeechManager. + +``speech.manager`` is imported for real from the sibling NVDA source checkout (see +:mod:`tests._stubs`), so these tests exercise the actual callback-to-index conversion, +index dispatch and cancel behavior the client relies on. +""" + +from __future__ import annotations + +import importlib +import unittest + +import queueHandler +import speech.commands +import synthDriverHandler +from lib.protocol.speech import remapIndexesToCallbacks + +speechManager = importlib.import_module("speech.manager") + +from speech.priorities import Spri # noqa: E402 + + +class FakeSynth: + def __init__(self): + self.spoken: list[list] = [] + self.cancelCount = 0 + + def speak(self, sequence: list): + self.spoken.append(sequence) + + def cancel(self): + self.cancelCount += 1 + + +class SpeechManagerIntegrationTests(unittest.TestCase): + def setUp(self): + self.reached: list[int] = [] + self.synth = FakeSynth() + synthDriverHandler._currentSynth = self.synth + self.manager = speechManager.SpeechManager() + + def tearDown(self): + synthDriverHandler._currentSynth = None + + def _speakRemapped(self, sequence: list) -> list: + """Speak a converted remote sequence and return the utterance pushed to the synth.""" + self.manager.speak(remapIndexesToCallbacks(sequence, self.reached.append), priority=Spri.NORMAL) + return self.synth.spoken[-1] + + def _synthIndexes(self, utterance: list) -> list[int]: + return [item.index for item in utterance if isinstance(item, speech.commands.IndexCommand)] + + def _reachIndex(self, index: int): + synthDriverHandler.synthIndexReached.notify(synth=self.synth, index=index) + queueHandler.pumpAll() + + def test_remoteIndexesReportedInOrderWithDoneSpeakingSentinel(self): + utterance = self._speakRemapped( + ["Hello", speech.commands.IndexCommand(5), "world", speech.commands.IndexCommand(7)], + ) + for index in self._synthIndexes(utterance): + self._reachIndex(index) + self.assertEqual(self.reached, [5, 7, 0]) + + def test_synthOnlyReceivesManagerAllocatedIndexes(self): + utterance = self._speakRemapped(["Hello", speech.commands.IndexCommand(5000)]) + indexes = self._synthIndexes(utterance) + self.assertNotIn(5000, indexes) + self.assertTrue(all(0 < index <= speechManager.SpeechManager.MAX_INDEX for index in indexes)) + + def test_cancelDropsPendingCallbacks(self): + utterance = self._speakRemapped( + ["Hello", speech.commands.IndexCommand(5), "world", speech.commands.IndexCommand(7)], + ) + indexes = self._synthIndexes(utterance) + self._reachIndex(indexes[0]) + self.manager.cancel() + for index in indexes[1:]: + self._reachIndex(index) + self.assertEqual(self.reached, [5]) + self.assertEqual(self.synth.cancelCount, 1) + + def test_speaksNormallyAfterCancel(self): + self._speakRemapped(["Hello", speech.commands.IndexCommand(5)]) + self.manager.cancel() + utterance = self._speakRemapped(["again", speech.commands.IndexCommand(6)]) + for index in self._synthIndexes(utterance): + self._reachIndex(index) + self.assertEqual(self.reached, [6, 0]) From cc05a1403409b188c7a9cc88742672df6e4afdb1 Mon Sep 17 00:00:00 2001 From: Leonard de Ruijter Date: Tue, 28 Jul 2026 09:25:04 +0200 Subject: [PATCH 2/2] Collapse the two done speaking sends into a single _notifyDoneSpeaking method speechCanceled registers it directly and _handleDriverChanged calls it; both triggers remain needed because setSynth cancels the synth without going through speech.cancelSpeech, so speechCanceled does not fire on a synth change. Co-Authored-By: Claude Fable 5 --- .../rdAccess/handlers/remoteSpeechHandler.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/addon/globalPlugins/rdAccess/handlers/remoteSpeechHandler.py b/addon/globalPlugins/rdAccess/handlers/remoteSpeechHandler.py index 6d13e57..4e50421 100644 --- a/addon/globalPlugins/rdAccess/handlers/remoteSpeechHandler.py +++ b/addon/globalPlugins/rdAccess/handlers/remoteSpeechHandler.py @@ -32,12 +32,12 @@ class RemoteSpeechHandler(RemoteHandler[synthDriverHandler.SynthDriver]): def __init__(self, ioThread: IoThread, pipeName: str): super().__init__(ioThread, pipeName) - speechCanceled.register(self._onSpeechCanceled) + speechCanceled.register(self._notifyDoneSpeaking) synthDriverHandler.synthChanged.register(self._handleDriverChanged) def terminate(self): synthDriverHandler.synthChanged.unregister(self._handleDriverChanged) - speechCanceled.unregister(self._onSpeechCanceled) + speechCanceled.unregister(self._notifyDoneSpeaking) super().terminate() def _get__driver(self): @@ -77,7 +77,7 @@ def _sendIndex(self, index: int): except OSError: log.warning("Error sending index", exc_info=True) - def _onSpeechCanceled(self): + def _notifyDoneSpeaking(self): self._sendIndex(0) @protocol.commandHandler(protocol.RdMessageType.CANCEL) @@ -111,7 +111,7 @@ def _command_playWaveFile(self, **kwargs): nvwave.playWaveFile(**kwargs) def _handleDriverChanged(self, synth: synthDriverHandler.SynthDriver): - self._sendIndex(0) + self._notifyDoneSpeaking() super()._handleDriverChanged(synth) self._attributeSenderStore( protocol.SpeechAttribute.SUPPORTED_COMMANDS,