From b6d08a0dd6717d7a198b26334af6951a19883028 Mon Sep 17 00:00:00 2001 From: mltony <34804124+mltony@users.noreply.github.com> Date: Tue, 3 Sep 2024 00:56:11 -0700 Subject: [PATCH] Keystrokes to adjust applications volume and mute [take 2] (#16591) Closes #16052. Please note that this is my second attempt to implement this. The first attempt was #16273 which was reverted in #16440# by commit f63841f. Summary of the issue: Provide a way to adjust volume of other applications, so that when using sound split the volume of aplications channel can be adjusted separately from NVDA volume. Description of user facing changes New keystrokes: NVDA+alt+pageUp/pageDown adjusts volume of all other applications. NVDA+alt+delete Cycles between three states Disabled (default) Enabled Muted - all applications except NVDA will be muted Description of development approach I refactored sound split code. Sound split and applications volume adjustment need to use same callbacks in a similar fashion. In order to reduce code reduplication, I extracted common logic and put it in audio/utils.py. Now it provides an abstract class AudioSessionCallback and customers (sound split and app volume adjuster) are implementing this class. Two methods that need to be implemented are onSessionUpdate and onSessionTerminated, which operate on a single audio session. All the plumbing is hidden in utils.py so that clients don't need to worry about setting up and dealing with multiple lower-level callbacks. Applications' voluem adjusting logic is implemented in audio/appsVolume.py and is quite straightforward now. Volume and mute status are adjusted via ISimpleAudioVolume interface, which means that both are reflected in Windows Volume Mixer. 4.Initial volume and mute status of applications is storedand upon exit will be restored. Applciation volume adjustment is now completely independent of sound split and both features can operate independently of each other. --- source/audio/__init__.py | 39 ++++++ source/audio/appsVolume.py | 194 ++++++++++++++++++++++++++++++ source/audio/soundSplit.py | 181 ++++++++-------------------- source/audio/utils.py | 190 +++++++++++++++++++++++++++++ source/config/configSpec.py | 3 + source/config/featureFlagEnums.py | 15 +++ source/core.py | 8 +- source/globalCommands.py | 45 +++++++ source/gui/settingsDialogs.py | 52 ++++++++ user_docs/en/changes.md | 3 + user_docs/en/userGuide.md | 42 +++++++ 11 files changed, 634 insertions(+), 138 deletions(-) create mode 100644 source/audio/appsVolume.py create mode 100644 source/audio/utils.py diff --git a/source/audio/__init__.py b/source/audio/__init__.py index 90dcd50fbba..e6b6619c29c 100644 --- a/source/audio/__init__.py +++ b/source/audio/__init__.py @@ -8,9 +8,48 @@ _setSoundSplitState, _toggleSoundSplitState, ) +from . import appsVolume, soundSplit, utils +import atexit +import nvwave +from pycaw.utils import AudioUtilities +from comtypes import COMError +from logHandler import log __all__ = [ "SoundSplitState", "_setSoundSplitState", "_toggleSoundSplitState", ] + +audioUtilitiesInitialized: bool = False + + +def initialize() -> None: + if nvwave.usingWasapiWavePlayer(): + try: + AudioUtilities.GetAudioSessionManager() + except COMError: + log.exception("Could not initialize audio session manager") + return + log.debug("Initializing utils") + utils.initialize() + log.debug("Initializing appsVolume") + appsVolume.initialize() + log.debug("Initializing soundSplit") + soundSplit.initialize() + global audioUtilitiesInitialized + audioUtilitiesInitialized = True + else: + log.debug("Cannot initialize audio utilities as WASAPI is disabled") + + +@atexit.register +def terminate(): + if not audioUtilitiesInitialized: + log.debug("Skipping terminating audio utilities as initialization was skipped.") + elif not nvwave.usingWasapiWavePlayer(): + log.debug("Skipping terminating audio utilites as WASAPI is disabled.") + else: + soundSplit.terminate() + appsVolume.terminate() + utils.terminate() diff --git a/source/audio/appsVolume.py b/source/audio/appsVolume.py new file mode 100644 index 00000000000..14149fb44ac --- /dev/null +++ b/source/audio/appsVolume.py @@ -0,0 +1,194 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2024 NV Access Limited +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +import config +import globalVars +from logHandler import log +import nvwave +from pycaw.utils import AudioSession +import ui +from dataclasses import dataclass +from threading import Lock +from config.featureFlagEnums import AppsVolumeAdjusterFlag +from typing import NamedTuple +from .utils import AudioSessionCallback, DummyAudioSessionCallback +from comtypes import COMError + + +class VolumeAndMute(NamedTuple): + volume: float + mute: bool + + +_appVolumesCache: dict[int, VolumeAndMute] = {} +_appVolumesCacheLock = Lock() +_activeCallback: DummyAudioSessionCallback | None = None + + +def initialize() -> None: + state = config.conf["audio"]["applicationsVolumeMode"] + volume = config.conf["audio"]["applicationsSoundVolume"] + muted = config.conf["audio"]["applicationsSoundMuted"] + if muted: + # Muted flag should not be persistent. + config.conf["audio"]["applicationsSoundMuted"] = False + muted = False + _updateAppsVolumeImpl(volume / 100.0, muted, state) + + +def terminate(): + global _activeCallback + if _activeCallback is not None: + _activeCallback.unregister() + _activeCallback = None + + +@dataclass(unsafe_hash=True) +class VolumeSetter(AudioSessionCallback): + volumeAndMute: VolumeAndMute | None = None + + def getOriginalVolumeAndMute(self, pid: int) -> VolumeAndMute: + try: + with _appVolumesCacheLock: + originalVolumeAndMute = _appVolumesCache[pid] + del _appVolumesCache[pid] + except KeyError: + originalVolumeAndMute = VolumeAndMute(volume=1.0, mute=False) + return originalVolumeAndMute + + def onSessionUpdate(self, session: AudioSession) -> None: + pid = session.ProcessId + simpleVolume = session.SimpleAudioVolume + with _appVolumesCacheLock: + if pid not in _appVolumesCache: + _appVolumesCache[pid] = VolumeAndMute( + volume=simpleVolume.GetMasterVolume(), + mute=simpleVolume.GetMute(), + ) + if pid != globalVars.appPid: + simpleVolume.SetMasterVolume(self.volumeAndMute.volume, None) + simpleVolume.SetMute(self.volumeAndMute.mute, None) + + def onSessionTerminated(self, session: AudioSession) -> None: + pid = session.ProcessId + simpleVolume = session.SimpleAudioVolume + originalVolumeAndMute = self.getOriginalVolumeAndMute(pid) + try: + simpleVolume.SetMasterVolume(originalVolumeAndMute.volume, None) + simpleVolume.SetMute(originalVolumeAndMute.mute, None) + except (COMError, RuntimeError) as e: + log.exception(f"Could not restore master volume of process {pid} upon exit: {e}") + + +def _updateAppsVolumeImpl( + volume: float, + muted: bool, + state: AppsVolumeAdjusterFlag, +): + global _activeCallback + if state == AppsVolumeAdjusterFlag.DISABLED: + newCallback = DummyAudioSessionCallback() + runTerminators = True + else: + newCallback = VolumeSetter( + volumeAndMute=VolumeAndMute( + volume=volume, + mute=muted, + ), + ) + runTerminators = False + if _activeCallback is not None: + _activeCallback.unregister(runTerminators=runTerminators) + _activeCallback = newCallback + _activeCallback.register() + + +def _adjustAppsVolume( + volumeAdjustment: int | None = None, +): + if not nvwave.usingWasapiWavePlayer(): + message = _( + # Translators: error message when wasapi is turned off. + "Other applications' volume cannot be adjusted. " + "Please enable WASAPI in the Advanced category in NVDA Settings to use it.", + ) + ui.message(message) + return + volume: int = config.conf["audio"]["applicationsSoundVolume"] + muted: bool = config.conf["audio"]["applicationsSoundMuted"] + state = config.conf["audio"]["applicationsVolumeMode"] + if state != AppsVolumeAdjusterFlag.ENABLED: + # Translators: error message when applications' volume is disabled + msg = _("Please enable applications' volume adjuster in order to adjust applications' volume") + ui.message(msg) + return + volume += volumeAdjustment + volume = max(0, min(100, volume)) + log.debug(f"Adjusting applications volume by {volumeAdjustment}% to {volume}%") + config.conf["audio"]["applicationsSoundVolume"] = volume + + # We skip running terminators here to avoid application volume spiking to 100% for a split second. + _updateAppsVolumeImpl(volume / 100.0, muted, state) + # Translators: Announcing new applications' volume message + msg = _("Applications volume {}").format(volume) + ui.message(msg) + + +_APPS_VOLUME_STATES_ORDER = [ + AppsVolumeAdjusterFlag.DISABLED, + AppsVolumeAdjusterFlag.ENABLED, +] + + +def _toggleAppsVolumeState(): + if not nvwave.usingWasapiWavePlayer(): + message = _( + # Translators: error message when wasapi is turned off. + "Other applications' volume cannot be adjusted. " + "Please enable WASAPI in the Advanced category in NVDA Settings to use it.", + ) + ui.message(message) + return + state = config.conf["audio"]["applicationsVolumeMode"] + volume: int = config.conf["audio"]["applicationsSoundVolume"] + muted: bool = config.conf["audio"]["applicationsSoundMuted"] + try: + index = _APPS_VOLUME_STATES_ORDER.index(state) + except ValueError: + index = -1 + index = (index + 1) % len(_APPS_VOLUME_STATES_ORDER) + state = _APPS_VOLUME_STATES_ORDER[index] + config.conf["audio"]["applicationsVolumeMode"] = state.name + _updateAppsVolumeImpl(volume / 100.0, muted, state) + ui.message(state.displayString) + + +def _toggleAppsVolumeMute(): + if not nvwave.usingWasapiWavePlayer(): + message = _( + # Translators: error message when wasapi is turned off. + "Other applications' mute status cannot be adjusted. " + "Please enable WASAPI in the Advanced category in NVDA Settings to use it.", + ) + ui.message(message) + return + state = config.conf["audio"]["applicationsVolumeMode"] + volume: int = config.conf["audio"]["applicationsSoundVolume"] + muted: bool = config.conf["audio"]["applicationsSoundMuted"] + if state != AppsVolumeAdjusterFlag.ENABLED: + # Translators: error message when applications' volume is disabled + msg = _("Please enable applications' volume adjuster in order to mute other applications") + ui.message(msg) + return + muted = not muted + config.conf["audio"]["applicationsSoundMuted"] = muted + _updateAppsVolumeImpl(volume / 100.0, muted, state) + if muted: + # Translators: Announcing new applications' mute status message + msg = _("Muted other applications") + else: + # Translators: Announcing new applications' mute status message + msg = _("Unmuted other applications") + ui.message(msg) diff --git a/source/audio/soundSplit.py b/source/audio/soundSplit.py index 1c7b74a35dc..cef01f70cea 100644 --- a/source/audio/soundSplit.py +++ b/source/audio/soundSplit.py @@ -3,21 +3,16 @@ # This file is covered by the GNU General Public License. # See the file COPYING for more details. -import atexit import config from enum import IntEnum, unique import globalVars from logHandler import log import nvwave -from pycaw.api.audiopolicy import IAudioSessionManager2 -from pycaw.callbacks import AudioSessionNotification, AudioSessionEvents -from pycaw.utils import AudioSession, AudioUtilities +from pycaw.utils import AudioSession import ui from utils.displayString import DisplayStringIntEnum from dataclasses import dataclass -from comtypes import COMError -from threading import Lock -import core +from .utils import AudioSessionCallback, DummyAudioSessionCallback VolumeTupleT = tuple[float, float] @@ -88,91 +83,32 @@ def getNVDAVolume(self) -> VolumeTupleT: raise RuntimeError(f"Unexpected or unknown state {self=}") -_audioSessionManager: IAudioSessionManager2 | None = None -_activeCallback: AudioSessionNotification | None = None +_activeCallback: DummyAudioSessionCallback | None = None def initialize() -> None: - if nvwave.usingWasapiWavePlayer(): - global _audioSessionManager - try: - _audioSessionManager = AudioUtilities.GetAudioSessionManager() - except COMError: - log.exception("Could not initialize audio session manager") - return - state = SoundSplitState(config.conf["audio"]["soundSplitState"]) - _setSoundSplitState(state, initial=True) - else: - log.debug("Cannot initialize sound split as WASAPI is disabled") + state = SoundSplitState(config.conf["audio"]["soundSplitState"]) + _setSoundSplitState(state) -@atexit.register def terminate(): - if nvwave.usingWasapiWavePlayer(): - state = SoundSplitState(config.conf["audio"]["soundSplitState"]) - if state != SoundSplitState.OFF: - _setSoundSplitState(SoundSplitState.OFF) - _unregisterCallback() - else: - log.debug("Skipping terminating sound split as WASAPI is disabled.") - - -@dataclass(unsafe_hash=True) -class _AudioSessionNotificationWrapper(AudioSessionNotification): - listener: AudioSessionNotification - - def on_session_created(self, new_session: AudioSession): - pid = new_session.ProcessId - with _applicationExitCallbacksLock: - if pid not in _applicationExitCallbacks: - volumeRestorer = _VolumeRestorer(pid, new_session) - new_session.register_notification(volumeRestorer) - _applicationExitCallbacks[pid] = volumeRestorer - self.listener.on_session_created(new_session) - - -def _applyToAllAudioSessions( - callback: AudioSessionNotification, - applyToFuture: bool = True, -) -> None: - """ - Executes provided callback function on all active audio sessions. - Additionally, if applyToFuture is True, then it will register a notification with audio session manager, - which will execute the same callback for all future sessions as they are created. - That notification will be active until next invokation of this function, - or until _unregisterCallback() is called. - """ - _unregisterCallback() - callback = _AudioSessionNotificationWrapper(callback) - if applyToFuture: - _audioSessionManager.RegisterSessionNotification(callback) - # The following call is required to make callback to work: - _audioSessionManager.GetSessionEnumerator() - global _activeCallback - _activeCallback = callback - sessions: list[AudioSession] = AudioUtilities.GetAllSessions() - for session in sessions: - callback.on_session_created(session) - - -def _unregisterCallback() -> None: global _activeCallback if _activeCallback is not None: - _audioSessionManager.UnregisterSessionNotification(_activeCallback) + _activeCallback.unregister() _activeCallback = None @dataclass(unsafe_hash=True) -class _VolumeSetter(AudioSessionNotification): - leftVolume: float - rightVolume: float - leftNVDAVolume: float - rightNVDAVolume: float +class ChannelVolumeSetter(AudioSessionCallback): + leftVolume: float = 0.0 + rightVolume: float = 0.0 + leftNVDAVolume: float = 0.0 + rightNVDAVolume: float = 0.0 foundSessionWithNot2Channels: bool = False - def on_session_created(self, new_session: AudioSession): - pid = new_session.ProcessId - channelVolume = new_session.channelAudioVolume() + def onSessionUpdate(self, session: AudioSession) -> None: + pid = session.ProcessId + channelVolume = session.channelAudioVolume() channelCount = channelVolume.GetChannelCount() if channelCount != 2: log.warning( @@ -187,23 +123,42 @@ def on_session_created(self, new_session: AudioSession): channelVolume.SetChannelVolume(0, self.leftNVDAVolume, None) channelVolume.SetChannelVolume(1, self.rightNVDAVolume, None) + def onSessionTerminated(self, session: AudioSession) -> None: + pid = session.ProcessId + try: + channelVolume = session.channelAudioVolume() + channelCount = channelVolume.GetChannelCount() + if channelCount != 2: + log.warning( + f"Audio session for pid {pid} has {channelCount} channels instead of 2 - cannot set volume!", + ) + return + channelVolume.SetChannelVolume(0, 1.0, None) + channelVolume.SetChannelVolume(1, 1.0, None) + except Exception: + log.exception(f"Could not restore channel volume of process {pid} upon exit.") + -def _setSoundSplitState(state: SoundSplitState, initial: bool = False) -> dict: - applyToFuture = True +def _setSoundSplitState(state: SoundSplitState) -> dict: + global _activeCallback + if _activeCallback is not None: + _activeCallback.unregister() + _activeCallback = None if state == SoundSplitState.OFF: - if initial: - return {} - else: - # Disabling sound split via command or via settings - # We need to restore volume of all applications, but then don't set up callback for future audio sessions - state = SoundSplitState.NVDA_BOTH_APPS_BOTH - applyToFuture = False - leftVolume, rightVolume = state.getAppVolume() - leftNVDAVolume, rightNVDAVolume = state.getNVDAVolume() - volumeSetter = _VolumeSetter(leftVolume, rightVolume, leftNVDAVolume, rightNVDAVolume) - _applyToAllAudioSessions(volumeSetter, applyToFuture=applyToFuture) + _activeCallback = DummyAudioSessionCallback() + else: + leftVolume, rightVolume = state.getAppVolume() + leftNVDAVolume, rightNVDAVolume = state.getNVDAVolume() + _activeCallback = ChannelVolumeSetter( + leftVolume=leftVolume, + rightVolume=rightVolume, + leftNVDAVolume=leftNVDAVolume, + rightNVDAVolume=rightNVDAVolume, + ) + _activeCallback.register() + notTwoChannels = False if state == SoundSplitState.OFF else _activeCallback.foundSessionWithNot2Channels return { - "foundSessionWithNot2Channels": volumeSetter.foundSessionWithNot2Channels, + "foundSessionWithNot2Channels": notTwoChannels, } @@ -236,45 +191,3 @@ def _toggleSoundSplitState() -> None: "one of audio sessions is either mono, or has more than 2 audio channels.", ) ui.message(msg) - - -@dataclass(unsafe_hash=True) -class _VolumeRestorer(AudioSessionEvents): - pid: int - audioSession: AudioSession - - def on_state_changed(self, new_state: str, new_state_id: int): - if new_state == "Expired": - # For some reason restoring volume doesn't work in this thread, so scheduling in the main thread. - core.callLater(0, self.restoreVolume) - - def restoreVolume(self): - # Application connected to this audio session is terminating. Restore its volume. - try: - channelVolume = self.audioSession.channelAudioVolume() - channelCount = channelVolume.GetChannelCount() - if channelCount != 2: - log.warning( - f"Audio session for pid {self.pid} has {channelCount} channels instead of 2 - cannot set volume!", - ) - return - channelVolume.SetChannelVolume(0, 1.0, None) - channelVolume.SetChannelVolume(1, 1.0, None) - except Exception: - log.exception(f"Could not restore volume of process {self.pid} upon exit.") - self.unregister() - - def unregister(self): - with _applicationExitCallbacksLock: - try: - del _applicationExitCallbacks[self.pid] - except KeyError: - pass - try: - self.audioSession.unregister_notification() - except Exception: - log.exception(f"Cannot unregister audio session for process {self.pid}") - - -_applicationExitCallbacksLock = Lock() -_applicationExitCallbacks: dict[int, _VolumeRestorer] = {} diff --git a/source/audio/utils.py b/source/audio/utils.py new file mode 100644 index 00000000000..6e999de4abc --- /dev/null +++ b/source/audio/utils.py @@ -0,0 +1,190 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2024 NV Access Limited +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +from logHandler import log +from pycaw.api.audiopolicy import IAudioSessionManager2 +from pycaw.callbacks import AudioSessionNotification, AudioSessionEvents +from pycaw.utils import AudioSession, AudioUtilities +from dataclasses import dataclass, field +from comtypes import COMError +from threading import Lock +from pycaw.api.audiopolicy import IAudioSessionControl2 +import weakref +import globalVars +import os + + +_audioSessionManager: IAudioSessionManager2 | None = None + + +def initialize() -> None: + global _audioSessionManager + try: + _audioSessionManager = AudioUtilities.GetAudioSessionManager() + except COMError: + log.exception("Could not initialize audio session manager") + return + + +def terminate(): + global _audioSessionManager + _audioSessionManager = None + + +class _AudioSessionEventsListener(AudioSessionEvents): + """ + This class is a listener for audio session termination event. It is registered in WASAPI by + `_AudioSessionNotificationListener.on_session_created()` method. It calls custom logic defined in + `AudioSessionCallback.onSessionTerminated()` implementation and allows customers to restore audio volume. + """ + + callback: "weakref.ReferenceType[AudioSessionCallback]" + pid: int + audioSession: AudioSession + + def __init__(self, callback: "AudioSessionCallback", pid: int, audioSession: AudioSession): + self.callback = weakref.ref(callback) + self.pid = pid + self.audioSession = audioSession + + def on_state_changed(self, new_state: str, new_state_id: int): + if new_state == "Expired": + self.onSessionTerminated() + + def onSessionTerminated(self): + log.debug(f"Audio session for pid {self.pid} terminated") + try: + self.callback().onSessionTerminated(self.audioSession) + finally: + self.unregister() + with self.callback()._lock: + self.callback()._audioSessionEventListeners.remove(self) + + def unregister(self): + try: + self.audioSession.unregister_notification() + except Exception: + log.exception(f"Cannot unregister audio session for process {self.pid}") + + +class _AudioSessionNotificationListener(AudioSessionNotification): + """ + This class is a handler for existing and newly created audio sessions. Its method `on_session_created` + will be called from `AudioSessionCallback.register()` for all existing audio sessions; and, additionally, + it will be notified by WASAPI for every new audio session created. + It sets up a callback for session termination - an instance of `_AudioSessionEventsListener` class; + then it calls custom logic provided in `AudioSessionCallback.onSessionUpdate()` implementation. + """ + + callback: "weakref.ReferenceType[AudioSessionCallback]" + + def __init__(self, callback: "AudioSessionCallback"): + self.callback = weakref.ref(callback) + + def on_session_created(self, new_session: AudioSession): + pid = new_session.ProcessId + if pid != globalVars.appPid: + process = new_session.Process + if process is not None: + exe = os.path.basename(process.exe()) + isNvda = exe.lower() == "nvda.exe" + if isNvda: + # This process must be NVDA running on secure screen. + # We shouldn't change volume of such process. + return + audioSessionEventsListener = _AudioSessionEventsListener(self.callback(), pid, new_session) + new_session.register_notification(audioSessionEventsListener) + with self.callback()._lock: + self.callback()._audioSessionEventListeners.add(audioSessionEventsListener) + self.callback().onSessionUpdate(new_session) + + +class DummyAudioSessionCallback: + def register(self, applyToFuture: bool = True): + pass + + def unregister(self, runTerminators: bool = True): + pass + + +@dataclass(unsafe_hash=True) +class AudioSessionCallback(DummyAudioSessionCallback): + """ + This is an abstract class that allows implementing custom logic, that will be applied to all WASAPI + audio sessions. Consumers are expected to implement functions: + * def onSessionUpdate(self, session: AudioSession): + It will be called once for every existing audio session and also will be scheduled to be called + for every new audio session. + * def onSessionTerminated(self, session: AudioSession): + It will be called when an audio session is terminated or when unregister() is called, which + typically happens when NVDA quits. + """ + + _lock: Lock = Lock() + _audioSessionNotification: AudioSessionNotification | None = None + _audioSessionEventListeners: set[_AudioSessionEventsListener] = field(default_factory=set) + + def onSessionUpdate(self, session: AudioSession) -> None: + """ + Callback function to be implemented by the customer. + Will be called for all existing sessions and newly created sessions. + """ + pass + + def onSessionTerminated(self, session: AudioSession) -> None: + """ + Callback function to be implemented by the customer. + Will be called when each session is terminated or when unregister() method is called. + """ + pass + + def register(self, applyToFuture: bool = True): + """ + Registers this callback. + This will internally call onSessionUpdate() for all current audio sessions. + Additionally if applyToFuture is True, it will also call onSessionUpdate() for all newly created sessions + until this callback is unregistered. + """ + _applyToAllAudioSessions(self, applyToFuture) + + def unregister(self, runTerminators: bool = True): + """ + Unregisters this callback. + If runTerminators is True, it will also trigger onSessionTerminated for all current audio sessions. + """ + if self._audioSessionNotification is not None: + _audioSessionManager.UnregisterSessionNotification(self._audioSessionNotification) + with self._lock: + listenersCopy = list(self._audioSessionEventListeners) + for audioSessionEventListener in listenersCopy: + if runTerminators: + audioSessionEventListener.on_state_changed("Expired", 0) + else: + audioSessionEventListener.audioSession.unregister_notification() + + +def _applyToAllAudioSessions( + callback: AudioSessionCallback, + applyToFuture: bool = True, +) -> None: + """ + Executes provided callback function on all active audio sessions. + Additionally, if applyToFuture is True, then it will register a notification with audio session manager, + which will execute the same callback for all future sessions as they are created. + """ + listener = _AudioSessionNotificationListener(callback) + if applyToFuture: + _audioSessionManager.RegisterSessionNotification(listener) + callback._audioSessionNotification = listener + sessionEnumerator = _audioSessionManager.GetSessionEnumerator() + count = sessionEnumerator.GetCount() + for i in range(count): + ctl = sessionEnumerator.GetSession(i) + if ctl is None: + continue + ctl2 = ctl.QueryInterface(IAudioSessionControl2) + if ctl2 is not None: + audioSession = AudioSession(ctl2) + listener.on_session_created(audioSession) diff --git a/source/config/configSpec.py b/source/config/configSpec.py index fe440fe9a16..f5699c28c3c 100644 --- a/source/config/configSpec.py +++ b/source/config/configSpec.py @@ -63,6 +63,9 @@ whiteNoiseVolume = integer(default=0, min=0, max=100) soundSplitState = integer(default=0) includedSoundSplitModes = int_list(default=list(0, 2, 3)) + applicationsSoundVolume = integer(default=100, min=0, max=100) + applicationsSoundMuted = boolean(default=False) + applicationsVolumeMode = featureFlag(optionsEnum="AppsVolumeAdjusterFlag", behaviorOfDefault="DISABLED") # Braille settings [braille] diff --git a/source/config/featureFlagEnums.py b/source/config/featureFlagEnums.py index da65fb4d86d..e643abca9ec 100644 --- a/source/config/featureFlagEnums.py +++ b/source/config/featureFlagEnums.py @@ -66,6 +66,21 @@ def __bool__(self): return self == BoolFlag.ENABLED +class AppsVolumeAdjusterFlag(DisplayStringEnum): + @property + def _displayStringLabels(self): + return { + # Translators: Label for applications volume adjuster in NVDA settings. + self.DISABLED: _("Disabled Applications volume adjuster"), + # Translators: Label for applications volume adjuster in NVDA settings. + self.ENABLED: _("Enabled Applications volume adjuster"), + } + + DEFAULT = enum.auto() + DISABLED = enum.auto() + ENABLED = enum.auto() + + class ParagraphNavigationFlag(DisplayStringEnum): @property def _displayStringLabels(self): diff --git a/source/core.py b/source/core.py index f77c264b2a8..7cbe4fa7a12 100644 --- a/source/core.py +++ b/source/core.py @@ -317,7 +317,7 @@ def resetConfiguration(factoryDefaults=False): log.debug("terminating tones") tones.terminate() log.debug("terminating sound split") - audio.soundSplit.terminate() + audio.terminate() log.debug("Terminating background braille display detection") bdDetect.terminate() log.debug("Terminating background i/o") @@ -349,8 +349,8 @@ def resetConfiguration(factoryDefaults=False): # Tones tones.initialize() # Sound split - log.debug("initializing sound split") - audio.soundSplit.initialize() + log.debug("initializing audio") + audio.initialize() # Character processing log.debug("initializing character processing") characterProcessing.initialize() @@ -745,7 +745,7 @@ def main(): log.debug("Initializing sound split") import audio - audio.soundSplit.initialize() + audio.initialize() import speechDictHandler log.debug("Speech Dictionary processing") diff --git a/source/globalCommands.py b/source/globalCommands.py index 8759807a22b..1369281ca33 100755 --- a/source/globalCommands.py +++ b/source/globalCommands.py @@ -68,6 +68,7 @@ import vision from utils.security import objectBelowLockScreenAndWindowsIsLocked import audio +from audio import appsVolume #: Script category for text review commands. @@ -4706,6 +4707,50 @@ def script_cycleParagraphStyle(self, gesture: "inputCore.InputGesture") -> None: def script_cycleSoundSplit(self, gesture: "inputCore.InputGesture") -> None: audio._toggleSoundSplitState() + @script( + description=_( + # Translators: Describes a command. + "Increases the volume of the other applications", + ), + category=SCRCAT_AUDIO, + gesture="kb:NVDA+alt+pageUp", + ) + def script_increaseApplicationsVolume(self, gesture: "inputCore.InputGesture") -> None: + appsVolume._adjustAppsVolume(5) + + @script( + description=_( + # Translators: Describes a command. + "Decreases the volume of the other applications", + ), + category=SCRCAT_AUDIO, + gesture="kb:NVDA+alt+pageDown", + ) + def script_decreaseApplicationsVolume(self, gesture: "inputCore.InputGesture") -> None: + appsVolume._adjustAppsVolume(-5) + + @script( + description=_( + # Translators: Describes a command. + "Toggles other applications volume adjuster status", + ), + category=SCRCAT_AUDIO, + gesture=None, + ) + def script_toggleApplicationsVolumeAdjuster(self, gesture: "inputCore.InputGesture") -> None: + appsVolume._toggleAppsVolumeState() + + @script( + description=_( + # Translators: Describes a command. + "Mutes or unmutes other applications", + ), + category=SCRCAT_AUDIO, + gesture="kb:NVDA+alt+delete", + ) + def script_toggleApplicationsMute(self, gesture: "inputCore.InputGesture") -> None: + appsVolume._toggleAppsVolumeMute() + #: The single global commands instance. #: @type: L{GlobalCommands} diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index 1070986edb6..8dae51bb79a 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -3016,6 +3016,40 @@ def makeSettings(self, settingsSizer: wx.BoxSizer) -> None: self._appendSoundSplitModesList(sHelper) + # Translators: This is a label for the applications volume adjuster combo box in settings. + label = _("&Application volume adjuster status") + self.appVolAdjusterCombo: nvdaControls.FeatureFlagCombo = sHelper.addLabeledControl( + labelText=label, + wxCtrlClass=nvdaControls.FeatureFlagCombo, + keyPath=["audio", "applicationsVolumeMode"], + conf=config.conf, + ) + self.appVolAdjusterCombo.Bind(wx.EVT_CHOICE, self._onSoundVolChange) + self.bindHelpEvent("AppsVolumeAdjusterStatus", self.appVolAdjusterCombo) + + # Translators: This is the label for a slider control in the + # Audio settings panel. + label = _("Volume of other applications") + self.appSoundVolSlider: nvdaControls.EnhancedInputSlider = sHelper.addLabeledControl( + label, + nvdaControls.EnhancedInputSlider, + minValue=0, + maxValue=100, + ) + self.bindHelpEvent("OtherAppVolume", self.appSoundVolSlider) + volume = config.conf["audio"]["applicationsSoundVolume"] + if 0 <= volume <= 100: + self.appSoundVolSlider.SetValue(volume) + else: + log.error("Invalid volume level: {}", volume) + defaultVolume = config.conf.getConfigValidation(["audio", "applicationsSoundVolume"]).default + self.appSoundVolSlider.SetValue(defaultVolume) + + # Translators: Mute other apps checkbox in settings + self.muteOtherAppsCheckBox = wx.CheckBox(self, label=_("Mute other apps")) + self.muteOtherAppsCheckBox.SetValue(config.conf["audio"]["applicationsSoundMuted"]) + self.bindHelpEvent("OtherAppMute", self.muteOtherAppsCheckBox) + self._onSoundVolChange(None) audioAwakeTimeLabelText = _( @@ -3076,6 +3110,15 @@ def onSave(self): for mIndex in range(len(self._allSoundSplitModes)) if mIndex in self.soundSplitModesList.CheckedItems ] + config.conf["audio"]["applicationsSoundVolume"] = self.appSoundVolSlider.GetValue() + config.conf["audio"]["applicationsSoundMuted"] = self.muteOtherAppsCheckBox.GetValue() + self.appVolAdjusterCombo.saveCurrentValueToConf() + audio.appsVolume._updateAppsVolumeImpl( + volume=self.appSoundVolSlider.GetValue() / 100.0, + muted=self.muteOtherAppsCheckBox.GetValue(), + state=self.appVolAdjusterCombo._getControlCurrentValue(), + ) + if audioDucking.isAudioDuckingSupported(): index = self.duckingList.GetSelection() config.conf["audio"]["audioDuckingMode"] = index @@ -3097,6 +3140,15 @@ def _onSoundVolChange(self, event: wx.Event) -> None: self.soundSplitComboBox.Enable(wasapi) self.soundSplitModesList.Enable(wasapi) + avEnabled = config.featureFlagEnums.AppsVolumeAdjusterFlag.ENABLED + self.appSoundVolSlider.Enable( + wasapi and self.appVolAdjusterCombo._getControlCurrentValue() == avEnabled, + ) + self.muteOtherAppsCheckBox.Enable( + wasapi and self.appVolAdjusterCombo._getControlCurrentValue() == avEnabled, + ) + self.appVolAdjusterCombo.Enable(wasapi) + def isValid(self) -> bool: enabledSoundSplitModes = self.soundSplitModesList.CheckedItems if len(enabledSoundSplitModes) < 1: diff --git a/user_docs/en/changes.md b/user_docs/en/changes.md index 26044c0377e..e00e23a4510 100644 --- a/user_docs/en/changes.md +++ b/user_docs/en/changes.md @@ -5,6 +5,9 @@ ### Important notes ### New Features +* The volume of other applications can be adjusted by `NVDA+alt+pageUp` and `NVDA+alt+pageDown`. In order to use this feature, application volume adjuster needs to be enabled in Audio pane of NVDA settings. (#16052, @mltony) +* Added command to mute or unmute all other applications, assigned to `NVDA+alt+delete`. +In order to use this feature, the application volume adjuster needs to be enabled in the Audio category of NVDA settings. (#16052, @mltony) * When editing in Microsoft PowerPoint text boxes, you can now move per sentence with `alt+upArrow`/`alt+downArrow`. (#17015, @LeonarddeR) * In Mozilla Firefox, NVDA will report the highlighted text when a URL containing a text fragment is visited. (#16910, @jcsteh) diff --git a/user_docs/en/userGuide.md b/user_docs/en/userGuide.md index cda42f73fce..f5d4f44e933 100644 --- a/user_docs/en/userGuide.md +++ b/user_docs/en/userGuide.md @@ -2431,6 +2431,8 @@ This mode may differ from "Sound split disabled" mode in case other audio proces Please note, that sound split doesn't work as a mixer. For example, if an application is playing a stereo sound track while sound split is set to "NVDA on the left and applications on the right", then you will only hear the right channel of the sound track, while the left channel of the sound track will be muted. +If you wish to adjust volume of all applications except for NVDA, consider using [the dedicated commands](#OtherAppVolume). + This option is not available if you have started NVDA with [WASAPI disabled for audio output](#WASAPI) in Advanced Settings. Please note, that if NVDA crashes, then it won't be able to restore application sounds volume, and those applications might still output sound only in one channel after NVDA crash. @@ -2449,6 +2451,46 @@ By default only three modes are included. Note that it is necessary to check at least one mode. This option is not available if you have started NVDA with [WASAPI disabled for audio output](#WASAPI) in Advanced Settings. +##### Applications volume adjuster status {#AppsVolumeAdjusterStatus} + +This combo box allows you to select the status of the applications volume adjuster. +The applications volume adjuster allows you to adjust volume of all other applications except for NVDA or mute them with a single keystroke. + +Possible values are: + +* Disabled: NVDA doesn't interfere with volume levels of other applications. +* Enabled: volume of other applications can be adjusted via [other applications volume slider](#OtherAppVolume). + +This option is not available if you have started NVDA with [WASAPI disabled for audio output](#WASAPI) in Advanced Settings. + +##### Volume of other applications {#OtherAppVolume} + +This slider allows you to adjust the volume of all currently running applications other than NVDA. +This volume setting will apply to all other applications sound output, even if they start after this setting is changed. +This volume can also be controlled via the following keyboard commands from anywhere: + +| Name | Key | Description | +|---|---|---| +| Increase applications volume | `NVDA+alt+pageUp` | Increases volume of all applications except NVDA. | +| Decrease applications volume | `NVDA+alt+pageDown` | Decreases volume of all applications except NVDA. | + +This option is not available if you have started NVDA with [WASAPI disabled for audio output](#WASAPI) in Advanced Settings. + +##### Muting other applications {#OtherAppMute} + +This check box allows you to mute or unmute all applications except NVDA at once. +This mute setting will apply to all other applications sound output, even if they start after this setting is changed. + +The following keyboard command can also be used from anywhere: + +| Name | Key | Description | +|---|---|---| +| Mute or unmute other applications | `NVDA+alt+delete` | Toggles mute/unmute on other applications | + +Please note, that this option is not persistent: other apps will always be unmuted when NVDA restarts. + +This option is not available if you have started NVDA with [WASAPI disabled for audio output](#WASAPI) in Advanced Settings. + ##### Time to keep audio device awake after speech {#AudioAwakeTime} This edit box specifies how long NVDA keeps the audio device awake after speech ends.