Skip to content

Pronunciation dictionaries - #8

Open
guilevi wants to merge 3 commits into
daiverd:mainfrom
guilevi:dictionary-support
Open

Pronunciation dictionaries#8
guilevi wants to merge 3 commits into
daiverd:mainfrom
guilevi:dictionary-support

Conversation

@guilevi

@guilevi guilevi commented Aug 8, 2026

Copy link
Copy Markdown

Adds user-editable pronunciation dictionaries to the emulator and to the NVDA
add-on: rules that rewrite text on its way to the card, matched by whole word,
by substring, or by regular expression, substituting a respelling, a phoneme
string, an embedded command, or nothing at all.

word		Sean	Shawn
word		arthritis	[AA R TH R AY DX IX S]
word	C	US	[Y UW EH S]
regex		\bv([0-9]+)\.([0-9]+)\b	version \1 point \2

In NVDA they are arranged from Preferences → Settings → DoubleTalk PC
dictionaries
— a list in load order with Add, Remove, Move up, Move down and
Reload. Files are read where the user keeps them; Add records a path rather
than copying, so the file being edited is the file the card reads and Reload is
the whole round trip.

Nothing changes for anyone who does not create a dictionary. With no files
present, dtalk_say takes exactly the path it took before and allocates
nothing, and the driver's byte stream is unchanged.

Three commits: the .gitignore line, the emulator side, the NVDA side.


The part that needs your judgement: doubletalk/rcdict/ is vendored

The dictionary layer is not written for this repository. It is mirrored
byte-for-byte from an upstream project, where it serves RC Systems' later
RC8650 — a different package around the same synthesizer. The RC8650 datasheet
captions its phoneme table "DoubleTalk Phoneme Symbols", and in practice the
symbols, the Table 6 modifiers, the D/T/C mode commands and the Ctrl-A
command character are the same on both. What differs is carried by a per-card
profile (rcdict_doubletalk_pc), so the rules, the file format and the loaders
are genuinely one body of code.

That is seven files (rcdict.{c,h}, rcdict_regex.{c,h}, remimu.h,
example.dict, DICTIONARY-GUIDE.md), about 2.5k lines including the vendored
regex engine. A sync script on the upstream side copies them across, and its
--check mode exits non-zero if the two copies have drifted — worth a CI step
if this lands.

Licensing was the design constraint, not an afterthought. rcdict is
BSD-3-Clause and depends on nothing but libc. That is what lets the same
sources also be linked into the upstream project, which is GPL-3. The sharing
works in that direction only, so nothing under rcdict/ may acquire a GPL
header or a dependency on either host, and LICENSING.md says so. remimu.h
is a third-party regex engine (wareya) released under CC0; it imposes no
conditions on a redistributor and is listed for attribution rather than
obligation.

I have used SPDX identifiers in rcdict/** rather than the MAME-style
// license: headers the rest of the tree uses, because those files have to
read identically in both repositories. LICENSING.md notes the exception. Say
the word if you would rather they matched the house style and I will take it up
on the other side.

If vendoring is not what you want, the alternatives are a submodule (awkward:
an NVDA add-on build has to zip a working tree), or dropping the regex
matcher and writing something smaller from scratch here.


Emulator side

  • dtalk_set_dictionary(dt, d, inline_phonemes) attaches a dictionary.
    It is borrowed, not owned — it must outlive the instance or be replaced
    with NULL first, so a host can swap dictionaries without tearing the card
    down. inline_phonemes enables the [[K AE T]] escape in ordinary text and
    is off by default, because [[ is wiki link syntax and someone reading a
    wiki must not silently lose text to it.

  • dtalk_expand() runs the rules without queueing. Callers that split long
    text into utterances themselves need this and it is not optional for them:
    one word can become forty characters of phonemes plus the mode switches, so
    splitting first and expanding after can push a piece past what the card will
    accept. Expand, then split.

  • dtalk_dict_new/_free/_add_file/_rule_count are four forwarding calls that
    exist only for the DLL. dtalk.h marks its entry points
    __declspec(dllexport), which turns off mingw's export-everything default —
    so rcdict's own symbols are absent from the export table and a ctypes
    caller cannot reach them. Re-exporting the four calls a host actually needs
    seemed better than exporting every symbol in the image, and it keeps the
    export marker out of rcdict, which has to stay free of anything Windows- or
    project-specific to go on being shared.

Build change worth a look: rcdict is C and has to be compiled as C —
handed to g++, its designated initializers and implicit void * conversions
are errors. Each DLL is therefore two compilers' output linked together, the
C++ emulator plus rcdict built by the matching gcc. Warnings are disabled
for rcdict_regex.c alone, because vendored remimu.h is not clean under
-Wall.

Leaving rcdict out of the mingw rules is a silent failure worth naming, since
I made it first: the DLL links, dtalk_set_dictionary is exported, and the
caller simply has no way to build a dictionary to hand it.

Two behaviours that are decisions, not accidents

First match in load order wins — never longest match, and the output is never
rescanned.
This is the card's own convention for its exception dictionaries;
the datasheet warns that (RAT) placed before (RATING) means (RATING) is
never reached. It is also what makes "load this file first" a usable way to
override, which is what the NVDA panel is for.

A phoneme span breaks the letter-to-sound stage's context on both sides of
itself.
So a trailing . or , is moved inside the span, and a preceding
article "a" is absorbed into it. Measured on the RC8650, with both rules
applied the rendered audio is byte-identical to what text mode produces; without
them it is audibly not. That measurement is what the substitution shape is built
on rather than on reading the datasheet's prose.

NVDA side

Expansion happens in speak(), not in dtalk_say. This driver queues its own
bytes with dtalk_queue because it interleaves index marks and settings — the
raw path, which deliberately bypasses dtalk_say — so the driver applies the
dictionary itself, before _split.

The settings panel is a global plugin rather than part of the driver
package, because a driver module comes and goes with the synthesizer and a
settings category cannot. That means globalPlugins/ is a second directory in
the add-on; build_addon.sh now packages it, prints the resulting package
listing, and refreshes the DLLs from the build tree, since shipping the previous
library is the kind of bug that builds, installs and runs.

Dictionaries are referenced, not copied. The configuration holds a path to
the file where the user keeps it, so the file they edit is the file the card
reads. A copy would have made every dictionary two files, one of them stale
from the first edit onwards, and "why did my change do nothing" the commonest
question about the panel. A reference whose file is not reachable today — an
unplugged drive, a share not mounted yet — stays in the list marked not
found
and is skipped at load time, rather than being dropped: the add-on does
not own that file, so discarding the reference is not its call to make.

%APPDATA%\nvda\doubletalkpc\ is still scanned as a second source, so a
.dict dropped in there works with no configuration at all — anything the
saved order has not seen is appended in sorted order. That is the one place
Remove has to delete rather than unlist, since a scanned file would come
straight back; the panel says so and asks first, and Remove on anything else
touches nothing on disk. The order lives in NVDA's configuration, not in the
filenames.

Failures are contained. The ctypes bindings are guarded, so an add-on running
against a DLL built before this change loses dictionaries rather than the
synthesizer; each file is loaded in its own try, so one unencodable path costs
one file; the new dictionary is attached before the old one is freed, so a
reload cannot leave the card pointing at freed memory.

Testing

doubletalk/nvda/tests/ stubs NVDA's API and drives the real speak() against
the host's shared library, so it runs without NVDA and without Windows:

make -C doubletalk            # tests load libdtalk in place of dtalk.dll
sh doubletalk/nvda/tests/run.sh

It covers the load-order rules on their own — including references to files
outside the add-on's folder, and what happens when one of them is missing —
that a substitution reaches the queued bytes, that expansion happens before the
split so no piece can overrun the card, that reload swaps and detaches cleanly,
and — the check the others rest on — that the card really renders different
audio with a dictionary loaded than without.

Verified before opening this:

  • native make and both mingw cross-builds are clean, and all six new entry
    points appear in the export tables of dtalk.dll and dtalk64.dll;
  • the NVDA test suite passes;
  • the upstream rcdict suite passes (102 unit checks, plus 12 that render both
    sides through a chip emulator and compare the audio);
  • the add-on has been run under NVDA on Windows, installed from a package built
    from this branch, with the DLLs rebuilt from these sources.

Left for you

  • No version bump. manifest.ini is still 0.1.12 — bumps look like your
    own commits in this history, and it is a predictable conflict. Say what you
    want and I will add it.
  • The prebuilt DLLs are gitignored, so anyone building the add-on from this
    branch needs make -C doubletalk win32 win64 first.

🤖 Generated with Claude Code

guilevi and others added 3 commits August 8, 2026 17:55
macOS drops these into every directory anyone browses in Finder, and
they were showing up as untracked in the working tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rules that rewrite text on its way to the card: respellings, phonemes
through the card's own phoneme mode, or embedded commands, matched by
whole word, by substring, or by regular expression.
dtalk_set_dictionary attaches one; dtalk_say then runs its text through
it, and with no dictionary set it takes the old path unchanged and
allocates nothing.

doubletalk/rcdict/ is vendored, not written for this repository. It is
mirrored byte-for-byte from an upstream project, where it serves RC
Systems' later RC8650 -- a chip that shares this card's synthesizer.
The RC8650 datasheet captions its phoneme table "DoubleTalk Phoneme
Symbols", so the symbols, the Table 6 modifiers, the D/T/C mode
commands and the Ctrl-A command character are the same on both, and
what differs is carried by a per-card profile (rcdict_doubletalk_pc).
A sync script upstream copies the seven files across and its --check
fails if the copies have drifted; that check belongs in CI if this
lands.

The licence is what makes the sharing legal, and it only works one way.
rcdict is BSD-3-Clause and depends on nothing but libc, so it can be
linked into the upstream project, which is GPL-3. The reverse could
never happen, and so nothing under rcdict/ may acquire a GPL header or
a dependency on either host. LICENSING.md records this, along with
remimu.h, a third-party regex engine its author placed in the public
domain under CC0.

Two behaviours are worth stating because they are decisions, not
accidents. Rules are first-match-wins in load order -- never longest
match -- which is the card's own convention for its exception
dictionaries and is what makes "load this file first" a usable way to
override. And a phoneme span breaks the letter-to-sound stage's context
on both sides of itself, so a trailing '.' or ',' is moved inside the
span and a preceding article "a" is absorbed into it; measured on the
RC8650, that makes the rendered audio byte-identical to what text mode
produces, where without it the audio is audibly different.

dtalk_expand runs the rules without queueing. Callers that split long
text into utterances themselves need it: one word can become forty
characters of phonemes plus the mode switches, so splitting first and
expanding after can push a piece past what the card will take.

dtalk_dict_new/_free/_add_file/_rule_count are forwarding calls that
exist only for the DLL. dtalk.h marks its entry points
__declspec(dllexport), which turns off mingw's export-everything
default, so rcdict's own symbols are absent from the export table and a
ctypes caller cannot reach them. Re-exporting the four calls a host
actually needs beats exporting every symbol in the image, and it keeps
the export marker out of rcdict, which has to stay free of anything
Windows- or project-specific to go on being shared.

rcdict is C and must be compiled as C -- handed to g++ its designated
initializers and implicit void* conversions are errors -- so each DLL is
now two compilers' output linked together. Leaving rcdict out of the
mingw rules is a silent failure worth naming: the DLL links,
dtalk_set_dictionary is exported, and the caller simply has no way to
build a dictionary to hand it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The driver expands text before _split, not after: a word can become
forty characters of phonemes plus the mode switches, and splitting
first would let a piece overrun what the card will take.

The expansion happens in speak(), not in dtalk_say. This driver queues
its own bytes with dtalk_queue because it needs to interleave index
marks and settings, which is the raw path and deliberately bypasses
dtalk_say -- so the driver has to apply the dictionary itself.

Preferences -> Settings -> DoubleTalk PC dictionaries lists the files in
load order with Add, Remove, Move up, Move down and Reload, each with an
access key. Order is the whole point of the list -- first match wins, so
a file higher up overrides one below it, and a lower file can only add.
The order lives in NVDA's configuration rather than in the filenames, so
renaming a file to make it sort earlier is not something anyone has to
think of.

Dictionaries are referenced, never copied. The configuration holds a
path to the file where the user keeps it, so the file they edit is the
file the card reads and Reload is the whole round trip; a copy would
have made every dictionary two files, one of them stale from the first
edit onwards. A reference whose file is not reachable today -- an
unplugged drive, a share not mounted yet -- stays in the list marked
"not found" and is skipped at load time rather than being dropped,
because the add-on does not own that file.

%APPDATA%\nvda\doubletalkpc\ is still scanned as a second source, so a
.dict dropped in there works with no configuration at all: anything the
saved order has not seen is appended in sorted order. That is the one
place Remove has to delete rather than unlist, since a scanned file
would come straight back, and the panel says so and asks first; Remove
on anything else touches nothing on disk.

It is a global plugin rather than part of the driver package because a
driver module comes and goes with the synthesizer and a settings
category cannot. That means globalPlugins/ is a second directory in the
add-on, which build_addon.sh now packages -- and which is easy to leave
out, so the script also prints the package listing and refreshes the
DLLs from the build tree while it is there.

Failures are contained. The ctypes bindings are guarded, so an add-on
running against a DLL built before this change loses dictionaries rather
than the synthesizer; each file is loaded in its own try, so one
unencodable path costs one file; the new dictionary is attached before
the old one is freed, so a reload cannot leave the card pointing at
freed memory.

tests/ stubs NVDA's API and drives the real speak() against the host's
shared library. It covers the ordering rules on their own -- including
references outside the add-on's folder and what happens when one is
missing -- that a substitution reaches the queued bytes, that expansion
happens before the split, that reload swaps and detaches cleanly, and
-- the check the others rest on -- that the card really renders
different audio with a dictionary loaded than without.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant