Skip to content

Commit db34b63

Browse files
authored
Merge adf2a8e into 6740805
2 parents 6740805 + adf2a8e commit db34b63

17 files changed

Lines changed: 1407 additions & 0 deletions

File tree

source/audio.py

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
# A part of NonVisual Desktop Access (NVDA)
2+
# Copyright (C) 2015-2021 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+
from comInterfaces.coreAudio.constants import (
8+
CLSID_MMDeviceEnumerator,
9+
EDataFlow,
10+
ERole,
11+
)
12+
import comInterfaces.coreAudio.audioclient as audioclient
13+
import comInterfaces.coreAudio.audiopolicy as audiopolicy
14+
import comInterfaces.coreAudio.mmdeviceapi as mmdeviceapi
15+
import comtypes
16+
import config
17+
from enum import IntEnum, unique
18+
import globalVars
19+
import json
20+
from logHandler import log
21+
import nvwave
22+
from typing import Callable
23+
import ui
24+
from utils.displayString import DisplayStringIntEnum
25+
import _ctypes
26+
27+
VolumeTupleT = tuple[float, float]
28+
29+
30+
@unique
31+
class SoundSplitState(DisplayStringIntEnum):
32+
OFF = 0
33+
NVDA_LEFT_APPS_RIGHT = 1
34+
NVDA_LEFT_APPS_BOTH = 2
35+
NVDA_RIGHT_APPS_LEFT = 3
36+
NVDA_RIGHT_APPS_BOTH = 4
37+
NVDA_BOTH_APPS_LEFT = 5
38+
NVDA_BOTH_APPS_RIGHT = 6
39+
40+
@property
41+
def _displayStringLabels(self) -> dict[IntEnum, str]:
42+
return {
43+
# Translators: Sound split state
44+
SoundSplitState.OFF: _("Disabled sound split"),
45+
# Translators: Sound split state
46+
SoundSplitState.NVDA_LEFT_APPS_RIGHT: _("NVDA on the left and applications on the right"),
47+
# Translators: Sound split state
48+
SoundSplitState.NVDA_LEFT_APPS_BOTH: _("NVDA on the left and applications in both channels"),
49+
# Translators: Sound split state
50+
SoundSplitState.NVDA_RIGHT_APPS_LEFT: _("NVDA on the right and applications on the left"),
51+
# Translators: Sound split state
52+
SoundSplitState.NVDA_RIGHT_APPS_BOTH: _("NVDA on the right and applications in both channels"),
53+
# Translators: Sound split state
54+
SoundSplitState.NVDA_BOTH_APPS_LEFT: _("NVDA in both channels and applications on the left"),
55+
# Translators: Sound split state
56+
SoundSplitState.NVDA_BOTH_APPS_RIGHT: _("NVDA in both channels and applications on the right"),
57+
}
58+
59+
def getAppVolume(self) -> VolumeTupleT:
60+
if self == SoundSplitState.OFF or 'APPS_BOTH' in self.name:
61+
return (1.0, 1.0)
62+
elif 'APPS_LEFT' in self.name:
63+
return (1.0, 0.0)
64+
elif 'APPS_RIGHT' in self.name:
65+
return (0.0, 1.0)
66+
else:
67+
raise RuntimeError
68+
69+
def getNVDAVolume(self) -> VolumeTupleT:
70+
if self == SoundSplitState.OFF or 'NVDA_BOTH' in self.name:
71+
return (1.0, 1.0)
72+
elif 'NVDA_LEFT' in self.name:
73+
return (1.0, 0.0)
74+
elif 'NVDA_RIGHT' in self.name:
75+
return (0.0, 1.0)
76+
else:
77+
raise RuntimeError
78+
79+
80+
sessionManager: audiopolicy.IAudioSessionManager2 = None
81+
activeCallback: comtypes.COMObject | None = None
82+
83+
84+
def initialize() -> None:
85+
global sessionManager
86+
try:
87+
sessionManager = getSessionManager()
88+
except _ctypes.COMError as e:
89+
log.error("Could not initialize audio session manager! ", e)
90+
return
91+
if sessionManager is None:
92+
log.error("Could not initialize audio session manager! ")
93+
return
94+
if nvwave.usingWasapiWavePlayer():
95+
state = SoundSplitState(config.conf['audio']['soundSplitState'])
96+
global activeCallback
97+
activeCallback = setSoundSplitState(state)
98+
99+
100+
@atexit.register
101+
def terminate():
102+
global activeCallback
103+
if nvwave.usingWasapiWavePlayer():
104+
setSoundSplitState(SoundSplitState.OFF)
105+
if activeCallback is not None:
106+
unregisterCallback(activeCallback)
107+
activeCallback = None
108+
109+
110+
def getDefaultAudioDevice(kind: EDataFlow = EDataFlow.eRender) -> mmdeviceapi.IMMDevice | None:
111+
deviceEnumerator = comtypes.CoCreateInstance(
112+
CLSID_MMDeviceEnumerator,
113+
mmdeviceapi.IMMDeviceEnumerator,
114+
comtypes.CLSCTX_INPROC_SERVER,
115+
)
116+
device = deviceEnumerator.GetDefaultAudioEndpoint(
117+
kind.value,
118+
ERole.eMultimedia.value,
119+
)
120+
return device
121+
122+
123+
def getSessionManager() -> audiopolicy.IAudioSessionManager2:
124+
audioDevice = getDefaultAudioDevice()
125+
if audioDevice is None:
126+
raise RuntimeError("No default output audio device found!")
127+
tmp = audioDevice.Activate(audiopolicy.IAudioSessionManager2._iid_, comtypes.CLSCTX_ALL, None)
128+
sessionManager: audiopolicy.IAudioSessionManager2 = tmp.QueryInterface(audiopolicy.IAudioSessionManager2)
129+
return sessionManager
130+
131+
132+
def applyToAllAudioSessions(
133+
func: Callable[[audiopolicy.IAudioSessionControl2], None],
134+
applyToFuture: bool = True,
135+
) -> comtypes.COMObject | None:
136+
sessionEnumerator: audiopolicy.IAudioSessionEnumerator = sessionManager.GetSessionEnumerator()
137+
for i in range(sessionEnumerator.GetCount()):
138+
session: audiopolicy.IAudioSessionControl = sessionEnumerator.GetSession(i)
139+
session2: audiopolicy.IAudioSessionControl2 = session.QueryInterface(audiopolicy.IAudioSessionControl2)
140+
func(session2)
141+
if applyToFuture:
142+
class AudioSessionNotification(comtypes.COMObject):
143+
_com_interfaces_ = (audiopolicy.IAudioSessionNotification,)
144+
145+
def OnSessionCreated(self, session: audiopolicy.IAudioSessionControl):
146+
session2 = session.QueryInterface(audiopolicy.IAudioSessionControl2)
147+
func(session2)
148+
callback = AudioSessionNotification()
149+
sessionManager.RegisterSessionNotification(callback)
150+
return callback
151+
else:
152+
return None
153+
154+
155+
def unregisterCallback(callback: comtypes.COMObject) -> None:
156+
sessionManager .UnregisterSessionNotification(callback)
157+
158+
159+
def setSoundSplitState(state: SoundSplitState) -> None:
160+
global activeCallback
161+
if activeCallback is not None:
162+
unregisterCallback(activeCallback)
163+
activeCallback = None
164+
leftVolume, rightVolume = state.getAppVolume()
165+
leftNVDAVolume, rightNVDAVolume = state.getNVDAVolume()
166+
167+
def volumeSetter(session2: audiopolicy.IAudioSessionControl2) -> None:
168+
channelVolume: audioclient.IChannelAudioVolume = session2.QueryInterface(audioclient.IChannelAudioVolume)
169+
channelCount = channelVolume.GetChannelCount()
170+
if channelCount != 2:
171+
pid = session2.GetProcessId()
172+
log.warning(f"Audio session for pid {pid} has {channelCount} channels instead of 2 - cannot set volume!")
173+
return
174+
pid: int = session2.GetProcessId()
175+
if pid != globalVars.appPid:
176+
channelVolume.SetChannelVolume(0, leftVolume, None)
177+
channelVolume.SetChannelVolume(1, rightVolume, None)
178+
else:
179+
channelVolume.SetChannelVolume(0, leftNVDAVolume, None)
180+
channelVolume.SetChannelVolume(1, rightNVDAVolume, None)
181+
182+
activeCallback = applyToAllAudioSessions(volumeSetter)
183+
184+
185+
def toggleSoundSplitState() -> None:
186+
if not nvwave.usingWasapiWavePlayer():
187+
message = _(
188+
# Translators: error message when wasapi is turned off.
189+
"Sound split is only available in wasapi mode. "
190+
"Please enable wasapi on the Advanced panel in NVDA Settings."
191+
)
192+
ui.message(message)
193+
return
194+
state = SoundSplitState(config.conf['audio']['soundSplitState'])
195+
allowedStates: list[int] = json.loads(config.conf["audio"]["includedSoundSplitModes"])
196+
try:
197+
i = allowedStates.index(state)
198+
except ValueError:
199+
i = -1
200+
i = (i + 1) % len(allowedStates)
201+
newState = SoundSplitState(allowedStates[i])
202+
setSoundSplitState(newState)
203+
config.conf['audio']['soundSplitState'] = newState.value
204+
ui.message(newState.displayString)

source/comInterfaces/coreAudio/__init__.py

Whitespace-only changes.
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
# This file is a part of pycaw library (https://github.com/AndreMiras/pycaw).
2+
# Please note that it is distributed under MIT license:
3+
# https://github.com/AndreMiras/pycaw#MIT-1-ov-file
4+
5+
from ctypes import HRESULT, POINTER, c_float
6+
from ctypes import c_longlong as REFERENCE_TIME
7+
from ctypes import c_uint32 as UINT32
8+
from ctypes.wintypes import BOOL, DWORD, HANDLE, UINT
9+
10+
from comtypes import COMMETHOD, GUID, IUnknown
11+
12+
from .depend import WAVEFORMATEX
13+
14+
15+
class ISimpleAudioVolume(IUnknown):
16+
_iid_ = GUID("{87CE5498-68D6-44E5-9215-6DA47EF883D8}")
17+
_methods_ = (
18+
# HRESULT SetMasterVolume(
19+
# [in] float fLevel,
20+
# [in] LPCGUID EventContext);
21+
COMMETHOD(
22+
[],
23+
HRESULT,
24+
"SetMasterVolume",
25+
(["in"], c_float, "fLevel"),
26+
(["in"], POINTER(GUID), "EventContext"),
27+
),
28+
# HRESULT GetMasterVolume([out] float *pfLevel);
29+
COMMETHOD(
30+
[], HRESULT, "GetMasterVolume", (["out"], POINTER(c_float), "pfLevel")
31+
),
32+
# HRESULT SetMute(
33+
# [in] BOOL bMute,
34+
# [in] LPCGUID EventContext);
35+
COMMETHOD(
36+
[],
37+
HRESULT,
38+
"SetMute",
39+
(["in"], BOOL, "bMute"),
40+
(["in"], POINTER(GUID), "EventContext"),
41+
),
42+
# HRESULT GetMute([out] BOOL *pbMute);
43+
COMMETHOD([], HRESULT, "GetMute", (["out"], POINTER(BOOL), "pbMute")),
44+
)
45+
46+
47+
class IAudioClient(IUnknown):
48+
_iid_ = GUID("{1cb9ad4c-dbfa-4c32-b178-c2f568a703b2}")
49+
_methods_ = (
50+
# HRESULT Initialize(
51+
# [in] AUDCLNT_SHAREMODE ShareMode,
52+
# [in] DWORD StreamFlags,
53+
# [in] REFERENCE_TIME hnsBufferDuration,
54+
# [in] REFERENCE_TIME hnsPeriodicity,
55+
# [in] const WAVEFORMATEX *pFormat,
56+
# [in] LPCGUID AudioSessionGuid);
57+
COMMETHOD(
58+
[],
59+
HRESULT,
60+
"Initialize",
61+
(["in"], DWORD, "ShareMode"),
62+
(["in"], DWORD, "StreamFlags"),
63+
(["in"], REFERENCE_TIME, "hnsBufferDuration"),
64+
(["in"], REFERENCE_TIME, "hnsPeriodicity"),
65+
(["in"], POINTER(WAVEFORMATEX), "pFormat"),
66+
(["in"], POINTER(GUID), "AudioSessionGuid"),
67+
),
68+
# HRESULT GetBufferSize(
69+
# [out] UINT32 *pNumBufferFrames);
70+
COMMETHOD(
71+
[], HRESULT, "GetBufferSize", (["out"], POINTER(UINT32), "pNumBufferFrames")
72+
),
73+
# HRESULT GetStreamLatency(
74+
# [out] REFERENCE_TIME *phnsLatency);
75+
COMMETHOD(
76+
[],
77+
HRESULT,
78+
"GetStreamLatency",
79+
(["out"], POINTER(REFERENCE_TIME), "phnsLatency"),
80+
),
81+
# HRESULT GetCurrentPadding(
82+
# [out] UINT32 *pNumPaddingFrames);
83+
COMMETHOD(
84+
[],
85+
HRESULT,
86+
"GetCurrentPadding",
87+
(["out"], POINTER(UINT32), "pNumPaddingFrames"),
88+
),
89+
# HRESULT IsFormatSupported(
90+
# [in] AUDCLNT_SHAREMODE ShareMode,
91+
# [in] const WAVEFORMATEX *pFormat,
92+
# [out,unique] WAVEFORMATEX **ppClosestMatch);
93+
COMMETHOD(
94+
[],
95+
HRESULT,
96+
"IsFormatSupported",
97+
(["in"], DWORD, "ShareMode"),
98+
(["in"], POINTER(WAVEFORMATEX), "pFormat"),
99+
(["out"], POINTER(POINTER(WAVEFORMATEX)), "ppClosestMatch"),
100+
),
101+
# HRESULT GetMixFormat(
102+
# [out] WAVEFORMATEX **ppDeviceFormat
103+
# );
104+
COMMETHOD(
105+
[],
106+
HRESULT,
107+
"GetMixFormat",
108+
(["out"], POINTER(POINTER(WAVEFORMATEX)), "ppDeviceFormat"),
109+
),
110+
# HRESULT GetDevicePeriod(
111+
# [out] REFERENCE_TIME *phnsDefaultDevicePeriod,
112+
# [out] REFERENCE_TIME *phnsMinimumDevicePeriod);
113+
COMMETHOD(
114+
[],
115+
HRESULT,
116+
"GetDevicePeriod",
117+
(["out"], POINTER(REFERENCE_TIME), "phnsDefaultDevicePeriod"),
118+
(["out"], POINTER(REFERENCE_TIME), "phnsMinimumDevicePeriod"),
119+
),
120+
# HRESULT Start(void);
121+
COMMETHOD([], HRESULT, "Start"),
122+
# HRESULT Stop(void);
123+
COMMETHOD([], HRESULT, "Stop"),
124+
# HRESULT Reset(void);
125+
COMMETHOD([], HRESULT, "Reset"),
126+
# HRESULT SetEventHandle([in] HANDLE eventHandle);
127+
COMMETHOD(
128+
[],
129+
HRESULT,
130+
"SetEventHandle",
131+
(["in"], HANDLE, "eventHandle"),
132+
),
133+
# HRESULT GetService(
134+
# [in] REFIID riid,
135+
# [out] void **ppv);
136+
COMMETHOD(
137+
[],
138+
HRESULT,
139+
"GetService",
140+
(["in"], POINTER(GUID), "iid"),
141+
(["out"], POINTER(POINTER(IUnknown)), "ppv"),
142+
),
143+
)
144+
145+
146+
class IChannelAudioVolume (IUnknown):
147+
_iid_ = GUID('{1c158861-b533-4b30-b1cf-e853e51c59b8}')
148+
_methods_ = (
149+
COMMETHOD([], HRESULT, 'GetChannelCount',
150+
(['out'], POINTER(UINT), 'pnChannelCount')),
151+
COMMETHOD([], HRESULT, 'SetChannelVolume',
152+
(['in'], UINT, 'dwIndex'),
153+
(['in'], c_float, 'fLevel'),
154+
(['in'], POINTER(GUID), 'EventContext')),
155+
)
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# This file is a part of pycaw library (https://github.com/AndreMiras/pycaw).
2+
# Please note that it is distributed under MIT license:
3+
# https://github.com/AndreMiras/pycaw#MIT-1-ov-file
4+
5+
from ctypes import Structure
6+
from ctypes.wintypes import WORD
7+
8+
9+
class WAVEFORMATEX(Structure):
10+
_fields_ = [
11+
("wFormatTag", WORD),
12+
("nChannels", WORD),
13+
("nSamplesPerSec", WORD),
14+
("nAvgBytesPerSec", WORD),
15+
("nBlockAlign", WORD),
16+
("wBitsPerSample", WORD),
17+
("cbSize", WORD),
18+
]
19+
20+
21+
22+

0 commit comments

Comments
 (0)