Skip to content

Commit

Permalink
Support associating a stream with an audio session so it can be separ…
Browse files Browse the repository at this point in the history
…ately controlled in the system Volume Mixer. Split NVDA sounds into their own session. Support setting the volume of a session.
  • Loading branch information
jcsteh committed Apr 2, 2023
1 parent 075ddf6 commit 007b6fc
Show file tree
Hide file tree
Showing 4 changed files with 96 additions and 12 deletions.
1 change: 1 addition & 0 deletions nvdaHelper/local/nvdaHelperLocal.def
Expand Up @@ -75,5 +75,6 @@ EXPORTS
wasPlay_sync
wasPlay_pause
wasPlay_resume
wasPlay_setSessionVolume
wasPlay_startup
wasPlay_getDevices
44 changes: 38 additions & 6 deletions nvdaHelper/local/wasapi.cpp
Expand Up @@ -16,6 +16,7 @@ This license can be found at:
#include <windows.h>
#include <atlcomcli.h>
#include <audioclient.h>
#include <audiopolicy.h>
#include <functiondiscoverykeys.h>
#include <Functiondiscoverykeys_devpkey.h>
#include <mmdeviceapi.h>
Expand All @@ -37,6 +38,8 @@ const IID IID_IAudioClient = __uuidof(IAudioClient);
const IID IID_IAudioRenderClient = __uuidof(IAudioRenderClient);
const IID IID_IAudioClock = __uuidof(IAudioClock);
const IID IID_IMMNotificationClient = __uuidof(IMMNotificationClient);
const IID IID_IAudioSessionControl = __uuidof(IAudioSessionControl);
const IID IID_ISimpleAudioVolume = __uuidof(ISimpleAudioVolume);

/**
* C++ RAII class to manage the lifecycle of a standard Windows HANDLE closed
Expand Down Expand Up @@ -151,7 +154,7 @@ class WasapiPlayer {
* Specify an empty (not null) deviceId to use the default device.
*/
WasapiPlayer(wchar_t* deviceId, WAVEFORMATEX format,
ChunkCompletedCallback callback);
ChunkCompletedCallback callback, GUID sessionGuid, wchar_t* sessionName);

/**
* Open the audio device.
Expand All @@ -171,6 +174,7 @@ class WasapiPlayer {
HRESULT sync();
HRESULT pause();
HRESULT resume();
HRESULT setSessionVolume(float level);

private:
void maybeFireCallback();
Expand Down Expand Up @@ -204,6 +208,8 @@ class WasapiPlayer {
// The maximum number of frames that will fit in the buffer.
UINT32 bufferFrames;
std::wstring deviceId;
GUID sessionGuid;
std::wstring sessionName;
WAVEFORMATEX format;
ChunkCompletedCallback callback;
PlayState playState = PlayState::stopped;
Expand All @@ -219,8 +225,9 @@ class WasapiPlayer {
};

WasapiPlayer::WasapiPlayer(wchar_t* deviceId, WAVEFORMATEX format,
ChunkCompletedCallback callback)
: deviceId(deviceId), format(format), callback(callback) {
ChunkCompletedCallback callback, GUID sessionGuid, wchar_t* sessionName)
: deviceId(deviceId), format(format), callback(callback),
sessionGuid(sessionGuid), sessionName(sessionName) {
wakeEvent = CreateEvent(nullptr, false, false, nullptr);
}

Expand Down Expand Up @@ -250,10 +257,21 @@ HRESULT WasapiPlayer::open(bool force) {
}
hr = client->Initialize(AUDCLNT_SHAREMODE_SHARED,
AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM | AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY,
BUFFER_SIZE, 0, &format, nullptr);
BUFFER_SIZE, 0, &format, &sessionGuid);
if (FAILED(hr)) {
return hr;
}
if (!sessionName.empty()) {
CComPtr<IAudioSessionControl> control;
hr = client->GetService(IID_IAudioSessionControl, (void**)&control);
if (FAILED(hr)) {
return hr;
}
hr = control->SetDisplayName(sessionName.c_str(), nullptr);
if (FAILED(hr)) {
return hr;
}
}
hr = client->GetBufferSize(&bufferFrames);
if (FAILED(hr)) {
return hr;
Expand Down Expand Up @@ -489,15 +507,25 @@ HRESULT WasapiPlayer::resume() {
return S_OK;
}

HRESULT WasapiPlayer::setSessionVolume(float level) {
CComPtr<ISimpleAudioVolume> volume;
HRESULT hr = client->GetService(IID_ISimpleAudioVolume, (void**)&volume);
if (FAILED(hr)) {
return hr;
}
return volume->SetMasterVolume(level, nullptr);
}

/*
* NVDA calls the functions below. Most of these just wrap calls to
* WasapiPlayer, with the exception of wasPlay_startup and wasPlay_getDevices.
*/

WasapiPlayer* wasPlay_create(wchar_t* deviceId, WAVEFORMATEX format,
WasapiPlayer::ChunkCompletedCallback callback
WasapiPlayer::ChunkCompletedCallback callback, GUID sessionGuid,
wchar_t* sessionName
) {
return new WasapiPlayer(deviceId, format, callback);
return new WasapiPlayer(deviceId, format, callback, sessionGuid, sessionName);
}

void wasPlay_destroy(WasapiPlayer* player) {
Expand Down Expand Up @@ -530,6 +558,10 @@ HRESULT wasPlay_resume(WasapiPlayer* player) {
return player->resume();
}

HRESULT wasPlay_setSessionVolume(WasapiPlayer* player, float level) {
return player->setSessionVolume(level);
}

/**
* This must be called once per session at startup before wasPlay_create is
* called.
Expand Down
60 changes: 55 additions & 5 deletions source/nvwave.py
Expand Up @@ -12,6 +12,7 @@
from typing import (
Optional,
Callable,
NamedTuple,
)
from ctypes import (
windll,
Expand All @@ -24,6 +25,7 @@
c_void_p,
CFUNCTYPE,
string_at,
c_float,
)
from ctypes.wintypes import (
HANDLE,
Expand All @@ -34,7 +36,7 @@
UINT,
LPUINT
)
from comtypes import HRESULT, BSTR
from comtypes import HRESULT, BSTR, GUID
from comtypes.hresult import S_OK
import atexit
import weakref
Expand Down Expand Up @@ -147,6 +149,28 @@ def _isDebugForNvWave():
return config.conf["debugLog"]["nvwave"]


class AudioSession(NamedTuple):
"""Identifies an audio session.
An audio session may contain multiple streams. The guid identifies the
session. The name is shown in the system Volume Mixer.
"""
guid: GUID
name: str


#: The audio session to use by default.
defaultSession = AudioSession(
GUID("{C302B781-00AF-4ECC-ACB7-7DF16AF7D55E}"),
"NVDA"
)
#: The audio session to use for sounds.
soundsSession = AudioSession(
GUID("{A560CE90-E9D9-44AF-8C3C-0D9734642D48}"),
# Translators: Shown in the system Volume Mixer for controlling NVDA sounds.
_("NVDA sounds")
)


class WinmmWavePlayer(garbageHandler.TrackedObject):
"""Synchronously play a stream of audio.
To use, construct an instance and feed it waveform audio using L{feed}.
Expand Down Expand Up @@ -195,7 +219,8 @@ def __init__(
outputDevice: typing.Union[str, int] = WAVE_MAPPER,
closeWhenIdle: bool = False,
wantDucking: bool = True,
buffered: bool = False
buffered: bool = False,
session: AudioSession = defaultSession,
):
"""Constructor.
@param channels: The number of channels of audio; e.g. 2 for stereo, 1 for mono.
Expand Down Expand Up @@ -689,7 +714,8 @@ def playWaveFile(
samplesPerSec=f.getframerate(),
bitsPerSample=f.getsampwidth() * 8,
outputDevice=config.conf["speech"]["outputDevice"],
wantDucking=False
wantDucking=False,
session=soundsSession
)

def play():
Expand Down Expand Up @@ -757,7 +783,8 @@ def __init__(
outputDevice: typing.Union[str, int] = WAVE_MAPPER,
closeWhenIdle: bool = False,
wantDucking: bool = True,
buffered: bool = False
buffered: bool = False,
session: AudioSession = defaultSession,
):
"""Constructor.
@param channels: The number of channels of audio; e.g. 2 for stereo, 1 for mono.
Expand All @@ -768,6 +795,7 @@ def __init__(
@param closeWhenIdle: Deprecated; ignored.
@param wantDucking: if true then background audio will be ducked on Windows 8 and higher
@param buffered: Whether to buffer small chunks of audio to prevent audio glitches.
@param session: The audio session which should be used.
@note: If C{outputDevice} is a name and no such device exists, the default device will be used.
@raise WindowsError: If there was an error opening the audio output device.
"""
Expand All @@ -789,7 +817,7 @@ def __init__(
self._player = NVDAHelper.localLib.wasPlay_create(
self._deviceNameToId(outputDevice),
format,
WasapiWavePlayer._callback)
WasapiWavePlayer._callback, session.guid, session.name)
self._doneCallbacks = {}
self._instances[self._player] = self
self.open()
Expand Down Expand Up @@ -891,6 +919,13 @@ def pause(self, switch: bool):
else:
NVDAHelper.localLib.wasPlay_resume(self._player)

def setSessionVolume(self, level: float):
"""Set the volume for the audio session.
This sets the volume for all streams in this session, not just the stream
associated with this WavePlayer instance.
"""
NVDAHelper.localLib.wasPlay_setSessionVolume(self._player, c_float(level))

@staticmethod
def _getDevices():
rawDevs = BSTR()
Expand Down Expand Up @@ -934,8 +969,23 @@ def initialize():
NVDAHelper.localLib.wasPlay_sync,
NVDAHelper.localLib.wasPlay_pause,
NVDAHelper.localLib.wasPlay_resume,
NVDAHelper.localLib.wasPlay_setSessionVolume,
NVDAHelper.localLib.wasPlay_getDevices,
):
func.restype = HRESULT
func.errcheck = _wasPlay_errcheck
NVDAHelper.localLib.wasPlay_startup()
# Some audio clients won't specify a session; e.g. speech synthesizers which
# use their own audio output code rather than nvwave. We don't want these to
# end up in the wrong session, so we set a specific default session. To do
# that, first create a stream in that session (defaultSession).
WasapiWavePlayer(channels=1, samplesPerSec=44100, bitsPerSample=16)
# Now create a stream with the null session (GUID_NULL). This will use the
# session we created above. All subsequent streams created without a specific
# session will use this session.
WasapiWavePlayer(
channels=1,
samplesPerSec=44100,
bitsPerSample=16,
session=AudioSession(GUID(), "")
)
3 changes: 2 additions & 1 deletion source/tones.py
Expand Up @@ -26,7 +26,8 @@ def initialize():
samplesPerSec=int(SAMPLE_RATE),
bitsPerSample=16,
outputDevice=config.conf["speech"]["outputDevice"],
wantDucking=False
wantDucking=False,
session=nvwave.soundsSession
)
except Exception:
log.warning("Failed to initialize audio for tones", exc_info=True)
Expand Down

0 comments on commit 007b6fc

Please sign in to comment.