Skip to content

Commit

Permalink
Fix SAPI 4 driver (#17599)
Browse files Browse the repository at this point in the history
Fixes #17516

Summary of the issue:
After the move to exclusively Windows core audio APIs, the SAPI4 driver stopped working.

Description of user facing changes
The SAPI4 driver works again.

A warning is shown the first time the user uses SAPI4 informing them that it is deprecated.

Description of development approach
Implemented a function to translate between MMDevice Endpoint IDs and WaveOut device IDs, based on <https://learn.microsoft.com/en-gb/windows/win32/coreaudio/device-roles-for-legacy-windows-multimedia-applications>.

Added a config key, `speech.hasSapi4WarningBeenShown`, which defaults to False.
Added a synthChanged callback that shows a dialog when the synth is set to SAPI4 if this config key is False and this is not a fallback synthesizer.

Testing strategy:
Ran NVDA, from source and installed, and on the user desktop, in secure mode, and on secure desktops, and used it with SAPI4. Changed the audio output device to ensure audio was routed as expected.

Known issues with pull request:
When first updating to a version with this PR merged, if the user uses SAPI4 as their primary speech synth, they will be warned about its deprecation in the launcher and when they first start the newly updated NVDA. This is unavoidable as we don't save config from the launcher.

The dialog is only shown once per config profile, so may be missed by some users.
Other options I have considered include:

* Making this a nag dialog that appears, say, once a week or once a month.
* Also making a dialog appear whenever the user manually sets their synth to SAPI4.
* Adding a new dialog in 2025.4 (or the last release before 2026.1) that warns users that this will be the last release to support SAPI4.
* Adding a dialog when updating to 2026.1 that warns users that they will no longer be able to use SAPI4.
* Adding a Windows toast notification that appears every time NVDA starts with SAPI4 as the synth.

The warning dialog is shown after SAPI4 is loaded. In the instance that the user is already using SAPI4, this is correct behaviour. In the case of switching to SAPI4, perhaps a dialog should appear before we terminate the current synth and initialise SAPI4.
  • Loading branch information
SaschaCowley authored Jan 23, 2025
1 parent 93bbe4f commit bf3ba01
Show file tree
Hide file tree
Showing 6 changed files with 134 additions and 10 deletions.
1 change: 1 addition & 0 deletions source/autoSettingsUtils/driverSetting.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ def __init__(
):
"""
@param id: internal identifier of the setting
If this starts with a `_`, it will not be shown in the settings GUI.
@param displayNameWithAccelerator: the localized string shown in voice or braille settings dialog
@param availableInSettingsRing: Will this option be available in a settings ring?
@param defaultVal: Specifies the default value for a driver setting.
Expand Down
3 changes: 3 additions & 0 deletions source/gui/settingsDialogs.py
Original file line number Diff line number Diff line change
Expand Up @@ -1553,6 +1553,9 @@ def updateDriverSettings(self, changedSetting=None):
continue
if setting.id in self.sizerDict: # update a value
self._updateValueForControl(setting, settingsStorage)
elif setting.id.startswith("_"):
# Skip private settings.
continue
else: # create a new control
self._createNewControl(setting, settingsStorage)
# Update graphical layout of the dialog
Expand Down
21 changes: 18 additions & 3 deletions source/synthDrivers/_sapi4.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
# _sapi4.py
# Contributed by Serotek Corporation under the GPL
# A part of NonVisual Desktop Access (NVDA)
# Copyright (C) 2006-2008 NVDA Contributors <http://www.nvda-project.org/>
# Copyright (C) 2006-2025 NV Access Limited, Serotek Corporation
# This file is covered by the GNU General Public License.
# See the file COPYING for more details.

Expand All @@ -20,6 +18,7 @@
Structure,
)
from ctypes.wintypes import BYTE, DWORD, LPCWSTR, WORD
from enum import IntEnum
from comtypes import GUID, IUnknown, STDMETHOD

import winKernel
Expand Down Expand Up @@ -227,3 +226,19 @@ class ITTSNotifySinkW(IUnknown):

CLSID_MMAudioDest = GUID("{CB96B400-C743-11cd-80E5-00AA003E4B50}")
CLSID_TTSEnumerator = GUID("{D67C0280-C743-11cd-80E5-00AA003E4B50}")


# Defined in mmsyscom.h
MMSYSERR_NOERROR = 0


class DriverMessage(IntEnum):
"""WaveOutMessage message codes
Defined in mmddk.h
"""

QUERY_INSTANCE_ID = 2065
"""DRV_QUERYFUNCTIONINSTANCEID """

QUERY_INSTANCE_ID_SIZE = 2066
"""DRV_QUERYFUNCTIONINSTANCEIDSIZE """
113 changes: 106 additions & 7 deletions source/synthDrivers/sapi4.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,26 @@
# A part of NonVisual Desktop Access (NVDA)
# Copyright (C) 2006-2024 NV Access Limited, Leonard de Ruijter
# Copyright (C) 2006-2025 NV Access Limited, Leonard de Ruijter
# This file is covered by the GNU General Public License.
# See the file COPYING for more details.
# This module is deprecated, pending removal in NVDA 2026.1.

import locale
from collections import OrderedDict, deque
import winreg
from comtypes import CoCreateInstance, COMObject, COMError, GUID
from ctypes import byref, c_ulong, POINTER
from ctypes.wintypes import DWORD, WORD
from ctypes import byref, c_ulong, POINTER, c_wchar, create_string_buffer, sizeof, windll
from ctypes.wintypes import DWORD, HANDLE, WORD
from typing import Optional
from synthDriverHandler import SynthDriver, VoiceInfo, synthIndexReached, synthDoneSpeaking
from autoSettingsUtils.driverSetting import BooleanDriverSetting
import gui.contextHelp
import gui.message
import queueHandler
from synthDriverHandler import SynthDriver, VoiceInfo, synthIndexReached, synthDoneSpeaking, synthChanged
from logHandler import log
import warnings
from utils.security import isRunningOnSecureDesktop
from ._sapi4 import (
MMSYSERR_NOERROR,
CLSID_MMAudioDest,
CLSID_TTSEnumerator,
IAudioMultiMediaDevice,
Expand All @@ -34,9 +42,9 @@
TTSFEATURE_VOLUME,
TTSMODEINFO,
VOICECHARSET,
DriverMessage,
)
import config
import nvwave
import weakref

from speech.commands import (
Expand All @@ -52,6 +60,9 @@
from speech.types import SpeechSequence


warnings.warn("synthDrivers.sapi4 is deprecated, pending removal in NVDA 2026.1.", DeprecationWarning)


class SynthDriverBufSink(COMObject):
_com_interfaces_ = [ITTSBufNotifySink]

Expand Down Expand Up @@ -122,7 +133,10 @@ def ITTSNotifySinkW_AudioStop(self, this, qTimeStamp: int):
class SynthDriver(SynthDriver):
name = "sapi4"
description = "Microsoft Speech API version 4"
supportedSettings = [SynthDriver.VoiceSetting()]
supportedSettings = [
SynthDriver.VoiceSetting(),
BooleanDriverSetting("_hasWarningBeenShown", ""),
]
supportedCommands = {
IndexCommand,
CharacterModeCommand,
Expand Down Expand Up @@ -286,7 +300,7 @@ def _set_voice(self, val):
raise ValueError("no such mode: %s" % val)
self._currentMode = mode
self._ttsAudio = CoCreateInstance(CLSID_MMAudioDest, IAudioMultiMediaDevice)
self._ttsAudio.DeviceNumSet(nvwave.outputDeviceNameToID(config.conf["audio"]["outputDevice"], True))
self._ttsAudio.DeviceNumSet(_mmDeviceEndpointIdToWaveOutId(config.conf["audio"]["outputDevice"]))
if self._ttsCentral:
self._ttsCentral.UnRegister(self._sinkRegKey)
self._ttsCentral = POINTER(ITTSCentralW)()
Expand Down Expand Up @@ -421,3 +435,88 @@ def _set_volume(self, val: int):
# using the low word for the left channel and the high word for the right channel.
val |= val << 16
self._ttsAttrs.VolumeSet(val)


def _mmDeviceEndpointIdToWaveOutId(targetEndpointId: str) -> int:
"""Translate from an MMDevice Endpoint ID string to a WaveOut Device ID number.
:param targetEndpointId: MMDevice endpoint ID string to translate from, or the default value of the `audio.outputDevice` configuration key for the default output device.
:return: An integer WaveOut device ID for use with SAPI4.
If no matching device is found, or the default output device is requested, `-1` is returned, which means output will be handled by Microsoft Sound Mapper.
"""
if targetEndpointId != config.conf.getConfigValidation(("audio", "outputDevice")).default:
targetEndpointIdByteCount = (len(targetEndpointId) + 1) * sizeof(c_wchar)
currEndpointId = create_string_buffer(targetEndpointIdByteCount)
currEndpointIdByteCount = DWORD()
# Defined in mmeapi.h
winmm = windll.winmm
waveOutMessage = winmm.waveOutMessage
waveOutGetNumDevs = winmm.waveOutGetNumDevs
for devID in range(waveOutGetNumDevs()):
# Get the length of this device's endpoint ID string.
mmr = waveOutMessage(
HANDLE(devID),
DriverMessage.QUERY_INSTANCE_ID_SIZE,
byref(currEndpointIdByteCount),
None,
)
if (mmr != MMSYSERR_NOERROR) or (currEndpointIdByteCount.value != targetEndpointIdByteCount):
# ID lengths don't match, so this device can't be a match.
continue
# Get the device's endpoint ID string.
mmr = waveOutMessage(
HANDLE(devID),
DriverMessage.QUERY_INSTANCE_ID,
byref(currEndpointId),
currEndpointIdByteCount,
)
if mmr != MMSYSERR_NOERROR:
continue
# Decode the endpoint ID string to a python string, and strip the null terminator.
if (
currEndpointId.raw[: targetEndpointIdByteCount - sizeof(c_wchar)].decode("utf-16")
== targetEndpointId
):
return devID
# No matching device found, or default requested explicitly.
# Return the ID of Microsoft Sound Mapper
return -1


def _sapi4DeprecationWarning(synth: SynthDriver, audioOutputDevice: str, isFallback: bool):
"""A synthChanged event handler to alert the user about the deprecation of SAPI4."""

def setShown():
synth._hasWarningBeenShown = True
synth.saveSettings()

def impl():
gui.message.MessageDialog(
parent=None,
message=_(
# Translators: Message warning users that SAPI4 is deprecated.
"Microsoft Speech API version 4 is obsolete. "
"Using this speech synthesizer may pose a security risk. "
"This synthesizer driver will be removed in NVDA 2026.1. "
"You are strongly encouraged to choose a more modern speech synthesizer. "
"Consult the Supported Speech Synthesizers section in the User Guide for suggestions. ",
),
# Translators: Title of a message dialog.
title=_("Warning"),
buttons=None,
).addOkButton(
callback=setShown,
).addHelpButton(
# Translators: A button in a dialog.
label=_("Open user guide"),
callback=lambda: gui.contextHelp.showHelp("SupportedSpeechSynths"),
).Show()

if (not isFallback) and (synth.name == "sapi4") and (not getattr(synth, "_hasWarningBeenShown", False)):
# We need to queue the dialog to appear, as wx may not have been initialised the first time this is called.
queueHandler.queueFunction(queueHandler.eventQueue, impl)


if not isRunningOnSecureDesktop():
# Don't warn users about SAPI4 deprecation when running on a secure desktop.
synthChanged.register(_sapi4DeprecationWarning)
5 changes: 5 additions & 0 deletions user_docs/en/changes.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

### Important notes

* The support for Microsoft Speech API version 4 synthesizers is planned for removal in NVDA 2026.1.
Any remaining users of SAPI4 speech synthesizers are encouraged to choose a more modern speech synthesizer. (#17599)

### New Features

* Support for math in PDFs has been added.
Expand Down Expand Up @@ -188,6 +191,7 @@ Instead, a `callback` property has been added, which returns a function that per
* Because SAPI5 voices now use `nvwave.WavePlayer` to output audio: (#17592, @gexgd0419)
* `synthDrivers.sapi5.SPAudioState` has been removed.
* `synthDrivers.sapi5.SynthDriver.ttsAudioStream` has been removed.
* Instances of `autoSettingsUtils.driverSetting.DriverSetting` with an `id` that starts with an underscore (_) are no longer shown in NVDA's settings. (#17599)
* Changed keyboard typing echo configuration from boolean to integer values. (#17505, @Cary-rowen)
* `config.conf["keyboard"]["speakTypedCharacters"]` and `config.conf["keyboard"]["speakTypedWords"]` now use integer values.
* Added `TypingEcho` enum in `config.configFlags` to represent these modes, 0=Off, 1=Only in edit controls, 2=Always.
Expand All @@ -203,6 +207,7 @@ Use `gui.message.MessageDialog` instead. (#17582)
* `NoConsoleOptionParser`, `stringToBool`, `stringToLang` in `__main__`; use the same symbols in `argsParsing` instead.
* `__main__.parser`; use `argsParsing.getParser()` instead.
* `bdDetect.DeviceType` is deprecated in favour of `bdDetect.ProtocolType` and `bdDetect.CommunicationType` to take into account the fact that both HID and Serial communication can take place over USB and Bluetooth. (#17537 , @LeonarddeR)
* SAPI4, `synthDrivers.sapi4`, is deprecated and planned for removal in 2026.1. (#17599)

## 2024.4.2

Expand Down
1 change: 1 addition & 0 deletions user_docs/en/userGuide.md
Original file line number Diff line number Diff line change
Expand Up @@ -3873,6 +3873,7 @@ There are also many variants which can be chosen to alter the sound of the voice
SAPI 4 is an older Microsoft standard for software speech synthesizers.
NVDA still supports this for users who already have SAPI 4 synthesizers installed.
However, Microsoft no longer support this and needed components are no longer available from Microsoft.
Support for SAPI4 will be removed in NVDA 2026.1.

When using this synthesizer with NVDA, the available voices (accessed from the [Speech category](#SpeechSettings) of the [NVDA Settings](#NVDASettings) dialog or by the [Synth Settings Ring](#SynthSettingsRing)) will contain all the voices from all the installed SAPI 4 engines found on your system.

Expand Down

0 comments on commit bf3ba01

Please sign in to comment.