Skip to content

Commit

Permalink
Merge a856a87 into 1c59ac2
Browse files Browse the repository at this point in the history
  • Loading branch information
mltony authored Jan 23, 2024
2 parents 1c59ac2 + a856a87 commit b4f60f4
Show file tree
Hide file tree
Showing 17 changed files with 1,407 additions and 0 deletions.
204 changes: 204 additions & 0 deletions source/audio.py
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):
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:
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(
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).
# 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

0 comments on commit b4f60f4

Please sign in to comment.