Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 50 additions & 11 deletions source/synthDrivers/sapi5.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
ISpNotifySink,
ISpVoice,
ISpeechVoice,
SpObjectToken,
SPAUDIOSTATE,
SPEVENT,
WAVEFORMATEX,
Expand Down Expand Up @@ -552,6 +553,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}"
Comment thread
michaelDCurran marked this conversation as resolved.

name = "sapi5"
# Translators: Description for a speech synthesizer.
Expand Down Expand Up @@ -632,12 +636,52 @@ 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

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)
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}"
Comment thread
michaelDCurran marked this conversation as resolved.
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(f"Could not create voice token for {tokenId}. Skipping...", exc_info=True)
continue
tokens.append(token)
return tokens

def _get_rate(self):
return self._rate
Expand Down Expand Up @@ -762,15 +806,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.
Expand Down
1 change: 1 addition & 0 deletions user_docs/en/changes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading