Skip to content

Commit

Permalink
Keystrokes to adjust applications volume and mute [take 2] (#16591)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
mltony authored Sep 3, 2024
1 parent 805fb12 commit b6d08a0
Show file tree
Hide file tree
Showing 11 changed files with 634 additions and 138 deletions.
39 changes: 39 additions & 0 deletions source/audio/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
194 changes: 194 additions & 0 deletions source/audio/appsVolume.py
Original file line number Diff line number Diff line change
@@ -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)
Loading

0 comments on commit b6d08a0

Please sign in to comment.