Skip to content

Commit 73bf543

Browse files
Merge ca97521 into a46d8d6
2 parents a46d8d6 + ca97521 commit 73bf543

1 file changed

Lines changed: 62 additions & 80 deletions

File tree

source/synthDrivers/sapi5.py

Lines changed: 62 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
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
78
from enum import IntEnum
89
import locale
910
from collections import OrderedDict
@@ -13,7 +14,6 @@
1314
from comtypes import COMError
1415
import winreg
1516
import audioDucking
16-
import NVDAHelper
1717
from synthDriverHandler import SynthDriver, VoiceInfo, synthIndexReached, synthDoneSpeaking
1818
import config
1919
import nvwave
@@ -50,85 +50,11 @@ class SpeechVoiceSpeakFlags(IntEnum):
5050

5151
class 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-
13258
class 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

15595
class 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

Comments
 (0)