44# This file is covered by the GNU General Public License.
55# See the file COPYING for more details.
66
7+ from typing import Optional
78from enum import IntEnum
89import locale
910from collections import OrderedDict
1314from comtypes import COMError
1415import winreg
1516import audioDucking
16- import NVDAHelper
1717from synthDriverHandler import SynthDriver , VoiceInfo , synthIndexReached , synthDoneSpeaking
1818import config
1919import nvwave
@@ -50,85 +50,11 @@ class SpeechVoiceSpeakFlags(IntEnum):
5050
5151class SpeechVoiceEvents (IntEnum ):
5252 # https://msdn.microsoft.com/en-us/previous-versions/windows/desktop/ms720886(v=vs.85)
53+ StartInputStream = 2
5354 EndInputStream = 4
5455 Bookmark = 16
5556
5657
57- class FunctionHooker (object ):
58- def __init__ (
59- self ,
60- targetDll : str ,
61- importDll : str ,
62- funcName : str ,
63- newFunction # result of ctypes.WINFUNCTYPE
64- ):
65- # dllImportTableHooks_hookSingle expects byte strings.
66- try :
67- self ._hook = NVDAHelper .localLib .dllImportTableHooks_hookSingle (
68- targetDll .encode ("mbcs" ),
69- importDll .encode ("mbcs" ),
70- funcName .encode ("mbcs" ),
71- newFunction
72- )
73- except UnicodeEncodeError :
74- log .error ("Error encoding FunctionHooker input parameters" , exc_info = True )
75- self ._hook = None
76- if self ._hook :
77- log .debug (f"Hooked { funcName } " )
78- else :
79- log .error (f"Could not hook { funcName } " )
80- raise RuntimeError (f"Could not hook { funcName } " )
81-
82- def __del__ (self ):
83- if self ._hook :
84- NVDAHelper .localLib .dllImportTableHooks_unhookSingle (self ._hook )
85-
86-
87- _duckersByHandle = {}
88-
89-
90- @WINFUNCTYPE (windll .winmm .waveOutOpen .restype ,* windll .winmm .waveOutOpen .argtypes ,use_errno = False ,use_last_error = False )
91- def waveOutOpen (pWaveOutHandle ,deviceID ,wfx ,callback ,callbackInstance ,flags ):
92- if audioDucking ._isDebug ():
93- log .debugWarning ("Ducking audio requested for SAPI5 synthdriver" )
94- try :
95- res = windll .winmm .waveOutOpen (pWaveOutHandle ,deviceID ,wfx ,callback ,callbackInstance ,flags ) or 0
96- except WindowsError as e :
97- res = e .winerror
98- if res == 0 and pWaveOutHandle :
99- h = pWaveOutHandle .contents .value
100- d = audioDucking .AudioDucker ()
101- if not d .enable ():
102- log .warning ("Ducking audio failed for SAPI5 synthdriver" )
103- _duckersByHandle [h ]= d
104- else :
105- log .warning ("Opening wave out failed for SAPI5 synthdriver" )
106- log .debugWarning (f"Win Error: { res } \n WaveOutHandle: { pWaveOutHandle } " )
107- return res
108-
109- @WINFUNCTYPE (c_long ,c_long )
110- def waveOutClose (waveOutHandle ):
111- if audioDucking ._isDebug ():
112- log .debugWarning ("End ducking audio requested for SAPI5 synthdriver" )
113- try :
114- res = windll .winmm .waveOutClose (waveOutHandle ) or 0
115- except WindowsError as e :
116- res = e .winerror
117- if res == 0 and waveOutHandle :
118- _duckersByHandle .pop (waveOutHandle ,None )
119- else :
120- log .warning ("Closing wave out failed for SAPI5 synthdriver" )
121- log .debugWarning (f"Res: { res } \n waveOutHandle: { waveOutHandle } " )
122- return res
123-
124- _waveOutHooks = []
125- def ensureWaveOutHooks ():
126- if not _waveOutHooks and audioDucking .isAudioDuckingSupported ():
127- sapiPath = os .path .join (os .path .expandvars ("$SYSTEMROOT" ),"system32" ,"speech" ,"common" ,"sapi.dll" )
128- _waveOutHooks .append (FunctionHooker (sapiPath ,"WINMM.dll" ,"waveOutOpen" ,waveOutOpen ))
129- _waveOutHooks .append (FunctionHooker (sapiPath ,"WINMM.dll" ,"waveOutClose" ,waveOutClose ))
130-
131-
13258class SapiSink (object ):
13359 """Handles SAPI event notifications.
13460 See https://msdn.microsoft.com/en-us/library/ms723587(v=vs.85).aspx
@@ -137,6 +63,16 @@ class SapiSink(object):
13763 def __init__ (self , synthRef : weakref .ReferenceType ):
13864 self .synthRef = synthRef
13965
66+ def StartStream (self , streamNum , pos ):
67+ synth = self .synthRef ()
68+ if synth is None :
69+ log .debugWarning ("Called StartStream method on SapiSink while driver is dead" )
70+ return
71+ if synth ._audioDucker :
72+ if audioDucking ._isDebug ():
73+ log .debug ("Enabling audio ducking due to starting speech stream" )
74+ synth ._audioDucker .enable ()
75+
14076 def Bookmark (self , streamNum , pos , bookmark , bookmarkId ):
14177 synth = self .synthRef ()
14278 if synth is None :
@@ -150,6 +86,10 @@ def EndStream(self, streamNum, pos):
15086 log .debugWarning ("Called Bookmark method on EndStream while driver is dead" )
15187 return
15288 synthDoneSpeaking .notify (synth = synth )
89+ if synth ._audioDucker :
90+ if audioDucking ._isDebug ():
91+ log .debug ("Disabling audio ducking due to speech stream end" )
92+ synth ._audioDucker .disable ()
15393
15494
15595class SynthDriver (SynthDriver ):
@@ -181,13 +121,15 @@ def check(cls):
181121 return False
182122
183123 ttsAudioStream = None #: Holds the ISPAudio interface for the current voice, to aid in stopping and pausing audio
124+ _audioDucker : Optional [audioDucking .AudioDucker ] = None
184125
185126 def __init__ (self ,_defaultVoiceToken = None ):
186127 """
187128 @param _defaultVoiceToken: an optional sapi voice token which should be used as the default voice (only useful for subclasses)
188129 @type _defaultVoiceToken: ISpeechObjectToken
189130 """
190- ensureWaveOutHooks ()
131+ if audioDucking .isAudioDuckingSupported ():
132+ self ._audioDucker = audioDucking .AudioDucker ()
191133 self ._pitch = 50
192134 self ._initTts (_defaultVoiceToken )
193135
@@ -261,7 +203,7 @@ def _initTts(self, voice=None):
261203 if outputDeviceID >= 0 :
262204 self .tts .audioOutput = self .tts .getAudioOutputs ()[outputDeviceID ]
263205 self ._eventsConnection = comtypes .client .GetEvents (self .tts , SapiSink (weakref .ref (self )))
264- self .tts .EventInterests = SpeechVoiceEvents .Bookmark | SpeechVoiceEvents .EndInputStream
206+ self .tts .EventInterests = SpeechVoiceEvents .StartInputStream | SpeechVoiceEvents . Bookmark | SpeechVoiceEvents .EndInputStream
265207 from comInterfaces .SpeechLib import ISpAudio
266208 try :
267209 self .ttsAudioStream = self .tts .audioOutputStream .QueryInterface (ISpAudio )
@@ -396,18 +338,58 @@ def outputTags():
396338
397339 text = "" .join (textList )
398340 flags = SpeechVoiceSpeakFlags .IsXML | SpeechVoiceSpeakFlags .Async
399- self .tts .Speak (text , flags )
341+ # Although background audio is ducked in SAPISink.StartStream
342+ # and unducks in SAPISink.EndStream,
343+ # those events are asynchronous and therefor enabling audio ducking cannot enforce a delay before the speech stream starts,
344+ # while audio is being faded down.
345+ # Therefore, create a temporary AudioDucker object, and enable and disable it directly around the call to speak,
346+ # So that enabling audio ducking suitably delays the speak call if necessary.
347+ # Although speak does not block and audio ducking is disabled straight after,
348+ # Audio will be still ducked for enough time for the subsequent StartStream to keep it enabled.
349+ if audioDucking .isAudioDuckingSupported ():
350+ tempAudioDucker = audioDucking .AudioDucker ()
351+ else :
352+ tempAudioDucker = None
353+ if tempAudioDucker :
354+ if audioDucking ._isDebug ():
355+ log .debug ("Enabling audio ducking due to speak call" )
356+ tempAudioDucker .enable ()
357+ try :
358+ self .tts .Speak (text , flags )
359+ finally :
360+ if tempAudioDucker :
361+ if audioDucking ._isDebug ():
362+ log .debug ("Disabling audio ducking after speak call" )
363+ tempAudioDucker .disable ()
400364
401365 def cancel (self ):
402366 # SAPI5's default means of stopping speech can sometimes lag at end of speech, especially with Win8 / Win 10 Microsoft Voices.
403367 # Therefore instruct the underlying audio interface to stop first, before interupting and purging any remaining speech.
404368 if self .ttsAudioStream :
405369 self .ttsAudioStream .setState (SPAudioState .STOP , 0 )
406370 self .tts .Speak (None , SpeechVoiceSpeakFlags .Async | SpeechVoiceSpeakFlags .PurgeBeforeSpeak )
371+ if self ._audioDucker :
372+ if audioDucking ._isDebug ():
373+ log .debug ("Disabling audio ducking due to setting output audio state to stop" )
374+ self ._audioDucker .disable ()
407375
408376 def pause (self , switch : bool ):
409377 # SAPI5's default means of pausing in most cases is either extremely slow
410378 # (e.g. takes more than half a second) or does not work at all.
411379 # Therefore instruct the underlying audio interface to pause instead.
412380 if self .ttsAudioStream :
413- self .ttsAudioStream .setState (SPAudioState .PAUSE if switch else SPAudioState .RUN , 0 )
381+ oldState = self .ttsAudioStream .GetStatus ().State
382+ if switch and oldState == SPAudioState .RUN :
383+ # pausing
384+ if self ._audioDucker :
385+ if audioDucking ._isDebug ():
386+ log .debug ("Disabling audio ducking due to setting output audio state to pause" )
387+ self ._audioDucker .disable ()
388+ self .ttsAudioStream .setState (SPAudioState .PAUSE , 0 )
389+ elif not switch and oldState == SPAudioState .PAUSE :
390+ # unpausing
391+ if self ._audioDucker :
392+ if audioDucking ._isDebug ():
393+ log .debug ("Enabling audio ducking due to setting output audio state to run" )
394+ self ._audioDucker .enable ()
395+ self .ttsAudioStream .setState (SPAudioState .RUN , 0 )
0 commit comments