Skip to content

Commit 6c9a566

Browse files
authored
Merge e249f1e into ada7908
2 parents ada7908 + e249f1e commit 6c9a566

6 files changed

Lines changed: 135 additions & 61 deletions

File tree

nvdaHelper/local/wasapi.cpp

Lines changed: 45 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/*
22
This file is a part of the NVDA project.
33
URL: http://www.nvda-project.org/
4-
Copyright 2023 James Teh.
4+
Copyright 2023-2024 NV Access Limited, James Teh.
55
This program is free software: you can redistribute it and/or modify
66
it under the terms of the GNU General Public License version 2.0, as published by
77
the Free Software Foundation.
@@ -45,6 +45,7 @@ const IID IID_IMMNotificationClient = __uuidof(IMMNotificationClient);
4545
const IID IID_IAudioStreamVolume = __uuidof(IAudioStreamVolume);
4646
const IID IID_IAudioSessionManager2 = __uuidof(IAudioSessionManager2);
4747
const IID IID_IAudioSessionControl2 = __uuidof(IAudioSessionControl2);
48+
const IID IID_IMMEndpoint = __uuidof(IMMEndpoint);
4849

4950
/**
5051
* C++ RAII class to manage the lifecycle of a standard Windows HANDLE closed
@@ -167,9 +168,9 @@ class WasapiPlayer {
167168

168169
/**
169170
* Constructor.
170-
* Specify an empty (not null) deviceName to use the default device.
171+
* Specify an empty (not null) endpointId to use the default device.
171172
*/
172-
WasapiPlayer(wchar_t* deviceName, WAVEFORMATEX format,
173+
WasapiPlayer(wchar_t* endpointId, WAVEFORMATEX format,
173174
ChunkCompletedCallback callback);
174175

175176
/**
@@ -229,7 +230,7 @@ class WasapiPlayer {
229230
CComPtr<IAudioClock> clock;
230231
// The maximum number of frames that will fit in the buffer.
231232
UINT32 bufferFrames;
232-
std::wstring deviceName;
233+
std::wstring endpointId;
233234
WAVEFORMATEX format;
234235
ChunkCompletedCallback callback;
235236
PlayState playState = PlayState::stopped;
@@ -246,9 +247,9 @@ class WasapiPlayer {
246247
bool isUsingPreferredDevice = false;
247248
};
248249

249-
WasapiPlayer::WasapiPlayer(wchar_t* deviceName, WAVEFORMATEX format,
250+
WasapiPlayer::WasapiPlayer(wchar_t* endpointId, WAVEFORMATEX format,
250251
ChunkCompletedCallback callback)
251-
: deviceName(deviceName), format(format), callback(callback) {
252+
: endpointId(endpointId), format(format), callback(callback) {
252253
wakeEvent = CreateEvent(nullptr, false, false, nullptr);
253254
}
254255

@@ -266,7 +267,7 @@ HRESULT WasapiPlayer::open(bool force) {
266267
}
267268
CComPtr<IMMDevice> device;
268269
isUsingPreferredDevice = false;
269-
if (deviceName.empty()) {
270+
if (endpointId.empty()) {
270271
hr = enumerator->GetDefaultAudioEndpoint(eRender, eConsole, &device);
271272
} else {
272273
hr = getPreferredDevice(device);
@@ -491,48 +492,47 @@ HRESULT WasapiPlayer::getPreferredDevice(CComPtr<IMMDevice>& preferredDevice) {
491492
if (FAILED(hr)) {
492493
return hr;
493494
}
494-
CComPtr<IMMDeviceCollection> devices;
495-
hr = enumerator->EnumAudioEndpoints(eRender, DEVICE_STATE_ACTIVE, &devices);
495+
CComPtr<IMMDevice> device;
496+
hr = enumerator->GetDevice(endpointId.c_str(), &device);
496497
if (FAILED(hr)) {
497498
return hr;
498499
}
499-
UINT count = 0;
500-
devices->GetCount(&count);
501-
for (UINT d = 0; d < count; ++d) {
502-
CComPtr<IMMDevice> device;
503-
hr = devices->Item(d, &device);
504-
if (FAILED(hr)) {
505-
return hr;
506-
}
507-
CComPtr<IPropertyStore> props;
508-
hr = device->OpenPropertyStore(STGM_READ, &props);
509-
if (FAILED(hr)) {
510-
return hr;
511-
}
512-
PROPVARIANT val;
513-
hr = props->GetValue(PKEY_Device_FriendlyName, &val);
514-
if (FAILED(hr)) {
515-
return hr;
516-
}
517-
// WinMM device names are truncated to MAXPNAMELEN characters, including the
518-
// null terminator.
519-
constexpr size_t MAX_CHARS = MAXPNAMELEN - 1;
520-
if (wcsncmp(val.pwszVal, deviceName.c_str(), MAX_CHARS) == 0) {
521-
PropVariantClear(&val);
522-
preferredDevice = std::move(device);
523-
return S_OK;
524-
}
525-
PropVariantClear(&val);
500+
501+
// We only want to use the device if it is plugged in and enabled.
502+
DWORD state;
503+
hr = device->GetState(&state);
504+
if (FAILED(hr)) {
505+
return hr;
506+
} else if (state != DEVICE_STATE_ACTIVE) {
507+
return E_NOTFOUND;
526508
}
527-
return E_NOTFOUND;
509+
510+
// We only want to use the device if it is an output device.
511+
IMMEndpoint* endpoint;
512+
hr = device->QueryInterface(IID_IMMEndpoint, (void**)&endpoint);
513+
if (FAILED(hr)) {
514+
return hr;
515+
}
516+
EDataFlow dataFlow;
517+
hr = endpoint->GetDataFlow(&dataFlow);
518+
if (FAILED(hr)) {
519+
return hr;
520+
} else if(dataFlow != eRender) {
521+
return E_NOTFOUND;
522+
}
523+
preferredDevice = std::move(device);
524+
endpoint->Release();
525+
device.Release();
526+
enumerator.Release();
527+
return S_OK;
528528
}
529529

530530
bool WasapiPlayer::didPreferredDeviceBecomeAvailable() {
531531
if (
532532
// We're already using the preferred device.
533533
isUsingPreferredDevice ||
534534
// A preferred device was not specified.
535-
deviceName.empty() ||
535+
endpointId.empty() ||
536536
// A device hasn't recently changed state.
537537
deviceStateChangeCount == notificationClient->getDeviceStateChangeCount()
538538
) {
@@ -673,7 +673,7 @@ HRESULT WasapiPlayer::disableCommunicationDucking(IMMDevice* device) {
673673
*/
674674
class SilencePlayer {
675675
public:
676-
SilencePlayer(wchar_t* deviceName);
676+
SilencePlayer(wchar_t* endpointId);
677677
HRESULT init();
678678
// Play silence for the specified duration.
679679
void playFor(DWORD ms, float volume);
@@ -698,8 +698,8 @@ class SilencePlayer {
698698
std::vector<INT16> whiteNoiseData;
699699
};
700700

701-
SilencePlayer::SilencePlayer(wchar_t* deviceName):
702-
player(deviceName, getFormat(), nullptr),
701+
SilencePlayer::SilencePlayer(wchar_t* endpointId):
702+
player(endpointId, getFormat(), nullptr),
703703
whiteNoiseData(
704704
SILENCE_BYTES / (
705705
sizeof(INT16) / sizeof(unsigned char)
@@ -791,10 +791,10 @@ void SilencePlayer::terminate() {
791791
* WasapiPlayer or SilencePlayer, with the exception of wasPlay_startup.
792792
*/
793793

794-
WasapiPlayer* wasPlay_create(wchar_t* deviceName, WAVEFORMATEX format,
794+
WasapiPlayer* wasPlay_create(wchar_t* endpointId, WAVEFORMATEX format,
795795
WasapiPlayer::ChunkCompletedCallback callback
796796
) {
797-
return new WasapiPlayer(deviceName, format, callback);
797+
return new WasapiPlayer(endpointId, format, callback);
798798
}
799799

800800
void wasPlay_destroy(WasapiPlayer* player) {
@@ -855,9 +855,9 @@ HRESULT wasPlay_startup() {
855855

856856
SilencePlayer* silence = nullptr;
857857

858-
HRESULT wasSilence_init(wchar_t* deviceName) {
858+
HRESULT wasSilence_init(wchar_t* endpointId) {
859859
assert(!silence);
860-
silence = new SilencePlayer(deviceName);
860+
silence = new SilencePlayer(endpointId);
861861
return silence->init();
862862
}
863863

source/config/configSpec.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,6 @@
4141
includeCLDR = boolean(default=True)
4242
symbolDictionaries = string_list(default=list("cldr"))
4343
beepSpeechModePitch = integer(default=10000,min=50,max=11025)
44-
outputDevice = string(default=default)
4544
autoLanguageSwitching = boolean(default=true)
4645
autoDialectSwitching = boolean(default=false)
4746
delayedCharacterDescriptions = boolean(default=false)
@@ -55,6 +54,7 @@
5554
5655
# Audio settings
5756
[audio]
57+
outputDevice = string(default=default)
5858
audioDuckingMode = integer(default=0)
5959
soundVolumeFollowsVoice = boolean(default=false)
6060
soundVolume = integer(default=100, min=0, max=100)

source/config/profileUpgradeSteps.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,3 +414,58 @@ def upgradeConfigFrom_12_to_13(profile: ConfigObj) -> None:
414414
log.debug(
415415
f"Handled cldr value of {setting!r}. List is now: {profile['speech']['symbolDictionaries']}",
416416
)
417+
418+
419+
def upgradeConfigFrom_13_to_14(profile: ConfigObj):
420+
"""Set [audio][outputDevice] to the endpointID of [speech][outputDevice], and delete the latter."""
421+
try:
422+
friendlyName = profile["speech"]["outputDevice"]
423+
except KeyError:
424+
log.debug("Output device not present in config. Taking no action.")
425+
return
426+
if friendlyName == "default":
427+
log.debug("Output device is set to default. Not writing a new value to config.")
428+
elif endpointId := _friendlyNameToEndpointId(friendlyName):
429+
log.debug(
430+
f"Best match for device with {friendlyName=} has {endpointId=}. Writing new value to config."
431+
)
432+
profile["audio"]["outputDevice"] = endpointId
433+
else:
434+
log.debug(
435+
f"Could not find an audio output device with {friendlyName=}. Not writing a new value to config."
436+
)
437+
log.debug("Deleting old config value.")
438+
del profile["speech"]["outputDevice"]
439+
440+
441+
def _friendlyNameToEndpointId(friendlyName: str) -> str | None:
442+
"""_summary_
443+
444+
Since friendly names are not unique, there may be many devices on one system with the same friendly name.
445+
As the order of devices in an IMMEndpointEnumerator is arbitrary, we cannot assume that the first device with a matching friendly name is the device the user wants.
446+
We also can't guarantee that the device the user has selected is active, so we need to retrieve devices by state, in order from most to least preferable.
447+
It is probably a safe bet that the device the user wants to use is either active or unplugged.
448+
Thus, the preference order for states is:
449+
1. ACTIVE- The audio adapter that connects to the endpoint device is present and enabled.
450+
In addition, if the endpoint device plugs into a jack on the adapter, then the endpoint device is plugged in.
451+
2. UNPLUGGED - The audio adapter that contains the jack for the endpoint device is present and enabled, but the endpoint device is not plugged into the jack.
452+
3. DISABLED - The user has disabled the device in the Windows multimedia control panel.
453+
4. NOTPRESENT - The audio adapter that connects to the endpoint device has been removed from the system, or the user has disabled the adapter device in Device Manager.
454+
Within a state, if there is more than one device with the selected friendly name, we use the first one.
455+
456+
:param friendlyName: Friendly name of the device to search for.
457+
:return: Endpoint ID string of the best match device, or `None` if no device with a matching friendly name is available.
458+
"""
459+
from nvwave import _getOutputDevices
460+
from pycaw.constants import DEVICE_STATE
461+
462+
states = (DEVICE_STATE.ACTIVE, DEVICE_STATE.UNPLUGGED, DEVICE_STATE.DISABLED, DEVICE_STATE.NOTPRESENT)
463+
for state in states:
464+
try:
465+
return next(
466+
device for device in _getOutputDevices(stateMask=state) if device.friendlyName == friendlyName
467+
).id
468+
except StopIteration:
469+
# Proceed to the next device state.
470+
continue
471+
return None

source/gui/settingsDialogs.py

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3041,17 +3041,15 @@ def makeSettings(self, settingsSizer: wx.BoxSizer) -> None:
30413041
# Translators: This is the label for the select output device combo in NVDA audio settings.
30423042
# Examples of an output device are default soundcard, usb headphones, etc.
30433043
deviceListLabelText = _("Audio output &device:")
3044-
# The Windows Core Audio device enumeration does not have the concept of an ID for the default output device, so we have to insert something ourselves instead.
3045-
# Translators: Value to show when choosing to use the default audio output device.
3046-
deviceNames = (_("Default output device"), *nvwave.getOutputDeviceNames())
3044+
self._deviceIds, deviceNames = zip(*nvwave._getOutputDevices(includeDefault=True))
30473045
self.deviceList = sHelper.addLabeledControl(deviceListLabelText, wx.Choice, choices=deviceNames)
30483046
self.bindHelpEvent("SelectSynthesizerOutputDevice", self.deviceList)
30493047
selectedOutputDevice = config.conf["speech"]["outputDevice"]
3050-
if selectedOutputDevice == "default":
3048+
if selectedOutputDevice == config.conf.getConfigValidation(("speech", "outputDevice")).default:
30513049
selection = 0
30523050
else:
30533051
try:
3054-
selection = deviceNames.index(selectedOutputDevice)
3052+
selection = self._deviceIds.index(selectedOutputDevice)
30553053
except ValueError:
30563054
selection = 0
30573055
self.deviceList.SetSelection(selection)
@@ -3176,10 +3174,8 @@ def _appendSoundSplitModesList(self, settingsSizerHelper: guiHelper.BoxSizerHelp
31763174
self.soundSplitModesList.Select(0)
31773175

31783176
def onSave(self):
3179-
# We already use "default" as the key in the config spec, so use it here as an alternative to Microsoft Sound Mapper.
3180-
selectedOutputDevice = (
3181-
"default" if self.deviceList.GetSelection() == 0 else self.deviceList.GetStringSelection()
3182-
)
3177+
selectedOutputDevice = self._deviceIds[self.deviceList.GetSelection()]
3178+
log.info(f"{selectedOutputDevice=}")
31833179
if config.conf["speech"]["outputDevice"] != selectedOutputDevice:
31843180
# Synthesizer must be reload if output device changes
31853181
config.conf["speech"]["outputDevice"] = selectedOutputDevice

source/nvwave.py

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
# A part of NonVisual Desktop Access (NVDA)
2-
# Copyright (C) 2007-2023 NV Access Limited, Aleksey Sadovoy, Cyrille Bougot, Peter Vágner, Babbage B.V.,
2+
# Copyright (C) 2007-2024 NV Access Limited, Aleksey Sadovoy, Cyrille Bougot, Peter Vágner, Babbage B.V.,
33
# Leonard de Ruijter, James Teh
44
# This file is covered by the GNU General Public License.
55
# See the file COPYING for more details.
66

7-
"""Provides a simple Python interface to playing audio using the Windows multimedia waveOut functions, as well as other useful utilities."""
7+
"""Provides a simple Python interface to playing audio using the Windows Audio Session API (WASAPI), as well as other useful utilities."""
88

99
from collections.abc import Generator
1010
import threading
@@ -100,19 +100,40 @@ class AudioPurpose(Enum):
100100
SOUNDS = auto()
101101

102102

103-
def _getOutputDevices() -> Generator[tuple[str, str]]:
104-
"""Generator, yielding device ID and device Name in device ID order.
103+
class _AudioOutputDevice(typing.NamedTuple):
104+
id: str
105+
friendlyName: str
106+
107+
108+
def _getOutputDevices(
109+
*,
110+
includeDefault: bool = False,
111+
stateMask: DEVICE_STATE = DEVICE_STATE.ACTIVE,
112+
) -> Generator[_AudioOutputDevice]:
113+
"""Generator, yielding device ID and device Name.
105114
..note: Depending on number of devices being fetched, this may take some time (~3ms)
115+
116+
:param includeDefault: Whether to include a value representing the system default output device in the generator, defaults to False.
117+
..note: The ID of this device is **not** a valid mmdevice endpoint ID string, and is for internal use only.
118+
The friendly name is **not** generated by the operating system, and it is highly unlikely that it will match any real output device.
119+
:param state: What device states to include in the resultant generator, defaults to DEVICE_STATE.ACTIVE.
120+
:return: Generator of :class:`_AudioOutputDevices` containing all enabled and present audio output devices on the system.
106121
"""
122+
if includeDefault:
123+
yield _AudioOutputDevice(
124+
id=typing.cast(str, config.conf.getConfigValidation(("speech", "outputDevice")).default),
125+
# Translators: Value to show when choosing to use the default audio output device.
126+
friendlyName=_("Default output device"),
127+
)
107128
endpointCollection = AudioUtilities.GetDeviceEnumerator().EnumAudioEndpoints(
108129
EDataFlow.eRender.value,
109-
DEVICE_STATE.ACTIVE.value,
130+
stateMask.value,
110131
)
111132
for i in range(endpointCollection.GetCount()):
112133
device = AudioUtilities.CreateDevice(endpointCollection.Item(i))
113134
# This should never be None, but just to be sure
114135
if device is not None:
115-
yield device.id, device.FriendlyName
136+
yield _AudioOutputDevice(device.id, device.FriendlyName)
116137
else:
117138
continue
118139

user_docs/en/changes.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,8 @@ As the NVDA update check URL is now configurable directly within NVDA, no replac
146146
* `gui.settingsDialogs.AdvancedPanelControls.wasapiComboBox` has been removed.
147147
* The `WASAPI` key has been removed from the `audio` section of the config spec.
148148
* The output from `nvwave.outputDeviceNameToID`, and input to `nvwave.outputDeviceIDToName` are now string identifiers.
149+
* The configuration key `config.conf["speech"]["outputDevice"]` has been removed.
150+
It has been replaced by `config.conf["audio"]["outputDevice"]`, which stores a Windows core audio endpoint device ID. (#17547)
149151
* In `NVDAObjects.window.scintilla.ScintillaTextInfo`, if no text is selected, the `collapse` method is overriden to expand to line if the `end` parameter is set to `True` (#17431, @nvdaes)
150152
* The following symbols have been removed with no replacement: `languageHandler.getLanguageCliArgs`, `__main__.quitGroup` and `__main__.installGroup` . (#17486, @CyrilleB79)
151153
* Prefix matching on command line flags, e.g. using `--di` for `--disable-addons` is no longer supported. (#11644, @CyrilleB79)

0 commit comments

Comments
 (0)