From 910d7e119c96e0650d98196aa690163f49e6f829 Mon Sep 17 00:00:00 2001 From: Michael Curran Date: Wed, 1 Jul 2026 10:40:01 +1000 Subject: [PATCH 1/6] sapi5: manually enumerate voices directly from the registry rather than just calling getVoices, so that invalid voices can be skipped without causing the entire driver to fail. Also setting a voice from an ID creates the token directly, rather than needing to enumerate all tokens. --- source/synthDrivers/sapi5.py | 60 +++++++++++++++++++++++++++++------- 1 file changed, 49 insertions(+), 11 deletions(-) diff --git a/source/synthDrivers/sapi5.py b/source/synthDrivers/sapi5.py index d13c2423b22..a3c8660be3e 100644 --- a/source/synthDrivers/sapi5.py +++ b/source/synthDrivers/sapi5.py @@ -552,6 +552,9 @@ class SynthDriver(SynthDriver): COM_CLASS = "SAPI.SPVoice" CUSTOMSTREAM_COM_CLASS = "SAPI.SpCustomStream" + OBJECT_TOKEN_COM_CLASS = "SAPI.SpObjectToken" + VOICE_TOKEN_REGISTRY_PATH = r"SOFTWARE\Microsoft\Speech\Voices\Tokens" + VOICE_TOKEN_ID_PREFIX = f"HKEY_LOCAL_MACHINE\\{VOICE_TOKEN_REGISTRY_PATH}" name = "sapi5" # Translators: Description for a speech synthesizer. @@ -635,9 +638,49 @@ def _getAvailableVoices(self): voices[ID] = VoiceInfo(ID, name, language) return voices + def _createVoiceToken(self, tokenId: str): + """Create a SAPI object token from a token ID. + """ + token = comtypes.client.CreateObject(self.OBJECT_TOKEN_COM_CLASS) + token.SetId(tokenId, "", False) + return token + + def _getVoiceTokenIds(self) -> Generator[str, None, None]: + """Provides SAPI 5 voice token IDs by reading the registry directly. + + Using SAPI's token enumerator can fail the entire voice list when a single + installed voice has a malformed token. Reading the registry allows each + token to be created and validated independently. + """ + try: + with winreg.OpenKeyEx( + winreg.HKEY_LOCAL_MACHINE, + self.VOICE_TOKEN_REGISTRY_PATH, + 0, + winreg.KEY_READ | winreg.KEY_WOW64_64KEY, + ) as tokensKey: + index = 0 + while True: + try: + tokenName = winreg.EnumKey(tokensKey, index) + except OSError: + break + index += 1 + yield rf"{self.VOICE_TOKEN_ID_PREFIX}\{tokenName}" + except OSError: + log.warning("Could not open SAPI 5 voice token registry key", exc_info=True) + def _getVoiceTokens(self): - """Provides a collection of sapi5 voice tokens. Can be overridden by subclasses if tokens should be looked for in some other registry location.""" - return self.tts.GetVoices() + """Provides sapi5 voice tokens. Can be overridden by subclasses if tokens should be looked for in some other registry location.""" + tokens = [] + for tokenId in self._getVoiceTokenIds(): + try: + token = self._createVoiceToken(tokenId) + except COMError: + log.warning("Could not create voice token for %s. Skipping...", tokenId, exc_info=True) + continue + tokens.append(token) + return tokens def _get_rate(self): return self._rate @@ -762,15 +805,10 @@ def _initTts(self, voice: str | None = None): notifySource.SetNotifySink(SapiSink(weakref.ref(self))) def _set_voice(self, value): - tokens = self._getVoiceTokens() - # #2629: Iterating uses IEnumVARIANT and GetBestInterface doesn't work on tokens returned by some token enumerators. - # Therefore, fetch the items by index, as that method explicitly returns the correct interface. - for i in range(len(tokens)): - voice = tokens[i] - if value == voice.Id: - break - else: - # Voice not found. + try: + voice = self._createVoiceToken(value) + except COMError: + # Could not create a voice. return self._initTts(voice=voice) # As _initTts resets the voice parameters on the tts object, set them back to current values. From 27e716a5861fae29bea530d0e2a001bd08e76ba6 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 00:56:19 +0000 Subject: [PATCH 2/6] Pre-commit auto-fix --- source/synthDrivers/sapi5.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/source/synthDrivers/sapi5.py b/source/synthDrivers/sapi5.py index a3c8660be3e..cd82a08062c 100644 --- a/source/synthDrivers/sapi5.py +++ b/source/synthDrivers/sapi5.py @@ -639,8 +639,7 @@ def _getAvailableVoices(self): return voices def _createVoiceToken(self, tokenId: str): - """Create a SAPI object token from a token ID. - """ + """Create a SAPI object token from a token ID.""" token = comtypes.client.CreateObject(self.OBJECT_TOKEN_COM_CLASS) token.SetId(tokenId, "", False) return token From 3d8044ca9548c4cc485c7ed738d8e133bbd35d4e Mon Sep 17 00:00:00 2001 From: Michael Curran Date: Wed, 1 Jul 2026 11:05:57 +1000 Subject: [PATCH 3/6] sapi5's getAvailableVoices: don't add voices with broken properties to the voice list. --- source/synthDrivers/sapi5.py | 1 + 1 file changed, 1 insertion(+) diff --git a/source/synthDrivers/sapi5.py b/source/synthDrivers/sapi5.py index cd82a08062c..793cd993537 100644 --- a/source/synthDrivers/sapi5.py +++ b/source/synthDrivers/sapi5.py @@ -635,6 +635,7 @@ def _getAvailableVoices(self): language = None except COMError: log.warning("Could not get the voice info. Skipping...") + continue voices[ID] = VoiceInfo(ID, name, language) return voices From 9fd6e7d2fa63a9544af3703ccc7efbb92c03be4c Mon Sep 17 00:00:00 2001 From: Michael Curran Date: Fri, 3 Jul 2026 07:14:15 +1000 Subject: [PATCH 4/6] Update source/synthDrivers/sapi5.py Co-authored-by: Sean Budd --- source/synthDrivers/sapi5.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/synthDrivers/sapi5.py b/source/synthDrivers/sapi5.py index 793cd993537..9b924e7fb2e 100644 --- a/source/synthDrivers/sapi5.py +++ b/source/synthDrivers/sapi5.py @@ -677,7 +677,7 @@ def _getVoiceTokens(self): try: token = self._createVoiceToken(tokenId) except COMError: - log.warning("Could not create voice token for %s. Skipping...", tokenId, exc_info=True) + log.warning(f"Could not create voice token for {tokenId}. Skipping...", exc_info=True) continue tokens.append(token) return tokens From 60e2624c564f878a70b2d77eeadc770c8ef1d50f Mon Sep 17 00:00:00 2001 From: Michael Curran Date: Fri, 3 Jul 2026 07:25:18 +1000 Subject: [PATCH 5/6] sapi5: add return type --- source/synthDrivers/sapi5.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/source/synthDrivers/sapi5.py b/source/synthDrivers/sapi5.py index 9b924e7fb2e..923d0d6705c 100644 --- a/source/synthDrivers/sapi5.py +++ b/source/synthDrivers/sapi5.py @@ -36,6 +36,7 @@ ISpNotifySink, ISpVoice, ISpeechVoice, + SpObjectToken, SPAUDIOSTATE, SPEVENT, WAVEFORMATEX, @@ -639,7 +640,7 @@ def _getAvailableVoices(self): voices[ID] = VoiceInfo(ID, name, language) return voices - def _createVoiceToken(self, tokenId: str): + def _createVoiceToken(self, tokenId: str) -> SpObjectToken: """Create a SAPI object token from a token ID.""" token = comtypes.client.CreateObject(self.OBJECT_TOKEN_COM_CLASS) token.SetId(tokenId, "", False) From 6079105b4d1faef20b16b082be6cf47b2fa61234 Mon Sep 17 00:00:00 2001 From: Michael Curran Date: Tue, 7 Jul 2026 07:26:59 +1000 Subject: [PATCH 6/6] Update what'sn new --- user_docs/en/changes.md | 1 + 1 file changed, 1 insertion(+) diff --git a/user_docs/en/changes.md b/user_docs/en/changes.md index 99b075d7ff7..c99457c6c95 100644 --- a/user_docs/en/changes.md +++ b/user_docs/en/changes.md @@ -102,6 +102,7 @@ The setting is disabled by default. (#20013, @LeonarddeR) * In particular, Whole word entries no longer incorrectly match inside larger words when those words contain combining marks. * Fixed a case which could cause NVDA to freeze while reading math in braille. (#20319, @AAClause) * NVDA no longer fails to load sapi4 voices that do not support pitch, rate or volume. (#20302) +* The SAPI5 synthesizer driver no longer completely fails to load if one of the voices is invalid or corrupt. (#20128) ### Changes for Developers