Repository: nvaccess/nvda

Issue:
NVDA stops playing audio when audio output device is changed using latest betas
Hi.

I use a USB sound device with my computer. While using the beta, the following has been consistently reproduceable:
1. Use NVDA when the sound card is plugged in.
2. Unplug the USB sound device.
3. Try to invoke progress bar sounds, browse/focus mode sounds etc.
4. Plug back in the USB sound device.5.

Expected behaviour: NVDA should play the sounds using the device that is in use. Both synthesizer and voice should come through the same audio output device.
Actual behaviour: NVDA's synthesizer output comes through the right device in steps 3 and 4, but the minute the audio device changes, NVDA stops playing its sounds until it is restarted.

NVDA versions where this happens: NVDA 2020.4beta1, NVDA 2020.4beta2, but not NVDA 2019.3 or older
Default audio output device: Microsoft Sound Mapper
Installed or portable: Installed
Windows version: Windows 10 20H2 (64-bit) build 19042.685



Discussion:

Resolving patch:
diff --git a/source/nvwave.py b/source/nvwave.py
--- a/source/nvwave.py
+++ b/source/nvwave.py
@@ -9,6 +9,10 @@
 
 import threading
 import typing
+from typing import (
+	Optional,
+	Callable,
+)
 from ctypes import (
 	windll,
 	POINTER,
@@ -123,18 +127,6 @@ def _isDebugForNvWave():
 	return config.conf["debugLog"]["nvwave"]
 
 
-def safe_winmm_waveOutReset(_waveout) -> bool:
-	""" Wrap waveOutReset in try block and log exceptions,
-	it seems to fail randomly on some systems.
-	@return True on success.
-	"""
-	try:
-		winmm.waveOutReset(_waveout)
-		return True
-	except WindowsError:
-		log.debug("Exception while resetting wave out device.", exc_info=True)
-		return False
-
 class WavePlayer(garbageHandler.TrackedObject):
 	"""Synchronously play a stream of audio.
 	To use, construct an instance and feed it waveform audio using L{feed}.
@@ -311,13 +303,9 @@ def open(self):
 						CALLBACK_EVENT
 					)
 			except WindowsError:
-				if _isDebugForNvWave():
-					log.debug(
-						f"Error opening"
-						f" outputDeviceName: {self._outputDeviceName}"
-						f" with id: {self._outputDeviceID}"
-					)
-				if self._outputDeviceID != WAVE_MAPPER:
+				lastOutputDeviceID = self._outputDeviceID
+				self._handleWinmmError(message="Error opening")
+				if lastOutputDeviceID != WAVE_MAPPER:
 					if _isDebugForNvWave():
 						log.debug(f"Falling back to WAVE_MAPPER")
 					self._setCurrentDevice(WAVE_MAPPER)
@@ -326,7 +314,7 @@ def open(self):
 					log.warning(f"Unable to open WAVE_MAPPER device, there may be no audio devices.")
 					raise  # can't open the default device.
 				return
-			self._waveout = waveout.value
+			self._waveout: typing.Optional[int] = waveout.value
 			self._prev_whdr = None
 
 	def feed(
@@ -444,12 +432,11 @@ def pause(self, switch):
 		with self._waveout_lock:
 			if not self._waveout:
 				return
-			if switch:
-				with self._global_waveout_lock:
-					winmm.waveOutPause(self._waveout)
-			else:
-				with self._global_waveout_lock:
-					winmm.waveOutRestart(self._waveout)
+			with self._global_waveout_lock:
+				if switch:
+					self._safe_winmm_call(winmm.waveOutPause, "Pause")
+				else:
+					self._safe_winmm_call(winmm.waveOutRestart, "Restart")
 
 	def idle(self):
 		"""Indicate that this player is now idle; i.e. the current continuous segment  of audio is complete.
@@ -498,8 +485,10 @@ def stop(self):
 			self._prevOnDone = self.STOPPING
 			with self._global_waveout_lock:
 				# Pausing first seems to make waveOutReset respond faster on some systems.
-				winmm.waveOutPause(self._waveout)
-				safe_winmm_waveOutReset(self._waveout)
+				self._safe_winmm_call(winmm.waveOutPause, "Pause")
+				self._safe_winmm_call(winmm.waveOutReset, "Reset")
+				# Allow fall through to idleUnbuffered if either pause or reset fail.
+
 				# The documentation is not explicit about whether waveOutReset will signal the event,
 				# so trigger it to be sure that sync isn't blocking on 'waitForSingleObject'.
 				windll.kernel32.SetEvent(self._waveout_event)
@@ -524,6 +513,7 @@ def _close(self):
 			if not self._waveout:
 				return
 			try:
+				# don't use '_safe_winmm_call' here, on error it would re-enter _close infinitely
 				winmm.waveOutClose(self._waveout)
 			except WindowsError:
 				log.debug("Error closing the device, it may have been removed.", exc_info=True)
@@ -535,6 +525,30 @@ def __del__(self):
 		self._waveout_event = None
 		super().__del__()
 
+	def _handleWinmmError(self, message: str):
+		if _isDebugForNvWave():
+			log.debug(
+				f"Winmm Error: {message}"
+				f" outputDeviceName: {self._outputDeviceName}"
+				f" with id: {self._outputDeviceID}",
+				stack_info=True
+			)
+		self._close()
+
+	def _safe_winmm_call(
+			self,
+			winmmCall: Callable[[Optional[int]], None],
+			messageOnFailure: str
+	) -> bool:
+		try:
+			winmmCall(self._waveout)
+			return True
+		except WindowsError:
+			# device will be closed and _waveout set to None,
+			# triggering re-open.
+			self._handleWinmmError(message=messageOnFailure)
+			return False
+
 
 def _getOutputDevices():
 	"""Generator, returning device ID and device Name in device ID order.


Test patch: