Skip to content

Commit 9a8fbb0

Browse files
authored
Merge f87641b into e71916d
2 parents e71916d + f87641b commit 9a8fbb0

8 files changed

Lines changed: 225 additions & 8 deletions

File tree

nvdaHelper/local/nvdaHelperLocal.def

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,6 @@ EXPORTS
8181
wasPlay_resume
8282
wasPlay_setChannelVolume
8383
wasPlay_startup
84+
wasSilence_init
85+
wasSilence_playFor
86+
wasSilence_terminate

nvdaHelper/local/wasapi.cpp

Lines changed: 155 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ This license can be found at:
1212
http://www.gnu.org/licenses/old-licenses/gpl-2.0.html
1313
*/
1414

15+
#include <thread>
1516
#include <vector>
1617
#include <windows.h>
1718
#include <atlbase.h>
@@ -22,16 +23,18 @@ This license can be found at:
2223
#include <Functiondiscoverykeys_devpkey.h>
2324
#include <mmdeviceapi.h>
2425
#include <common/log.h>
26+
#include <random>
2527

2628
/**
2729
* Support for audio playback using WASAPI.
2830
* Most of the core work happens in the WasapiPlayer class. Because Python
2931
* ctypes can't call C++ classes, NVDA interfaces with this using the wasPlay_*
30-
* functions.
32+
* and wasSilence_* functions.
3133
*/
3234

3335
constexpr REFERENCE_TIME REFTIMES_PER_MILLISEC = 10000;
34-
constexpr REFERENCE_TIME BUFFER_SIZE = 400 * REFTIMES_PER_MILLISEC;
36+
constexpr DWORD BUFFER_MS = 400;
37+
constexpr REFERENCE_TIME BUFFER_SIZE = BUFFER_MS * REFTIMES_PER_MILLISEC;
3538

3639
const CLSID CLSID_MMDeviceEnumerator = __uuidof(MMDeviceEnumerator);
3740
const IID IID_IMMDeviceEnumerator = __uuidof(IMMDeviceEnumerator);
@@ -187,7 +190,6 @@ class WasapiPlayer {
187190
HRESULT pause();
188191
HRESULT resume();
189192
HRESULT setChannelVolume(unsigned int channel, float level);
190-
191193
private:
192194
void maybeFireCallback();
193195

@@ -385,8 +387,13 @@ HRESULT WasapiPlayer::feed(unsigned char* data, unsigned int size,
385387
if (FAILED(hr)) {
386388
return hr;
387389
}
388-
memcpy(buffer, data, sendBytes);
389-
hr = render->ReleaseBuffer(sendFrames, 0);
390+
if (data) {
391+
memcpy(buffer, data, sendBytes);
392+
hr = render->ReleaseBuffer(sendFrames, 0);
393+
} else {
394+
// Null data means play silence.
395+
hr = render->ReleaseBuffer(sendFrames, AUDCLNT_BUFFERFLAGS_SILENT);
396+
}
390397
if (FAILED(hr)) {
391398
return hr;
392399
}
@@ -403,7 +410,9 @@ HRESULT WasapiPlayer::feed(unsigned char* data, unsigned int size,
403410
playState = PlayState::playing;
404411
}
405412
maybeFireCallback();
406-
data += sendBytes;
413+
if (data) {
414+
data += sendBytes;
415+
}
407416
size -= sendBytes;
408417
remainingFrames -= sendFrames;
409418
sentFrames += sendFrames;
@@ -624,9 +633,128 @@ HRESULT WasapiPlayer::setChannelVolume(unsigned int channel, float level) {
624633
return volume->SetChannelVolume(channel, level);
625634
}
626635

636+
/**
637+
* Asynchronously play silence for requested durations.
638+
* Silence is played in a background thread. The duration can be adjusted from
639+
* any thread.
640+
*/
641+
class SilencePlayer {
642+
public:
643+
SilencePlayer(wchar_t* deviceName);
644+
HRESULT init();
645+
// Play silence for the specified duration.
646+
void playFor(DWORD ms, float volume);
647+
void terminate();
648+
649+
private:
650+
static WAVEFORMATEX getFormat();
651+
void generateWhiteNoise(float volume);
652+
// The code which is run in the silence thread.
653+
void run();
654+
655+
static constexpr DWORD SAMPLES_PER_SEC = 48000;
656+
// How many bytes of silence in each buffer.
657+
static constexpr unsigned int SILENCE_BYTES = SAMPLES_PER_SEC * 2 * BUFFER_MS
658+
/ 1000;
659+
WasapiPlayer player;
660+
AutoHandle wakeEvent;
661+
// The time (not duration) at which silence should end.
662+
ULONGLONG endTime = 0;
663+
std::thread silenceThread;
664+
float volume;
665+
std::vector<INT16> whiteNoiseData;
666+
};
667+
668+
SilencePlayer::SilencePlayer(wchar_t* deviceName):
669+
player(deviceName, getFormat(), nullptr),
670+
whiteNoiseData(
671+
SILENCE_BYTES / (
672+
sizeof(INT16) / sizeof(unsigned char)
673+
)
674+
),
675+
volume(-1) {
676+
wakeEvent = CreateEvent(nullptr, false, false, nullptr);
677+
}
678+
679+
WAVEFORMATEX SilencePlayer::getFormat() {
680+
WAVEFORMATEX format;
681+
format.wFormatTag = WAVE_FORMAT_PCM;
682+
format.nChannels = 1;
683+
format.nSamplesPerSec = SAMPLES_PER_SEC;
684+
format.wBitsPerSample = 16;
685+
format.nBlockAlign = 2;
686+
format.nAvgBytesPerSec = SAMPLES_PER_SEC * 2;
687+
format.cbSize = 0;
688+
return format;
689+
}
690+
691+
void SilencePlayer::generateWhiteNoise(float volume) {
692+
if (volume == 0) {
693+
return;
694+
}
695+
UINT32 n = whiteNoiseData.size();
696+
const double mean = 0.0;
697+
const double stddev = volume * 256;
698+
std::default_random_engine generator;
699+
std::normal_distribution<double> dist(mean, stddev);
700+
for (UINT32 i = 0; i < n; i++) {
701+
whiteNoiseData[i] = (INT16)dist(generator);
702+
}
703+
}
704+
HRESULT SilencePlayer::init() {
705+
HRESULT hr = player.open();
706+
if (FAILED(hr)) {
707+
return hr;
708+
}
709+
silenceThread = std::thread(&SilencePlayer::run, this);
710+
return S_OK;
711+
}
712+
713+
void SilencePlayer::run() {
714+
for (;;) {
715+
// Wait for silence or termination to be requested.
716+
WaitForSingleObject(wakeEvent, INFINITE);
717+
if (endTime == 0) {
718+
// We have been asked to terminate.
719+
// std::thread cannot be destroyed while it is attached, so detach it first.
720+
silenceThread.detach();
721+
delete this;
722+
return;
723+
}
724+
// Play silence until the desired time. This time might increase or decrease
725+
// as we're looping. This is fine because we're only pushing BUFFER_MS each
726+
// iteration.
727+
while (GetTickCount64() < endTime) {
728+
unsigned char* whiteNoisePtr = volume > 0
729+
? reinterpret_cast<unsigned char*>(&whiteNoiseData[0])
730+
: nullptr;
731+
player.feed(whiteNoisePtr, SILENCE_BYTES, nullptr);
732+
}
733+
player.idle();
734+
}
735+
}
736+
737+
void SilencePlayer::playFor(DWORD ms, float volume) {
738+
if (volume != this->volume) {
739+
generateWhiteNoise(volume);
740+
this->volume = volume;
741+
}
742+
endTime = ms == INFINITE ? ULLONG_MAX : GetTickCount64() + ms;
743+
SetEvent(wakeEvent);
744+
}
745+
746+
void SilencePlayer::terminate() {
747+
// 0 signals silenceThread to exit.
748+
endTime = 0;
749+
// If silenceThread is feeding, this will make feed return early.
750+
player.stop();
751+
// If silenceThread is waiting, this will wake it up.
752+
SetEvent(wakeEvent);
753+
}
754+
627755
/*
628756
* NVDA calls the functions below. Most of these just wrap calls to
629-
* WasapiPlayer, with the exception of wasPlay_startup.
757+
* WasapiPlayer or SilencePlayer, with the exception of wasPlay_startup.
630758
*/
631759

632760
WasapiPlayer* wasPlay_create(wchar_t* deviceName, WAVEFORMATEX format,
@@ -690,3 +818,23 @@ HRESULT wasPlay_startup() {
690818
notificationClient = new NotificationClient();
691819
return enumerator->RegisterEndpointNotificationCallback(notificationClient);
692820
}
821+
822+
SilencePlayer* silence = nullptr;
823+
824+
HRESULT wasSilence_init(wchar_t* deviceName) {
825+
assert(!silence);
826+
silence = new SilencePlayer(deviceName);
827+
return silence->init();
828+
}
829+
830+
void wasSilence_playFor(DWORD ms, float volume) {
831+
assert(silence);
832+
silence->playFor(ms, volume);
833+
}
834+
835+
void wasSilence_terminate() {
836+
assert(silence);
837+
silence->terminate();
838+
// silence will delete itself once the thread terminates.
839+
silence = nullptr;
840+
}

source/config/configSpec.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@
5454
WASAPI = featureFlag(optionsEnum="BoolFlag", behaviorOfDefault="enabled")
5555
soundVolumeFollowsVoice = boolean(default=false)
5656
soundVolume = integer(default=100, min=0, max=100)
57+
silenceTimeSeconds = integer(default=30,min=0,max=3600)
58+
whiteNoiseVolume = integer(default=0, min=0, max=100)
5759
5860
# Braille settings
5961
[braille]

source/core.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -906,7 +906,6 @@ def _doPostNvdaStartupAction():
906906
_terminate(bdDetect)
907907
_terminate(hwIo)
908908
_terminate(addonHandler)
909-
_terminate(nvwave)
910909
_terminate(garbageHandler)
911910
# DMP is only started if needed.
912911
# Terminate manually (and let it write to the log if necessary)
@@ -925,6 +924,7 @@ def _doPostNvdaStartupAction():
925924
)
926925
except:
927926
pass
927+
_terminate(nvwave)
928928
# #5189: Destroy the message window as late as possible
929929
# so new instances of NVDA can find this one even if it freezes during exit.
930930
messageWindow.destroy()

source/gui/settingsDialogs.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3295,6 +3295,40 @@ def __init__(self, parent):
32953295

32963296
self.Layout()
32973297

3298+
silenceDurationLabelText = _(
3299+
# Translators: The label for a setting in advanced panel to change silence duration
3300+
"Duration in seconds of silence or white noise played to keep audio device open (or 0 to disable)"
3301+
)
3302+
minDuration = int(config.conf.getConfigValidation(
3303+
("audio", "silenceTimeSeconds")
3304+
).kwargs["min"])
3305+
maxDuration = int(config.conf.getConfigValidation(("audio", "silenceTimeSeconds")).kwargs["max"])
3306+
self.silenceDurationEdit = sHelper.addLabeledControl(
3307+
silenceDurationLabelText,
3308+
nvdaControls.SelectOnFocusSpinCtrl,
3309+
min=minDuration,
3310+
max=maxDuration,
3311+
initial=config.conf["audio"]["silenceTimeSeconds"]
3312+
)
3313+
self.bindHelpEvent("silenceDuration", self.silenceDurationEdit)
3314+
self.silenceDurationEdit.Bind(wx.EVT_TEXT, self._onSilenceDurationChanged)
3315+
3316+
# Translators: This is the label for a slider control in the
3317+
# advanced settings panel.
3318+
label = _("White noise Volume - or set to 0 for silence")
3319+
self.whiteNoiseVolSlider: nvdaControls.EnhancedInputSlider = sHelper.addLabeledControl(
3320+
label,
3321+
nvdaControls.EnhancedInputSlider,
3322+
minValue=0,
3323+
maxValue=100
3324+
)
3325+
self.bindHelpEvent("whiteNoiseVolume", self.whiteNoiseVolSlider)
3326+
self.whiteNoiseVolSlider.SetValue(config.conf["audio"]["whiteNoiseVolume"])
3327+
self._onSilenceDurationChanged(None)
3328+
3329+
def _onSilenceDurationChanged(self, event: wx.Event) -> None:
3330+
self.whiteNoiseVolSlider.Enable(self.silenceDurationEdit.GetValue() > 0)
3331+
32983332
def onOpenScratchpadDir(self,evt):
32993333
path=config.getScratchpadDir(ensureExists=True)
33003334
os.startfile(path)
@@ -3394,6 +3428,8 @@ def onSave(self):
33943428
for index,key in enumerate(self.logCategories):
33953429
config.conf['debugLog'][key]=self.logCategoriesList.IsChecked(index)
33963430
config.conf["featureFlag"]["playErrorSound"] = self.playErrorSoundCombo.GetSelection()
3431+
config.conf["audio"]["silenceTimeSeconds"] = self.silenceDurationEdit.GetValue()
3432+
config.conf["audio"]["whiteNoiseVolume"] = self.whiteNoiseVolSlider.GetValue()
33973433

33983434

33993435
class AdvancedPanel(SettingsPanel):

source/nvwave.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -779,6 +779,8 @@ class WasapiWavePlayer(garbageHandler.TrackedObject):
779779
_isIdleCheckPending: bool = False
780780
#: Use the default device, this is the configSpec default value.
781781
DEFAULT_DEVICE_KEY = "default"
782+
#: The silence output device, None if not initialized.
783+
_silenceDevice: typing.Optional[str] = None
782784

783785
def __init__(
784786
self,
@@ -832,6 +834,13 @@ def __init__(
832834
self.open()
833835
self._lastActiveTime: typing.Optional[float] = None
834836
self._isPaused: bool = False
837+
if config.conf["audio"]["silenceTimeSeconds"] > 0 and WasapiWavePlayer._silenceDevice != outputDevice:
838+
# The output device has changed. (Re)initialize silence.
839+
if self._silenceDevice is not None:
840+
NVDAHelper.localLib.wasSilence_terminate()
841+
if config.conf["audio"]["silenceTimeSeconds"] > 0:
842+
NVDAHelper.localLib.wasSilence_init(outputDevice)
843+
WasapiWavePlayer._silenceDevice = outputDevice
835844

836845
@wasPlay_callback
837846
def _callback(cppPlayer, feedId):
@@ -912,6 +921,11 @@ def feed(
912921
self._doneCallbacks[feedId.value] = onDone
913922
self._lastActiveTime = time.time()
914923
self._scheduleIdleCheck()
924+
if config.conf["audio"]["silenceTimeSeconds"] > 0:
925+
NVDAHelper.localLib.wasSilence_playFor(
926+
1000 * config.conf["audio"]["silenceTimeSeconds"],
927+
c_float(config.conf["audio"]["whiteNoiseVolume"] / 100.0),
928+
)
915929

916930
def sync(self):
917931
"""Synchronise with playback.
@@ -1063,6 +1077,7 @@ def initialize():
10631077
NVDAHelper.localLib.wasPlay_pause,
10641078
NVDAHelper.localLib.wasPlay_resume,
10651079
NVDAHelper.localLib.wasPlay_setChannelVolume,
1080+
NVDAHelper.localLib.wasSilence_init,
10661081
):
10671082
func.restype = HRESULT
10681083
func.errcheck = _wasPlay_errcheck
@@ -1071,6 +1086,8 @@ def initialize():
10711086

10721087

10731088
def terminate() -> None:
1089+
if WasapiWavePlayer._silenceDevice is not None:
1090+
NVDAHelper.localLib.wasSilence_terminate()
10741091
getOnErrorSoundRequested().unregister(playErrorSound)
10751092

10761093

user_docs/en/changes.t2t

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ What's New in NVDA
77

88
== New Features ==
99
- In Windows 11, NVDA will announce alerts from voice typing and suggested actions including the top suggestion when copying data such as phone numbers to the clipboard (Windows 11 2022 Update and later). (#16009, @josephsl)
10+
- Playing silence after every utterance in order to keep audio device open (#14386, @jcsteh, @mltony)
1011
-
1112

1213

user_docs/en/userGuide.t2t

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2594,6 +2594,16 @@ This option allows you to specify if NVDA will play an error sound in case an er
25942594
Choosing Only in test versions (default) makes NVDA play error sounds only if the current NVDA version is a test version (alpha, beta or run from source).
25952595
Choosing Yes allows to enable error sounds whatever your current NVDA version is.
25962596

2597+
==== Silence duration ====[silenceDuration]
2598+
2599+
This edit box specifies how long NVDA plays silence after every utterance. We play silence to keep audio device open and avoid certain glitches like dropped parts of utterances, that happen due to audio devices (especially Bluetooth and wireless devices) entering stand by mode.
2600+
2601+
You can specify silence duration to be 0 in order to disable this feature.
2602+
2603+
==== White noise volume slider ====[whiteNoiseVolume]
2604+
2605+
When you set white noise volume to any value above zero, then instead of silence NVDA would playh white noise in order to keep audio device open. This feature is useful for debugging audio issues.
2606+
25972607
++ miscellaneous Settings ++[MiscSettings]
25982608
Besides the [NVDA Settings #NVDASettings] dialog, The Preferences sub-menu of the NVDA Menu contains several other items which are outlined below.
25992609

0 commit comments

Comments
 (0)