Skip to content

Commit 15fc797

Browse files
authored
Fix profile upgrade from 13 to 14 (#17589)
Fixes #17587 Fixes #17588 Summary of the issue: The profile upgrade steps introduced in #17547 would cause NVDA to crash, and the user's configuration to be reset, if the user had an audio output device set in their NVDA configuration. Description of user facing changes Upgrading from configuration version 13 to 14 no longer crashes NVDA and resets the user's configuration. Description of development approach The issue was caused because `config.profileUpgradeSteps.upgradeConfigFrom_13_to_14` attempts to import `nvwave`, which, through its own imports, eventually attempts to use gettext. Since gettext has not yet been initialised, this raises an exception. As we are so early in NVDA initialisation, this exception causes the rest of NVDA's initialisation to fail critically. To resolve this, `_getOutputDevices` and `_AudioOutputDevice` have been moved to the new `utils.mmdevice`. I initially thought to move them to `audio.utils`, but initialisation of `audio` runs into the same problem. Testing strategy: Checked out `a7fa0d6`, made sure the config version was `13`, ran NVDA and selected an audio output device. Checked out `fixProfileUpgrade`, ran NVDA, and ensured that it ran as expected, the profile was correctly updated, there was no loss of settings, and the correct output device was in use.
1 parent 8c771f0 commit 15fc797

5 files changed

Lines changed: 59 additions & 48 deletions

File tree

‎source/config/profileUpgradeSteps.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -458,7 +458,7 @@ def _friendlyNameToEndpointId(friendlyName: str) -> str | None:
458458
:param friendlyName: Friendly name of the device to search for.
459459
:return: Endpoint ID string of the best match device, or `None` if no device with a matching friendly name is available.
460460
"""
461-
from nvwave import _getOutputDevices
461+
from utils.mmdevice import _getOutputDevices
462462
from pycaw.constants import DEVICE_STATE
463463

464464
states = (DEVICE_STATE.ACTIVE, DEVICE_STATE.UNPLUGGED, DEVICE_STATE.DISABLED, DEVICE_STATE.NOTPRESENT)

‎source/gui/settingsDialogs.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import wx
2121
from NVDAState import WritePaths
2222

23+
from utils import mmdevice
2324
from vision.providerBase import VisionEnhancementProviderSettings
2425
from wx.lib.expando import ExpandoTextCtrl
2526
import wx.lib.newevent
@@ -46,7 +47,6 @@
4647
import gui.contextHelp
4748
import globalVars
4849
from logHandler import log
49-
import nvwave
5050
import audio
5151
import audioDucking
5252
import queueHandler
@@ -3041,7 +3041,7 @@ 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-
self._deviceIds, deviceNames = zip(*nvwave._getOutputDevices(includeDefault=True))
3044+
self._deviceIds, deviceNames = zip(*mmdevice._getOutputDevices(includeDefault=True))
30453045
self.deviceList = sHelper.addLabeledControl(deviceListLabelText, wx.Choice, choices=deviceNames)
30463046
self.bindHelpEvent("SelectSynthesizerOutputDevice", self.deviceList)
30473047
selectedOutputDevice = config.conf["audio"]["outputDevice"]

‎source/nvwave.py‎

Lines changed: 2 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66

77
"""Provides a simple Python interface to playing audio using the Windows Audio Session API (WASAPI), as well as other useful utilities."""
88

9-
from collections.abc import Generator
109
import threading
1110
import typing
1211
from typing import (
@@ -40,7 +39,8 @@
4039
import core
4140
import globalVars
4241
from pycaw.utils import AudioUtilities
43-
from pycaw.constants import EDataFlow, DEVICE_STATE
42+
43+
from utils.mmdevice import _getOutputDevices
4444

4545

4646
__all__ = (
@@ -89,44 +89,6 @@ class AudioPurpose(Enum):
8989
SOUNDS = auto()
9090

9191

92-
class _AudioOutputDevice(typing.NamedTuple):
93-
id: str
94-
friendlyName: str
95-
96-
97-
def _getOutputDevices(
98-
*,
99-
includeDefault: bool = False,
100-
stateMask: DEVICE_STATE = DEVICE_STATE.ACTIVE,
101-
) -> Generator[_AudioOutputDevice]:
102-
"""Generator, yielding device ID and device Name.
103-
.. note:: Depending on number of devices being fetched, this may take some time (~3ms)
104-
105-
:param includeDefault: Whether to include a value representing the system default output device in the generator, defaults to False.
106-
.. note:: The ID of this device is **not** a valid mmdevice endpoint ID string, and is for internal use only.
107-
The friendly name is **not** generated by the operating system, and it is highly unlikely that it will match any real output device.
108-
:param state: What device states to include in the resultant generator, defaults to DEVICE_STATE.ACTIVE.
109-
:return: Generator of :class:`_AudioOutputDevices` containing all enabled and present audio output devices on the system.
110-
"""
111-
if includeDefault:
112-
yield _AudioOutputDevice(
113-
id=typing.cast(str, config.conf.getConfigValidation(("audio", "outputDevice")).default),
114-
# Translators: Value to show when choosing to use the default audio output device.
115-
friendlyName=_("Default output device"),
116-
)
117-
endpointCollection = AudioUtilities.GetDeviceEnumerator().EnumAudioEndpoints(
118-
EDataFlow.eRender.value,
119-
stateMask.value,
120-
)
121-
for i in range(endpointCollection.GetCount()):
122-
device = AudioUtilities.CreateDevice(endpointCollection.Item(i))
123-
# This should never be None, but just to be sure
124-
if device is not None:
125-
yield _AudioOutputDevice(device.id, device.FriendlyName)
126-
else:
127-
continue
128-
129-
13092
def getOutputDeviceNames() -> list[str]:
13193
"""Obtain the names of all audio output devices on the system.
13294
:return: The names of all output devices on the system.

‎source/utils/mmdevice.py‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# A part of NonVisual Desktop Access (NVDA)
2+
# Copyright (C) 2025 NV Access Limited
3+
# This file is covered by the GNU General Public License.
4+
# See the file COPYING for more details.
5+
6+
from collections.abc import Generator
7+
from typing import NamedTuple, cast
8+
9+
import config
10+
from pycaw.constants import DEVICE_STATE, EDataFlow
11+
from pycaw.utils import AudioUtilities
12+
13+
14+
class _AudioOutputDevice(NamedTuple):
15+
id: str
16+
friendlyName: str
17+
18+
19+
def _getOutputDevices(
20+
*,
21+
includeDefault: bool = False,
22+
stateMask: DEVICE_STATE = DEVICE_STATE.ACTIVE,
23+
) -> Generator[_AudioOutputDevice]:
24+
"""Generator, yielding device ID and device Name.
25+
.. note:: Depending on number of devices being fetched, this may take some time (~3ms)
26+
27+
:param includeDefault: Whether to include a value representing the system default output device in the generator, defaults to False.
28+
.. note:: The ID of this device is **not** a valid mmdevice endpoint ID string, and is for internal use only.
29+
The friendly name is **not** generated by the operating system, and it is highly unlikely that it will match any real output device.
30+
:param state: What device states to include in the resultant generator, defaults to DEVICE_STATE.ACTIVE.
31+
:return: Generator of :class:`_AudioOutputDevices` containing all enabled and present audio output devices on the system.
32+
"""
33+
if includeDefault:
34+
yield _AudioOutputDevice(
35+
id=cast(str, config.conf.getConfigValidation(("audio", "outputDevice")).default),
36+
# Translators: Value to show when choosing to use the default audio output device.
37+
friendlyName=_("Default output device"),
38+
)
39+
endpointCollection = AudioUtilities.GetDeviceEnumerator().EnumAudioEndpoints(
40+
EDataFlow.eRender.value,
41+
stateMask.value,
42+
)
43+
for i in range(endpointCollection.GetCount()):
44+
device = AudioUtilities.CreateDevice(endpointCollection.Item(i))
45+
# This should never be None, but just to be sure
46+
if device is not None:
47+
yield _AudioOutputDevice(device.id, device.FriendlyName)
48+
else:
49+
continue

‎tests/unit/test_config.py‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
from utils.displayString import (
4848
DisplayStringEnum,
4949
)
50-
from nvwave import _AudioOutputDevice
50+
from utils.mmdevice import _AudioOutputDevice
5151

5252

5353
class Config_FeatureFlagEnums_getAvailableEnums(unittest.TestCase):
@@ -924,7 +924,7 @@ def test_updateToDifferentValue(self):
924924
def getOutputDevicesFactory(
925925
devices: _DevicesT,
926926
) -> Callable[[DEVICE_STATE], Generator[_AudioOutputDevice]]:
927-
"""Create a callable that can be used to patch nvwave._getOutputDevices."""
927+
"""Create a callable that can be used to patch utils.mmdevice._getOutputDevices."""
928928

929929
def getOutputDevices(stateMask: DEVICE_STATE, **kw) -> Generator[_AudioOutputDevice]:
930930
yield from devices.get(stateMask, [])
@@ -981,11 +981,11 @@ def test_noDevices(self):
981981
self.performTest(friendlyName="Anything", expectedId=None, devices=devices)
982982

983983
def performTest(self, expectedId: str | None, friendlyName: str, devices: _DevicesT):
984-
"""Patch nvwave._getOutputDevices to return what we tell it, then test that friendlyNameToEndpointId returns the correct ID given a friendly name.
984+
"""Patch utils.mmdevice._getOutputDevices to return what we tell it, then test that friendlyNameToEndpointId returns the correct ID given a friendly name.
985985
The odd order of arguments is so you can directly unpack an AudioOutputDevice.
986986
"""
987987
with patch(
988-
"nvwave._getOutputDevices",
988+
"utils.mmdevice._getOutputDevices",
989989
autospec=True,
990990
side_effect=getOutputDevicesFactory(devices),
991991
):
@@ -998,7 +998,7 @@ def setUp(self):
998998
DEVICE_STATE.ACTIVE: [_AudioOutputDevice("id", "Friendly name")],
999999
}
10001000
self._getOutputDevicesPatcher = patch(
1001-
"nvwave._getOutputDevices",
1001+
"utils.mmdevice._getOutputDevices",
10021002
autospec=True,
10031003
side_effect=getOutputDevicesFactory(devices),
10041004
)

0 commit comments

Comments
 (0)