Skip to content

Add speech dictionary support to add-ons#19558

Open
LeonarddeR wants to merge 21 commits intonvaccess:masterfrom
LeonarddeR:speechDictInAddons
Open

Add speech dictionary support to add-ons#19558
LeonarddeR wants to merge 21 commits intonvaccess:masterfrom
LeonarddeR:speechDictInAddons

Conversation

@LeonarddeR
Copy link
Collaborator

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:

  1. Add a new SpeechDictDefinition class that is based on the SymbolDictionaryDefinition class in characterProcessing.
  2. Allow adding definitions to add-on manifests and create SPeechDictDefinition instances from them.
  3. When processing text, loop through the enabled definitions. The sub method 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 of sub, e.g. when you want to use a custom function. This is undocumented though.

Testing strategy:

  • Tested enabling/disabling the default and voice dictionaries
  • Tested providing a dictionary in a manifest, enable/disable it, ensure that it actually works.

Known issues with pull request:

None known

Code Review Checklist:

  • Documentation:
    • Change log entry
    • User Documentation
    • Developer / Technical Documentation
    • Context sensitive help for GUI changes
  • Testing:
    • Unit tests
    • System (end to end) tests
    • Manual testing
  • UX of all users considered:
    • Speech
    • Braille
    • Low Vision
    • Different web browsers
    • Localization in other languages / culture than English
  • API is compatible with existing add-ons.
  • Security precautions taken.

@LeonarddeR LeonarddeR marked this pull request as ready for review February 4, 2026 21:20
@LeonarddeR LeonarddeR requested a review from a team as a code owner February 4, 2026 21:21
@LeonarddeR LeonarddeR marked this pull request as draft February 4, 2026 21:21
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 SpeechDictDefinition and VoiceSpeechDictDefinition classes 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.

@SaschaCowley SaschaCowley added the merge-early Merge Early in a developer cycle label Feb 5, 2026
@LeonarddeR LeonarddeR marked this pull request as ready for review February 10, 2026 11:37
@LeonarddeR LeonarddeR requested a review from Copilot February 10, 2026 11:37
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 / VoiceSpeechDictDefinition introduce new enablement and loading behavior (config-driven enabled, readOnly, add-on source handling, voice reload via load). There are existing unit tests for speechDictHandler.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>
"""


def listAvailableSpeechDictDefinitions(alphabetized: bool = False) -> list[SpeechDictDefinition]:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we avoid adding new logic to the init file beyond initialize and terminate?
e.g. `definitions.py?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

@seanbudd seanbudd marked this pull request as draft February 13, 2026 06:13
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +1 to +74
# 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)
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
@LeonarddeR LeonarddeR marked this pull request as ready for review February 14, 2026 08:39
@@ -325,30 +325,29 @@ def onRemoveAll(self, evt):

class DefaultDictionaryDialog(DictionaryDialog):
def __init__(self, parent):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could you add the types for parent to these

@seanbudd seanbudd added the conceptApproved Similar 'triaged' for issues, PR accepted in theory, implementation needs review. label Feb 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

conceptApproved Similar 'triaged' for issues, PR accepted in theory, implementation needs review. merge-early Merge Early in a developer cycle

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Allow bundling speech dictionaries in add-ons

3 participants