Repository: nvaccess/nvda

Issue:
NVDA doesn't speak files names during letter-key navigation for IVONA SAPI 5 voices
This issue occurs in NVDA 2019.3.1 and above. 

During the key navigation in Windows directories three separate speak calls are generated from the sapi5.py synthDriver. For example, the calls may contain following texts:
```
1. <pitch absmiddle="0"><spell>s<Bookmark Mark="166" /></spell></pitch>
2. <Bookmark Mark="167" />
3. <pitch absmiddle="0">Spotify  4 z 16  <Bookmark Mark="168" /></pitch>
```
Unfortunately, IVONA SAPI 5 voices don't generate the bookmark callback for a speak command that contains text with only bookmark tag. In effect the speak command with the last text is not called.
IVONA voice authors reported they can't fix that on their side.



Discussion:

Resolving patch:
diff --git a/source/speech/manager.py b/source/speech/manager.py
--- a/source/speech/manager.py
+++ b/source/speech/manager.py
@@ -312,28 +312,36 @@ def _processSpeechSequence(self, inSeq: SpeechSequence):
 		paramTracker = ParamChangeTracker()
 		enteredTriggers = []
 		outSeqs = []
+		paramsToReplay = []
 
 		def ensureEndUtterance(seq: SpeechSequence):
 			# We split at EndUtteranceCommands so the ends of utterances are easily found.
+			# This function ensures the given sequence ends with an EndUtterance command,
+			# Ensures that the sequence also includes an index command at the end,
+			# It places the complete sequence in outSeqs,
+			# It clears the given sequence list ready to build a new one,
+			# And clears the paramsToReplay list
+			# and refills it with any params that need to be repeated if a new sequence is going to be built.
 			if seq:
 				# There have been commands since the last split.
-				outSeqs.append(seq)
-				lastOutSeq = seq
+				lastOutSeq = paramsToReplay + seq
+				outSeqs.append(lastOutSeq)
+				paramsToReplay.clear()
+				seq.clear()
 				# Re-apply parameters that have been changed from their defaults.
-				seq = paramTracker.getChanged()
+				paramsToReplay.extend(paramTracker.getChanged())
 			else:
 				lastOutSeq = outSeqs[-1] if outSeqs else None
 			lastCommand = lastOutSeq[-1] if lastOutSeq else None
 			if not lastCommand or isinstance(lastCommand, (EndUtteranceCommand, ConfigProfileTriggerCommand)):
 				# It doesn't make sense to start with or repeat EndUtteranceCommands.
 				# We also don't want an EndUtteranceCommand immediately after a ConfigProfileTriggerCommand.
-				return seq
+				return
 			if not isinstance(lastCommand, IndexCommand):
 				# Add an index so we know when we've reached the end of this utterance.
 				reachedIndex = next(self._indexCounter)
 				lastOutSeq.append(IndexCommand(reachedIndex))
 			outSeqs.append([EndUtteranceCommand()])
-			return seq
 
 		outSeq = []
 		for command in inSeq:
@@ -343,8 +351,9 @@ def ensureEndUtterance(seq: SpeechSequence):
 				outSeq.append(IndexCommand(speechIndex))
 				self._indexesToCallbacks[speechIndex] = command
 				# We split at indexes so we easily know what has completed speaking.
-				outSeqs.append(outSeq)
-				outSeq = []
+				outSeqs.append(paramsToReplay + outSeq)
+				paramsToReplay.clear()
+				outSeq.clear()
 				continue
 			if isinstance(command, ConfigProfileTriggerCommand):
 				if not command.trigger.hasProfile:
@@ -356,7 +365,7 @@ def ensureEndUtterance(seq: SpeechSequence):
 				if not command.enter and command.trigger not in enteredTriggers:
 					log.debugWarning("Request to exit trigger which wasn't entered: %r" % command.trigger.spec)
 					continue
-				outSeq = ensureEndUtterance(outSeq)
+				ensureEndUtterance(outSeq)
 				outSeqs.append([command])
 				if command.enter:
 					enteredTriggers.append(command.trigger)
@@ -364,7 +373,7 @@ def ensureEndUtterance(seq: SpeechSequence):
 					enteredTriggers.remove(command.trigger)
 				continue
 			if isinstance(command, EndUtteranceCommand):
-				outSeq = ensureEndUtterance(outSeq)
+				ensureEndUtterance(outSeq)
 				continue
 			if isinstance(command, SynthParamCommand):
 				paramTracker.update(command)


Test patch:
diff --git a/tests/unit/test_speechManager/__init__.py b/tests/unit/test_speechManager/__init__.py
--- a/tests/unit/test_speechManager/__init__.py
+++ b/tests/unit/test_speechManager/__init__.py
@@ -17,6 +17,8 @@
 	PitchCommand,
 	ConfigProfileTriggerCommand,
 	_CancellableSpeechCommand,
+	CharacterModeCommand,
+	EndUtteranceCommand,
 )
 from .speechManagerTestHarness import (
 	_IndexT,
@@ -826,10 +828,16 @@ def test_4_profiles(self):
 			"Testing testing ",
 			speech.PitchCommand(offset=100),
 			"1 2 3 4",
-			smi.create_ConfigProfileTriggerCommand(t1, True, expectedToBecomeIndex=1),
-			smi.create_ConfigProfileTriggerCommand(t2, True, expectedToBecomeIndex=2),
+			smi.create_ExpectedIndex(1),
+			# The preceeding index is expected,
+			# as the following profile trigger commands will cause the utterance to be split here.
+			ConfigProfileTriggerCommand(t1, True),
+			ConfigProfileTriggerCommand(t2, True),
 			"5 6 7 8",
-			smi.create_ConfigProfileTriggerCommand(t1, False, expectedToBecomeIndex=3),
+			smi.create_ExpectedIndex(2),
+			# The preceeding index is expected,
+			# as the following profile trigger commands will cause the utterance to be split here.
+			ConfigProfileTriggerCommand(t1, False),
 			"9 10 11 12"
 		]
 		with smi.expectation():
@@ -842,28 +850,17 @@ def test_4_profiles(self):
 			smi.doneSpeaking()
 			smi.pumpAll()
 			smi.expect_synthCancel()
-			smi.expect_synthSpeak(sequence=[
-				seq[1],  # PitchCommand
-				seq[4],  # IndexCommand index=2 (derived from ConfigProfileTriggerCommand)
-			])
 			smi.expect_mockCall(t1.enter)
-
-		with smi.expectation():
-			smi.indexReached(2)
-			smi.pumpAll()
-		with smi.expectation():
-			smi.doneSpeaking()
-			smi.pumpAll()
 			smi.expect_synthCancel()
+			smi.expect_mockCall(t2.enter)
 			smi.expect_synthSpeak(sequence=[
 				seq[1],  # PitchCommand
 				'5 6 7 8',
-				seq[6],  # IndexCommand index=3 (derived from ConfigProfileTriggerCommand)
+				seq[7],  # IndexCommand index=2 (due to a  ConfigProfileTriggerCommand following it)
 			])
-			smi.expect_mockCall(t2.enter)
 
 		with smi.expectation():
-			smi.indexReached(3)
+			smi.indexReached(2)
 			smi.pumpAll()
 		with smi.expectation():
 			smi.doneSpeaking()
@@ -872,12 +869,12 @@ def test_4_profiles(self):
 			smi.expect_synthSpeak(sequence=[
 				seq[1],  # PitchCommand
 				'9 10 11 12',
-				smi.create_ExpectedIndex(expectedToBecomeIndex=4)
+				smi.create_ExpectedIndex(expectedToBecomeIndex=3)
 			])
 			smi.expect_mockCall(t1.exit)
 
 		with smi.expectation():
-			smi.indexReached(4)
+			smi.indexReached(3)
 			smi.pumpAll()
 		with smi.expectation():
 			smi.doneSpeaking()
@@ -1115,3 +1112,34 @@ class InitialDevelopmentTests_withCancellableSpeechEnabled(InitialDevelopmentTes
 	def setUp(self):
 		super().setUp()
 		config.conf['featureFlag']['cancelExpiredFocusSpeech'] = 1  # yes
+
+
+class Test_pr11651(unittest.TestCase):
+
+	def test_redundantSequenceAfterEndUtterance(self):
+		"""
+		Tests that redundant param change and index commands are not emitted as an extra utterance
+		when the preceeding utterance contained param change commands and an EndUtterance command.
+		E.g. speaking a character.
+		"""
+		smi = SpeechManagerInteractions(self)
+		seq = [
+			CharacterModeCommand(True),
+			"a",
+			smi.create_ExpectedIndex(1),
+			EndUtteranceCommand(),
+		]
+		with smi.expectation():
+			smi.speak(seq)
+			# synth should receive the characterMode, the letter 'a' and an index command.
+			smi.expect_synthSpeak(sequence=seq[:-1])
+		with smi.expectation():
+			# Previously, this would result in synth.speak receiving
+			# a call with sequence:
+			# [CharacterModeCommand(True), IndexCommand(2)]
+			# This is a problem because it includes an index command but no speech.
+			# This is inefficient, and also some SAPI5 synths such as Ivona will not
+			# notify of this bookmark.
+			smi.indexReached(1)
+			smi.doneSpeaking()
+			smi.pumpAll()