Add speech dictionary support to add-ons#19558
Add speech dictionary support to add-ons#19558LeonarddeR wants to merge 21 commits intonvaccess:masterfrom
Conversation
There was a problem hiding this comment.
Pull request overview
This PR implements support for bundling speech dictionaries in add-ons, addressing issue #17468. It introduces a new SpeechDictDefinition class to represent dictionary definitions, refactors the existing speech dictionary system to use this abstraction, and adds UI controls for managing speech dictionaries in NVDA's settings.
Changes:
- Introduced
SpeechDictDefinitionandVoiceSpeechDictDefinitionclasses to represent speech dictionary definitions - Added ability to bundle speech dictionaries in add-ons via manifest configuration
- Added GUI controls in the Speech settings panel for enabling/disabling speech dictionaries
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
| source/speechDictHandler/types.py | Added SpeechDictDefinition and VoiceSpeechDictDefinition classes; enhanced SpeechDict.load() with error handling |
| source/speechDictHandler/init.py | Refactored to use definition-based architecture; added functions to list and manage speech dictionaries; deprecated old dictionaries and dictTypes symbols |
| source/utils/_deprecate.py | Added callValue parameter to RemovedSymbol to support callable values |
| source/gui/settingsDialogs.py | Added speech dictionaries checklist UI in Speech settings panel; fixed typo in symbol dictionaries comment |
| source/config/configSpec.py | Added speechDictionaries config option with default values |
| source/addonHandler/init.py | Extended manifest spec to support speech dictionary definitions with display names and mandatory flags |
| projectDocs/dev/developerGuide/developerGuide.md | Added comprehensive documentation for bundling speech dictionaries in add-ons, including examples |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.
Comments suppressed due to low confidence (1)
source/speechDictHandler/types.py:304
SpeechDictDefinition/VoiceSpeechDictDefinitionintroduce new enablement and loading behavior (config-drivenenabled,readOnly, add-on source handling, voice reload viaload). There are existing unit tests forspeechDictHandler.types, but none covering these new definition classes or add-on dictionary loading. Adding targeted unit tests would help prevent regressions (e.g., ordering/enablement, readOnly semantics, and voice path reload).
@dataclass(frozen=True, kw_only=True)
class SpeechDictDefinition:
"""An abstract class for a speech dictionary definition."""
name: str
"""The name of the dictionary."""
path: str | None = None
"""The path to the dictionary."""
source: DictionaryType | str
"""The source of the dictionary."""
displayName: str | None = None
"""The translatable name of the dictionary.
When not provided, the dictionary can not be visible to the end user.
"""
mandatory: bool = False
"""Whether this dictionary is mandatory.
Mandatory dictionaries are always enabled."""
_dictionary: SpeechDict = field(init=False, repr=False, compare=False, default_factory=SpeechDict)
def __post_init__(self):
if not self.displayName and not self.mandatory:
raise ValueError("A non-mandatory dictionary without a display name is unsupported")
if self.path:
self._dictionary.load(self.path, raiseOnError=self.source not in DictionaryType)
@property
def readOnly(self) -> bool:
"""Whether this dictionary is read-only."""
return self.source not in DictionaryType
@property
def userVisible(self) -> bool:
"""Whether this dictionary is visible to end users (i.e. in the GUI).
Mandatory dictionaries are hidden.
"""
return not self.mandatory and bool(self.displayName)
@property
def enabled(self) -> bool:
return self.mandatory or self.name in config.conf["speech"]["speechDictionaries"]
def sub(self, text: str) -> str:
"""Applies the dictionary to the given text.
:param text: The text to apply the dictionary to.
:return: The text after applying the dictionary.
"""
return self._dictionary.sub(text)
@dataclass(frozen=True, kw_only=True)
class VoiceSpeechDictDefinition(SpeechDictDefinition):
source: DictionaryType = field(init=False, default=DictionaryType.VOICE)
name: str = field(init=False, default=DictionaryType.VOICE.value)
displayName: str = field(
init=False,
# Translators: Name of the voice-specific speech dictionary.
default=_("Voice Dictionary"),
)
def load(self, synth: "synthDriverHandler.SynthDriver"):
"""Loads appropriate dictionary for the given synthesizer.
It handles the case when the synthesizer doesn't support the voice setting.
"""
try:
dictFormatUpgrade.doAnyUpgrades(synth)
except Exception:
log.exception("error trying to upgrade dictionaries")
if synth.isSupported("voice"):
voice = synth.availableVoices[synth.voice].displayName
baseName = dictFormatUpgrade.createVoiceDictFileName(synth.name, voice)
else:
baseName = f"{synth.name}.dic"
object.__setattr__(self, "path", os.path.join(WritePaths.voiceDictsDir, synth.name, baseName))
self.__post_init__()
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
source/speechDictHandler/__init__.py
Outdated
| """ | ||
|
|
||
|
|
||
| def listAvailableSpeechDictDefinitions(alphabetized: bool = False) -> list[SpeechDictDefinition]: |
There was a problem hiding this comment.
can we avoid adding new logic to the init file beyond initialize and terminate?
e.g. `definitions.py?
… into speechDictInAddons
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # A part of NonVisual Desktop Access (NVDA) | ||
| # Copyright (C) 2006-2026 NV Access Limited, Aleksey Sadovoy, Peter Vagner, Aaron Cannon, Leonard de Ruijter, Cyrille Bougot | ||
| # This file may be used under the terms of the GNU General Public License, version 2 or later, as modified by the NVDA license. | ||
| # For full terms and any additional permissions, see the NVDA license file: https://github.com/nvaccess/nvda/blob/master/copying.txt | ||
|
|
||
| import os | ||
| import typing | ||
|
|
||
| import globalVars | ||
| from logHandler import log | ||
| from NVDAState import WritePaths | ||
| from utils._deprecate import MovedSymbol, handleDeprecations | ||
| from utils._deprecate import MovedSymbol, RemovedSymbol, handleDeprecations | ||
|
|
||
| from . import dictFormatUpgrade | ||
| from .types import DictionaryType | ||
| from .types import SpeechDict as _SpeechDict | ||
| from . import definitions | ||
| from .types import ( | ||
| DictionaryType, | ||
| VoiceSpeechDictDefinition, | ||
| ) | ||
|
|
||
| if typing.TYPE_CHECKING: | ||
| import synthDriverHandler | ||
|
|
||
| __getattr__ = handleDeprecations( | ||
| MovedSymbol("speechDictsPath", "NVDAState", "WritePaths", "speechDictsDir"), | ||
| MovedSymbol("ENTRY_TYPE_ANYWHERE", "speechDictHandler.types", "EntryType", "ANYWHERE"), | ||
| MovedSymbol("ENTRY_TYPE_WORD", "speechDictHandler.types", "EntryType", "WORD"), | ||
| MovedSymbol("ENTRY_TYPE_REGEXP", "speechDictHandler.types", "EntryType", "REGEXP"), | ||
| MovedSymbol("SpeechDict", "speechDictHandler.types"), | ||
| MovedSymbol("SpeechDictEntry", "speechDictHandler.types"), | ||
| RemovedSymbol( | ||
| "dictionaries", | ||
| lambda: { | ||
| d.source: d.dictionary for d in definitions._speechDictDefinitions if d.source in DictionaryType | ||
| }, | ||
| callValue=True, | ||
| ), | ||
| RemovedSymbol("dictTypes", tuple(t.value for t in DictionaryType)), | ||
| ) | ||
|
|
||
| dictionaries: dict[DictionaryType | str, _SpeechDict] = {} | ||
| dictTypes = ( | ||
| DictionaryType.TEMP.value, | ||
| DictionaryType.VOICE.value, | ||
| DictionaryType.DEFAULT.value, | ||
| DictionaryType.BUILTIN.value, | ||
| ) | ||
| """Types ordered by their priority E.G. voice specific speech dictionary is processed before the default.""" | ||
|
|
||
|
|
||
| def processText(text: str) -> str: | ||
| """Processes the given text through all speech dictionaries.""" | ||
| """Processes the given text through all speech dictionaries. | ||
| :param text: The text to process. | ||
| :returns: The processed text. | ||
| """ | ||
| if not globalVars.speechDictionaryProcessing: | ||
| return text | ||
| for type in dictTypes: | ||
| text = dictionaries[type].sub(text) | ||
| for definition in definitions._speechDictDefinitions: | ||
| if not definition.enabled: | ||
| continue | ||
| text = definition.sub(text) | ||
| return text | ||
|
|
||
|
|
||
| def initialize() -> None: | ||
| for type in dictTypes: | ||
| dictionaries[type] = _SpeechDict() | ||
| dictionaries[DictionaryType.DEFAULT].load(WritePaths.speechDictDefaultFile) | ||
| dictionaries[DictionaryType.BUILTIN].load(os.path.join(globalVars.appDir, "builtin.dic")) | ||
| definitions._addSpeechDictionaries() | ||
|
|
||
|
|
||
| def terminate() -> None: | ||
| definitions._speechDictDefinitions.clear() | ||
|
|
||
|
|
||
| def loadVoiceDict(synth: "synthDriverHandler.SynthDriver") -> None: | ||
| """Loads appropriate dictionary for the given synthesizer. | ||
| It handles case when the synthesizer doesn't support voice setting. | ||
| """ | ||
| try: | ||
| dictFormatUpgrade.doAnyUpgrades(synth) | ||
| except: # noqa: E722 | ||
| log.exception("error trying to upgrade dictionaries") | ||
| if synth.isSupported("voice"): | ||
| voice = synth.availableVoices[synth.voice].displayName | ||
| baseName = dictFormatUpgrade.createVoiceDictFileName(synth.name, voice) | ||
| else: | ||
| baseName = f"{synth.name}.dic" | ||
| fileName = os.path.join(WritePaths.voiceDictsDir, synth.name, baseName) | ||
| dictionaries[DictionaryType.VOICE].load(fileName) | ||
| definition = next( | ||
| (d for d in definitions._speechDictDefinitions if isinstance(d, VoiceSpeechDictDefinition)), | ||
| None, | ||
| ) | ||
| if definition is None: | ||
| log.error( | ||
| "No VoiceSpeechDictDefinition found in _speechDictDefinitions. " | ||
| "Speech dictionaries may not have been initialized.", | ||
| ) | ||
| raise RuntimeError("No voice speech dictionary definition is available to load.") | ||
| definition.load(synth) |
There was a problem hiding this comment.
The function listAvailableSpeechDictDefinitions is not exported from the speechDictHandler package's __init__.py, forcing callers to access it via the internal definitions submodule. This is inconsistent with the similar characterProcessing.listAvailableSymbolDictionaryDefinitions() function which is exported at the package level. For a consistent and stable public API, this function should be exported from speechDictHandler/__init__.py, similar to how characterProcessing exports its corresponding function.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
| @@ -325,30 +325,29 @@ def onRemoveAll(self, evt): | |||
|
|
|||
| class DefaultDictionaryDialog(DictionaryDialog): | |||
| def __init__(self, parent): | |||
There was a problem hiding this comment.
could you add the types for parent to these
Link to issue number:
Closes #17468
Summary of the issue:
it is currently not possible to bundle speech dictionaries in add-ons.
Description of user facing changes:
SPeech dictionaries can be enabled in the speech category of NVDA's settings. This includes dictionaries bundled in add-ons
Description of developer facing changes:
Dictionaries can be bundled in add-ons.
Description of development approach:
SpeechDictDefinitionclass that is based on the SymbolDictionaryDefinition class incharacterProcessing.submethod on a definition forwards the call to the underlying dictionary. It is therefore possible to register an instance of your own SPeechDictDefinition subtype that has a custom implementation ofsub, e.g. when you want to use a custom function. This is undocumented though.Testing strategy:
Known issues with pull request:
None known
Code Review Checklist: