Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Sound split #16071

Merged
merged 48 commits into from
Mar 6, 2024
Merged
Show file tree
Hide file tree
Changes from 45 commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
f73618e
Sound split
mltony Jan 18, 2024
adf2a8e
Addressing comments
mltony Jan 22, 2024
a856a87
Addressing comments by @@CyrilleB79
mltony Jan 23, 2024
8f8ce12
Update user_docs/en/userGuide.t2t
mltony Jan 23, 2024
501737f
According to @lukaszgo1 converting json-encoded list into native list…
mltony Jan 23, 2024
bfef0fc
Update source/audio.py
mltony Jan 24, 2024
eff0c6d
Addressing comment by @LeonarddeR
mltony Jan 24, 2024
7834395
Addressing comments
mltony Jan 25, 2024
7873e96
Addresing comment
mltony Jan 25, 2024
1f6d4a4
lint
mltony Jan 25, 2024
4202ab6
Addressing comments
mltony Jan 26, 2024
11f5edc
Merge branch 'master' into soundSplit
mltony Jan 26, 2024
68e5d8d
Update source/audio/soundSplit.py
mltony Jan 26, 2024
74f525c
Addressing comment
mltony Jan 26, 2024
f2d5701
lint
mltony Jan 26, 2024
1d4ffb3
Update source/audio/soundSplit.py
mltony Feb 1, 2024
62f9415
Update source/audio/soundSplit.py
mltony Feb 1, 2024
64aac39
Update doc
mltony Feb 1, 2024
8bddb3b
Update user_docs/en/changes.t2t
mltony Feb 8, 2024
007e108
Update source/audio/__init__.py
mltony Feb 8, 2024
6d60437
Switching to pycaw
mltony Feb 11, 2024
780a402
Merge branch 'master' into soundSplit
mltony Feb 11, 2024
14db885
lint
mltony Feb 11, 2024
66753cb
Revert sconscript
mltony Feb 11, 2024
0fc96c7
Update pycaw version
mltony Feb 21, 2024
5da261d
Update source/config/configSpec.py
mltony Feb 22, 2024
4671781
Update source/audio/soundSplit.py
mltony Feb 22, 2024
549dde3
Update source/audio/soundSplit.py
mltony Feb 22, 2024
ce52f0f
Update source/audio/soundSplit.py
mltony Feb 22, 2024
a9bfe6a
Update source/audio/soundSplit.py
mltony Feb 22, 2024
01ce970
Update source/audio/soundSplit.py
mltony Feb 22, 2024
04eb90a
Update source/gui/settingsDialogs.py
mltony Feb 22, 2024
466075c
doc
mltony Feb 22, 2024
5832eb7
Merge branch 'master' into soundSplit
mltony Feb 22, 2024
cfe3015
Update source/audio/soundSplit.py
mltony Feb 22, 2024
b6adad1
Addressing comments
mltony Feb 22, 2024
cf1f6ba
Merge branch 'master' into soundSplit
mltony Feb 22, 2024
26d319d
docs
mltony Feb 22, 2024
520b4fc
commit suggestion
seanbudd Feb 28, 2024
633b814
Update source/audio/soundSplit.py
mltony Feb 28, 2024
880544a
Update source/audio/soundSplit.py
mltony Feb 28, 2024
34cc3ab
Update source/gui/settingsDialogs.py
mltony Feb 28, 2024
161bc2a
Update user_docs/en/userGuide.t2t
mltony Feb 28, 2024
ef5c81c
Update user_docs/en/userGuide.t2t
mltony Feb 28, 2024
fb9c561
Update user_docs/en/userGuide.t2t
mltony Feb 28, 2024
26f75fd
Update source/gui/settingsDialogs.py
seanbudd Mar 6, 2024
e3227ca
fixup changes
seanbudd Mar 6, 2024
9c109d3
Update user_docs/en/changes.t2t
seanbudd Mar 6, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 16 additions & 0 deletions source/audio/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
189 changes: 189 additions & 0 deletions source/audio/soundSplit.py
Original file line number Diff line number Diff line change
@@ -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():
mltony marked this conversation as resolved.
Show resolved Hide resolved
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():
mltony marked this conversation as resolved.
Show resolved Hide resolved
setSoundSplitState(SoundSplitState.OFF)
unregisterCallback()
else:
log.debug("Skipping terminating sound split as WASAPI is disabled.")


def applyToAllAudioSessions(
seanbudd marked this conversation as resolved.
Show resolved Hide resolved
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 = _(
seanbudd marked this conversation as resolved.
Show resolved Hide resolved
# 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)
2 changes: 2 additions & 0 deletions source/config/configSpec.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
9 changes: 9 additions & 0 deletions source/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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")
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
16 changes: 16 additions & 0 deletions source/globalCommands.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
from base64 import b16encode
import vision
from utils.security import objectBelowLockScreenAndWindowsIsLocked
import audio


#: Script category for text review commands.
Expand Down Expand Up @@ -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).
Expand All @@ -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):
Expand Down Expand Up @@ -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}
Expand Down
Loading