From 49815bcefecd9f245da9c2cfe50879ed878fd3d2 Mon Sep 17 00:00:00 2001 From: Leonard de Ruijter Date: Wed, 29 Jul 2026 14:10:54 +0200 Subject: [PATCH 1/3] Keep remote speech active across config profile switches SynthDetector swaps the remote synth into synthDriverHandler._curSynth without touching config.conf["speech"]["synth"], but NVDA's synthDriverHandler.handlePostConfigProfileSwitch reloads the configured synthesizer whenever it does not match the active one. Every profile switch therefore terminated the remote synth, and nothing brought it back: the rescan on focus change bails out unless remote speech is the configured synthesizer. While a SynthDetector is alive, take NVDA's handler over, both on config.post_configProfileSwitch and as the module attribute, so the calls from speech.manager are covered too, and skip the reload while remote speech is active without being configured. The original handler and registration are put back on terminate. Also broaden the wording of the setting that governs this, as it now covers more than recovery after a connection loss. Co-Authored-By: Claude Opus 5 (1M context) --- addon/globalPlugins/rdAccess/settingsPanel.py | 6 +-- addon/globalPlugins/rdAccess/synthDetect.py | 45 +++++++++++++++++++ readme.md | 5 ++- 3 files changed, 51 insertions(+), 5 deletions(-) diff --git a/addon/globalPlugins/rdAccess/settingsPanel.py b/addon/globalPlugins/rdAccess/settingsPanel.py index c009f61..1df1aa4 100644 --- a/addon/globalPlugins/rdAccess/settingsPanel.py +++ b/addon/globalPlugins/rdAccess/settingsPanel.py @@ -58,9 +58,9 @@ def makeSettings(self, sizer: wx.BoxSizer): serverGroup = guiHelper.BoxSizerHelper(self, sizer=serverGroupSizer) # ty: ignore[invalid-argument-type] sizer_helper.addItem(serverGroup) - # Translators: The label for a setting in RDAccess settings to enable - # automatic recovery of remote speech when the connection was lost. - recoverRemoteSpeechText = _("&Automatically recover remote speech after connection loss") + # Translators: The label for a setting in RDAccess settings to let NVDA + # activate remote speech whenever a remote session offers it. + recoverRemoteSpeechText = _("&Automatically switch to remote speech when available") self.recoverRemoteSpeechCheckbox = serverGroup.addItem( wx.CheckBox(serverGroupBox, label=recoverRemoteSpeechText), ) diff --git a/addon/globalPlugins/rdAccess/synthDetect.py b/addon/globalPlugins/rdAccess/synthDetect.py index 468db3c..6a6acb4 100644 --- a/addon/globalPlugins/rdAccess/synthDetect.py +++ b/addon/globalPlugins/rdAccess/synthDetect.py @@ -4,6 +4,7 @@ import threading import typing +from collections.abc import Callable from concurrent.futures import Future, ThreadPoolExecutor import addonHandler @@ -25,11 +26,15 @@ class SynthDetector(AutoPropertyObject): + #: NVDA's own synthesizer profile switch handler, while replaced by ours. + _nvdaHandlePostConfigProfileSwitch: Callable[[bool], None] | None = None + def __init__(self): remoteSynthDriver.synthRemoteDisconnected.register(self._handleRemoteDisconnect) self._executor = ThreadPoolExecutor(1, thread_name_prefix=self.__class__.__name__) self._queuedFuture: Future | None = None self._stopEvent = threading.Event() + self._takeOverPostConfigProfileSwitch() currentSynthesizer: synthDriverHandler.SynthDriver @@ -54,6 +59,45 @@ def _get_isRemoteSynthConfigured(self): assert config.conf is not None return config.conf[remoteSynthDriver._configSection]["synth"] == remoteSynthDriver.name + def _takeOverPostConfigProfileSwitch(self): + """Puts our own handler in NVDA's place, both on L{config.post_configProfileSwitch} + and as L{synthDriverHandler.handlePostConfigProfileSwitch}, + at the start of the registration order. + """ + if self._nvdaHandlePostConfigProfileSwitch is not None: + return + original = synthDriverHandler.handlePostConfigProfileSwitch + if not config.post_configProfileSwitch.unregister(original): + log.debugWarning("NVDA's synthesizer profile switch handler was not registered") + self._nvdaHandlePostConfigProfileSwitch = original + handler = self._handlePostConfigProfileSwitch + config.post_configProfileSwitch.register(handler) + config.post_configProfileSwitch.moveToEnd(handler, last=False) + synthDriverHandler.handlePostConfigProfileSwitch = handler # ty: ignore[invalid-assignment] + + def _restorePostConfigProfileSwitch(self): + """Reverses L{_takeOverPostConfigProfileSwitch}. + The module attribute is only restored when it still holds our handler. + """ + original = self._nvdaHandlePostConfigProfileSwitch + if original is None: + return + self._nvdaHandlePostConfigProfileSwitch = None + handler = self._handlePostConfigProfileSwitch + config.post_configProfileSwitch.unregister(handler) + config.post_configProfileSwitch.register(original) + config.post_configProfileSwitch.moveToEnd(original, last=False) + if synthDriverHandler.handlePostConfigProfileSwitch != handler: + return + synthDriverHandler.handlePostConfigProfileSwitch = original # ty: ignore[invalid-assignment] + + def _handlePostConfigProfileSwitch(self, resetSpeechIfNeeded: bool = True): + """Skips NVDA's synthesizer reload while remote speech is active without being configured.""" + if self.isRemoteSynthActive and not self.isRemoteSynthConfigured: + return + assert self._nvdaHandlePostConfigProfileSwitch is not None + self._nvdaHandlePostConfigProfileSwitch(resetSpeechIfNeeded) + def _handleRemoteDisconnect(self, synth: remoteSynthDriver): log.error(f"Handling remote disconnect for {synth!r}") queueHandler.queueFunction(queueHandler.eventQueue, self._fallback) @@ -112,6 +156,7 @@ def rescan(self, force: bool = False): self._queueBgScan(force) def terminate(self): + self._restorePostConfigProfileSwitch() remoteSynthDriver.synthRemoteDisconnected.unregister(self._handleRemoteDisconnect) self._stopBgScan() self._executor.shutdown(wait=False) diff --git a/readme.md b/readme.md index 4d7dee9..90a2552 100644 --- a/readme.md +++ b/readme.md @@ -109,10 +109,11 @@ Choose between: To ensure a smooth start with the add-on, all options are enabled by default. However, you are encouraged to disable server or client mode as appropriate. -### Automatically Recover Remote Speech after Connection Loss +### Automatically Switch to Remote Speech when Available This option is only available in server mode. -It ensures that the connection will automatically be re-established when the Remote Speech synthesizer is active and the connection is lost, similar to braille display auto-detection. +It ensures that Remote Speech is activated as soon as a remote desktop client offers it, similar to braille display auto-detection, and that the connection is automatically re-established when it is lost. +While Remote Speech is active this way, your configured synthesizer is left untouched, and switching configuration profiles no longer falls back to it. This option is enabled by default. It is strongly encouraged to leave this option enabled if the Remote Desktop server has no audio output. From 3523d6a38ffe47baaefa03e951cfccb7ac9a8849 Mon Sep 17 00:00:00 2001 From: Leonard de Ruijter Date: Wed, 29 Jul 2026 14:17:11 +0200 Subject: [PATCH 2/3] Document the changed behaviour as version 2.0.1 Co-Authored-By: Claude Opus 5 (1M context) --- addon/globalPlugins/rdAccess/synthDetect.py | 2 +- buildVars.py | 2 +- readme.md | 5 +++++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/addon/globalPlugins/rdAccess/synthDetect.py b/addon/globalPlugins/rdAccess/synthDetect.py index 6a6acb4..a0f8be5 100644 --- a/addon/globalPlugins/rdAccess/synthDetect.py +++ b/addon/globalPlugins/rdAccess/synthDetect.py @@ -26,8 +26,8 @@ class SynthDetector(AutoPropertyObject): - #: NVDA's own synthesizer profile switch handler, while replaced by ours. _nvdaHandlePostConfigProfileSwitch: Callable[[bool], None] | None = None + """NVDA's own synthesizer profile switch handler, while replaced by ours.""" def __init__(self): remoteSynthDriver.synthRemoteDisconnected.register(self._handleRemoteDisconnect) diff --git a/buildVars.py b/buildVars.py index 927d2e7..e01c732 100644 --- a/buildVars.py +++ b/buildVars.py @@ -30,7 +30,7 @@ + "Citrix Workspace, Parallels RAS and VMware Horizon", ), # version - addon_version="2.0.0", + addon_version="2.0.1", # Brief changelog for this version # Translators: what's new content for the add-on version to be shown in the add-on store addon_changelog=_(""), diff --git a/readme.md b/readme.md index 90a2552..417e484 100644 --- a/readme.md +++ b/readme.md @@ -22,6 +22,11 @@ This enables a user experience where managing a remote system feels as seamless ## Changelog +### Version 2.0.1 + +* When remote speech is switched on automatically, it now keeps speaking after a configuration profile switch. Previously, NVDA fell back to the synthesizer you have configured as soon as a profile was activated, for example when you moved to an application with its own profile. +* Renamed the option "Automatically recover remote speech after connection loss" to "Automatically switch to remote speech when available", which better describes what it does. + ### Version 2.0 * Speech and braille coming from the remote system are now presented sooner, which makes working in a remote session feel more responsive. From 1fe04d15f4b2f873405a2c018cf99db7bad2e6f8 Mon Sep 17 00:00:00 2001 From: Leonard de Ruijter <3049216+LeonarddeR@users.noreply.github.com> Date: Fri, 31 Jul 2026 08:13:55 +0200 Subject: [PATCH 3/3] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- addon/globalPlugins/rdAccess/synthDetect.py | 1 + 1 file changed, 1 insertion(+) diff --git a/addon/globalPlugins/rdAccess/synthDetect.py b/addon/globalPlugins/rdAccess/synthDetect.py index a0f8be5..0b98504 100644 --- a/addon/globalPlugins/rdAccess/synthDetect.py +++ b/addon/globalPlugins/rdAccess/synthDetect.py @@ -69,6 +69,7 @@ def _takeOverPostConfigProfileSwitch(self): original = synthDriverHandler.handlePostConfigProfileSwitch if not config.post_configProfileSwitch.unregister(original): log.debugWarning("NVDA's synthesizer profile switch handler was not registered") + return self._nvdaHandlePostConfigProfileSwitch = original handler = self._handlePostConfigProfileSwitch config.post_configProfileSwitch.register(handler)