# -*- coding: UTF-8 -*- # A part of NonVisual Desktop Access (NVDA) # Copyright (C) 2006-2025 NV Access Limited, Peter Vágner, Aleksey Sadovoy, gexgd0419 # This file is covered by the GNU General Public License. # See the file COPYING for more details. from ctypes import ( HRESULT, POINTER, WINFUNCTYPE, byref, c_ubyte, c_ulong, c_void_p, c_wchar_p, cast, create_unicode_buffer, memmove, memset, sizeof, ) from enum import IntEnum import locale from collections import OrderedDict, deque import threading from typing import TYPE_CHECKING, Any, NamedTuple, Generator import winBindings.ole32 import audioDucking from ctypes.wintypes import _LARGE_INTEGER, _ULARGE_INTEGER from comInterfaces.SpeechLib import ( GUID, ISpAudio, ISpEventSource, ISpEventSink, ISpNotifySource, ISpNotifySink, ISpVoice, ISpeechVoice, SPAUDIOSTATE, SPEVENT, WAVEFORMATEX, ) import comtypes.client from comtypes import COMError, COMObject, hresult import winreg import nvwave from synthDriverHandler import SynthDriver, VoiceInfo, synthIndexReached, synthDoneSpeaking import config from logHandler import log import weakref import languageHandler from speech.commands import ( IndexCommand, CharacterModeCommand, LangChangeCommand, BreakCommand, PitchCommand, RateCommand, VolumeCommand, PhonemeCommand, SpeechCommand, ) from ._sonic import SonicStream, initialize as sonicInitialize import NVDAState class _SPAudioState(IntEnum): # https://docs.microsoft.com/en-us/previous-versions/windows/desktop/ms720596(v=vs.85) CLOSED = 0 STOP = 1 PAUSE = 2 RUN = 3 class SpeechVoiceSpeakFlags(IntEnum): # https://docs.microsoft.com/en-us/previous-versions/windows/desktop/ms720892(v=vs.85) Async = 1 PurgeBeforeSpeak = 2 IsXML = 8 class SpeechVoiceEvents(IntEnum): # https://msdn.microsoft.com/en-us/previous-versions/windows/desktop/ms720886(v=vs.85) StartInputStream = 2 EndInputStream = 4 Bookmark = 16 class _SpeakRequest(NamedTuple): text: str bookmarks: deque[int] if TYPE_CHECKING: from ctypes import _Pointer else: # ctypes._Pointer is not available at run time. # A custom class is created to make `_Pointer[type]` still work. class _Pointer: def __class_getitem__(cls, item: type) -> type: return POINTER(item) # The following types are deprecated and no longer used. # Definitions are kept here in order not to break static type checking. if TYPE_CHECKING and NVDAState._allowDeprecatedAPI(): LP_c_ubyte = _Pointer[c_ubyte] LP_c_ulong = _Pointer[c_ulong] LP__ULARGE_INTEGER = _Pointer[_ULARGE_INTEGER] _deprecatedTypes: dict[str, type] = { "LP_c_ubyte": _Pointer[c_ubyte], "LP_c_ulong": _Pointer[c_ulong], "LP__ULARGE_INTEGER": _Pointer[_ULARGE_INTEGER], } def __getattr__(attrName: str) -> Any: """Module level `__getattr__` used to preserve backward compatibility.""" if attrName in _deprecatedTypes and NVDAState._allowDeprecatedAPI(): log.warning( f"Importing {attrName} is deprecated for removal with no replacement. ", stack_info=True, ) return _deprecatedTypes[attrName] raise AttributeError(f"module {repr(__name__)} has no attribute {repr(attrName)}") class _SPEventLParamType(IntEnum): # https://learn.microsoft.com/en-us/previous-versions/windows/desktop/ms717255(v=vs.85) UNDEFINED = 0 TOKEN = 1 OBJECT = 2 POINTER = 3 STRING = 4 class _SPEventEnum(IntEnum): # https://learn.microsoft.com/en-us/previous-versions/windows/desktop/ms717254(v=vs.85) START_INPUT_STREAM = 1 END_INPUT_STREAM = 2 TTS_BOOKMARK = 4 # Function types for calling COM methods _Com_AddRef = WINFUNCTYPE(c_ulong)(1, "AddRef") _Com_Release = WINFUNCTYPE(c_ulong)(2, "Release") _ISpEventSource_GetEvents = WINFUNCTYPE(HRESULT, c_ulong, POINTER(SPEVENT), POINTER(c_ulong))(11, "GetEvents") _SPDFID_WaveFormatEx = GUID("{c31adbae-527f-4ff5-a230-f62bb61ff70c}") _FIRST_AUDIO_CHUNK_MIN_DURATION_MS = 50 """ The minimum duration of the first audio chunk in each utterance. Audio will not be played until there's at least this amount of audio data ready to be read in the SonicStream. This is to avoid audio gaps if further processing takes longer time, especially when SonicStream is changing the speed to be faster. This will also increase the speech latency, so it should not be too big. """ class _SapiEvent(SPEVENT): """Enhanced version of the SPEVENT structure that supports freeing lParam data automatically.""" def clear(self) -> None: """Clear and free related data.""" if self.elParamType in (_SPEventLParamType.TOKEN, _SPEventLParamType.OBJECT): _Com_Release(cast(self.lParam, c_void_p)) elif self.elParamType in (_SPEventLParamType.POINTER, _SPEventLParamType.STRING): winBindings.ole32.CoTaskMemFree(cast(self.lParam, c_void_p)) memset(byref(self), 0, sizeof(self)) def __del__(self): self.clear() @staticmethod def copy(dst: SPEVENT, src: SPEVENT) -> None: memmove(byref(dst), byref(src), sizeof(src)) if not src.lParam: return if src.elParamType == _SPEventLParamType.POINTER: dst.lParam = winBindings.ole32.CoTaskMemAlloc(src.wParam) if not dst.lParam: raise COMError(hresult.E_OUTOFMEMORY, "CoTaskMemAlloc failed", (None, None, None, None, None)) memmove(dst.lParam, src.lParam, src.wParam) elif src.elParamType == _SPEventLParamType.STRING: strbuf = create_unicode_buffer(cast(src.lParam, c_wchar_p).value) bufsize = sizeof(strbuf) dst.lParam = winBindings.ole32.CoTaskMemAlloc(bufsize) if not dst.lParam: raise COMError(hresult.E_OUTOFMEMORY, "CoTaskMemAlloc failed", (None, None, None, None, None)) memmove(dst.lParam, byref(strbuf), bufsize) elif src.elParamType in (_SPEventLParamType.TOKEN, _SPEventLParamType.OBJECT): _Com_AddRef(cast(src.lParam, c_void_p)) def copyTo(self, dst: SPEVENT) -> None: _SapiEvent.copy(dst, self) def copyFrom(self, src: SPEVENT) -> None: _SapiEvent.copy(self, src) def getFrom(self, eventSource: _Pointer[ISpEventSource]) -> bool: """Get one event from the event source and store it in this object. Return False if there is no event.""" self.clear() # Use the raw ctypes.WINFUNCTYPE to call ISpEventSource::GetEvents() # instead of using comtypes, because we want to retrieve the event in-place # rather than letting comtypes allocating a new SPEVENT structure. hr = _ISpEventSource_GetEvents(eventSource, 1, byref(self), None) return hr == hresult.S_OK @staticmethod def enumerateFrom(eventSource: _Pointer[ISpEventSource]) -> Generator["_SapiEvent", None, None]: """Enumerate all events in the event source.""" while True: event = _SapiEvent() if not event.getFrom(eventSource): break yield event def getString(self) -> str: """Get the string parameter stored in lParam.""" if self.elParamType != _SPEventLParamType.STRING: raise TypeError("The lParam of this event is not a string") return cast(self.lParam, c_wchar_p).value class SynthDriverAudioStream(COMObject): """ Implements ISpAudio, ISpEventSource, and ISpEventSink. ISpAudio extends IStream which is used to stream in audio data, and also has `SetFormat` to tell the audio object what wave format is preferred. Should be set as the audio output via `ISpAudio::SetOutput`. ISpEventSource and ISpEventSink are also required for `SetOutput` to work, although we only need to pass the event from the sink to the source, and leave most functions unimplemented. """ _com_interfaces_ = [ISpAudio, ISpEventSource, ISpEventSink] def __init__(self, synthRef: weakref.ReferenceType["SynthDriver"]): self.synthRef = synthRef self._writtenBytes = 0 self.waveFormat = WAVEFORMATEX() self._writeDefaultFormat(self.waveFormat) self._events: deque[_SapiEvent] = deque() self._notifySink = None def ISequentialStream_RemoteWrite( self, this: int, pv: _Pointer[c_ubyte], cb: int, pcbWritten: _Pointer[c_ulong], ) -> int: """This is called when SAPI wants to write (output) a wave data chunk. :param pv: A pointer to the first wave data byte. :param cb: The number of bytes to write. :param pcbWritten: A pointer to a variable where the actual number of bytes written will be stored. Can be null. :returns: HRESULT code. """ synth = self.synthRef() if pcbWritten: pcbWritten[0] = 0 if synth is None: log.debugWarning("Called Write method on AudioStream while driver is dead") return hresult.E_UNEXPECTED if synth._isCancelling: return hresult.S_OK synth.sonicStream.writeShort(pv, cb // 2 // synth.sonicStream.channels) # For the first audio chunk, wait for some amount of audio data # in order to avoid audio gaps if further processing takes longer time if ( synth._isFirstAudioChunk and synth.sonicStream.samplesAvailable < synth.sonicStream.sampleRate * _FIRST_AUDIO_CHUNK_MIN_DURATION_MS // 1000 ): return synth._isFirstAudioChunk = False audioData = synth.sonicStream.readShort() synth.player.feed(audioData, len(audioData) * 2) if pcbWritten: pcbWritten[0] = cb self._writtenBytes += cb return hresult.S_OK def IStream_RemoteSeek( self, this: int, dlibMove: _LARGE_INTEGER, # same as c_longlong dwOrigin: int, plibNewPosition: _Pointer[_ULARGE_INTEGER], # same as pointer to c_ulonglong ) -> int: """This is called when SAPI wants to get the current stream position. Seeking to another position is not supported. :param dlibMove: The displacement to be added to the location indicated by the dwOrigin parameter. Only 0 is supported. :param dwOrigin: The origin for the displacement specified in dlibMove. Only 1 (STREAM_SEEK_CUR) is supported. :param plibNewPosition: A pointer to a ULARGE_INTEGER where the current stream position will be stored. Can be null. :returns: HRESULT code. """ if dwOrigin == 1 and dlibMove == 0: # SAPI is querying the current position. if plibNewPosition: plibNewPosition[0] = self._writtenBytes return hresult.S_OK return hresult.E_NOTIMPL def IStream_Commit(self, grfCommitFlags: int): """This is called when MSSP wants to flush the written data. Does nothing.""" pass def ISpStreamFormat_GetFormat(self, pguidFormatId: _Pointer[GUID]) -> _Pointer[WAVEFORMATEX]: """This is called when SAPI wants to get the current wave format. :param pguidFormatId: Receives the current format GUID. Should be SPDFID_WaveFormatEx for WAVEFORMATEX formats. This parameter is incorrectly marked as "in" by comtypes, but is actually an out parameter. :returns: Pointer to a WAVEFORMATEX structure that is allocated by CoTaskMemAlloc. """ # pguidFormatId is actually an out parameter pguidFormatId.contents = _SPDFID_WaveFormatEx pwfx = cast(winBindings.ole32.CoTaskMemAlloc(sizeof(WAVEFORMATEX)), POINTER(WAVEFORMATEX)) if not pwfx: raise COMError(hresult.E_OUTOFMEMORY, "CoTaskMemAlloc failed", (None, None, None, None, None)) memmove(pwfx, byref(self.waveFormat), sizeof(WAVEFORMATEX)) return pwfx def ISpAudio_SetState(self, NewState: SPAUDIOSTATE, ullReserved: int) -> None: """This is called when the audio state changes, for example, when the audio stream is paused or closed.""" pass # do nothing def ISpAudio_SetFormat(self, rguidFmtId: _Pointer[GUID], pWaveFormatEx: _Pointer[WAVEFORMATEX]): """This is called when SAPI wants to tell us what wave format we should use. We can get the best format for the specific voice here. :param rguidFmtId: Format GUID. Should be SPDFID_WaveFormatEx. :param pWaveFormatEx: Pointer to a WAVEFORMATEX structure. We should copy the data to our own structure to keep the format data.""" if rguidFmtId.contents != _SPDFID_WaveFormatEx: return memmove(byref(self.waveFormat), pWaveFormatEx, sizeof(WAVEFORMATEX)) # Force the wave format to be 16-bit integer (which Sonic uses internally). # SAPI will convert the format for us if it isn't supported by the voice. wfx = self.waveFormat wfx.wFormatTag = nvwave.WAVE_FORMAT_PCM wfx.cbSize = 0 if wfx.nChannels > 2: wfx.nChannels = 2 wfx.wBitsPerSample = 16 wfx.nBlockAlign = wfx.nChannels * 2 wfx.nAvgBytesPerSec = wfx.nSamplesPerSec * wfx.nBlockAlign @staticmethod def _writeDefaultFormat(wfx: WAVEFORMATEX) -> None: """Put the default format into wfx. The default format is 48kHz 16-bit stereo.""" wfx.wFormatTag = nvwave.WAVE_FORMAT_PCM wfx.cbSize = 0 wfx.nChannels = 2 wfx.nSamplesPerSec = 48000 wfx.wBitsPerSample = 16 wfx.nBlockAlign = 4 wfx.nAvgBytesPerSec = 48000 * 4 def ISpAudio_GetDefaultFormat(self) -> tuple[GUID, _Pointer[WAVEFORMATEX]]: """Returns the default format that is guaranteed to work on this audio object. :returns: A tuple of a GUID, which should always be SPDFID_WaveFormatEx, and a pointer to a WAVEFORMATEX structure, allocated by CoTaskMemAlloc.""" pwfx = cast(winBindings.ole32.CoTaskMemAlloc(sizeof(WAVEFORMATEX)), POINTER(WAVEFORMATEX)) if not pwfx: raise COMError(hresult.E_OUTOFMEMORY, "CoTaskMemAlloc failed", (None, None, None, None, None)) self._writeDefaultFormat(pwfx.contents) return (_SPDFID_WaveFormatEx, pwfx) def ISpAudio_EventHandle(self) -> int: return 0 def ISpNotifySource_SetNotifySink(self, pNotifySink: _Pointer[ISpNotifySink]) -> None: """SAPI will pass in an ISpNotifySink pointer to be notified of events. We just need to pass the events we have received back to the sink.""" self._notifySink = pNotifySink def ISpNotifySource_GetNotifyEventHandle(self) -> int: return 0 def ISpEventSource_SetInterest(self, ullEventInterest: int, ullQueuedInterest: int) -> None: """SAPI uses this to tell us the types of events it is interested in. We just ignore this and assume that it's interested in everything. :param ullEventInterest: Types of events that should cause ISpNotifySink::Notify() to be called. :param ullQueuedInterest: Types of events than should be stored in the event queue and can be retrieved later with ISpEventSource::GetEvents().""" pass # do nothing def ISpEventSource_GetEvents( self, this: int, ulCount: int, pEventArray: _Pointer[SPEVENT], pulFetched: _Pointer[c_ulong], ) -> None: """Send the events that was passed in via AddEvents back to the event sink. Events that has been retrieved will be removed. :param ulCount: The maximum number of events pEventArray can hold. :param pEventArray: Pointer to an array of SPEVENT structures that is used to receive the event data. :param pulFetched: Used to store the actual number of events fetched. This pointer can be NULL when ulCount is 1.""" countToFetch = min(ulCount, len(self._events)) if pulFetched: pulFetched[0] = countToFetch for i in range(countToFetch): self._events.popleft().copyTo(pEventArray[i]) def ISpEventSink_AddEvents(self, pEventArray: _Pointer[SPEVENT], ulCount: int) -> None: """SAPI will send all events to our ISpAudio implementation, such as StartStream events and Bookmark events. To let the ISpVoice client get notified as well, we should store those events, then pass the events to the ISpNotifySink we got earlier. :param pEventArray: Pointer to an array of SPEVENT structures. :param ulCount: Number of events.""" # Store the events for i in range(ulCount): event = _SapiEvent() event.copyFrom(pEventArray[i]) self._events.append(event) # Notify the sink to check the events if self._notifySink: self._notifySink.Notify() class SapiSink(COMObject): """ Implements ISpNotifySink to handle SAPI event notifications. Should be passed to ISpNotifySource::SetNotifySink(). Notifications will be sent on the original thread, instead of being routed to the main thread. """ _com_interfaces_ = [ISpNotifySink] def __init__(self, synthRef: weakref.ReferenceType["SynthDriver"]): self.synthRef = synthRef def ISpNotifySink_Notify(self): """This is called when there's a new event notification. Queued events will be retrieved.""" synth = self.synthRef() if synth is None: log.debugWarning("Called Notify method on SapiSink while driver is dead") return # Get all queued events eventSource = synth.tts.QueryInterface(ISpEventSource) for event in _SapiEvent.enumerateFrom(eventSource): if event.eEventId == _SPEventEnum.START_INPUT_STREAM: self.StartStream(event.ulStreamNum, event.ullAudioStreamOffset) elif event.eEventId == _SPEventEnum.END_INPUT_STREAM: self.EndStream(event.ulStreamNum, event.ullAudioStreamOffset) elif event.eEventId == _SPEventEnum.TTS_BOOKMARK: self.Bookmark( event.ulStreamNum, event.ullAudioStreamOffset, event.getString(), event.wParam, ) def StartStream(self, streamNum: int, pos: int): synth = self.synthRef() if synth._audioDucker: if audioDucking._isDebug(): log.debug("Enabling audio ducking due to starting speech stream") synth._audioDucker.enable() synth._isFirstAudioChunk = True synth._isSpeaking = True def Bookmark(self, streamNum: int, pos: int, bookmark: str, bookmarkId: int): synth = self.synthRef() if synth._isCancelling: return if synth.player: # Bookmark event is raised before the audio after that point. # Queue an IndexReached event at this point. synth.player.feed(None, 0, lambda: self.onIndexReached(streamNum, bookmarkId)) else: # Bookmark notifications should be sent immediately when WASAPI is off. self.onIndexReached(streamNum, bookmarkId) def EndStream(self, streamNum: int, pos: int): synth = self.synthRef() if synth._isCancelling: synth._bookmarkLists.clear() return if synth.player: # WASAPI is on # Notify the thread # Handle EndStream in that thread with synth._threadCond: synth._isCompleted = True synth._threadCond.notify() else: # WASAPI is off # Handle EndStream immediately synth._onEndStream() def onIndexReached(self, streamNum: int, index: int): synth = self.synthRef() if synth is None: log.debugWarning("Called onIndexReached method on SapiSink while driver is dead") return synthIndexReached.notify(synth=synth, index=index) # remove already triggered bookmarks if synth._bookmarkLists: bookmarks = synth._bookmarkLists[0] while bookmarks: if bookmarks.popleft() == index: break class SynthDriver(SynthDriver): supportedSettings = ( SynthDriver.VoiceSetting(), SynthDriver.RateSetting(), SynthDriver.RateBoostSetting(), SynthDriver.PitchSetting(), SynthDriver.VolumeSetting(), SynthDriver.UseWasapiSetting(), ) supportedCommands = { IndexCommand, CharacterModeCommand, LangChangeCommand, BreakCommand, PitchCommand, RateCommand, VolumeCommand, PhonemeCommand, } supportedNotifications = {synthIndexReached, synthDoneSpeaking} COM_CLASS = "SAPI.SPVoice" CUSTOMSTREAM_COM_CLASS = "SAPI.SpCustomStream" name = "sapi5" # Translators: Description for a speech synthesizer. description = _("Microsoft Speech API version 5 (64 bit)") @classmethod def check(cls): try: r = winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, cls.COM_CLASS) r.Close() return True except: # noqa: E722 return False ttsAudioStream: ISpAudio | None = ( None #: Holds the ISPAudio interface for the current voice, to aid in stopping and pausing audio ) _audioDucker: audioDucking.AudioDucker | None = None def __init__(self, _defaultVoiceToken=None): """ @param _defaultVoiceToken: an optional sapi voice token which should be used as the default voice (only useful for subclasses) @type _defaultVoiceToken: ISpeechObjectToken """ self._pitch = 50 self._rate = 50 self._volume = 100 self._useWasapi = True self.player: nvwave.WavePlayer | None = None self.sonicStream: SonicStream | None = None self._isSpeaking = False """Backing variable for the deprecated property "isSpeaking". This variable is not doing anything useful, and may be removed together with all its references when the property "isSpeaking" is removed.""" self._isCancelling = False self._isStoppingThread = False self._isFirstAudioChunk = False self._rateBoost = False self._bookmarkLists: deque[deque[int]] = deque() self._thread: threading.Thread | None = None self._threadCond = threading.Condition() self._speakRequests: deque[_SpeakRequest] = deque() self._isCompleted = False # True when the last speak request reaches EndStream self._initTts(_defaultVoiceToken) def _stopThread(self) -> None: """Stops the WASAPI speak thread (if it's running) and waits for the thread to quit.""" self._isStoppingThread = True # Wake up and wait for the speak thread. if self.player: self.player.stop() # Ensure the player is stopped to avoid blocking the thread. if self._thread and self._thread.is_alive(): with self._threadCond: self._threadCond.notify_all() self._thread.join() self._thread = None self._isStoppingThread = False def terminate(self): self._stopThread() self.tts = None if self.player: self.player.close() self.player = None def _getAvailableVoices(self): voices = OrderedDict() v = 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(v)): try: ID = v[i].Id name = v[i].GetDescription() try: language = locale.windows_locale[int(v[i].getattribute("language").split(";")[0], 16)] except KeyError: language = None except COMError: log.warning("Could not get the voice info. Skipping...") voices[ID] = VoiceInfo(ID, name, language) return voices 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() def _get_rate(self): return self._rate def _get_rateBoost(self): return self._rateBoost def _get_pitch(self): return self._pitch def _get_volume(self) -> int: return self._volume def _get_voice(self): return self.tts.Voice.Id def _get_useWasapi(self) -> bool: return self._useWasapi def _get_lastIndex(self): bookmark = self.tts.Status.LastBookmark if bookmark != "" and bookmark is not None: return int(bookmark) else: return None @classmethod def _percentToParam(cls, percent, min, max) -> float: """Overrides SynthDriver._percentToParam to return floating point parameter values.""" return float(percent) / 100 * (max - min) + min def _percentToRate(self, percent): return (percent - 50) // 5 def _set_rate(self, rate): self._rate = rate if not self.sonicStream: self.tts.Rate = self._percentToRate(rate) return if self._rateBoost: # When rate boost is enabled, use sonicStream to change the speed. # Supports 0.5x~6x speed. self.tts.Rate = 0 self.sonicStream.speed = self._percentToParam(rate, 0.5, 6.0) else: # When rate boost is disabled, let the voice itself change the speed. self.tts.Rate = self._percentToRate(rate) self.sonicStream.speed = 1 def _set_rateBoost(self, enable: bool): if enable == self._rateBoost: return rate = self._rate self._rateBoost = enable self.rate = rate def _set_pitch(self, value): # pitch is really controled with xml around speak commands self._pitch = value def _set_volume(self, value): self._volume = value self.tts.Volume = value def _initWasapiAudio(self): audioObject = SynthDriverAudioStream(weakref.ref(self)) spVoice = self.tts.QueryInterface(ISpVoice) spVoice.SetOutput(audioObject, True) wfx = audioObject.waveFormat self.player = nvwave.WavePlayer( channels=wfx.nChannels, samplesPerSec=wfx.nSamplesPerSec, bitsPerSample=wfx.wBitsPerSample, outputDevice=config.conf["audio"]["outputDevice"], ) sonicInitialize() self.sonicStream = SonicStream(wfx.nSamplesPerSec, wfx.nChannels) self._thread = threading.Thread(target=self._speakThread, name="Sapi5SpeakThread") self._thread.start() def _initLegacyAudio(self): if audioDucking.isAudioDuckingSupported(): self._audioDucker = audioDucking.AudioDucker() from comInterfaces.SpeechLib import ISpAudio try: self.ttsAudioStream = self.tts.AudioOutputStream.QueryInterface(ISpAudio) except COMError: log.debugWarning("SAPI5 voice does not support ISPAudio") self.ttsAudioStream = None def _initTts(self, voice: str | None = None): self.tts: ISpeechVoice | None = comtypes.client.CreateObject(self.COM_CLASS) if voice: # #749: It seems that SAPI 5 doesn't reset the audio parameters when the voice is changed, # but only when the audio output is changed. # Therefore, set the voice before setting the audio output. # Otherwise, we will get poor speech quality in some cases. self.tts.Voice = voice self._stopThread() if self.player: self.player.close() self.player = None self.sonicStream = None self.ttsAudioStream = None self._audioDucker = None if self.useWasapi: self._initWasapiAudio() else: self._initLegacyAudio() # Set event notify sink self.tts.EventInterests = ( SpeechVoiceEvents.StartInputStream | SpeechVoiceEvents.Bookmark | SpeechVoiceEvents.EndInputStream ) notifySource = self.tts.QueryInterface(ISpNotifySource) 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. return self._initTts(voice=voice) # As _initTts resets the voice parameters on the tts object, set them back to current values. self._set_rate(self._rate) self._set_volume(self._volume) def _set_useWasapi(self, value: bool): if value == self._useWasapi: return self._useWasapi = value self.voice = self.voice # reload the current voice def _percentToPitch(self, percent): return percent // 2 - 25 IPA_TO_SAPI = { "θ": "th", "s": "s", } def _convertPhoneme(self, ipa): # We only know about US English phonemes. # Rather than just ignoring unknown phonemes, SAPI throws an exception. # Therefore, don't bother with any other language. if self.tts.Voice.GetAttribute("language") != "409": raise LookupError("No data for this language") out = [] outAfter = None for ipaChar in ipa: if ipaChar == "ˈ": outAfter = "1" continue out.append(self.IPA_TO_SAPI[ipaChar]) if outAfter: out.append(outAfter) outAfter = None if outAfter: out.append(outAfter) return " ".join(out) def _requestsAvailable(self) -> bool: return self._speakRequests or self._isCancelling or self._isStoppingThread def _requestCompleted(self) -> bool: return self._isCompleted or self._isCancelling or self._isStoppingThread def _onEndStream(self) -> None: """Common handling when a speech stream ends.""" # trigger all untriggered bookmarks if self._bookmarkLists: for bookmark in self._bookmarkLists[0]: synthIndexReached.notify(synth=self, index=bookmark) self._bookmarkLists.pop() self._isSpeaking = False synthDoneSpeaking.notify(synth=self) if self._audioDucker: if audioDucking._isDebug(): log.debug("Disabling audio ducking due to speech stream end") self._audioDucker.disable() def _speakThread(self): """Thread that processes speech when WASAPI is enabled.""" # Handles speak requests in the queue one by one. # Only one request will be processed (spoken) at a time. # We don't use SAPI5's built-in speech queue, # because SpVoice.Speak() waits for SAPI5's audio thread, # and if the audio thread waits for WavePlayer.idle(), # SpVoice.Speak() will also block, causing dead-locks sometimes (#18298). # Here we manage the queue ourselves, and call WavePlayer.idle() here # to avoid blocking the audio thread or the main thread. request: _SpeakRequest | None = None # Process requests one by one. while not self._isStoppingThread: # Fetch the next request with self._threadCond: self._threadCond.wait_for(self._requestsAvailable) if self._speakRequests: request = self._speakRequests.popleft() self._isCancelling = False self._isCompleted = False if request is not None: # There is one request text, bookmarks = request self._bookmarkLists.append(bookmarks) try: # Process one request, and wait for it to finish self.tts.Speak(text, SpeechVoiceSpeakFlags.IsXML | SpeechVoiceSpeakFlags.Async) with self._threadCond: self._threadCond.wait_for(self._requestCompleted) if not self._isCancelling: # Flush the stream and play the remaining data. self.sonicStream.flush() audioData = self.sonicStream.readShort() self.player.feed(audioData, len(audioData) * 2) self._onEndStream() except Exception: self._bookmarkLists.pop() log.error("Error speaking", exc_info=True) request = None if not self._requestsAvailable(): # No more requests, so call idle(). self.player.idle() if self._isCancelling: self.tts.Speak(None, SpeechVoiceSpeakFlags.Async | SpeechVoiceSpeakFlags.PurgeBeforeSpeak) self._bookmarkLists.clear() if self.sonicStream: self.sonicStream.flush() self.sonicStream.readShort() # discard data left in stream self._isCancelling = False def speak(self, speechSequence): textList = [] bookmarks: deque[int] = deque() # NVDA SpeechCommands are linear, but XML is hierarchical. # Therefore, we track values for non-empty tags. # When a tag changes, we close all previously opened tags and open new ones. tags = {} # We have to use something mutable here because it needs to be changed by the inner function. tagsChanged = [True] openedTags = [] def outputTags(): if not tagsChanged[0]: return for tag in reversed(openedTags): textList.append("" % tag) del openedTags[:] for tag, attrs in tags.items(): textList.append("<%s" % tag) for attr, val in attrs.items(): textList.append(' %s="%s"' % (attr, val)) textList.append(">") openedTags.append(tag) tagsChanged[0] = False pitch = self._pitch # Pitch must always be specified in the markup. tags["pitch"] = {"absmiddle": self._percentToPitch(pitch)} rate = self.rate volume = self.volume for item in speechSequence: if isinstance(item, str): outputTags() textList.append(item.replace("<", "<")) elif isinstance(item, IndexCommand): textList.append('' % item.index) bookmarks.append(item.index) elif isinstance(item, CharacterModeCommand): if item.state: tags["spell"] = {} else: try: del tags["spell"] except KeyError: pass tagsChanged[0] = True elif isinstance(item, BreakCommand): textList.append('' % item.time) elif isinstance(item, PitchCommand): tags["pitch"] = {"absmiddle": self._percentToPitch(int(pitch * item.multiplier))} tagsChanged[0] = True elif isinstance(item, VolumeCommand): if item.multiplier == 1: try: del tags["volume"] except KeyError: pass else: tags["volume"] = {"level": int(volume * item.multiplier)} tagsChanged[0] = True elif isinstance(item, RateCommand): if item.multiplier == 1: try: del tags["rate"] except KeyError: pass else: tags["rate"] = {"absspeed": self._percentToRate(int(rate * item.multiplier))} tagsChanged[0] = True elif isinstance(item, PhonemeCommand): try: textList.append( '%s' % (self._convertPhoneme(item.ipa), item.text or ""), ) except LookupError: log.debugWarning("Couldn't convert character in IPA string: %s" % item.ipa) if item.text: textList.append(item.text) elif isinstance(item, LangChangeCommand): lcid = ( languageHandler.localeNameToWindowsLCID(item.lang) if item.lang else languageHandler.LCID_NONE ) if lcid is languageHandler.LCID_NONE: try: del tags["lang"] except KeyError: pass else: tags["lang"] = {"langid": "%x" % lcid} tagsChanged[0] = True elif isinstance(item, SpeechCommand): log.debugWarning("Unsupported speech command: %s" % item) else: log.error("Unknown speech: %s" % item) # Close any tags that are still open. tags.clear() tagsChanged[0] = True outputTags() text = "".join(textList) flags = SpeechVoiceSpeakFlags.IsXML | SpeechVoiceSpeakFlags.Async if self.useWasapi: with self._threadCond: # put the request in queue and wake up the thread self._speakRequests.append(_SpeakRequest(text, bookmarks)) self._threadCond.notify() else: self._bookmarkLists.append(bookmarks) try: self._speak_legacy(text, flags) except Exception: self._bookmarkLists.pop() raise def _speak_legacy(self, text: str, flags: int) -> int: """Legacy way of calling SpVoice.Speak that uses a temporary audio ducker.""" # Ducking should be complete before the synth starts producing audio. # For this to happen, the speech method must block until ducking is complete. # Ducking should be disabled when the synth is finished producing audio. # Note that there may be calls to speak with a string that results in no audio, # it is important that in this case the audio does not get stuck ducked. # When there is no audio produced the startStream and endStream handlers are not called. # To prevent audio getting stuck ducked, it is unducked at the end of speech. # There are some known issues: # - When there is no audio produced by the synth, a user may notice volume lowering (ducking) temporarily. # - If the call to startStream handler is delayed significantly, users may notice a variation in volume # (as ducking is disabled at the end of speak, and re-enabled when the startStream handler is called) # A note on the synchronicity of components of this approach: # SAPISink.StartStream event handler (callback): # the synth speech is not blocked by this event callback. # SAPISink.EndStream event handler (callback): # assumed also to be async but not confirmed. Synchronicity is irrelevant to the current approach. # AudioDucker.disable returns before the audio is completely unducked. # AudioDucker.enable() ducking will complete before the function returns. # It is not possible to "double duck the audio", calling twice yields the same result as calling once. # AudioDucker class instances count the number of enables/disables, # in order to unduck there must be no remaining enabled audio ducker instances. # Due to this a temporary audio ducker is used around the call to speak. # SAPISink.StartStream: Ducking here may allow the early speech to start before ducking is completed. if audioDucking.isAudioDuckingSupported(): tempAudioDucker = audioDucking.AudioDucker() else: tempAudioDucker = None if tempAudioDucker: if audioDucking._isDebug(): log.debug("Enabling audio ducking due to speak call") tempAudioDucker.enable() try: return self.tts.Speak(text, flags) finally: if tempAudioDucker: if audioDucking._isDebug(): log.debug("Disabling audio ducking after speak call") tempAudioDucker.disable() def cancel(self): self._isCancelling = True if self.player: self.player.stop() # stop the audio and stop waiting for idle() with self._threadCond: # clear the queue and wake up the thread self._speakRequests.clear() self._threadCond.notify() if self.ttsAudioStream: # For legacy audio # SAPI5's default means of stopping speech can sometimes lag at end of speech, especially with Win8 / Win 10 Microsoft Voices. # Therefore instruct the audio player to stop first, before interrupting and purging any remaining speech. self.ttsAudioStream.SetState(_SPAudioState.STOP, 0) self.tts.Speak(None, SpeechVoiceSpeakFlags.Async | SpeechVoiceSpeakFlags.PurgeBeforeSpeak) if self._audioDucker: if audioDucking._isDebug(): log.debug("Disabling audio ducking due to setting output audio state to stop") self._audioDucker.disable() self._bookmarkLists.clear() self._isCancelling = False def pause(self, switch: bool): if self.player: self.player.pause(switch) # SAPI5's default means of pausing in most cases is either extremely slow # (e.g. takes more than half a second) or does not work at all. # Therefore instruct the underlying audio interface to pause instead. if self.ttsAudioStream: # For legacy audio oldState = self.ttsAudioStream.GetStatus().State if switch and oldState == _SPAudioState.RUN: # pausing if self._audioDucker: if audioDucking._isDebug(): log.debug("Disabling audio ducking due to setting output audio state to pause") self._audioDucker.disable() self.ttsAudioStream.SetState(_SPAudioState.PAUSE, 0) elif not switch and oldState == _SPAudioState.PAUSE: # unpausing if self._audioDucker: if audioDucking._isDebug(): log.debug("Enabling audio ducking due to setting output audio state to run") self._audioDucker.enable() self.ttsAudioStream.SetState(_SPAudioState.RUN, 0) def __getattr__(self, attrName: str) -> Any: """This is used to reserve backward compatibility.""" if attrName == "isSpeaking" and NVDAState._allowDeprecatedAPI(): log.warning( "The property isSpeaking is deprecated for removal with no replacement. ", stack_info=True, ) # When the property is removed, the backing variable `_isSpeaking`, # and all its references can also be removed, # as it is not doing anything useful. return self._isSpeaking raise AttributeError(f"'{type(self).__name__}' object has no attribute '{attrName}'")