99import locale
1010from collections import OrderedDict , deque
1111from typing import TYPE_CHECKING
12+ import audioDucking
1213from comInterfaces .SpeechLib import ISpEventSource , ISpNotifySource , ISpNotifySink
1314import comtypes .client
1415from comtypes import COMError , COMObject , IUnknown , hresult
3536from ._sonic import SonicStream , initialize as sonicInitialize
3637
3738
39+ class _SPAudioState (IntEnum ):
40+ # https://docs.microsoft.com/en-us/previous-versions/windows/desktop/ms720596(v=vs.85)
41+ CLOSED = 0
42+ STOP = 1
43+ PAUSE = 2
44+ RUN = 3
45+
46+
3847class SpeechVoiceSpeakFlags (IntEnum ):
3948 # https://docs.microsoft.com/en-us/previous-versions/windows/desktop/ms720892(v=vs.85)
4049 Async = 1
@@ -181,6 +190,10 @@ def ISpNotifySink_Notify(self):
181190
182191 def StartStream (self , streamNum : int , pos : int ):
183192 synth = self .synthRef ()
193+ if synth ._audioDucker :
194+ if audioDucking ._isDebug ():
195+ log .debug ("Enabling audio ducking due to starting speech stream" )
196+ synth ._audioDucker .enable ()
184197 # The stream has been started. Move the bookmark list to _streamBookmarks.
185198 if streamNum in synth ._streamBookmarksNew :
186199 synth ._streamBookmarks [streamNum ] = synth ._streamBookmarksNew [streamNum ]
@@ -189,26 +202,31 @@ def StartStream(self, streamNum: int, pos: int):
189202
190203 def Bookmark (self , streamNum : int , pos : int , bookmark : str , bookmarkId : int ):
191204 synth = self .synthRef ()
192- if not synth .isSpeaking :
205+ if not synth .isSpeaking or not synth . player :
193206 return
194207 # Bookmark event is raised before the audio after that point.
195208 # Queue an IndexReached event at this point.
196209 synth .player .feed (None , 0 , lambda : self .onIndexReached (streamNum , bookmarkId ))
197210
198211 def EndStream (self , streamNum : int , pos : int ):
199212 synth = self .synthRef ()
200- # Flush the stream and get the remaining data.
201- synth .sonicStream .flush ()
202- audioData = synth .sonicStream .readShort ()
203- synth .player .feed (audioData , len (audioData ) * 2 )
204- synth .player .idle ()
213+ if synth .player :
214+ # Flush the stream and get the remaining data.
215+ synth .sonicStream .flush ()
216+ audioData = synth .sonicStream .readShort ()
217+ synth .player .feed (audioData , len (audioData ) * 2 )
218+ synth .player .idle ()
205219 # trigger all untriggered bookmarks
206220 if streamNum in synth ._streamBookmarks :
207221 for bookmark in synth ._streamBookmarks [streamNum ]:
208222 synthIndexReached .notify (synth = synth , index = bookmark )
209223 del synth ._streamBookmarks [streamNum ]
210224 synth .isSpeaking = False
211225 synthDoneSpeaking .notify (synth = synth )
226+ if synth ._audioDucker :
227+ if audioDucking ._isDebug ():
228+ log .debug ("Disabling audio ducking due to speech stream end" )
229+ synth ._audioDucker .disable ()
212230
213231 def onIndexReached (self , streamNum : int , index : int ):
214232 synth = self .synthRef ()
@@ -231,6 +249,7 @@ class SynthDriver(SynthDriver):
231249 SynthDriver .RateBoostSetting (),
232250 SynthDriver .PitchSetting (),
233251 SynthDriver .VolumeSetting (),
252+ SynthDriver .UseWasapiSetting (),
234253 )
235254 supportedCommands = {
236255 IndexCommand ,
@@ -259,6 +278,11 @@ def check(cls):
259278 except : # noqa: E722
260279 return False
261280
281+ ttsAudioStream = (
282+ None #: Holds the ISPAudio interface for the current voice, to aid in stopping and pausing audio
283+ )
284+ _audioDucker : audioDucking .AudioDucker | None = None
285+
262286 def __init__ (self , _defaultVoiceToken = None ):
263287 """
264288 @param _defaultVoiceToken: an optional sapi voice token which should be used as the default voice (only useful for subclasses)
@@ -267,7 +291,9 @@ def __init__(self, _defaultVoiceToken=None):
267291 self ._pitch = 50
268292 self ._rate = 50
269293 self ._volume = 100
270- self .player = None
294+ self ._useWasapi = True
295+ self .player : nvwave .WavePlayer | None = None
296+ self .sonicStream : SonicStream | None = None
271297 self .isSpeaking = False
272298 self ._rateBoost = False
273299 self ._initTts (_defaultVoiceToken )
@@ -318,6 +344,9 @@ def _get_volume(self) -> int:
318344 def _get_voice (self ):
319345 return self .tts .voice .Id
320346
347+ def _get_useWasapi (self ) -> bool :
348+ return self ._useWasapi
349+
321350 def _get_lastIndex (self ):
322351 bookmark = self .tts .status .LastBookmark
323352 if bookmark != "" and bookmark is not None :
@@ -335,6 +364,9 @@ def _percentToRate(self, percent):
335364
336365 def _set_rate (self , rate ):
337366 self ._rate = rate
367+ if not self .sonicStream :
368+ self .tts .Rate = self ._percentToRate (rate )
369+ return
338370 if self ._rateBoost :
339371 # When rate boost is enabled, use sonicStream to change the speed.
340372 # Supports 0.5x~6x speed.
@@ -360,25 +392,26 @@ def _set_volume(self, value):
360392 self ._volume = value
361393 self .tts .Volume = value
362394
363- def _initTts (self , voice = None ):
364- self .tts = comtypes .client .CreateObject (self .COM_CLASS )
365- if voice :
366- # #749: It seems that SAPI 5 doesn't reset the audio parameters when the voice is changed,
367- # but only when the audio output is changed.
368- # Therefore, set the voice before setting the audio output.
369- # Otherwise, we will get poor speech quality in some cases.
370- self .tts .voice = voice
395+ def _initAudioDevice (self ):
396+ # SAPI5 automatically selects the system default audio device, so there's no use doing work if the user has selected to use the system default.
397+ # Besides, our default value is not a valid endpoint ID.
398+ if (outputDevice := config .conf ["audio" ]["outputDevice" ]) != config .conf .getConfigValidation (
399+ ("audio" , "outputDevice" ),
400+ ).default :
401+ for audioOutput in self .tts .GetAudioOutputs ():
402+ # SAPI's audio output IDs are registry keys. It seems that the final path segment is the endpoint ID.
403+ if audioOutput .Id .endswith (outputDevice ):
404+ self .tts .audioOutput = audioOutput
405+ break
371406
372- self . tts . AudioOutput = self . tts . AudioOutput # Reset the audio and its format parameters
407+ def _initWasapiAudio ( self ):
373408 fmt = self .tts .AudioOutputStream .Format
374409 wfx = fmt .GetWaveFormatEx ()
375410 # Force the wave format to be 16-bit integer (which Sonic uses internally).
376411 # SAPI will convert the format for us if it isn't supported by the voice.
377412 wfx .FormatTag = nvwave .WAVE_FORMAT_PCM
378413 wfx .BitsPerSample = 16
379414 fmt .SetWaveFormatEx (wfx )
380- if self .player :
381- self .player .close ()
382415 self .player = nvwave .WavePlayer (
383416 channels = wfx .Channels ,
384417 samplesPerSec = wfx .SamplesPerSec ,
@@ -394,6 +427,39 @@ def _initTts(self, voice=None):
394427 sonicInitialize ()
395428 self .sonicStream = SonicStream (wfx .SamplesPerSec , wfx .Channels )
396429
430+ def _initLegacyAudio (self ):
431+ if audioDucking .isAudioDuckingSupported ():
432+ self ._audioDucker = audioDucking .AudioDucker ()
433+ from comInterfaces .SpeechLib import ISpAudio
434+
435+ try :
436+ self .ttsAudioStream = self .tts .audioOutputStream .QueryInterface (ISpAudio )
437+ except COMError :
438+ log .debugWarning ("SAPI5 voice does not support ISPAudio" )
439+ self .ttsAudioStream = None
440+
441+ def _initTts (self , voice : str | None = None ):
442+ self .tts = comtypes .client .CreateObject (self .COM_CLASS )
443+ if voice :
444+ # #749: It seems that SAPI 5 doesn't reset the audio parameters when the voice is changed,
445+ # but only when the audio output is changed.
446+ # Therefore, set the voice before setting the audio output.
447+ # Otherwise, we will get poor speech quality in some cases.
448+ self .tts .voice = voice
449+
450+ if self .player :
451+ self .player .close ()
452+ self .player = None
453+ self .sonicStream = None
454+ self .ttsAudioStream = None
455+ self ._audioDucker = None
456+
457+ self ._initAudioDevice ()
458+ if self .useWasapi :
459+ self ._initWasapiAudio ()
460+ else :
461+ self ._initLegacyAudio ()
462+
397463 # Set event notify sink
398464 self .tts .EventInterests = (
399465 SpeechVoiceEvents .StartInputStream | SpeechVoiceEvents .Bookmark | SpeechVoiceEvents .EndInputStream
@@ -417,6 +483,12 @@ def _set_voice(self, value):
417483 self ._set_rate (self ._rate )
418484 self ._set_volume (self ._volume )
419485
486+ def _set_useWasapi (self , value : bool ):
487+ if value == self ._useWasapi :
488+ return
489+ self ._useWasapi = value
490+ self .voice = self .voice # reload the current voice
491+
420492 def _percentToPitch (self , percent ):
421493 return percent // 2 - 25
422494
@@ -550,19 +622,91 @@ def outputTags():
550622
551623 text = "" .join (textList )
552624 flags = SpeechVoiceSpeakFlags .IsXML | SpeechVoiceSpeakFlags .Async
553- streamNum = self .tts .Speak (text , flags )
625+ if self .useWasapi :
626+ streamNum = self .tts .Speak (text , flags )
627+ else :
628+ streamNum = self ._speak_legacy (text , flags )
554629 # When Speak returns, the previous stream may not have been ended.
555630 # So the bookmark list is stored in another dict until this stream starts.
556631 self ._streamBookmarksNew [streamNum ] = bookmarks
557632
633+ def _speak_legacy (self , text : str , flags : int ) -> int :
634+ """Legacy way of calling SpVoice.Speak that uses a temporary audio ducker."""
635+ # Ducking should be complete before the synth starts producing audio.
636+ # For this to happen, the speech method must block until ducking is complete.
637+ # Ducking should be disabled when the synth is finished producing audio.
638+ # Note that there may be calls to speak with a string that results in no audio,
639+ # it is important that in this case the audio does not get stuck ducked.
640+ # When there is no audio produced the startStream and endStream handlers are not called.
641+ # To prevent audio getting stuck ducked, it is unducked at the end of speech.
642+ # There are some known issues:
643+ # - When there is no audio produced by the synth, a user may notice volume lowering (ducking) temporarily.
644+ # - If the call to startStream handler is delayed significantly, users may notice a variation in volume
645+ # (as ducking is disabled at the end of speak, and re-enabled when the startStream handler is called)
646+
647+ # A note on the synchronicity of components of this approach:
648+ # SAPISink.StartStream event handler (callback):
649+ # the synth speech is not blocked by this event callback.
650+ # SAPISink.EndStream event handler (callback):
651+ # assumed also to be async but not confirmed. Synchronicity is irrelevant to the current approach.
652+ # AudioDucker.disable returns before the audio is completely unducked.
653+ # AudioDucker.enable() ducking will complete before the function returns.
654+ # It is not possible to "double duck the audio", calling twice yields the same result as calling once.
655+ # AudioDucker class instances count the number of enables/disables,
656+ # in order to unduck there must be no remaining enabled audio ducker instances.
657+ # Due to this a temporary audio ducker is used around the call to speak.
658+ # SAPISink.StartStream: Ducking here may allow the early speech to start before ducking is completed.
659+ if audioDucking .isAudioDuckingSupported ():
660+ tempAudioDucker = audioDucking .AudioDucker ()
661+ else :
662+ tempAudioDucker = None
663+ if tempAudioDucker :
664+ if audioDucking ._isDebug ():
665+ log .debug ("Enabling audio ducking due to speak call" )
666+ tempAudioDucker .enable ()
667+ try :
668+ return self .tts .Speak (text , flags )
669+ finally :
670+ if tempAudioDucker :
671+ if audioDucking ._isDebug ():
672+ log .debug ("Disabling audio ducking after speak call" )
673+ tempAudioDucker .disable ()
674+
558675 def cancel (self ):
559676 # SAPI5's default means of stopping speech can sometimes lag at end of speech, especially with Win8 / Win 10 Microsoft Voices.
560677 # Therefore instruct the audio player to stop first, before interupting and purging any remaining speech.
561678 self .isSpeaking = False
562- self .player .stop ()
563- self .sonicStream .flush ()
564- self .sonicStream .readShort () # discard data left in stream
679+ if self .player :
680+ self .player .stop ()
681+ self .sonicStream .flush ()
682+ self .sonicStream .readShort () # discard data left in stream
683+ if self .ttsAudioStream :
684+ self .ttsAudioStream .setState (_SPAudioState .STOP , 0 )
565685 self .tts .Speak (None , SpeechVoiceSpeakFlags .Async | SpeechVoiceSpeakFlags .PurgeBeforeSpeak )
686+ if self ._audioDucker :
687+ if audioDucking ._isDebug ():
688+ log .debug ("Disabling audio ducking due to setting output audio state to stop" )
689+ self ._audioDucker .disable ()
566690
567691 def pause (self , switch : bool ):
568- self .player .pause (switch )
692+ if self .player :
693+ self .player .pause (switch )
694+ # SAPI5's default means of pausing in most cases is either extremely slow
695+ # (e.g. takes more than half a second) or does not work at all.
696+ # Therefore instruct the underlying audio interface to pause instead.
697+ if self .ttsAudioStream :
698+ oldState = self .ttsAudioStream .GetStatus ().State
699+ if switch and oldState == _SPAudioState .RUN :
700+ # pausing
701+ if self ._audioDucker :
702+ if audioDucking ._isDebug ():
703+ log .debug ("Disabling audio ducking due to setting output audio state to pause" )
704+ self ._audioDucker .disable ()
705+ self .ttsAudioStream .setState (_SPAudioState .PAUSE , 0 )
706+ elif not switch and oldState == _SPAudioState .PAUSE :
707+ # unpausing
708+ if self ._audioDucker :
709+ if audioDucking ._isDebug ():
710+ log .debug ("Enabling audio ducking due to setting output audio state to run" )
711+ self ._audioDucker .enable ()
712+ self .ttsAudioStream .setState (_SPAudioState .RUN , 0 )
0 commit comments