Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix SAPI 4 driver #17599

Open
wants to merge 28 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 26 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
df45ca7
Initial implementation of translation between mmDevice endpoint ID st…
SaschaCowley Jan 8, 2025
16b377d
Slight improvements to typing
SaschaCowley Jan 8, 2025
3e2794b
Fix incorrect config path
SaschaCowley Jan 8, 2025
0bee92c
Merge branch 'master' into fixSapi4
SaschaCowley Jan 8, 2025
173d75d
Merge branch 'master' into fixSapi4
SaschaCowley Jan 8, 2025
6bf3f01
Added a warning when SAPI4 is in use.
SaschaCowley Jan 8, 2025
c7d363b
Changelog
SaschaCowley Jan 8, 2025
6da072f
Update copyright headers
SaschaCowley Jan 8, 2025
d0781ba
Update changes
SaschaCowley Jan 8, 2025
2af45cf
Switch to a driver message enum
SaschaCowley Jan 8, 2025
ef06304
Move loading winmm into _mmDeviceEndpointIdToWaveOutId
SaschaCowley Jan 8, 2025
8c7007a
Documentation improvements
SaschaCowley Jan 9, 2025
48b4d6e
Add deprecation warning
SaschaCowley Jan 9, 2025
181b36a
Added note about SAPI4's deprecation to the UG
SaschaCowley Jan 9, 2025
d277513
Only show warning when not minimal
SaschaCowley Jan 9, 2025
306bd86
Mark hasSapi4WarningBeenShown as private
SaschaCowley Jan 9, 2025
b3bf403
Make SAPI4 warning translatable
SaschaCowley Jan 9, 2025
9ae4429
Merge branch 'master' into fixSapi4
SaschaCowley Jan 16, 2025
3b9c2bc
Don't show DriverSettings with ids that start with a _ in the GUI
SaschaCowley Jan 16, 2025
04f9a78
Switch to using a SAPI4 setting for the warning
SaschaCowley Jan 16, 2025
f08a487
Fixed accidental double underscore
SaschaCowley Jan 16, 2025
53a2d71
Change to using secure instead of minimal
SaschaCowley Jan 16, 2025
41774e9
Set _hasWarningBeenShown directly
SaschaCowley Jan 20, 2025
6331ee1
Add an "Open user guide" button and more explanatory text to the SAPI…
SaschaCowley Jan 20, 2025
1e2ae40
Moved the sapi4 deprecation warning code to synthDrivers.sapi4.
SaschaCowley Jan 20, 2025
834ee95
Restored files that should no longer be changed
SaschaCowley Jan 20, 2025
0e09b4b
Update source/synthDrivers/sapi4.py
SaschaCowley Jan 21, 2025
1916f0f
Only exclude the warning when running on a secure desktop
SaschaCowley Jan 21, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -1552,6 +1552,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 """
114 changes: 107 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)
SaschaCowley marked this conversation as resolved.
Show resolved Hide resolved
# 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 globalVars
import gui.contextHelp
import gui.message
import queueHandler
from synthDriverHandler import SynthDriver, VoiceInfo, synthIndexReached, synthDoneSpeaking, synthChanged
from logHandler import log
import warnings
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,89 @@ 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:
SaschaCowley marked this conversation as resolved.
Show resolved Hide resolved
"""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.",
SaschaCowley marked this conversation as resolved.
Show resolved Hide resolved
),
# 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 globalVars.appArgs.secure:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's important this is announced in other forms of secure mode too, as some users are daily drivers of this, and it's important secure context users get warned about this. they can get their admin to disable it by disabling secure mode temporarily.

I just don't think it should be done on secure screens (e.g password, UAC), as it is a forever nag because a user wouldn't be able to save the settings on secure screens directly. With this new behaviour of saving the variable, is it still saved the same way? does the nag always happen on secure mode?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The flag is still saved to config, it's just now done via the SAPI4 SynthDriver rather than as part of the config schema directly.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've now updated it to show the warning in all cases except when running on a secure desktop.

# Don't warn users about SAPI4 deprecation in secure mode.
# This stops the dialog appearing on secure screens and when secure mode has been forced.
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 @@ -179,6 +182,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)

#### Deprecations

Expand All @@ -190,6 +194,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 @@ -3883,6 +3883,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