Repository: nvaccess/nvda

Issue:
OneCore performance
<!--
Please thoroughly read NVDA's wiki article on how to fill in this template, including how to provide the required files.
Issues may be closed if the required information is not present.
https://github.com/nvaccess/nvda/wiki/Github-issue-template-explanation-and-examples
-->

### Steps to reproduce:
1. Switch to OneCore.
1. Press windows+m to go to the desktop.
1. Tab to the start button.
1. Shift+tab and tab a few times.

### Actual behavior:
The delay between Desktop list and the icon name varies between fast and slow.
### Expected behavior:
The delay should stay the same.
### System configuration
#### NVDA installed/portable/running from source:
Portable
#### NVDA version:
2019.3 Beta 2
#### Windows version:
Windows 10 build 1909
#### Name and version of other software in use when reproducing the issue:

#### Other information about your system:

### Other questions
#### Does the issue still occur after restarting your PC?
Yes
#### Have you tried any other versions of NVDA? If so, please report their behaviors.
2019.2 works fine.



Discussion:
I've been seeing this on a client's I7 quadcore laptop, running 2019.2.1 under 
Win 10 1903.

She keeps complaining about the "lag", and when using remote to operate it, I 
can observe it directly. Actions happen, but One Core David takes a notable 
length of time before mentioning them.

It seems worse during some boot cycles than others.

I haven't been able to figure out if it is an actual problem, or just the 
general ridiculous slowness of One Core as contrasted with Espeak.

@feerrenrut, I mentioned briefly when we spoke today that I had a theory (more of a hunch) regarding the OneCore lag. It turns out my hunch was correct.

If you take a look at ffa71c7c (#7495), you'll note that at one point, we were getting lag when typing characters. I tracked this down to WaveOutOpen (called by nvwave) blocking for ~100 ms whenever it was called from the OneCore engine callback if there were SSML marks (indexes) in the stream. I was never able to work out why that happened; I just worked around it by ensuring we open the device before pushing to the synth, rather than opening the device in the callback upon receiving the first chunk of audio.

What's new in NVDA 2019.3 is that *all* utterances contain at least one SSML mark, not just characters and say all. Digging into this, we call WavePlayer.idle() when there's nothing more in the queue, which (synchronously) pushes the last chunk of audio and closes the device. Because that blocks, something can arrive in the queue after that point. This is fine - we handle it - except that what this means is that in this case, we'll end up calling WaveOutOpen from the callback for the next chunk of audio, thus triggering this ~100 ms lag before the next utterance is played.

I have a patch which fixes this... most of the time. Instead of calling WavePlayer.idle(), we call WavePlayer.sync(). If the queue is empty after that returns, only then do we call WavePlayer.idle() and allow the device to close. Unfortunately, there's still a rare race condition where as we're closing the device, something can arrive in the queue, so we hit the same issue again.

I can submit this PR, but it's not a complete fix. I think the more complete solution here is to make WavePlayer.idle() only close the device if there's no activity for, say, 10 or 15 seconds. Aside from completely fixing this issue, that would improve audio performance between utterances in general, as well as dealing with some irritating audio drivers/hardware which take a long time to open the device and/or truncate the start of the audio.

Resolving patch:
diff --git a/source/nvwave.py b/source/nvwave.py
--- a/source/nvwave.py
+++ b/source/nvwave.py
@@ -18,6 +18,7 @@
 import config
 from logHandler import log
 import os.path
+import core
 
 __all__ = (
 	"WavePlayer", "getOutputDeviceNames", "outputDeviceIDToName", "outputDeviceNameToID",
@@ -100,6 +101,9 @@ class WavePlayer(object):
 	"""
 	#: Minimum length of buffer (in ms) before audio is played.
 	MIN_BUFFER_MS = 300
+	#: The time (in ms) to wait after L{idle} is called before closing the audio
+	#: device. This is only applicable if L{closeWhenIdle} is C{True}.
+	IDLE_CLOSE_DELAY_MS = 10000
 	#: Flag used to signal that L{stop} has been called.
 	STOPPING = "stopping"
 	#: A lock to prevent WaveOut* functions from being called simultaneously, as this can cause problems even if they are for different HWAVEOUTs.
@@ -141,6 +145,7 @@ def __init__(self, channels, samplesPerSec, bitsPerSample,
 		#: If C{True}, close the output device when no audio is being played.
 		#: @type: bool
 		self.closeWhenIdle = closeWhenIdle
+		self._closeTimer = None
 		if buffered:
 			#: Minimum size of the buffer before audio is played.
 			#: However, this is ignored if an C{onDone} callback is provided to L{feed}.
@@ -158,6 +163,12 @@ def __init__(self, channels, samplesPerSec, bitsPerSample,
 		self._lock = threading.RLock()
 		self.open()
 
+	def _callOnMainThread(self, func, *args):
+		if threading.get_ident() == core.mainThreadId:
+			func(*args)
+		else:
+			wx.CallAfter(func, *args)
+
 	def open(self):
 		"""Open the output device.
 		This will be called automatically when required.
@@ -165,6 +176,8 @@ def open(self):
 		"""
 		with self._waveout_lock:
 			if self._waveout:
+				if self._closeTimer and self._closeTimer.IsRunning():
+					self._callOnMainThread(self._closeTimer.Stop)
 				return
 			wfx = WAVEFORMATEX()
 			wfx.wFormatTag = WAVE_FORMAT_PCM
@@ -269,7 +282,8 @@ def pause(self, switch):
 	def idle(self):
 		"""Indicate that this player is now idle; i.e. the current continuous segment  of audio is complete.
 		This will first call L{sync} to synchronise with playback.
-		If L{closeWhenIdle} is C{True}, the output device will be closed.
+		If L{closeWhenIdle} is C{True}, the output device will be closed if there
+		is no further audio within L{IDLE_CLOSE_DELAY_MS}.
 		A subsequent call to L{feed} will reopen it.
 		"""
 		if not self._minBufferSize:
@@ -286,7 +300,13 @@ def _idleUnbuffered(self):
 				if not self._waveout:
 					return
 				if self.closeWhenIdle:
-					self._close()
+					if not self._closeTimer:
+						# We need a cancellable timer, so we can't use core.callLater.
+						self._closeTimer = wx.PyTimer(self._close)
+					self._callOnMainThread(
+						self._closeTimer.Start, self.IDLE_CLOSE_DELAY_MS,
+						wx.TIMER_ONE_SHOT
+					)
 			if self._audioDucker: self._audioDucker.disable()
 
 	def stop(self):
@@ -319,6 +339,9 @@ def close(self):
 			with self._waveout_lock:
 				if not self._waveout:
 					return
+				# We're explicitly closing, so kill the close timer.
+				if self._closeTimer and self._closeTimer.IsRunning():
+					self._callOnMainThread(self._closeTimer.Stop)
 				self._close()
 
 	def _close(self):


Test patch: