77"""
88
99import os
10- import sys
10+ from typing import Any , Callable , Generator , List , Optional , Set , Tuple , Union
1111from collections import OrderedDict
1212import ctypes
1313import winreg
2525import config
2626import nvwave
2727import queueHandler
28+ from speech .types import SpeechSequence
2829import speech
2930import speechXml
3031import languageHandler
4748ocSpeech_Callback = ctypes .CFUNCTYPE (None , ctypes .c_void_p , ctypes .c_int , ctypes .c_wchar_p )
4849
4950class _OcSsmlConverter (speechXml .SsmlConverter ):
51+ def __init__ (
52+ self ,
53+ defaultLanguage : str ,
54+ availableLanguages : Set [str ],
55+ ):
56+ self .lowerCaseAvailableLanguages = {language .lower () for language in availableLanguages }
57+ self .availableLanguagesWithoutLocale = {language .split ("_" )[0 ] for language in self .lowerCaseAvailableLanguages }
58+ super ().__init__ (defaultLanguage )
5059
5160 def _convertProsody (self , command , attr , default , base = None ):
5261 if base is None :
@@ -74,23 +83,40 @@ def convertCharacterModeCommand(self, command):
7483 # Therefore, we don't use it.
7584 return None
7685
77- def convertLangChangeCommand (self , command ) :
86+ def convertLangChangeCommand (self , command : LangChangeCommand ) -> Optional [ speechXml . SetAttrCommand ] :
7887 lcid = languageHandler .localeNameToWindowsLCID (command .lang )
7988 if lcid is languageHandler .LCID_NONE :
8089 log .debugWarning (f"Invalid language: { command .lang } " )
8190 return None
91+
92+ normalizedLanguage = command .lang .lower ().replace ("-" , "_" )
93+ normalizedLanguageWithoutLocale = normalizedLanguage .split ("_" )[0 ]
94+ if (
95+ normalizedLanguage not in self .lowerCaseAvailableLanguages
96+ and normalizedLanguageWithoutLocale not in self .availableLanguagesWithoutLocale
97+ ):
98+ log .warning (f"Language { command .lang } not supported ({ self .lowerCaseAvailableLanguages } )" )
99+ return None
100+
82101 return super ().convertLangChangeCommand (command )
83102
84103class _OcPreAPI5SsmlConverter (_OcSsmlConverter ):
85104
86- def __init__ (self , defaultLanguage , rate , pitch , volume ):
87- super (_OcPreAPI5SsmlConverter , self ).__init__ (defaultLanguage )
105+ def __init__ (
106+ self ,
107+ defaultLanguage : str ,
108+ availableLanguages : Set [str ],
109+ rate : float ,
110+ pitch : float ,
111+ volume : float ,
112+ ):
113+ super ().__init__ (defaultLanguage , availableLanguages )
88114 self ._rate = rate
89115 self ._pitch = pitch
90116 self ._volume = volume
91117
92- def generateBalancerCommands (self , speechSequence ) :
93- commands = super (_OcPreAPI5SsmlConverter , self ).generateBalancerCommands (speechSequence )
118+ def generateBalancerCommands (self , speechSequence : SpeechSequence ) -> Generator [ Any , None , None ] :
119+ commands = super ().generateBalancerCommands (speechSequence )
94120 # The EncloseAllCommand from SSML must be first.
95121 yield next (commands )
96122 # OneCore didn't provide a way to set base prosody values before API version 5.
@@ -135,6 +161,9 @@ class OneCoreSynthDriver(SynthDriver):
135161 }
136162 supportedNotifications = {synthIndexReached , synthDoneSpeaking }
137163
164+ _ocSpeechToken : Optional [ctypes .POINTER ]
165+ _queuedSpeech : List [Union [str , Tuple [Callable [[ctypes .POINTER , float ], None ], float ]]]
166+
138167 @classmethod
139168 def check (cls ):
140169 # Only present this as an available synth if this is Windows 10.
@@ -235,11 +264,17 @@ def cancel(self):
235264 if self ._player :
236265 self ._player .stop ()
237266
238- def speak (self , speechSequence ) :
267+ def speak (self , speechSequence : SpeechSequence ) -> None :
239268 if self .supportsProsodyOptions :
240- conv = _OcSsmlConverter (self .language )
269+ conv = _OcSsmlConverter (self .language , self . availableLanguages )
241270 else :
242- conv = _OcPreAPI5SsmlConverter (self .language , self ._rate , self ._pitch , self ._volume )
271+ conv = _OcPreAPI5SsmlConverter (
272+ self .language ,
273+ self .availableLanguages ,
274+ self ._rate ,
275+ self ._pitch ,
276+ self ._volume
277+ )
243278 text = conv .convertToXml (speechSequence )
244279 # #7495: Calling WaveOutOpen blocks for ~100 ms if called from the callback
245280 # when the SSML includes marks.
@@ -249,7 +284,7 @@ def speak(self, speechSequence):
249284 self ._player .open ()
250285 self ._queueSpeech (text )
251286
252- def _queueSpeech (self , item ) :
287+ def _queueSpeech (self , item : str ) -> None :
253288 self ._queuedSpeech .append (item )
254289 # We only process the queue here if it isn't already being processed.
255290 if not self ._isProcessing :
@@ -433,7 +468,8 @@ def _getVoiceInfoFromOnecoreVoiceString(self, voiceStr):
433468 def _getAvailableVoices (self ):
434469 voices = OrderedDict ()
435470 # Fetch the full list of voices that Onecore speech knows about.
436- # Note that it may give back voices that are uninstalled or broken.
471+ # Note that it may give back voices that are uninstalled or broken.
472+ # Refer to _isVoiceValid for information on uninstalled or broken voices.
437473 voicesStr = self ._dll .ocSpeech_getVoices (self ._ocSpeechToken ).split ('|' )
438474 for index ,voiceStr in enumerate (voicesStr ):
439475 voiceInfo = self ._getVoiceInfoFromOnecoreVoiceString (voiceStr )
@@ -444,14 +480,26 @@ def _getAvailableVoices(self):
444480 voices [voiceInfo .id ] = voiceInfo
445481 return voices
446482
447- def _isVoiceValid (self ,ID ) :
448- """
483+ def _isVoiceValid (self , ID : str ) -> bool :
484+ r """
449485 Checks that the given voice actually exists and is valid.
450486 It checks the Registry, and also ensures that its data files actually exist on this machine.
451487 @param ID: the ID of the requested voice.
452- @type ID: string
453- @returns: True if the voice is valid, false otherwise.
454- @rtype: boolean
488+ @returns: True if the voice is valid, False otherwise.
489+
490+ OneCore keeps specific registry caches of OneCore for AT applications.
491+ NVDA's OneCore cache is: `HKEY_CURRENT_USER\Software\Microsoft\Speech_OneCore\Isolated\Ny37kw9G-o42UiJ1z6Qc_sszEKkCNywTlrTOG0QKVB4`.
492+ The caches contain a subtree which is meant to mirror the path `HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Speech_OneCore\*`.
493+
494+ For example
495+ `HKEY_CURRENT_USER\Software\Microsoft\Speech_OneCore\Isolated\Ny37kw9G-o42UiJ1z6Qc_sszEKkCNywTlrTOG0QKVB4\
496+ HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Speech_OneCore\Voices\Tokens\MSTTS_V110_enUS_MarkM`
497+ refers to `HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Speech_OneCore\Voices\Tokens\MSTTS_V110_enUS_MarkM`.
498+
499+ Languages which have been used by an installed copy of NVDA, but uninstalled from the system are kept in the cache.
500+ OneCore will still attempt to use these languages, so we must check if they are valid first.
501+
502+ Refer to https://github.com/nvaccess/nvda/issues/13732#issuecomment-1149386711 for more information.
455503 """
456504 IDParts = ID .split ('\\ ' )
457505 rootKey = getattr (winreg , IDParts [0 ])
0 commit comments