Skip to content

Commit 7c0845a

Browse files
authored
Merge fb9c561 into 74a1998
2 parents 74a1998 + fb9c561 commit 7c0845a

9 files changed

Lines changed: 352 additions & 0 deletions

File tree

requirements.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ diff_match_patch_python==1.0.2
1616
# typing_extensions are required for specifying default value for `TypeVar`, which is not yet possible with any released version of Python (see PEP 696)
1717
typing-extensions==4.9.0
1818

19+
# pycaw is a Core Audio Windows Library used for sound split
20+
pycaw==20240210
21+
1922
# Packaging NVDA
2023
git+https://github.com/py2exe/py2exe@4e7b2b2c60face592e67cb1bc935172a20fa371d#egg=py2exe
2124

source/audio/__init__.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# A part of NonVisual Desktop Access (NVDA)
2+
# Copyright (C) 2024 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 .soundSplit import (
7+
SoundSplitState,
8+
setSoundSplitState,
9+
toggleSoundSplitState,
10+
)
11+
12+
__all__ = [
13+
"SoundSplitState",
14+
"setSoundSplitState",
15+
"toggleSoundSplitState",
16+
]

source/audio/soundSplit.py

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
# A part of NonVisual Desktop Access (NVDA)
2+
# Copyright (C) 2024 NV Access Limited
3+
# This file is covered by the GNU General Public License.
4+
# See the file COPYING for more details.
5+
6+
import atexit
7+
import config
8+
from enum import IntEnum, unique
9+
import globalVars
10+
from logHandler import log
11+
import nvwave
12+
from pycaw.api.audiopolicy import IAudioSessionManager2
13+
from pycaw.callbacks import AudioSessionNotification
14+
from pycaw.utils import AudioSession, AudioUtilities
15+
import ui
16+
from utils.displayString import DisplayStringIntEnum
17+
from dataclasses import dataclass
18+
19+
VolumeTupleT = tuple[float, float]
20+
21+
22+
@unique
23+
class SoundSplitState(DisplayStringIntEnum):
24+
OFF = 0
25+
NVDA_LEFT_APPS_RIGHT = 1
26+
NVDA_LEFT_APPS_BOTH = 2
27+
NVDA_RIGHT_APPS_LEFT = 3
28+
NVDA_RIGHT_APPS_BOTH = 4
29+
NVDA_BOTH_APPS_LEFT = 5
30+
NVDA_BOTH_APPS_RIGHT = 6
31+
32+
@property
33+
def _displayStringLabels(self) -> dict[IntEnum, str]:
34+
return {
35+
# Translators: Sound split state
36+
SoundSplitState.OFF: pgettext("SoundSplit", "Disabled"),
37+
# Translators: Sound split state
38+
SoundSplitState.NVDA_LEFT_APPS_RIGHT: _("NVDA on the left and applications on the right"),
39+
# Translators: Sound split state
40+
SoundSplitState.NVDA_LEFT_APPS_BOTH: _("NVDA on the left and applications in both channels"),
41+
# Translators: Sound split state
42+
SoundSplitState.NVDA_RIGHT_APPS_LEFT: _("NVDA on the right and applications on the left"),
43+
# Translators: Sound split state
44+
SoundSplitState.NVDA_RIGHT_APPS_BOTH: _("NVDA on the right and applications in both channels"),
45+
# Translators: Sound split state
46+
SoundSplitState.NVDA_BOTH_APPS_LEFT: _("NVDA in both channels and applications on the left"),
47+
# Translators: Sound split state
48+
SoundSplitState.NVDA_BOTH_APPS_RIGHT: _("NVDA in both channels and applications on the right"),
49+
}
50+
51+
def getAppVolume(self) -> VolumeTupleT:
52+
match self:
53+
case SoundSplitState.OFF | SoundSplitState.NVDA_LEFT_APPS_BOTH | SoundSplitState.NVDA_RIGHT_APPS_BOTH:
54+
return (1.0, 1.0)
55+
case SoundSplitState.NVDA_RIGHT_APPS_LEFT | SoundSplitState.NVDA_BOTH_APPS_LEFT:
56+
return (1.0, 0.0)
57+
case SoundSplitState.NVDA_LEFT_APPS_RIGHT | SoundSplitState.NVDA_BOTH_APPS_RIGHT:
58+
return (0.0, 1.0)
59+
case _:
60+
raise RuntimeError(f"Unexpected or unknown state {self=}")
61+
62+
def getNVDAVolume(self) -> VolumeTupleT:
63+
match self:
64+
case SoundSplitState.OFF | SoundSplitState.NVDA_BOTH_APPS_LEFT | SoundSplitState.NVDA_BOTH_APPS_RIGHT:
65+
return (1.0, 1.0)
66+
case SoundSplitState.NVDA_LEFT_APPS_RIGHT | SoundSplitState.NVDA_LEFT_APPS_BOTH:
67+
return (1.0, 0.0)
68+
case SoundSplitState.NVDA_RIGHT_APPS_LEFT | SoundSplitState.NVDA_RIGHT_APPS_BOTH:
69+
return (0.0, 1.0)
70+
case _:
71+
raise RuntimeError(f"Unexpected or unknown state {self=}")
72+
73+
74+
audioSessionManager: IAudioSessionManager2 | None = None
75+
activeCallback: AudioSessionNotification | None = None
76+
77+
78+
def initialize() -> None:
79+
if nvwave.usingWasapiWavePlayer():
80+
global audioSessionManager
81+
audioSessionManager = AudioUtilities.GetAudioSessionManager()
82+
state = SoundSplitState(config.conf["audio"]["soundSplitState"])
83+
setSoundSplitState(state)
84+
else:
85+
log.debug("Cannot initialize sound split as WASAPI is disabled")
86+
87+
88+
@atexit.register
89+
def terminate():
90+
if nvwave.usingWasapiWavePlayer():
91+
setSoundSplitState(SoundSplitState.OFF)
92+
unregisterCallback()
93+
else:
94+
log.debug("Skipping terminating sound split as WASAPI is disabled.")
95+
96+
97+
def applyToAllAudioSessions(
98+
callback: AudioSessionNotification,
99+
applyToFuture: bool = True,
100+
) -> None:
101+
"""
102+
Executes provided callback function on all active audio sessions.
103+
Additionally, if applyToFuture is True, then it will register a notification with audio session manager,
104+
which will execute the same callback for all future sessions as they are created.
105+
That notification will be active until next invokation of this function,
106+
or until unregisterCallback() is called.
107+
"""
108+
unregisterCallback()
109+
if applyToFuture:
110+
audioSessionManager.RegisterSessionNotification(callback)
111+
# The following call is required to make callback to work:
112+
audioSessionManager.GetSessionEnumerator()
113+
global activeCallback
114+
activeCallback = callback
115+
sessions: list[AudioSession] = AudioUtilities.GetAllSessions()
116+
for session in sessions:
117+
callback.on_session_created(session)
118+
119+
120+
def unregisterCallback() -> None:
121+
global activeCallback
122+
if activeCallback is not None:
123+
audioSessionManager.UnregisterSessionNotification(activeCallback)
124+
activeCallback = None
125+
126+
127+
@dataclass(unsafe_hash=True)
128+
class VolumeSetter(AudioSessionNotification):
129+
leftVolume: float
130+
rightVolume: float
131+
leftNVDAVolume: float
132+
rightNVDAVolume: float
133+
foundSessionWithNot2Channels: bool = False
134+
135+
def on_session_created(self, new_session: AudioSession):
136+
pid = new_session.ProcessId
137+
channelVolume = new_session.channelAudioVolume()
138+
channelCount = channelVolume.GetChannelCount()
139+
if channelCount != 2:
140+
log.warning(f"Audio session for pid {pid} has {channelCount} channels instead of 2 - cannot set volume!")
141+
self.foundSessionWithNot2Channels = True
142+
return
143+
if pid != globalVars.appPid:
144+
channelVolume.SetChannelVolume(0, self.leftVolume, None)
145+
channelVolume.SetChannelVolume(1, self.rightVolume, None)
146+
else:
147+
channelVolume.SetChannelVolume(0, self.leftNVDAVolume, None)
148+
channelVolume.SetChannelVolume(1, self.rightNVDAVolume, None)
149+
150+
151+
def setSoundSplitState(state: SoundSplitState) -> dict:
152+
leftVolume, rightVolume = state.getAppVolume()
153+
leftNVDAVolume, rightNVDAVolume = state.getNVDAVolume()
154+
volumeSetter = VolumeSetter(leftVolume, rightVolume, leftNVDAVolume, rightNVDAVolume)
155+
applyToAllAudioSessions(volumeSetter)
156+
return {
157+
"foundSessionWithNot2Channels": volumeSetter.foundSessionWithNot2Channels,
158+
}
159+
160+
161+
def toggleSoundSplitState() -> None:
162+
if not nvwave.usingWasapiWavePlayer():
163+
message = _(
164+
# Translators: error message when wasapi is turned off.
165+
"Sound split cannot be used. "
166+
"Please enable WASAPI in the Advanced category in NVDA Settings to use it."
167+
)
168+
ui.message(message)
169+
return
170+
state = SoundSplitState(config.conf["audio"]["soundSplitState"])
171+
allowedStates: list[int] = config.conf["audio"]["includedSoundSplitModes"]
172+
try:
173+
i = allowedStates.index(state)
174+
except ValueError:
175+
# State not found, resetting to default (OFF)
176+
i = -1
177+
i = (i + 1) % len(allowedStates)
178+
newState = SoundSplitState(allowedStates[i])
179+
result = setSoundSplitState(newState)
180+
config.conf["audio"]["soundSplitState"] = newState.value
181+
ui.message(newState.displayString)
182+
if result["foundSessionWithNot2Channels"]:
183+
msg = _(
184+
# Translators: warning message when sound split trigger wasn't successful due to one of audio sessions
185+
# had number of channels other than 2 .
186+
"Warning: couldn't set volumes for sound split: "
187+
"one of audio sessions is either mono, or has more than 2 audio channels."
188+
)
189+
ui.message(msg)

source/config/configSpec.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@
5757
soundVolume = integer(default=100, min=0, max=100)
5858
audioAwakeTime = integer(default=30, min=0, max=3600)
5959
whiteNoiseVolume = integer(default=0, min=0, max=100)
60+
soundSplitState = integer(default=0)
61+
includedSoundSplitModes = int_list(default=list(0, 1, 2))
6062
6163
# Braille settings
6264
[braille]

source/core.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,7 @@ def resetConfiguration(factoryDefaults=False):
276276
import bdDetect
277277
import hwIo
278278
import tones
279+
import audio
279280
log.debug("Terminating vision")
280281
vision.terminate()
281282
log.debug("Terminating braille")
@@ -286,6 +287,8 @@ def resetConfiguration(factoryDefaults=False):
286287
speech.terminate()
287288
log.debug("terminating tones")
288289
tones.terminate()
290+
log.debug("terminating sound split")
291+
audio.soundSplit.terminate()
289292
log.debug("Terminating background braille display detection")
290293
bdDetect.terminate()
291294
log.debug("Terminating background i/o")
@@ -315,6 +318,9 @@ def resetConfiguration(factoryDefaults=False):
315318
bdDetect.initialize()
316319
# Tones
317320
tones.initialize()
321+
# Sound split
322+
log.debug("initializing sound split")
323+
audio.soundSplit.initialize()
318324
#Speech
319325
log.debug("initializing speech")
320326
speech.initialize()
@@ -663,6 +669,9 @@ def main():
663669
log.debug("Initializing tones")
664670
import tones
665671
tones.initialize()
672+
log.debug("Initializing sound split")
673+
import audio
674+
audio.soundSplit.initialize()
666675
import speechDictHandler
667676
log.debug("Speech Dictionary processing")
668677
speechDictHandler.initialize()

source/globalCommands.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
from base64 import b16encode
6767
import vision
6868
from utils.security import objectBelowLockScreenAndWindowsIsLocked
69+
import audio
6970

7071

7172
#: Script category for text review commands.
@@ -113,6 +114,9 @@
113114
#: Script category for document formatting commands.
114115
# Translators: The name of a category of NVDA commands.
115116
SCRCAT_DOCUMENTFORMATTING = _("Document formatting")
117+
#: Script category for audio streaming commands.
118+
# Translators: The name of a category of NVDA commands.
119+
SCRCAT_AUDIO = _("Audio")
116120

117121
# Translators: Reported when there are no settings to configure in synth settings ring
118122
# (example: when there is no setting for language).
@@ -127,6 +131,7 @@ class GlobalCommands(ScriptableObject):
127131
# Translators: Describes the Cycle audio ducking mode command.
128132
"Cycles through audio ducking modes which determine when NVDA lowers the volume of other sounds"
129133
),
134+
category=SCRCAT_AUDIO,
130135
gesture="kb:NVDA+shift+d"
131136
)
132137
def script_cycleAudioDuckingMode(self,gesture):
@@ -4461,6 +4466,17 @@ def script_cycleParagraphStyle(self, gesture: "inputCore.InputGesture") -> None:
44614466
config.conf["documentNavigation"]["paragraphStyle"] = newFlag.name
44624467
ui.message(newFlag.displayString)
44634468

4469+
@script(
4470+
description=_(
4471+
# Translators: Describes a command.
4472+
"Cycles through sound split modes",
4473+
),
4474+
category=SCRCAT_AUDIO,
4475+
gesture="kb:NVDA+alt+s",
4476+
)
4477+
def script_cycleSoundSplit(self, gesture: "inputCore.InputGesture") -> None:
4478+
audio.toggleSoundSplitState()
4479+
44644480

44654481
#: The single global commands instance.
44664482
#: @type: L{GlobalCommands}

0 commit comments

Comments
 (0)