Speech engine process
Speech is generated by dengjen-tts-grpc.exe, a gRPC front end of
dengjen-tts that the add-on launches as a separate process.
The adapter lives in addon/synthDrivers/dengjen_neural_voices/adapters/dengjen_grpc/init.py.
It implements the TTSBackend port described in Speech pipeline.
Where the binary comes from
The executable is not committed. bin/dengjen-tts-grpc.exe is fetched from a release of
ZirekHQ/dengjen-tts whose tag starts with grpc-release-v.
-
dengjen-tts.lockpins the releaseversionand thesha256of its zip.python update_dengjen_tts.py fetchinstalls exactly that release, refusing a zip whose hash differs from the pin, and skips the download when the pinned version is already installed. -
python update_dengjen_tts.py bump [VERSION]re-pins toVERSION, or to the latest stable release when omitted. It downloads the Windows x64 zip, checks it against the sha256 digest the release publishes, and writes the version and hash to the lock file. -
The
update-dengjen-tts.yamlworkflow runsbumpdaily at 06:17 UTC, on adengjen-tts-releaserepository dispatch (optional payload{"version": "MAJOR.MINOR.PATCH"}), and on manual dispatch. It opens a pull request, and leaves a version alone when its branch already has a pull request, whether open, closed or merged. -
Run
fetchbeforesconsortests_contract/.
gRPC stubs
The generated stubs in adapters/dengjen_grpc/grpc_protos/ are never edited by hand. They come from
the proto file crates/frontends/grpc/proto/dengjen_grpc.proto in the release pinned in
dengjen-tts.lock:
uv run --isolated --group codegen python update_grpc_protos.py
grpcio-tools, the vendored protobuf and grpcio are all pinned in uv.lock. Generated stubs
reject a runtime older than the one that generated them, so the three move together. Linting
excludes the generated modules.
Startup
NVDA constructs SynthDriver, which calls _bootstrap_backend():
-
aio.ensure_running()starts the event loop and thread pool. -
DengjenGrpcBackend.initialize()runs the adapter’sinitialize()coroutine and waits for it up toSTARTUP_TIMEOUT(20 s). -
check_version()asks the server for its version, withinSERVER_CHECK_TIMEOUT(15 s). On failure the adapter kills the helper, drops the saved process, port and channel, starts a fresh process and retries once. A second failure raisesBackendUnavailableError.
If any of this fails, or no voices are installed, the driver logs the error and stays unavailable
(self.tts is None, and speak() drops speech).
The initialize() coroutine does the following:
-
Calls
_reap_if_needed, which looks for stale helpers (see below) unless a saved helper is still alive. -
Calls
start_grpc_server(). -
Opens
grpc.aio.insecure_channel("localhost:<port>"). An existing channel is reused only when it belongs to the running loop and the same port. Otherwise it is closed and replaced, because agrpc.aiochannel is bound to the loop that created it and a new helper gets a new port.
Starting the process
start_grpc_server():
-
Reuses the saved helper if it is still running. Its port and process object are kept in
globalVars, which survives an NVDA in-process restart where this module’s own globals do not. -
Checks that
vcruntime140_1.dllloads (ctypes.WinDLL). Without it the process would start and exit at once with a dialog nobody sees from inside NVDA, and the add-on would log a misleading "Connection refused". Instead the add-on logs the download URL, shows a warning and returnsFalse. -
Launches the executable with
DENGJEN_GRPC_SERVER_PORT=0so the OS assigns a port,DENGJEN_ESPEAKNG_DATA_DIRECTORYpointing at NVDA’ssynthDriversdirectory (the engine reuses NVDA’s bundled eSpeak NG data), andDENGJEN_GRPC=info. Creation flags areDETACHED_PROCESS,CREATE_NEW_PROCESS_GROUPandABOVE_NORMAL_PRIORITY_CLASS. Output goes tologs/dengjen-tts-grpc.logunder the voices base directory. -
Waits up to
PORT_HANDSHAKE_TIMEOUT(10 s) for the log to contain the lineDENGJEN_GRPC_LISTENING port=<n>. The newline is required, so a half-flushed line is never read as a short port number. If the process exits first, or time runs out, the process is killed and the function returnsFalse.
Cleaning up stale helpers
An NVDA in-process restart reuses the process id but loses the module state, which can leave a
helper running that nothing can reconnect to. When the saved helper is not alive,
_reap_stale_grpc_servers uses the bundled psutil to find processes that:
-
are named like
dengjen-tts-grpc, -
run the same executable file as the bundled one (
os.path.samefile), and -
have this NVDA process as their parent.
The parent check leaves alone a helper that belongs to another live NVDA process, such as the one on
the secure desktop. Matches are terminated, with PROCESS_EXIT_TIMEOUT (3 s) to exit. Any failure,
including psutil being unavailable, is logged and swallowed, because cleanup must never make the
synthesiser unavailable in a healthy session.
Shutdown
terminate() is registered with atexit. It closes the channel on the loop that owns it (waiting
CHANNEL_CLOSE_TIMEOUT, 5 s), stops aio, terminates the process and waits PROCESS_EXIT_TIMEOUT.
The process kill is in its own block, so a failure closing the channel does not leave the helper
running.
DengjenTextToSpeechSystem.shutdown() is deliberately a no-op. There is one engine process for the
whole NVDA process, however many SynthDriver instances come and go.
Calls and timeouts
Blocking TTSBackend calls such as load_voice wait at most CALL_TIMEOUT (10 s) and convert any
failure to a BackendError subclass. synthesize is an async generator that yields audio_bytes
from the server’s streaming speak call, and wraps failures in SynthesisError.
For a failed start, check the add-on’s log lines in NVDA’s log and dengjen-tts-grpc.log, which
holds the engine’s own output.