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 3 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
204 changes: 204 additions & 0 deletions source/audio.py
mltony marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
# A part of NonVisual Desktop Access (NVDA)
# Copyright (C) 2015-2021 NV Access Limited
# This file is covered by the GNU General Public License.
# See the file COPYING for more details.

import atexit
from comInterfaces.coreAudio.constants import (
CLSID_MMDeviceEnumerator,
EDataFlow,
ERole,
)
import comInterfaces.coreAudio.audioclient as audioclient
import comInterfaces.coreAudio.audiopolicy as audiopolicy
import comInterfaces.coreAudio.mmdeviceapi as mmdeviceapi
import comtypes
import config
from enum import IntEnum, unique
import globalVars
import json
from logHandler import log
import nvwave
from typing import Callable
import ui
from utils.displayString import DisplayStringIntEnum
import _ctypes

VolumeTupleT = tuple[float, float]


@unique
class SoundSplitState(DisplayStringIntEnum):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I'd prefer having two options, one to select where NVDA's sound is played and one for the application sounds. That feels more intuitive to me.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The original and main use case of this PR is sound split, so I would like users to be able to turn it on with a single keystroke instead of having to press two separate keystrokes one for NVDA and one for other apps.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that the list of possible sound split modes is a bit long, and if we can find something clearer, why not.

But, as @mltony writes, @LeonarddeR's suggestion of one option for NVDA sound and one for the sound of other apps would completely defeat the main use case of this feature.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re the concern with the sound split modes being too long:
If we really want 2 options, we may divide the size of the list by 2 and use a checkbox to switch left and right.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AH yes, I see. Having two combo boxes would also allow a user to set NVDA right, apps right, which makes no sense at all.

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: _("Disabled sound split"),
# 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:
if self == SoundSplitState.OFF or 'APPS_BOTH' in self.name:
return (1.0, 1.0)
elif 'APPS_LEFT' in self.name:
mltony marked this conversation as resolved.
Show resolved Hide resolved
return (1.0, 0.0)
elif 'APPS_RIGHT' in self.name:
return (0.0, 1.0)
else:
raise RuntimeError

def getNVDAVolume(self) -> VolumeTupleT:
if self == SoundSplitState.OFF or 'NVDA_BOTH' in self.name:
return (1.0, 1.0)
elif 'NVDA_LEFT' in self.name:
return (1.0, 0.0)
elif 'NVDA_RIGHT' in self.name:
return (0.0, 1.0)
else:
raise RuntimeError


sessionManager: audiopolicy.IAudioSessionManager2 = None
activeCallback: comtypes.COMObject | None = None


def initialize() -> None:
global sessionManager
try:
sessionManager = getSessionManager()
except _ctypes.COMError as e:
log.error("Could not initialize audio session manager! ", e)
return
if sessionManager is None:
log.error("Could not initialize audio session manager! ")
return
if nvwave.usingWasapiWavePlayer():
state = SoundSplitState(config.conf['audio']['soundSplitState'])
global activeCallback
activeCallback = setSoundSplitState(state)


@atexit.register
def terminate():
global activeCallback
if nvwave.usingWasapiWavePlayer():
setSoundSplitState(SoundSplitState.OFF)
if activeCallback is not None:
unregisterCallback(activeCallback)
activeCallback = None


def getDefaultAudioDevice(kind: EDataFlow = EDataFlow.eRender) -> mmdeviceapi.IMMDevice | None:
deviceEnumerator = comtypes.CoCreateInstance(
CLSID_MMDeviceEnumerator,
mmdeviceapi.IMMDeviceEnumerator,
comtypes.CLSCTX_INPROC_SERVER,
)
device = deviceEnumerator.GetDefaultAudioEndpoint(
mltony marked this conversation as resolved.
Show resolved Hide resolved
kind.value,
ERole.eMultimedia.value,
)
return device


def getSessionManager() -> audiopolicy.IAudioSessionManager2:
audioDevice = getDefaultAudioDevice()
if audioDevice is None:
raise RuntimeError("No default output audio device found!")
tmp = audioDevice.Activate(audiopolicy.IAudioSessionManager2._iid_, comtypes.CLSCTX_ALL, None)
sessionManager: audiopolicy.IAudioSessionManager2 = tmp.QueryInterface(audiopolicy.IAudioSessionManager2)
return sessionManager


def applyToAllAudioSessions(
func: Callable[[audiopolicy.IAudioSessionControl2], None],
applyToFuture: bool = True,
) -> comtypes.COMObject | None:
sessionEnumerator: audiopolicy.IAudioSessionEnumerator = sessionManager.GetSessionEnumerator()
for i in range(sessionEnumerator.GetCount()):
session: audiopolicy.IAudioSessionControl = sessionEnumerator.GetSession(i)
session2: audiopolicy.IAudioSessionControl2 = session.QueryInterface(audiopolicy.IAudioSessionControl2)
func(session2)
if applyToFuture:
class AudioSessionNotification(comtypes.COMObject):
_com_interfaces_ = (audiopolicy.IAudioSessionNotification,)

def OnSessionCreated(self, session: audiopolicy.IAudioSessionControl):
session2 = session.QueryInterface(audiopolicy.IAudioSessionControl2)
func(session2)
callback = AudioSessionNotification()
sessionManager.RegisterSessionNotification(callback)
return callback
else:
return None


def unregisterCallback(callback: comtypes.COMObject) -> None:
sessionManager .UnregisterSessionNotification(callback)


def setSoundSplitState(state: SoundSplitState) -> None:
global activeCallback
if activeCallback is not None:
unregisterCallback(activeCallback)
activeCallback = None
leftVolume, rightVolume = state.getAppVolume()
leftNVDAVolume, rightNVDAVolume = state.getNVDAVolume()

def volumeSetter(session2: audiopolicy.IAudioSessionControl2) -> None:
channelVolume: audioclient.IChannelAudioVolume = session2.QueryInterface(audioclient.IChannelAudioVolume)
channelCount = channelVolume.GetChannelCount()
if channelCount != 2:
pid = session2.GetProcessId()
log.warning(f"Audio session for pid {pid} has {channelCount} channels instead of 2 - cannot set volume!")
return
pid: int = session2.GetProcessId()
if pid != globalVars.appPid:
channelVolume.SetChannelVolume(0, leftVolume, None)
channelVolume.SetChannelVolume(1, rightVolume, None)
else:
channelVolume.SetChannelVolume(0, leftNVDAVolume, None)
channelVolume.SetChannelVolume(1, rightNVDAVolume, None)

activeCallback = applyToAllAudioSessions(volumeSetter)


def toggleSoundSplitState() -> None:
if not nvwave.usingWasapiWavePlayer():
message = _(
# Translators: error message when wasapi is turned off.
"Sound split is only available in wasapi mode. "
"Please enable wasapi on the Advanced panel in NVDA Settings."
)
ui.message(message)
return
state = SoundSplitState(config.conf['audio']['soundSplitState'])
allowedStates: list[int] = json.loads(config.conf["audio"]["includedSoundSplitModes"])
try:
i = allowedStates.index(state)
except ValueError:
i = -1
i = (i + 1) % len(allowedStates)
newState = SoundSplitState(allowedStates[i])
setSoundSplitState(newState)
config.conf['audio']['soundSplitState'] = newState.value
ui.message(newState.displayString)
Empty file.
155 changes: 155 additions & 0 deletions source/comInterfaces/coreAudio/audioclient/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
# This file is a part of pycaw library (https://github.com/AndreMiras/pycaw).
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why don't we include pycaw as a dependency?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pycaw has its own dependencies that we don't need. I don't want to pollute NVDA with subdependency libraries that we don't need.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As far as I can see, the only extra dependency Pycaw introduces is psutil. It is very likelike that py2exe will strip that, so in the end, there's no extra dependency.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. IIRC psutil has a bunch of dependencies of its own.
  2. Didn't know about py2exe is that smart.
  3. pycaw is missing one of the com interfaces - I had to add it myself. Sure it can be argued that we can import pycaw and write that new interface in a separate file - but current solution is more concise.
  4. pycaw is just a wrapper around com interfaces. It is marginally more useful when working with raw com interfaces. But NVDA is working with raw com interfaces all over the place. Plus the new com interface will have to be interacted with outside of pycaw object model. So in my mind there is not much utility in pycaw object model.
  5. Another PR that I'm planning - microphone mute - required changes to pycaw object model as well. So again it would be more convenient to work with raw com interfaces.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think generally we should avoid copying code from external dependencies where possible.
I think it would be much more elegant to use pycaw as a pip dedependency and patch as needed from NVDA, and/or make upstream PRs to fix pycaw.

# Please note that it is distributed under MIT license:
# https://github.com/AndreMiras/pycaw#MIT-1-ov-file

from ctypes import HRESULT, POINTER, c_float
from ctypes import c_longlong as REFERENCE_TIME
from ctypes import c_uint32 as UINT32
from ctypes.wintypes import BOOL, DWORD, HANDLE, UINT

from comtypes import COMMETHOD, GUID, IUnknown

from .depend import WAVEFORMATEX


class ISimpleAudioVolume(IUnknown):
_iid_ = GUID("{87CE5498-68D6-44E5-9215-6DA47EF883D8}")
_methods_ = (
# HRESULT SetMasterVolume(
# [in] float fLevel,
# [in] LPCGUID EventContext);
COMMETHOD(
[],
HRESULT,
"SetMasterVolume",
(["in"], c_float, "fLevel"),
(["in"], POINTER(GUID), "EventContext"),
),
# HRESULT GetMasterVolume([out] float *pfLevel);
COMMETHOD(
[], HRESULT, "GetMasterVolume", (["out"], POINTER(c_float), "pfLevel")
),
# HRESULT SetMute(
# [in] BOOL bMute,
# [in] LPCGUID EventContext);
COMMETHOD(
[],
HRESULT,
"SetMute",
(["in"], BOOL, "bMute"),
(["in"], POINTER(GUID), "EventContext"),
),
# HRESULT GetMute([out] BOOL *pbMute);
COMMETHOD([], HRESULT, "GetMute", (["out"], POINTER(BOOL), "pbMute")),
)


class IAudioClient(IUnknown):
_iid_ = GUID("{1cb9ad4c-dbfa-4c32-b178-c2f568a703b2}")
_methods_ = (
# HRESULT Initialize(
# [in] AUDCLNT_SHAREMODE ShareMode,
# [in] DWORD StreamFlags,
# [in] REFERENCE_TIME hnsBufferDuration,
# [in] REFERENCE_TIME hnsPeriodicity,
# [in] const WAVEFORMATEX *pFormat,
# [in] LPCGUID AudioSessionGuid);
COMMETHOD(
[],
HRESULT,
"Initialize",
(["in"], DWORD, "ShareMode"),
(["in"], DWORD, "StreamFlags"),
(["in"], REFERENCE_TIME, "hnsBufferDuration"),
(["in"], REFERENCE_TIME, "hnsPeriodicity"),
(["in"], POINTER(WAVEFORMATEX), "pFormat"),
(["in"], POINTER(GUID), "AudioSessionGuid"),
),
# HRESULT GetBufferSize(
# [out] UINT32 *pNumBufferFrames);
COMMETHOD(
[], HRESULT, "GetBufferSize", (["out"], POINTER(UINT32), "pNumBufferFrames")
),
# HRESULT GetStreamLatency(
# [out] REFERENCE_TIME *phnsLatency);
COMMETHOD(
[],
HRESULT,
"GetStreamLatency",
(["out"], POINTER(REFERENCE_TIME), "phnsLatency"),
),
# HRESULT GetCurrentPadding(
# [out] UINT32 *pNumPaddingFrames);
COMMETHOD(
[],
HRESULT,
"GetCurrentPadding",
(["out"], POINTER(UINT32), "pNumPaddingFrames"),
),
# HRESULT IsFormatSupported(
# [in] AUDCLNT_SHAREMODE ShareMode,
# [in] const WAVEFORMATEX *pFormat,
# [out,unique] WAVEFORMATEX **ppClosestMatch);
COMMETHOD(
[],
HRESULT,
"IsFormatSupported",
(["in"], DWORD, "ShareMode"),
(["in"], POINTER(WAVEFORMATEX), "pFormat"),
(["out"], POINTER(POINTER(WAVEFORMATEX)), "ppClosestMatch"),
),
# HRESULT GetMixFormat(
# [out] WAVEFORMATEX **ppDeviceFormat
# );
COMMETHOD(
[],
HRESULT,
"GetMixFormat",
(["out"], POINTER(POINTER(WAVEFORMATEX)), "ppDeviceFormat"),
),
# HRESULT GetDevicePeriod(
# [out] REFERENCE_TIME *phnsDefaultDevicePeriod,
# [out] REFERENCE_TIME *phnsMinimumDevicePeriod);
COMMETHOD(
[],
HRESULT,
"GetDevicePeriod",
(["out"], POINTER(REFERENCE_TIME), "phnsDefaultDevicePeriod"),
(["out"], POINTER(REFERENCE_TIME), "phnsMinimumDevicePeriod"),
),
# HRESULT Start(void);
COMMETHOD([], HRESULT, "Start"),
# HRESULT Stop(void);
COMMETHOD([], HRESULT, "Stop"),
# HRESULT Reset(void);
COMMETHOD([], HRESULT, "Reset"),
# HRESULT SetEventHandle([in] HANDLE eventHandle);
COMMETHOD(
[],
HRESULT,
"SetEventHandle",
(["in"], HANDLE, "eventHandle"),
),
# HRESULT GetService(
# [in] REFIID riid,
# [out] void **ppv);
COMMETHOD(
[],
HRESULT,
"GetService",
(["in"], POINTER(GUID), "iid"),
(["out"], POINTER(POINTER(IUnknown)), "ppv"),
),
)


class IChannelAudioVolume (IUnknown):
_iid_ = GUID('{1c158861-b533-4b30-b1cf-e853e51c59b8}')
_methods_ = (
COMMETHOD([], HRESULT, 'GetChannelCount',
(['out'], POINTER(UINT), 'pnChannelCount')),
COMMETHOD([], HRESULT, 'SetChannelVolume',
(['in'], UINT, 'dwIndex'),
(['in'], c_float, 'fLevel'),
(['in'], POINTER(GUID), 'EventContext')),
)
22 changes: 22 additions & 0 deletions source/comInterfaces/coreAudio/audioclient/depend.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# This file is a part of pycaw library (https://github.com/AndreMiras/pycaw).
# Please note that it is distributed under MIT license:
# https://github.com/AndreMiras/pycaw#MIT-1-ov-file

from ctypes import Structure
from ctypes.wintypes import WORD


class WAVEFORMATEX(Structure):
_fields_ = [
("wFormatTag", WORD),
("nChannels", WORD),
("nSamplesPerSec", WORD),
("nAvgBytesPerSec", WORD),
("nBlockAlign", WORD),
("wBitsPerSample", WORD),
("cbSize", WORD),
]




Loading