Skip to content

Commit 2303847

Browse files
authored
Merge b133520 into 2077431
2 parents 2077431 + b133520 commit 2303847

File tree

7 files changed

+203
-32
lines changed

7 files changed

+203
-32
lines changed

source/config/configSpec.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# -*- coding: UTF-8 -*-
22
# A part of NonVisual Desktop Access (NVDA)
3-
# Copyright (C) 2006-2021 NV Access Limited, Babbage B.V., Davy Kager, Bill Dengler, Julien Cochuyt,
3+
# Copyright (C) 2006-2022 NV Access Limited, Babbage B.V., Davy Kager, Bill Dengler, Julien Cochuyt,
44
# Joseph Lee, Dawid Pieper, mltony
55
# This file is covered by the GNU General Public License.
66
# See the file COPYING for more details.
@@ -45,6 +45,7 @@
4545
outputDevice = string(default=default)
4646
autoLanguageSwitching = boolean(default=true)
4747
autoDialectSwitching = boolean(default=false)
48+
delayedCharacterDescriptions = boolean(default=false)
4849
4950
[[__many__]]
5051
capPitchChange = integer(default=30,min=-100,max=100)

source/gui/settingsDialogs.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# -*- coding: UTF-8 -*-
22
# A part of NonVisual Desktop Access (NVDA)
3-
# Copyright (C) 2006-2021 NV Access Limited, Peter Vágner, Aleksey Sadovoy,
3+
# Copyright (C) 2006-2022 NV Access Limited, Peter Vágner, Aleksey Sadovoy,
44
# Rui Batista, Joseph Lee, Heiko Folkerts, Zahari Yurukov, Leonard de Ruijter,
55
# Derek Riemer, Babbage B.V., Davy Kager, Ethan Holliger, Bill Dengler, Thomas Stivers,
66
# Julien Cochuyt, Peter Vágner, Cyrille Bougot, Mesar Hameed, Łukasz Golonka, Aaron Cannon,
@@ -1560,6 +1560,8 @@ def makeSettings(self, settingsSizer):
15601560
)
15611561
self.includeCLDRCheckbox.SetValue(config.conf["speech"]["includeCLDR"])
15621562

1563+
self._appendDelayedCharacterDescriptions(settingsSizerHelper)
1564+
15631565
minPitchChange = int(config.conf.getConfigValidation(
15641566
("speech", self.driver.name, "capPitchChange")
15651567
).kwargs["min"])
@@ -1618,6 +1620,17 @@ def makeSettings(self, settingsSizer):
16181620
config.conf["speech"][self.driver.name]["useSpellingFunctionality"]
16191621
)
16201622

1623+
def _appendDelayedCharacterDescriptions(self, settingsSizerHelper: guiHelper.BoxSizerHelper) -> None:
1624+
# Translators: This is the label for a checkbox in the voice settings panel.
1625+
delayedCharacterDescriptionsText = _("&Delayed descriptions for characters on cursor movement")
1626+
self.delayedCharacterDescriptionsCheckBox = settingsSizerHelper.addItem(
1627+
wx.CheckBox(self, label=delayedCharacterDescriptionsText)
1628+
)
1629+
self.bindHelpEvent("delayedCharacterDescriptions", self.delayedCharacterDescriptionsCheckBox)
1630+
self.delayedCharacterDescriptionsCheckBox.SetValue(
1631+
config.conf["speech"]["delayedCharacterDescriptions"]
1632+
)
1633+
16211634
def onSave(self):
16221635
AutoSettingsMixin.onSave(self)
16231636

@@ -1632,10 +1645,12 @@ def onSave(self):
16321645
if currentIncludeCLDR is not newIncludeCldr:
16331646
# Either included or excluded CLDR data, so clear the cache.
16341647
characterProcessing.clearSpeechSymbols()
1648+
config.conf["speech"]["delayedCharacterDescriptions"] = delayedDescriptions
16351649
config.conf["speech"][self.driver.name]["capPitchChange"]=self.capPitchChangeEdit.Value
16361650
config.conf["speech"][self.driver.name]["sayCapForCapitals"]=self.sayCapForCapsCheckBox.IsChecked()
16371651
config.conf["speech"][self.driver.name]["beepForCapitals"]=self.beepForCapsCheckBox.IsChecked()
16381652
config.conf["speech"][self.driver.name]["useSpellingFunctionality"]=self.useSpellingFunctionalityCheckBox.IsChecked()
1653+
delayedDescriptions = self.delayedCharacterDescriptionsCheckBox.IsChecked()
16391654

16401655
class KeyboardSettingsPanel(SettingsPanel):
16411656
# Translators: This is the label for the keyboard settings panel.

source/speech/speech.py

Lines changed: 114 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
# This file is covered by the GNU General Public License.
33
# See the file COPYING for more details.
44
# Copyright (C) 2006-2022 NV Access Limited, Peter Vágner, Aleksey Sadovoy, Babbage B.V., Bill Dengler,
5-
# Julien Cochuyt
5+
# Julien Cochuyt, Derek Riemer
66

77
"""High-level functions to speak information.
88
"""
@@ -26,6 +26,7 @@
2626
from . import manager
2727
from .commands import (
2828
# Commands that are used in this file.
29+
BreakCommand,
2930
SpeechCommand,
3031
PitchCommand,
3132
LangChangeCommand,
@@ -338,6 +339,48 @@ def _getSpellingSpeechWithoutCharMode(
338339
yield EndUtteranceCommand()
339340

340341

342+
def getSingleCharDescriptionDelayMS() -> int:
343+
"""
344+
@returns: 1 second, a default delay.
345+
In the future, this should fetch its value from a user defined NVDA idle time.
346+
Blocked by: https://github.com/nvaccess/nvda/issues/13915
347+
"""
348+
return 1000
349+
350+
351+
def getSingleCharDescription(
352+
text: str,
353+
locale: Optional[str] = None,
354+
) -> Generator[SequenceItemT, None, None]:
355+
# This should only be used for single chars.
356+
if not len(text) == 1:
357+
return
358+
synth = getSynth()
359+
synthConfig = config.conf["speech"][synth.name]
360+
if synth.isSupported("pitch"):
361+
capPitchChange = synthConfig["capPitchChange"]
362+
else:
363+
capPitchChange = 0
364+
defaultLanguage = getCurrentLanguage()
365+
if not locale or (
366+
not config.conf['speech']['autoDialectSwitching']
367+
and locale.split('_')[0] == defaultLanguage.split('_')[0]
368+
):
369+
locale = defaultLanguage
370+
# If the description for the locale is unknown, we yield nothing.
371+
char, description = getCharDescListFromText(text, locale=locale)[0]
372+
uppercase = char.isupper()
373+
if description is None:
374+
return
375+
yield BreakCommand(getSingleCharDescriptionDelayMS())
376+
yield from _getSpellingCharAddCapNotification(
377+
description[0],
378+
sayCapForCapitals=uppercase and synthConfig["sayCapForCapitals"],
379+
capPitchChange=(capPitchChange if uppercase else 0),
380+
beepForCapitals=uppercase and synthConfig["beepForCapitals"],
381+
)
382+
383+
341384
def getSpellingSpeech(
342385
text: str,
343386
locale: Optional[str] = None,
@@ -1169,7 +1212,6 @@ def getTextInfoSpeech( # noqa: C901
11691212
onlyInitialFields: bool = False,
11701213
suppressBlanks: bool = False
11711214
) -> Generator[SpeechSequence, None, bool]:
1172-
onlyCache = reason == OutputReason.ONLYCACHE
11731215
if isinstance(useCache,SpeakTextInfoState):
11741216
speakTextInfoState=useCache
11751217
elif useCache:
@@ -1209,8 +1251,8 @@ def getTextInfoSpeech( # noqa: C901
12091251
except KeyError:
12101252
pass
12111253

1212-
#Make a new controlFieldStack and formatField from the textInfo's initialFields
1213-
newControlFieldStack=[]
1254+
# Make a new controlFieldStack and formatField from the textInfo's initialFields
1255+
newControlFieldStack: List[textInfos.ControlField] = []
12141256
newFormatField=textInfos.FormatField()
12151257
initialFields=[]
12161258
for field in textWithFields:
@@ -1340,32 +1382,29 @@ def getTextInfoSpeech( # noqa: C901
13401382
language=newFormatField.get('language')
13411383
speechSequence.append(LangChangeCommand(language))
13421384
lastLanguage=language
1343-
1344-
def isControlEndFieldCommand(x):
1345-
return isinstance(x, textInfos.FieldCommand) and x.command == "controlEnd"
1346-
13471385
isWordOrCharUnit = unit in (textInfos.UNIT_CHARACTER, textInfos.UNIT_WORD)
13481386
if onlyInitialFields or (
13491387
isWordOrCharUnit
13501388
and len(textWithFields) > 0
13511389
and len(textWithFields[0].strip() if not textWithFields[0].isspace() else textWithFields[0]) == 1
1352-
and all(isControlEndFieldCommand(x) for x in itertools.islice(textWithFields, 1, None))
1390+
and all(_isControlEndFieldCommand(x) for x in itertools.islice(textWithFields, 1, None))
13531391
):
1354-
if not onlyCache:
1355-
if onlyInitialFields or any(isinstance(x, str) for x in speechSequence):
1356-
yield speechSequence
1357-
if not onlyInitialFields:
1358-
spellingSequence = list(getSpellingSpeech(
1359-
textWithFields[0],
1360-
locale=language
1361-
))
1362-
logBadSequenceTypes(spellingSequence)
1363-
yield spellingSequence
1392+
if reason != OutputReason.ONLYCACHE:
1393+
yield from list(_getTextInfoSpeech_considerSpelling(
1394+
unit,
1395+
onlyInitialFields,
1396+
textWithFields,
1397+
reason,
1398+
speechSequence,
1399+
language,
1400+
))
13641401
if useCache:
1365-
speakTextInfoState.controlFieldStackCache=newControlFieldStack
1366-
speakTextInfoState.formatFieldAttributesCache=formatFieldAttributesCache
1367-
if not isinstance(useCache,SpeakTextInfoState):
1368-
speakTextInfoState.updateObj()
1402+
_getTextInfoSpeech_updateCache(
1403+
useCache,
1404+
speakTextInfoState,
1405+
newControlFieldStack,
1406+
formatFieldAttributesCache,
1407+
)
13691408
return False
13701409

13711410
# Similar to before, but If the most inner clickable is exited, then we allow announcing clickable for the next lot of clickable fields entered.
@@ -1511,12 +1550,14 @@ def isControlEndFieldCommand(x):
15111550
# Translators: This is spoken when the line is considered blank.
15121551
speechSequence.append(_("blank"))
15131552

1514-
#Cache a copy of the new controlFieldStack for future use
1553+
# Cache a copy of the new controlFieldStack for future use
15151554
if useCache:
1516-
speakTextInfoState.controlFieldStackCache=list(newControlFieldStack)
1517-
speakTextInfoState.formatFieldAttributesCache=formatFieldAttributesCache
1518-
if not isinstance(useCache,SpeakTextInfoState):
1519-
speakTextInfoState.updateObj()
1555+
_getTextInfoSpeech_updateCache(
1556+
useCache,
1557+
speakTextInfoState,
1558+
newControlFieldStack,
1559+
formatFieldAttributesCache,
1560+
)
15201561

15211562
if reason == OutputReason.ONLYCACHE or not speechSequence:
15221563
return False
@@ -1525,6 +1566,51 @@ def isControlEndFieldCommand(x):
15251566
return True
15261567

15271568

1569+
def _isControlEndFieldCommand(command: Union[str, textInfos.FieldCommand]):
1570+
return isinstance(command, textInfos.FieldCommand) and command.command == "controlEnd"
1571+
1572+
1573+
def _getTextInfoSpeech_considerSpelling(
1574+
unit: Optional[textInfos.TextInfo],
1575+
onlyInitialFields: bool,
1576+
textWithFields: textInfos.TextInfo.TextWithFieldsT,
1577+
reason: OutputReason,
1578+
speechSequence: SpeechSequence,
1579+
language: str,
1580+
) -> Generator[SpeechSequence, None, None]:
1581+
if onlyInitialFields or any(isinstance(x, str) for x in speechSequence):
1582+
yield speechSequence
1583+
if not onlyInitialFields:
1584+
spellingSequence = list(getSpellingSpeech(
1585+
textWithFields[0],
1586+
locale=language
1587+
))
1588+
logBadSequenceTypes(spellingSequence)
1589+
yield spellingSequence
1590+
if (
1591+
reason == OutputReason.CARET
1592+
and unit == textInfos.UNIT_CHARACTER
1593+
and config.conf["speech"]["delayedCharacterDescriptions"]
1594+
):
1595+
descriptionSequence = list(getSingleCharDescription(
1596+
textWithFields[0],
1597+
locale=language,
1598+
))
1599+
yield descriptionSequence
1600+
1601+
1602+
def _getTextInfoSpeech_updateCache(
1603+
useCache: Union[bool, SpeakTextInfoState],
1604+
speakTextInfoState: SpeakTextInfoState,
1605+
newControlFieldStack: List[textInfos.ControlField],
1606+
formatFieldAttributesCache: textInfos.Field,
1607+
):
1608+
speakTextInfoState.controlFieldStackCache = newControlFieldStack
1609+
speakTextInfoState.formatFieldAttributesCache = formatFieldAttributesCache
1610+
if not isinstance(useCache, SpeakTextInfoState):
1611+
speakTextInfoState.updateObj()
1612+
1613+
15281614
# C901 'getPropertiesSpeech' is too complex
15291615
# Note: when working on getPropertiesSpeech, look for opportunities to simplify
15301616
# and move logic out into smaller helper functions.

source/synthDriverHandler.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,10 @@ def _get_availableVoices(self) -> OrderedDict[str, VoiceInfo]:
251251
self._availableVoices = self._getAvailableVoices()
252252
return self._availableVoices
253253

254+
#: Typing information for auto-property: _get_rate
255+
rate: int
256+
"""Between 0-100"""
257+
254258
def _get_rate(self):
255259
return 0
256260

tests/system/robot/symbolPronunciationTests.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# A part of NonVisual Desktop Access (NVDA)
2-
# Copyright (C) 2021 NV Access Limited
2+
# Copyright (C) 2021-2022 NV Access Limited
33
# This file may be used under the terms of the GNU General Public License, version 2 or later.
44
# For more details see: https://www.gnu.org/licenses/gpl-2.0.html
55

@@ -276,6 +276,55 @@ def test_moveByChar():
276276
)
277277

278278

279+
_CHARACTER_DESCRIPTIONS = {
280+
# english character descriptions.
281+
'a': 'Alfa',
282+
'b': 'Bravo',
283+
'c': 'Charlie',
284+
}
285+
286+
287+
def _getDelayedDescriptionsTestSample() -> str:
288+
return "".join(_CHARACTER_DESCRIPTIONS.keys())
289+
290+
291+
def _testDelayedDescription(expectDescription: bool = True) -> None:
292+
"""
293+
Perform delayed character descriptions tests with with the specified parameters:
294+
@param expectDescription: whether or not a delayed description should be announced
295+
"""
296+
spoken = _NvdaLib.getSpeechAfterKey(Move.CARET_CHAR.value).split("\n")
297+
if not spoken:
298+
raise AssertionError(f"Nothing spoken after character press")
299+
if spoken[0] not in _CHARACTER_DESCRIPTIONS:
300+
raise AssertionError(
301+
f"First piece of speech not an expected character; got: '{spoken[0]}'"
302+
)
303+
if expectDescription:
304+
if len(spoken) != 2:
305+
raise AssertionError(
306+
f"Expected character with description; got: '{spoken}'"
307+
)
308+
_asserts.strings_match(spoken[1], _CHARACTER_DESCRIPTIONS[spoken[0]])
309+
else:
310+
if len(spoken) != 1:
311+
raise AssertionError(
312+
f"Expected single character; got: '{spoken}'"
313+
)
314+
315+
316+
def test_delayedDescriptions():
317+
_notepad.prepareNotepad(_getDelayedDescriptionsTestSample())
318+
# Ensure this feature is disabled by default.
319+
_testDelayedDescription(expectDescription=False)
320+
321+
# Activate delayed descriptions feature to do the next test.
322+
spy = _NvdaLib.getSpyLib()
323+
spy.set_configValue(['speech', 'delayedCharacterDescriptions'], True)
324+
325+
_testDelayedDescription()
326+
327+
279328
def test_selByWord():
280329
""" Select word by word with symbol level 'none' and symbol level 'all'.
281330
Note that the number of spaces between speech content and 'selected' varies, possibly due to the number

tests/system/robot/symbolPronunciationTests.robot

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# A part of NonVisual Desktop Access (NVDA)
2-
# Copyright (C) 2021 NV Access Limited
2+
# Copyright (C) 2021-2022 NV Access Limited
33
# This file may be used under the terms of the GNU General Public License, version 2 or later.
44
# For more details see: https://www.gnu.org/licenses/gpl-2.0.html
55
*** Settings ***
@@ -42,6 +42,10 @@ moveByCharacter
4242
[Documentation] Ensure symbols announced as expected when navigating by character (numpad 3).
4343
test_moveByChar
4444

45+
delayedCharacterDescriptions
46+
[Documentation] Ensure delayed character descriptions are announced as expected when navigating by character.
47+
test_delayedDescriptions
48+
4549
selectionByWord
4650
[Documentation] Ensure symbols announced as expected when selecting by word (shift+control+right arrow).
4751
[Tags] selection

user_docs/en/userGuide.t2t

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1265,6 +1265,18 @@ This option should generally be enabled.
12651265
However, some Microsoft Speech API synthesizers do not implement this correctly and behave strangely when it is enabled.
12661266
If you are having problems with the pronunciation of individual characters, try disabling this option.
12671267

1268+
==== Delayed descriptions for characters on cursor movement ====[delayedCharacterDescriptions]
1269+
: Default
1270+
Disabled
1271+
:
1272+
1273+
When this setting is checked, NVDA will say the character description when you move by characters.
1274+
1275+
For example, while reviewing a line by characters, when the letter "b" is read NVDA will say "Bravo" after a 1 second delay.
1276+
This can be useful if it is hard to distinguish between pronunciation of symbols, or for hearing impaired users.
1277+
1278+
The delayed character description will be cancelled if other text is spoken during that time, or if you press the ``control`` key.
1279+
12681280
+++ Select Synthesizer (NVDA+control+s) +++[SelectSynthesizer]
12691281
The Synthesizer dialog, which can be opened by activating the Change... button in the speech category of the NVDA settings dialog, allows you to select which Synthesizer NVDA should use to speak with.
12701282
Once you have selected your synthesizer of choice, you can press Ok and NVDA will load the selected Synthesizer.

0 commit comments

Comments
 (0)