This version is still in development and is not considered stable yet. For the latest stable version, please use dengjen-nvda 4.x!

Speech engine process

Speech is generated by dengjen-tts-grpc.exe, a gRPC front end of dengjen 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 whose tag starts with grpc-release-v.

  • dengjen-tts.lock pins the release version and the sha256 of its zip. python update_dengjen_tts.py fetch installs 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 to VERSION, 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.yaml workflow runs bump daily at 06:17 UTC, on a dengjen-tts-release repository 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 fetch before scons or tests_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():

  1. aio.ensure_running() starts the event loop and thread pool.

  2. DengjenGrpcBackend.initialize() runs the adapter’s initialize() coroutine and waits for it up to STARTUP_TIMEOUT (20 s).

  3. check_version() asks the server for its version, within SERVER_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 raises BackendUnavailableError.

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:

  1. Calls _reap_if_needed, which looks for stale helpers (see below) unless a saved helper is still alive.

  2. Calls start_grpc_server().

  3. 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 a grpc.aio channel is bound to the loop that created it and a new helper gets a new port.

Starting the process

start_grpc_server():

  1. 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.

  2. Checks that vcruntime140_1.dll loads (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 returns False.

  3. Launches the executable with DENGJEN_GRPC_SERVER_PORT=0 so the OS assigns a port, DENGJEN_ESPEAKNG_DATA_DIRECTORY pointing at NVDA’s synthDrivers directory (the engine reuses NVDA’s bundled eSpeak NG data), and DENGJEN_GRPC=info. Creation flags are DETACHED_PROCESS, CREATE_NEW_PROCESS_GROUP and ABOVE_NORMAL_PRIORITY_CLASS. Output goes to logs/dengjen-tts-grpc.log under the voices base directory.

  4. Waits up to PORT_HANDSHAKE_TIMEOUT (10 s) for the log to contain the line DENGJEN_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 returns False.

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.