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

Merged
merged 29 commits into from
Jan 23, 2025
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
29 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
f8c7d2a
Merge branch 'master' into fixSapi4
SaschaCowley Jan 23, 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
3 changes: 2 additions & 1 deletion source/config/configSpec.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# A part of NonVisual Desktop Access (NVDA)
# Copyright (C) 2006-2024 NV Access Limited, Babbage B.V., Davy Kager, Bill Dengler, Julien Cochuyt,
# Copyright (C) 2006-2025 NV Access Limited, Babbage B.V., Davy Kager, Bill Dengler, Julien Cochuyt,
# Joseph Lee, Dawid Pieper, mltony, Bram Duvigneau, Cyrille Bougot, Rob Meredith,
# Burman's Computer and Education Ltd., Leonard de Ruijter, Łukasz Golonka
# This file is covered by the GNU General Public License.
Expand Down Expand Up @@ -45,6 +45,7 @@
autoDialectSwitching = boolean(default=false)
delayedCharacterDescriptions = boolean(default=false)
excludedSpeechModes = int_list(default=list())
_hasSapi4WarningBeenShown = boolean(default=False)
SaschaCowley marked this conversation as resolved.
Show resolved Hide resolved

[[__many__]]
capPitchChange = integer(default=30,min=-100,max=100)
Expand Down
46 changes: 44 additions & 2 deletions source/speech/speech.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# A part of NonVisual Desktop Access (NVDA)
# This file is covered by the GNU General Public License.
# See the file COPYING for more details.
# Copyright (C) 2006-2024 NV Access Limited, Peter Vágner, Aleksey Sadovoy, Babbage B.V., Bill Dengler,
# Copyright (C) 2006-2025 NV Access Limited, Peter Vágner, Aleksey Sadovoy, Babbage B.V., Bill Dengler,
# Julien Cochuyt, Derek Riemer, Cyrille Bougot, Leonard de Ruijter, Łukasz Golonka

"""High-level functions to speak information."""
Expand All @@ -11,14 +11,18 @@
import weakref
import unicodedata
import time

import colors
import api
from annotation import _AnnotationRolesT
import controlTypes
from controlTypes import OutputReason, TextPosition
from controlTypes.state import State
import globalVars
from gui.message import MessageDialog
import queueHandler
import tones
from synthDriverHandler import getSynth
from synthDriverHandler import SynthDriver, getSynth, synthChanged
import re
import textInfos
import speechDictHandler
Expand Down Expand Up @@ -3059,3 +3063,41 @@ def clearTypedWordBuffer() -> None:
complete the word (such as a focus change or choosing to move the caret).
"""
_curWordChars.clear()


def _sapi4DeprecationWarning(synth: SynthDriver, audioOutputDevice: str, isFallback: bool):
SaschaCowley marked this conversation as resolved.
Show resolved Hide resolved
"""A synthChanged event handler to alert the user about the deprecation of SAPI4."""

def setShown():
config.conf["speech"]["_hasSapi4WarningBeenShown"] = True

def impl():
MessageDialog(
seanbudd marked this conversation as resolved.
Show resolved Hide resolved
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.",
SaschaCowley marked this conversation as resolved.
Show resolved Hide resolved
),
# Translators: Title of a message dialog.
title=_("Warning"),
buttons=None,
).addOkButton(
callback=setShown,
).Show()

if (
(not isFallback)
and (synth.name == "sapi4")
and (not config.conf["speech"]["_hasSapi4WarningBeenShown"])
):
# 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.minimal:
# Don't warn users about SAPI4 deprecation in minimal mode.
# This stops the dialog appearing on secure screens or in the launcher.
SaschaCowley marked this conversation as resolved.
Show resolved Hide resolved
synthChanged.register(_sapi4DeprecationWarning)
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
SaschaCowley marked this conversation as resolved.
Show resolved Hide resolved
"""DRV_QUERYFUNCTIONINSTANCEID """

QUERY_INSTANCE_ID_SIZE = 2066
"""DRV_QUERYFUNCTIONINSTANCEIDSIZE """
62 changes: 57 additions & 5 deletions source/synthDrivers/sapi4.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
# 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
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 logHandler import log
import warnings
from ._sapi4 import (
MMSYSERR_NOERROR,
CLSID_MMAudioDest,
CLSID_TTSEnumerator,
IAudioMultiMediaDevice,
Expand All @@ -33,9 +36,9 @@
TTSFEATURE_VOLUME,
TTSMODEINFO,
VOICECHARSET,
DriverMessage,
)
import config
import nvwave
import weakref

from speech.commands import (
Expand All @@ -51,6 +54,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 @@ -233,7 +239,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"]))
self._ttsCentral = POINTER(ITTSCentralW)()
self._ttsEngines.Select(self._currentMode.gModeID, byref(self._ttsCentral), self._ttsAudio)
self._ttsAttrs = self._ttsCentral.QueryInterface(ITTSAttributes)
Expand Down Expand Up @@ -365,3 +371,49 @@ 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
4 changes: 4 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 @@ 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