Skip to content

Commit aebd5b8

Browse files
authored
Add WASAPI toggle to SAPI5 (#18352)
Closes #18309. Summary of the issue: Users cannot turn off WASAPI for SAPI5 yet. Description of user facing changes: Add a synthesizer setting called `useWasapi`, which is displayed as a checkbox labeled `Use modern audio output system (WASAPI)` in the voice settings when the built-in SAPI5 synthesizer is selected. Description of developer facing changes: None Description of development approach: A new `SynthSetting` based on `BooleanDriverSetting` called `useWasapi` is added and defaults to `True`. It is not in the settings ring. Legacy SAPI5 code are re-introduced to be used when WASAPI for SAPI5 is disabled. Testing strategy: Tested manually.
1 parent 03a041f commit aebd5b8

4 files changed

Lines changed: 208 additions & 25 deletions

File tree

source/gui/settingsDialogs.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1669,8 +1669,17 @@ def driver(self):
16691669
def getSettings(self) -> AutoSettings:
16701670
return self.driver
16711671

1672-
def _getSettingControlHelpId(self, controlId):
1673-
standardSettings = ["voice", "variant", "rate", "rateBoost", "pitch", "inflection", "volume"]
1672+
def _getSettingControlHelpId(self, controlId: str) -> str:
1673+
standardSettings = [
1674+
"voice",
1675+
"variant",
1676+
"rate",
1677+
"rateBoost",
1678+
"pitch",
1679+
"inflection",
1680+
"volume",
1681+
"useWasapi",
1682+
]
16741683
if controlId in standardSettings:
16751684
capitalizedId = controlId[0].upper() + controlId[1:]
16761685
return f"{self.helpId}{capitalizedId}"

source/synthDriverHandler.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,18 @@ def InflectionSetting(cls, minStep=1):
221221
displayName=pgettext("synth setting", "Inflection"),
222222
)
223223

224+
@classmethod
225+
def UseWasapiSetting(cls) -> BooleanDriverSetting:
226+
"""Factory function for creating 'Use WASAPI' setting."""
227+
return BooleanDriverSetting(
228+
"useWasapi",
229+
# Translators: Label for a setting in voice settings dialog.
230+
# "WASAPI" is an acronym for an audio output framework, and should be translated as-is.
231+
_("Use modern audio output system (WASAPI)"),
232+
availableInSettingsRing=False,
233+
defaultVal=True,
234+
)
235+
224236
@abstractmethod
225237
def speak(self, speechSequence):
226238
"""

source/synthDrivers/sapi5.py

Lines changed: 167 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import locale
1010
from collections import OrderedDict, deque
1111
from typing import TYPE_CHECKING
12+
import audioDucking
1213
from comInterfaces.SpeechLib import ISpEventSource, ISpNotifySource, ISpNotifySink
1314
import comtypes.client
1415
from comtypes import COMError, COMObject, IUnknown, hresult
@@ -35,6 +36,14 @@
3536
from ._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+
3847
class 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)

user_docs/en/userGuide.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1921,6 +1921,24 @@ This option is a slider which goes from 0 to 100 - 0 being the lowest volume and
19211921

19221922
This option is a slider that lets you choose how much inflection (rise and fall in pitch) the synthesizer should use to speak with.
19231923

1924+
##### Use modern audio output system (WASAPI) {#SpeechSettingsUseWasapi}
1925+
1926+
This option enables audio output via the Windows Audio Session API (WASAPI).
1927+
This may improve the responsiveness of some voices.
1928+
However, some voices might not work with the current WASAPI implementation.
1929+
If you find that the voice you are using stops working, you may disable this option.
1930+
1931+
Note that the following features depend on WASAPI, and will be disabled if WASAPI is turned off.
1932+
1933+
* For Microsoft Speech API version 4 voices:
1934+
* Audio ducking
1935+
* Leading silence trimming
1936+
* Keep audio device awake
1937+
* For Microsoft Speech API version 5 voices:
1938+
* Rate boost
1939+
* Leading silence trimming
1940+
* Keep audio device awake
1941+
19241942
##### Automatic Language switching {#SpeechSettingsLanguageSwitching}
19251943

19261944
This checkbox allows you to toggle whether NVDA should switch speech synthesizer languages automatically if the text being read specifies its language.

0 commit comments

Comments
 (0)