diff --git a/requirements.txt b/requirements.txt index b9b58103ae9..705f1773d8a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,6 +16,9 @@ diff_match_patch_python==1.0.2 # typing_extensions are required for specifying default value for `TypeVar`, which is not yet possible with any released version of Python (see PEP 696) typing-extensions==4.9.0 +# pycaw is a Core Audio Windows Library used for sound split +pycaw==20240210 + # Packaging NVDA git+https://github.com/py2exe/py2exe@4e7b2b2c60face592e67cb1bc935172a20fa371d#egg=py2exe diff --git a/source/audio/__init__.py b/source/audio/__init__.py new file mode 100644 index 00000000000..47d07112d94 --- /dev/null +++ b/source/audio/__init__.py @@ -0,0 +1,16 @@ +# 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 .soundSplit import ( + SoundSplitState, + setSoundSplitState, + toggleSoundSplitState, +) + +__all__ = [ + "SoundSplitState", + "setSoundSplitState", + "toggleSoundSplitState", +] diff --git a/source/audio/soundSplit.py b/source/audio/soundSplit.py new file mode 100644 index 00000000000..5b2ff8e446d --- /dev/null +++ b/source/audio/soundSplit.py @@ -0,0 +1,189 @@ +# 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 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 +from pycaw.utils import AudioSession, AudioUtilities +import ui +from utils.displayString import DisplayStringIntEnum +from dataclasses import dataclass + +VolumeTupleT = tuple[float, float] + + +@unique +class SoundSplitState(DisplayStringIntEnum): + OFF = 0 + NVDA_LEFT_APPS_RIGHT = 1 + NVDA_LEFT_APPS_BOTH = 2 + NVDA_RIGHT_APPS_LEFT = 3 + NVDA_RIGHT_APPS_BOTH = 4 + NVDA_BOTH_APPS_LEFT = 5 + NVDA_BOTH_APPS_RIGHT = 6 + + @property + def _displayStringLabels(self) -> dict[IntEnum, str]: + return { + # Translators: Sound split state + SoundSplitState.OFF: pgettext("SoundSplit", "Disabled"), + # Translators: Sound split state + SoundSplitState.NVDA_LEFT_APPS_RIGHT: _("NVDA on the left and applications on the right"), + # Translators: Sound split state + SoundSplitState.NVDA_LEFT_APPS_BOTH: _("NVDA on the left and applications in both channels"), + # Translators: Sound split state + SoundSplitState.NVDA_RIGHT_APPS_LEFT: _("NVDA on the right and applications on the left"), + # Translators: Sound split state + SoundSplitState.NVDA_RIGHT_APPS_BOTH: _("NVDA on the right and applications in both channels"), + # Translators: Sound split state + SoundSplitState.NVDA_BOTH_APPS_LEFT: _("NVDA in both channels and applications on the left"), + # Translators: Sound split state + SoundSplitState.NVDA_BOTH_APPS_RIGHT: _("NVDA in both channels and applications on the right"), + } + + def getAppVolume(self) -> VolumeTupleT: + match self: + case SoundSplitState.OFF | SoundSplitState.NVDA_LEFT_APPS_BOTH | SoundSplitState.NVDA_RIGHT_APPS_BOTH: + return (1.0, 1.0) + case SoundSplitState.NVDA_RIGHT_APPS_LEFT | SoundSplitState.NVDA_BOTH_APPS_LEFT: + return (1.0, 0.0) + case SoundSplitState.NVDA_LEFT_APPS_RIGHT | SoundSplitState.NVDA_BOTH_APPS_RIGHT: + return (0.0, 1.0) + case _: + raise RuntimeError(f"Unexpected or unknown state {self=}") + + def getNVDAVolume(self) -> VolumeTupleT: + match self: + case SoundSplitState.OFF | SoundSplitState.NVDA_BOTH_APPS_LEFT | SoundSplitState.NVDA_BOTH_APPS_RIGHT: + return (1.0, 1.0) + case SoundSplitState.NVDA_LEFT_APPS_RIGHT | SoundSplitState.NVDA_LEFT_APPS_BOTH: + return (1.0, 0.0) + case SoundSplitState.NVDA_RIGHT_APPS_LEFT | SoundSplitState.NVDA_RIGHT_APPS_BOTH: + return (0.0, 1.0) + case _: + raise RuntimeError(f"Unexpected or unknown state {self=}") + + +audioSessionManager: IAudioSessionManager2 | None = None +activeCallback: AudioSessionNotification | None = None + + +def initialize() -> None: + if nvwave.usingWasapiWavePlayer(): + global audioSessionManager + audioSessionManager = AudioUtilities.GetAudioSessionManager() + state = SoundSplitState(config.conf["audio"]["soundSplitState"]) + setSoundSplitState(state) + else: + log.debug("Cannot initialize sound split as WASAPI is disabled") + + +@atexit.register +def terminate(): + if nvwave.usingWasapiWavePlayer(): + setSoundSplitState(SoundSplitState.OFF) + unregisterCallback() + else: + log.debug("Skipping terminating sound split as WASAPI is disabled.") + + +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() + 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 = None + + +@dataclass(unsafe_hash=True) +class VolumeSetter(AudioSessionNotification): + leftVolume: float + rightVolume: float + leftNVDAVolume: float + rightNVDAVolume: float + foundSessionWithNot2Channels: bool = False + + def on_session_created(self, new_session: AudioSession): + pid = new_session.ProcessId + channelVolume = new_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!") + self.foundSessionWithNot2Channels = True + return + if pid != globalVars.appPid: + channelVolume.SetChannelVolume(0, self.leftVolume, None) + channelVolume.SetChannelVolume(1, self.rightVolume, None) + else: + channelVolume.SetChannelVolume(0, self.leftNVDAVolume, None) + channelVolume.SetChannelVolume(1, self.rightNVDAVolume, None) + + +def setSoundSplitState(state: SoundSplitState) -> dict: + leftVolume, rightVolume = state.getAppVolume() + leftNVDAVolume, rightNVDAVolume = state.getNVDAVolume() + volumeSetter = VolumeSetter(leftVolume, rightVolume, leftNVDAVolume, rightNVDAVolume) + applyToAllAudioSessions(volumeSetter) + return { + "foundSessionWithNot2Channels": volumeSetter.foundSessionWithNot2Channels, + } + + +def toggleSoundSplitState() -> None: + if not nvwave.usingWasapiWavePlayer(): + message = _( + # Translators: error message when wasapi is turned off. + "Sound split cannot be used. " + "Please enable WASAPI in the Advanced category in NVDA Settings to use it." + ) + ui.message(message) + return + state = SoundSplitState(config.conf["audio"]["soundSplitState"]) + allowedStates: list[int] = config.conf["audio"]["includedSoundSplitModes"] + try: + i = allowedStates.index(state) + except ValueError: + # State not found, resetting to default (OFF) + i = -1 + i = (i + 1) % len(allowedStates) + newState = SoundSplitState(allowedStates[i]) + result = setSoundSplitState(newState) + config.conf["audio"]["soundSplitState"] = newState.value + ui.message(newState.displayString) + if result["foundSessionWithNot2Channels"]: + msg = _( + # Translators: warning message when sound split trigger wasn't successful due to one of audio sessions + # had number of channels other than 2 . + "Warning: couldn't set volumes for sound split: " + "one of audio sessions is either mono, or has more than 2 audio channels." + ) + ui.message(msg) diff --git a/source/config/configSpec.py b/source/config/configSpec.py index 33e6f4831b8..56eab8affc4 100644 --- a/source/config/configSpec.py +++ b/source/config/configSpec.py @@ -57,6 +57,8 @@ soundVolume = integer(default=100, min=0, max=100) audioAwakeTime = integer(default=30, min=0, max=3600) whiteNoiseVolume = integer(default=0, min=0, max=100) + soundSplitState = integer(default=0) + includedSoundSplitModes = int_list(default=list(0, 1, 2)) # Braille settings [braille] diff --git a/source/core.py b/source/core.py index 8b022e7f830..a27b81d520a 100644 --- a/source/core.py +++ b/source/core.py @@ -276,6 +276,7 @@ def resetConfiguration(factoryDefaults=False): import bdDetect import hwIo import tones + import audio log.debug("Terminating vision") vision.terminate() log.debug("Terminating braille") @@ -286,6 +287,8 @@ def resetConfiguration(factoryDefaults=False): speech.terminate() log.debug("terminating tones") tones.terminate() + log.debug("terminating sound split") + audio.soundSplit.terminate() log.debug("Terminating background braille display detection") bdDetect.terminate() log.debug("Terminating background i/o") @@ -315,6 +318,9 @@ def resetConfiguration(factoryDefaults=False): bdDetect.initialize() # Tones tones.initialize() + # Sound split + log.debug("initializing sound split") + audio.soundSplit.initialize() #Speech log.debug("initializing speech") speech.initialize() @@ -663,6 +669,9 @@ def main(): log.debug("Initializing tones") import tones tones.initialize() + log.debug("Initializing sound split") + import audio + audio.soundSplit.initialize() import speechDictHandler log.debug("Speech Dictionary processing") speechDictHandler.initialize() diff --git a/source/globalCommands.py b/source/globalCommands.py index b1066393789..8927b24ed81 100755 --- a/source/globalCommands.py +++ b/source/globalCommands.py @@ -66,6 +66,7 @@ from base64 import b16encode import vision from utils.security import objectBelowLockScreenAndWindowsIsLocked +import audio #: Script category for text review commands. @@ -113,6 +114,9 @@ #: Script category for document formatting commands. # Translators: The name of a category of NVDA commands. SCRCAT_DOCUMENTFORMATTING = _("Document formatting") +#: Script category for audio streaming commands. +# Translators: The name of a category of NVDA commands. +SCRCAT_AUDIO = _("Audio") # Translators: Reported when there are no settings to configure in synth settings ring # (example: when there is no setting for language). @@ -127,6 +131,7 @@ class GlobalCommands(ScriptableObject): # Translators: Describes the Cycle audio ducking mode command. "Cycles through audio ducking modes which determine when NVDA lowers the volume of other sounds" ), + category=SCRCAT_AUDIO, gesture="kb:NVDA+shift+d" ) def script_cycleAudioDuckingMode(self,gesture): @@ -4461,6 +4466,17 @@ def script_cycleParagraphStyle(self, gesture: "inputCore.InputGesture") -> None: config.conf["documentNavigation"]["paragraphStyle"] = newFlag.name ui.message(newFlag.displayString) + @script( + description=_( + # Translators: Describes a command. + "Cycles through sound split modes", + ), + category=SCRCAT_AUDIO, + gesture="kb:NVDA+alt+s", + ) + def script_cycleSoundSplit(self, gesture: "inputCore.InputGesture") -> None: + audio.toggleSoundSplitState() + #: The single global commands instance. #: @type: L{GlobalCommands} diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index 74d0236c0f2..c05b3fe5e7a 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -42,6 +42,7 @@ import globalVars from logHandler import log import nvwave +import audio import audioDucking import queueHandler import braille @@ -2717,6 +2718,19 @@ def makeSettings(self, settingsSizer: wx.BoxSizer) -> None: self.bindHelpEvent("SoundVolume", self.soundVolSlider) self.soundVolSlider.SetValue(config.conf["audio"]["soundVolume"]) + # Translators: This is a label for the sound split combo box in the Audio Settings dialog. + soundSplitLabelText = _("&Sound split mode:") + self.soundSplitComboBox = sHelper.addLabeledControl( + soundSplitLabelText, + wx.Choice, + choices=[mode.displayString for mode in audio.SoundSplitState] + ) + self.bindHelpEvent("SelectSoundSplitMode", self.soundSplitComboBox) + index = config.conf["audio"]["soundSplitState"] + self.soundSplitComboBox.SetSelection(index) + + self._appendSoundSplitModesList(sHelper) + self._onSoundVolChange(None) audioAwakeTimeLabelText = _( @@ -2736,6 +2750,48 @@ def makeSettings(self, settingsSizer: wx.BoxSizer) -> None: self.bindHelpEvent("AudioAwakeTime", self.audioAwakeTimeEdit) self.audioAwakeTimeEdit.Enable(nvwave.usingWasapiWavePlayer()) + def _appendSoundSplitModesList(self, settingsSizerHelper: guiHelper.BoxSizerHelper) -> None: + self._allSoundSplitModes = list(audio.SoundSplitState) + self.soundSplitModesList: nvdaControls.CustomCheckListBox = settingsSizerHelper.addLabeledControl( + # Translators: Label of the list where user can select sound split modes that will be available. + _("&Modes available in the 'Cycle sound split mode' command:"), + nvdaControls.CustomCheckListBox, + choices=[mode.displayString for mode in self._allSoundSplitModes] + ) + self.bindHelpEvent("CustomizeSoundSplitModes", self.soundSplitModesList) + includedModes: list[int] = config.conf["audio"]["includedSoundSplitModes"] + self.soundSplitModesList.Checked = [ + mIndex for mIndex in range(len(self._allSoundSplitModes)) if mIndex in includedModes + ] + self.soundSplitModesList.Bind(wx.EVT_CHECKLISTBOX, self._onSoundSplitModesListChange) + self.soundSplitModesList.Select(0) + + def _onSoundSplitModesListChange(self, evt: wx.CommandEvent): + # continue event propagation to custom control event handler + # to guarantee user is notified about checkbox being checked or unchecked + evt.Skip() + if ( + evt.GetInt() == self._allSoundSplitModes.index(audio.SoundSplitState.OFF) + and not self.soundSplitModesList.IsChecked(evt.GetInt()) + ): + if gui.messageBox( + _( + # Translators: Warning shown when 'OFF' sound split mode is disabled in settings. + "You did not choose 'Off' as one of your sound split mode options. " + "Please note that this may result in no speech output at all " + "in case one of your audio channels is malfunctioning. " + "Are you sure you want to continue?" + ), + # Translators: Title of the warning message. + _("Warning"), + wx.YES | wx.NO | wx.ICON_WARNING, + self, + ) == wx.NO: + self.soundSplitModesList.SetCheckedItems( + list(self.soundSplitModesList.GetCheckedItems()) + + [self._allSoundSplitModes.index(audio.SoundSplitState.OFF)] + ) + def onSave(self): if config.conf["speech"]["outputDevice"] != self.deviceList.GetStringSelection(): # Synthesizer must be reload if output device changes @@ -2752,6 +2808,14 @@ def onSave(self): config.conf["audio"]["soundVolumeFollowsVoice"] = self.soundVolFollowCheckBox.IsChecked() config.conf["audio"]["soundVolume"] = self.soundVolSlider.GetValue() + index = self.soundSplitComboBox.GetSelection() + config.conf["audio"]["soundSplitState"] = index + audio.setSoundSplitState(audio.SoundSplitState(index)) + config.conf["audio"]["includedSoundSplitModes"] = [ + mIndex + for mIndex in range(len(self._allSoundSplitModes)) + if mIndex in self.soundSplitModesList.CheckedItems + ] if audioDucking.isAudioDuckingSupported(): index = self.duckingList.GetSelection() config.conf["audio"]["audioDuckingMode"] = index @@ -2771,6 +2835,23 @@ def _onSoundVolChange(self, event: wx.Event) -> None: wasapi and not self.soundVolFollowCheckBox.IsChecked() ) + self.soundSplitComboBox.Enable(wasapi) + self.soundSplitModesList.Enable(wasapi) + + def isValid(self) -> bool: + enabledSoundSplitModes = self.soundSplitModesList.CheckedItems + if len(enabledSoundSplitModes) < 1: + log.debugWarning("No sound split modes enabled.") + gui.messageBox( + # Translators: Message shown when no sound split modes are enabled. + _("At least one sound split mode has to be checked."), + # Translators: The title of the message box + _("Error"), + wx.OK | wx.ICON_ERROR, + self, + ) + return False + return super().isValid() class AddonStorePanel(SettingsPanel): diff --git a/user_docs/en/changes.t2t b/user_docs/en/changes.t2t index 17a51756125..5ee9ee60ae1 100644 --- a/user_docs/en/changes.t2t +++ b/user_docs/en/changes.t2t @@ -25,6 +25,10 @@ What's New in NVDA - - Added support for the BrailleEdgeS2 braille device. (#16033) - NVDA will keep the audio device awake after speech stops, in order to prevent the start of the next speech being clipped with some audio devices such as Bluetooth headphones. (#14386, @jcsteh, @mltony) +- Sound split: (#12985, @mltony) + - Allows splitting NVDA sounds in one channel (e.g. left) while sounds from all other applications in the other channel (e.g. right). + - Toggled by ``NVDA+alt+s``. + - - diff --git a/user_docs/en/userGuide.t2t b/user_docs/en/userGuide.t2t index f04f56ede86..500b09f0bf3 100644 --- a/user_docs/en/userGuide.t2t +++ b/user_docs/en/userGuide.t2t @@ -1957,6 +1957,41 @@ Setting the value too high may cause the battery of the sound output device to d You can set the time to zero in order to disable this feature. + +==== Sound split====[SelectSoundSplitMode] + +The sound split feature allows users to make use of their stereo output devices, such as headphones and speakers. +Sound split makes it possible to have NVDA speech in one channel (e.g. left) and have all other applications play their sounds in the other channel (e.g. right). +By default sound split is disabled, which means that all applications including NVDA will play sounds in both left and right channels. +A gesture allows cycling through the various sound split modes: +%kc:beginInclude +|| Name | Key | Description | +| Cycle Sound Split Mode | ``NVDA+alt+s`` | Cycles between sound split modes. | + +%kc:endInclude + +By default this command will cycle between the following modes: +- Disabled sound split: both NVDA and other applications output sounds to both left and right channels. +- NVDA on the left and applications on the right: NVDA will speak in the left channel, while other applications will play sounds in the right channel. +- NVDA on the right and applications on the left: NVDA will speak in the right channel, while other applications will play sounds in the left channel. +- + +There are more advanced sound split modes available in NVDA setting combo box. +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. + +This option is not available if you have started NVDA with [WASAPI disabled for audio output #WASAPI] in Advanced Settings. + +==== Customizing Sound split modes====[CustomizeSoundSplitModes] +This checkable list allows selecting which sound split modes are included when cycling between them using ``NVDA+alt+s``. +Modes which are unchecked are excluded. +By default only three modes are included. +- Sound split disabled: both NVDA and applications play sounds in both left and right channels. +- NVDA on the left and all other applications on the right channel. +- NVDA on the right and all other applications on the left channel. +- + +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. +++ Vision +++[VisionSettings]