Spoken narration
An optional, off-by-default text-to-speech narrator that reads app events aloud in a user-chosen language and voice.
Behavior
The narrator speaks application events using the platform's speech synthesis voices, in English, Cantonese, or both in sequence (English first, then Cantonese, strictly serialized -- never overlapping), with independently selectable voice, rate, and pitch per language. It stays off until the user turns it on with the Narration switch, and everything else it reads is a real, already-happening console event -- the non-blocking notifications the console shows (fire, the title-and-body kind) and the toast messages (the lighter one-line kind), including the ones the compiled console already raises on its own for a settings change ("… set to true", "Nice … switched on."). Nothing was invented specifically to be narrated; the narrator reads what the console already says on screen.
Narration is infrequent by design: ordinary (non-error) lines are rate-limited per category (see Configuration), and a line still queued when a newer one in the same category arrives is replaced rather than stacked, so the console never reads a backlog of superseded status lines aloud. A genuine failure -- currently the two boolean-checked daemon failures ("The phone system did not start" and daemon start/stop/restart's "Not done") -- is passed through as an error and is never dropped by that rate limit, however soon after another notice it arrives.
The narrator also yields to two things outside its own settings: an active screen reader (detected through Electron's own accessibility-support signal, forwarded from the main process) and the Low stimulation attention mode, which doubles as this feature's quiet-hours setting. Either one silences the narrator immediately, live, without needing to turn narration itself off.
Configuration
Seven controls on the Customise screen (nar_*), each persisted independently:
- Narration (
nar_enabled) -- the master off/on switch. Off by default. - Narrated language (
nar_language) -- English, Cantonese, or Both. - English voice / Cantonese voice (
nar_en_voice/nar_yue_voice) -- populated from the voices this computer actually reports, defaulting to "Choose automatically" rather than a named voice nobody may have installed. The saved value is the voice's stable platform identity, not its display name, because names are not unique and are localized. - Narration rate / Narration pitch (
nar_rate/nar_pitch) -- 0.5-2.0 and 0-2 respectively, matching the platform's own ranges. - Narration status (
nar_status, read-only) -- states plainly which voice will actually speak right now, or exactly why nothing can: no voice chosen (using the system default), a chosen voice that isn't installed here (falling back, choice kept), a chosen voice that is network-backed and will go quiet offline, or no voice on this machine that can read the chosen language at all -- including the case where this computer has no speech synthesis whatsoever.
Every one of the seven applies live: the switch, the language, either voice, and both sliders reach the running narrator the instant they are chosen, not only on the next restart.
The documentation website ships the same six user-facing controls in its own ACCESSIBILITY card -- narration-enabled, narration-language, narration-voice-en, narration-voice-zh, narration-rate and narration-pitch -- plus one status line per narrated language rather than one combined read-only control, since each picker has its own thing to say. The site's ranges are the same 0.5-2.0 and 0-2, clamped in code as well as offered by the sliders, so a hand-edited settings blob cannot hand the browser a rate of 40. Its categories and their shortest gap between two ordinary lines are declared in one table: setting, export, search and notification at four seconds, and error at zero. A category no table row declares is refused rather than given a default, because a silent default is how a typo becomes a category of its own with nobody's rate limit on it.
Current status
Desktop application: implemented. app/renderer/src/narration.ts holds the pure, injectable Narrator (queue, per-category cooldown, "both" serialization, error bypass, voice-status resolution -- fully covered by tests/ui/narration.test.tsx). app/renderer/src/narration-engine.ts is the one real SpeechEngine adapter over the platform's Web Speech API, falling back to a null engine that never speaks but still resolves and reports honestly when no speechSynthesis exists at all (tests/ui/narration-engine.test.tsx). App.tsx constructs one Narrator for the life of the component, wires the seven controls to it, and narrates through the console's own existing notification paths (fire/toast) rather than a second, parallel event system -- proven reached from the real mount chain, not merely imported, by tests/ui/narration-wired.test.tsx.
Screen-reader ducking is wired through app/electron/main.ts (app.isAccessibilitySupportEnabled() and its change event) and app/electron/preload.ts/preload.cjs, exposed to the renderer as window.dingDesktop.accessibility. It is optional on the bridge, exactly like provisioning: the hosted HTTP surface has no Electron main process behind it and degrades to doing nothing rather than guessing.
Documentation website: implemented, on 2026-08-26. The sentence that used to sit here -- "a static documentation site has no application events of the kind this feature narrates" -- was wrong, and it is worth saying why rather than quietly replacing it. The site raises the same kind of events the console does and has for some time: it has its own notify(), its own toast region and its own notification history, and thirteen call sites reach them (a setting saved, an export written, a regular expression applied, a colour copied, a local revision restored, an idle nudge, and so on). What it had no way to do was read any of them aloud.
site/settings.html carries an ACCESSIBILITY card holding the master switch, the narrated-language choice, one voice picker per language with its own live status line, and rate and pitch sliders. site/app.js holds state.narration (enabled:false in DEFAULTS, so it is silent for anyone who never switches it on), the NARRATION_TRACKS and NARRATION_CATEGORIES tables, narrationGate() as the single pure decision about whether a line is spoken at all, a serialized queue in pumpNarration() so two utterances never overlap and "Both" means English and then Cantonese, and applyNarration() called from applyState().
Four things the site does differently from the console, each deliberate:
- Its narrated lines carry their own English and Cantonese wording. The console reads its English notification text through whichever voice is selected; the site's
notify()takes an explicit narration descriptor, so a Cantonese voice reads Cantonese words. A descriptor may name aCOPYkey instead, which reads that key at each language's own funny level. - A line is spoken in the language it actually has wording for. Where a line has no Cantonese text and Cantonese is the narrated language,
narrationTracksFor()falls back to English rather than handing English words to a Cantonese voice, which is not Cantonese narration but English mispronounced. The status line under the unselected picker says that is what it does. - The Cantonese track prefers a real Cantonese voice.
zh-CNis Mandarin, and a Mandarin voice reading Cantonese is a different language rather than an accent, soyue-*andzh-HKrank ahead of any otherzh-*. The console matches on thezhprefix alone; that has not been changed here and is not claimed to have been. - It cannot duck under a screen reader, and says so. The console detects one through Electron's own accessibility signal; a browser has no equivalent API. The card states that plainly rather than implying it steps aside, and Low stimulation doubles as the site's reduced-sound setting, silencing it live -- errors included, because "quieter" that keeps talking is not quieter.
The privacy boundary is the part worth reading. A voice whose localService is false is synthesised on somebody else's server, so the words handed to it leave the computer. Two rules follow. Narrated text never passes through applyVocabularyText: narrationTextFor() reads copyLevel(), the per-language copy *before* the personal vocabulary is substituted, so a private dictionary cannot be spoken through a remote voice. And a rejected personal-vocabulary file is the one rejection on the site whose reason is shown and never spoken, because several of those messages quote the file back -- a duplicate term, an over-long replacement. A rejected logo, by contrast, does speak its reason, because every reason that branch can carry is written by this site or by the browser's own file reader and none of them quotes the image. Both routes go through one writer each (rejectVocabulary, rejectLogo) so the rule holds for every branch rather than for whichever one somebody remembered.
Where the effective voice *is* network-backed, the status line says so, in those words, in the automatic and fallback cases as well as the chosen one.
Failure modes
Speech synthesis being unavailable is a reported state, not a silent no-op: the Narration status control says so plainly (either "no speech synthesis on this computer" from the initial enumeration, or "no voice on this machine can read \<language\>" once a control is touched and the status is recomputed from the same honest voice-resolution logic). Enabling narration and firing an event on a machine with no synthesis does not throw or hang -- there is simply nothing to speak through, and the status line says exactly that. A synthesis error mid-utterance (the adapter's onerror) resolves that one utterance and lets the queue continue, rather than blocking every line behind it.
Accessibility and localization
The narrator ducks under a real, currently-active screen reader (not a guess -- Electron's own accessibility-support signal) and under the Low stimulation attention mode, both live. Its own seven controls follow the console's standing accessibility contract (keyboard reachability, visible focus, correct roles and names) as ordinary compiled console controls. Narrated copy is currently the console's own English notification text; it is not yet independently translated per narrated-language selection beyond the voice/engine actually speaking Cantonese when chosen.
Verification
tests/ui/narration.test.tsx -- the pure Narrator logic (queue, cooldown, "both" serialization, supersession, voice-status resolution, dispose) against a fake SpeechEngine.
tests/ui/narration-engine.test.tsx -- the real SpeechEngine adapter against a fake speechSynthesis/SpeechSynthesisUtterance platform, including the no-synthesis-at-all fallback.
site/tests/contracts/narration.test.mjs -- the site's narrator, 62 tests. The behavioural half evaluates the real extracted source from site/app.js against a recording DOM and a fake speech engine that only finishes an utterance when the test says so, because "one at a time" is a claim about what happens while one is still going and an engine that completes instantly can never fail it. It covers off-by-default, the engine really receiving the words, cancelling mid-sentence, serialization, "Both" in order and in two voices, supersession, the per-category cooldown, the error bypass, Low stimulation silencing it live (including between the two halves of one bilingual line), an undeclared category being refused rather than defaulted, the vocabulary boundary in both directions, the automatic/kept-choice/fallback/network/no-voice/no-engine status sentences, the late voiceschanged list, pagehide cancelling and unsubscribing, clamping, and every control being read back and bound. It additionally walks every notify() and narrate() call site with a hand-written argument splitter -- hand-written because these call sites nest template literals inside template literals and a comma-splitting pattern would miscount in the direction that reads as "every call passes what it should" -- and that splitter has a self-check of its own.
scripts/negative-narration-site.mjs -- 48 planted breaks, each alone, each turning that contract test red and then green again on restore, wired into test:inventories. Two of the 48 survived on their first run and both were gaps in the tests rather than in the code: the error-bypass flag is unobservable through the error category (whose own cooldown is 0, so the two mechanisms say the same thing), and the queue's outer drain guard turned out to be a fast path -- the per-line guard is what actually stops the narrator, and it is only individually observable between the two halves of a bilingual line. Both properties now have assertions that name them.
tests/ui/narration-wired.test.tsx -- the real App, mounted (componentDidMount actually called, not skipped), driven through its real controls (setVal, fire, toast) exactly as a user or the compiled console would: off by default, enabling causes real speech, disabling silences it again, "Both" serialization, the cooldown-vs-error distinction at the real daemon-failure call sites, the honest no-synthesis status, late voice enumeration, screen-reader ducking, Low-stimulation quiet-hours ducking, and a source-anchored guard that the wiring itself (the Narrator import and the live .enqueue( calls) is actually present rather than merely available. Supersession specifically is proven in the pure suite above rather than re-derived at the mount level -- see the comment above the (deliberately absent) mount-level "burst" test in that file for why: the real, non-zero default cooldown structurally prevents observing "replace, don't stack" through a synchronous burst of same-category events, which is exactly why the pure test isolates that property with a near-zero cooldown.
What is not claimed
Nothing here has been driven in a browser by this pass. The site's narrator is proved against its own extracted source, a recording DOM and a fake speech engine, and no further: no real speechSynthesis has spoken a word of it, no real voice list has been enumerated, and the pages-site row for narration therefore stays unverified -- implementation, documentation, localized copy and a local check now exist, and the two artifacts that need a running program do not. The desktop console was not touched.
Suggested articles
Language modes, Personal vocabulary upload, Attention modes, Platform feature index.