Pronunciation dictionaries - #8
Open
guilevi wants to merge 3 commits into
Open
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
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_saytakes exactly the path it took before and allocatesnothing, and the driver's byte stream is unchanged.
Three commits: the
.gitignoreline, the emulator side, the NVDA side.The part that needs your judgement:
doubletalk/rcdict/is vendoredThe 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/Cmode commands and the Ctrl-Acommand 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 loadersare 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 vendoredregex engine. A sync script on the upstream side copies them across, and its
--checkmode exits non-zero if the two copies have drifted — worth a CI stepif this lands.
Licensing was the design constraint, not an afterthought.
rcdictisBSD-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 GPLheader or a dependency on either host, and
LICENSING.mdsays so.remimu.his 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 toread identically in both repositories.
LICENSING.mdnotes the exception. Saythe 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
zipa working tree), or dropping the regexmatcher 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
NULLfirst, so a host can swap dictionaries without tearing the carddown.
inline_phonemesenables the[[K AE T]]escape in ordinary text andis off by default, because
[[is wiki link syntax and someone reading awiki must not silently lose text to it.
dtalk_expand()runs the rules without queueing. Callers that split longtext 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_countare four forwarding calls thatexist only for the DLL.
dtalk.hmarks 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 actypescaller 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- orproject-specific to go on being shared.
Build change worth a look:
rcdictis C and has to be compiled as C —handed to
g++, its designated initializers and implicitvoid *conversionsare errors. Each DLL is therefore two compilers' output linked together, the
C++ emulator plus
rcdictbuilt by the matchinggcc. Warnings are disabledfor
rcdict_regex.calone, because vendoredremimu.his not clean under-Wall.Leaving
rcdictout of the mingw rules is a silent failure worth naming, sinceI made it first: the DLL links,
dtalk_set_dictionaryis exported, and thecaller 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)isnever 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 precedingarticle "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 indtalk_say. This driver queues its ownbytes with
dtalk_queuebecause it interleaves index marks and settings — theraw path, which deliberately bypasses
dtalk_say— so the driver applies thedictionary 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 inthe add-on;
build_addon.shnow packages it, prints the resulting packagelisting, 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.dictdropped in there works with no configuration at all — anything thesaved 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
ctypesbindings are guarded, so an add-on runningagainst a DLL built before this change loses dictionaries rather than the
synthesizer; each file is loaded in its own
try, so one unencodable path costsone 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 realspeak()againstthe host's shared library, so it runs without NVDA and without Windows:
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:
makeand both mingw cross-builds are clean, and all six new entrypoints appear in the export tables of
dtalk.dllanddtalk64.dll;rcdictsuite passes (102 unit checks, plus 12 that render bothsides through a chip emulator and compare the audio);
from this branch, with the DLLs rebuilt from these sources.
Left for you
manifest.iniis still0.1.12— bumps look like yourown commits in this history, and it is a predictable conflict. Say what you
want and I will add it.
branch needs
make -C doubletalk win32 win64first.🤖 Generated with Claude Code