Architecture¶
This page explains how YazSes turns a held key and a few spoken words into text in your focused application — entirely on your own machine. It is written for users and curious contributors who want to understand the moving parts, not a line-by-line internal reference.
For what each capability does and how to turn it on, see the features reference. For every configuration key, see the configuration reference. For the commands mentioned below, see the CLI reference.
How to read this page¶
Two things are marked throughout, and they are never blurred together:
BUILT — wired into the shipping build, with tests. You can run it today.
DESIGNED — decided and documented, and deliberately not in the running system. Some of these are waiting on hardware, some on a model, and one is deferred on purpose so a promise stays kept.
The figures below are generated from the code and the measurements, not drawn by hand — see how these figures are made for the provenance and how to regenerate them yourself. Every figure is followed by the same numbers as a table, so nothing here is only reachable by looking at a picture.
High-level overview¶
YazSes is a small background program — a daemon — that runs on your desktop and listens for a single hold-to-talk key. The model is deliberately simple:
- Hold the hotkey and start speaking.
- Release it when you finish.
- Your words appear in whatever window has focus, usually within about a second and a half on the default model.
Everything happens offline, on-device. Speech is transcribed locally by faster-whisper running on your CPU (int8, no GPU required). There is no cloud service, no API key, and no account. Nothing leaves your machine unless you explicitly point YazSes at a remote host over SSH (see the remote path). Because it is push-to-talk, YazSes is not always-listening: no audio is captured while the key is up.
A thin command-line tool (yazses) talks to the running daemon to start it, stop it, query its status, and tune it. The daemon does the actual work; the CLI is a remote control.
Subsystem map¶
The settings window closes the loop over IPC. Config is read at daemon startup, so a settings app that only writes config leaves the user looking at values that are not in effect. After Apply the window offers a restart, runs the same path as yazses restart, and then polls status over IPC until the daemon answers — success is defined as the daemon replying, not as a command exiting zero, because a daemon that dies while loading a model would otherwise be reported as restarted. Declining keeps a persistent "restart pending" hint. Decisions live in settingsui/restart.py (Qt-free, IPC injected); the dialog is dumb.
A row you cannot reach cannot explain itself. yazses features has had --on/--tier/--category since it was written; the window had none of it, so the only way to a capability was to scroll past every other one. settingsui/search.py is the filter — pure, matching name, slug, category and description text, with on:/off:/tier: tokens taking the same words the CLI flags take rather than inventing a second vocabulary. It changes visibility and nothing else: a hidden row keeps its staged edit, and both Apply and Restore defaults act on the whole model, never on what happens to be on screen.
A switch that cannot explain itself is not a setting. With ~200 capabilities, a checkbox and a tier tell the user nothing about what a row does, when to reach for it, or what ticking it writes. settingsui/help.py renders that from the same registry entries yazses features info prints — one source, three renderings: a one-line summary that is always visible (a category has to stay scannable), the full card as a hover tooltip, and the same card behind a ? button. The button is not redundant with the tooltip: hover is unreachable by keyboard, unavailable on touch, and never announced by a screen reader, so each row also carries an accessible description in plain text. The card names the exact [section] key = value writes a toggle performs, which is what keeps the window auditable against a config file the user may also be editing by hand.
Restore defaults stages rather than writes. "Default" has exactly one definition — features.default_state(), the same set system/firstrun.py seeds a new install with — read by both the window's button and yazses features reset, so the two cannot drift. The window computes the difference against what it is showing (staged edits included), names every capability it would touch before anything happens, and leaves it staged for Apply; the CLI writes only the rows that differ rather than rewriting all ~200 keys and churning the file's comments. Neither can turn an experimental capability on, because the defaults are by definition the advised set — a reset can only ever switch one off.
Staged dictation puts the review before the injection. staged/buffer.py is a pure state machine — chunks in, commit/discard/undo out — held by the daemon and consulted in _on_hold_end before anything is typed. Chunks are kept separately rather than concatenated so "scratch that" can remove exactly the last burst, which is what it means. A commit returns the buffer to the ordinary injection path instead of typing it directly, so the no-text-target guard and the injector selection still apply; a failed inject puts the text back rather than losing work the user already reviewed. The spoken commit grammar is opt-in and anchored at both ends, for the same reason commands/revise.py anchors "scratch that": a suffix match would turn "git commit" into a commit, which is precisely the accident the feature exists to prevent.
Decode latency is summarised, not just logged. The daemon has always timed every decode; stt/latency.py now keeps a bounded per-model window of those times and yazses status reports p50/p95 from it. Percentiles rather than a mean because decode time is right-skewed and the slow tail is what you actually wait through, per model because that is what makes "which model should I run" answerable, and with the sample count attached because a p95 over six utterances is not a p95. The window is in memory and bounded — a diagnostic that required the opt-in learning corpus would be missing exactly when it is wanted, and nothing about it touches disk, audio or transcript text.
One way in, on every OS. The window is reachable from the tray's Settings… entry on Linux, macOS and Windows, from yazses settings, and on Windows from a Start-menu shortcut. The three trays draw the label from one tray/menu.py constant rather than each spelling it themselves, so the menus cannot drift into offering a different product per platform. The Start-menu shortcut launches the single PyInstaller binary with --settings, which __main__.py dispatches alongside --daemon/--tray/--cli — a mode flag the bundle does not know falls through to the CLI and exits 2, and the windowed binary has no console to say so on, which is why the flag and its branch are asserted against each other in tests rather than trusted to stay in step across two files.
Offline Command Mode rewrites the selection locally. Select text, hold the command key, say "make this shorter" — commands/rewrite.py parses the request (whole-utterance grammar, so the same words dictated as prose are unaffected), rewrite/engine.py reads the selection through the clipboard's new PRIMARY-first read path, and the existing local GGUF from [filters.disfluency] produces the replacement. Voice-editing of selected text is the most-praised capability in the paid dictation tools and is cloud-only in every product that has it; this one never leaves the machine.
The guards are the feature, because a rewrite replaces writing the user already has. Getting it wrong is not a worse suggestion, it is lost text. So each instruction carries its own plausibility band — "make this shorter" returning something longer has not followed the instruction, and "fix the grammar" that halves the text has deleted content — and a model that answers with a preamble ("Sure, here is…") is refused as having treated the prompt as chat. Every rejection leaves the selection untouched, and the original is put on the clipboard before the model runs, so the undo path exists before the risky step rather than after it. Off by default under [commands] rewrite.
A squeeze can now carry which mode, not just when. platform/emg/pressure.py turns raw EMG into a level — relaxed / light / hard — using the Teager–Kaiser energy operator rather than a bare amplitude threshold, because TKEO responds to amplitude and frequency and therefore ignores the large, slow artefacts (baseline wander, cable sway, a shifting electrode) that a rectified threshold reads as a contraction. Light squeeze dictates, hard squeeze commands, mirroring the command key so all three mode switches behave identically. Thresholds are calibrated from the user's own relaxed baseline and maximum contraction, because muscle amplitude varies by an order of magnitude between people and fixed microvolt numbers are meaningless. platform/emg/brainflow_source.py adds buyable hardware (OpenBCI, Muse, …) through BrainFlow, keeping the serial YESP path as the DIY reference; a missing device or dependency is a logged no-op, never a crash. (#103)
Gaze calibration refines itself. gaze/implicit.py treats a mouse click as ground truth for where the user was looking and folds it into the existing affine map with recursive least squares — the same estimator fit_calibration uses, updated one sample at a time, so cost per click is constant and no sample history is kept. It is gated on eye-agreement confidence and a residual bound, carries a forgetting factor so it tracks a moved laptop lid rather than averaging both positions, and refined_if_better only replaces the wizard's map when the candidate wins on held-out samples (ADR-014's rule). Capture stays opt-in and on-device per ADR-011/012; the module itself opens no camera and listens for no clicks, which is why it is testable without a desktop.
Three STT engines now sit behind one seam. faster-whisper (default), parakeet (accuracy), and moonshine (#74 — built for short segments on CPU, which is the shape of hold-to-talk, and needs only onnxruntime + tokenizers, so it installs without torch). stt/factory.py selects on [stt] engine and falls back to faster-whisper with a warning whenever an engine's optional dependency is absent or its model fails to load — dictation always comes up.
Neither Parakeet nor Moonshine supports initial_prompt, so the personal dictionary reaches them a different way: postprocess/vocab_correct.py (#73) recovers mis-heard vocabulary after decoding, which is why it was built engine-agnostic rather than as a Whisper prompt trick.
Moonshine carries a hard upstream constraint the adapter absorbs rather than propagates: audio must be 2-D and between 0.1 s and 64 s, enforced with bare asserts. Both bounds are reachable — a stray key tap is under 0.1 s, a dictated paragraph is over 64 s — so short buffers return empty and long ones are split on the silence gate, instead of an AssertionError surfacing as a crash.
Noise suppression has a backend that can be installed. The denoise seam (ADR-v2-015) shipped with only a deepfilternet adapter, which no environment can satisfy: its latest release pins numpy<2.0 while this project needs numpy>=2.4.6, and those ranges are disjoint on every Python version — so the feature was designed, wired and permanently unusable. denoise/spectral.py (spectral gating over noisereduce) is the installable backend and the new default. It is weaker than DeepFilterNet and the docs say so: it removes steady broadband noise — fans, air conditioning, road hum — and does little against a second speaker, which is the Cocktail Filter's job. Selecting deepfilternet still parses and degrades to a passthrough, with no remedy offered, because advising an extra that can never install is worse than saying "unavailable".
The control plane never touches the pipeline — the CLI, the settings window and the tray all speak to the daemon over the same JSON-RPC channel, which is why yazses status reports the truth rather than a guess, and why the tray can be killed and relaunched without disturbing dictation.
Both front ends also share one definition of enabling a capability. Fifteen of the ~140 rows need an optional Python package (gaze needs mediapipe, cocktail needs speechbrain, and so on), and yazses features enable has always installed those before telling you to restart. The settings window now does the same, on a worker thread, through the identical system/deps.py call — so the two cannot drift on what "enabled" means. The decisions (what to install, what to say when it fails) are pure and Qt-free in settingsui/deps.py; only the thread and the widgets live in settingsui/app.py.
When an install fails, the config key stands. A failed install is usually transient — a network blip, a slow mirror, a wheel building — and silently un-toggling the switch a user just moved discards their intent and leaves no trace of why. Instead the capability is recorded as on and reported as dormant until its packages arrive, which is a state yazses doctor and yazses features already model and a user can fix by retrying. Enabling records intent; satisfying it is a separate, retryable step, and the gap between the two is always visible.
The four blocks in the bottom band are the ones people most often assume are already there. Each is a recorded decision rather than an omission:
| Designed, not built | Why it isn't in the build | Where the decision lives |
|---|---|---|
| Android dictation keyboard | Architecture and portability matrix are done; the app is not written. iOS follows Android because an iOS keyboard extension cannot use the microphone. | The mobile programme |
| Cloud escalation | Fully designed with guardrails, and deliberately not implemented, so "nothing leaves your machine" is never quietly weakened. | Roadmap — designed, but explicitly deferred |
| Personal speech adapters (LoRA) | On-device fine-tuning for your voice, including atypical speech. Gated on a measured accuracy win on held-out data — prompt-level personalization from your own corpus already ships. | [personalize] lora in the configuration reference |
| Code-switch models | The language-routing layer ships (src/yazses/polyglot/); the code-switch-adapted acoustic model does not, and the feature stays dormant until adapter_path is set. | v2 features preview |
The same subsystem map ships in three other formats under docs/diagrams/: Mermaid (renders on GitHub), ASCII for terminals and code review, and a self-contained HTML page.
The dictation pipeline¶
Each time you hold the key, speak, and release, your audio flows through a fixed sequence of stages. The figure below colours each stage by what it actually costs, measured rather than assumed.
Stage by stage:
- Hotkey. A hold-to-talk key press starts a recording; releasing it ends the burst. The trigger is usually a keyboard key, but it can also be an EMG muscle-sensor connected over USB serial — a hands-free / silent-input option (
src/yazses/platform/emg/). - Audio capture. The microphone is recorded into an in-memory buffer for the duration of the hold (
src/yazses/audio/recorder.py). - VAD gate. A calibrated voice-activity check discards audio that is mostly silence, so an accidental tap doesn't produce a spurious transcript (
src/yazses/audio/vad_calibrated.py). If you see "Silent audio -- discarding" in the logs, your speech fell below the threshold — runyazses mic-level --set. - Pre-speech padding. A short silent lead-in is prepended before decoding so faster-whisper doesn't clip your first word (
src/yazses/audio/padding.py). - STT decode. The buffered audio is transcribed on the CPU by a pluggable engine behind the
SttEngineprotocol (src/yazses/stt/base.py, selected by[stt] engine): faster-whisper by default, or NVIDIA Parakeet TDT (yazses features enable stt-parakeet) — 2.06 % word error rate against whisper-large-v3's 3.23 % on 200 LibriSpeech utterances, at roughly twice whisper-small's CPU speed, and an eighth of large-v3's insertions (benchmarks). On the Whisper path aninitial_promptbiases the model toward the right words — the app name and your personal vocabulary. (An editor-context prefix was designed and is not wired: the daemon never constructsLspContextProvider, so nothing from your editor reaches the prompt.) - Text cleanup.
clean_text()strips Whisper artefacts such as[BLANK_AUDIO]and stray leading punctuation (src/yazses/postprocess/cleaner.py). - Disfluency filter. A three-pass filter removes fillers, de-duplicates repeated words, and rolls back self-corrections (
src/yazses/stt/filters/disfluency.py). - Command classification. A fast Tier 1 regex grammar decides whether the utterance is plain dictation or a command like "undo that" or "go to line 42" (
src/yazses/commands/grammar.py). A Tier 2 small-language-model router (slm_router.py) handles the unsure case: when Tier 1 returns plain dictation and[commands] slm_model_pathnames a local GGUF model, the router gets a second look and its answer is accepted aboveslm_confidence_threshold. Off unless that model path is set — Tier 1 alone decides every utterance otherwise, which is what every install without a model does. Few-shot examples mined byyazses tuneare loaded fromfew_shots.tomlif present. A router that fails or raises falls back to Tier 1 rather than losing the burst. - Dispatch and inject. Plain dictation goes to text injection; commands go to a key sequence (
src/yazses/commands/dispatch.py). For dictation only, two optional finishing steps run first: an offline LLM cleanup pass that lightly reformats the text (src/yazses/postprocess/llm_cleanup.py, off by default), and continuation spacing so back-to-back bursts don't glue together (src/yazses/postprocess/spacing.py).
The measured cost of each stage that is separately timed:
| Stage | Median | Share of a burst |
|---|---|---|
| VAD gate | 0.063 ms | 0.004 % |
| Text cleaner | 0.005 ms | 0.000 % |
| Disfluency filter | 0.147 ms | 0.009 % |
| Command grammar (dictation) | 0.075 ms | 0.005 % |
STT decode (base.en) | 1 561 ms | 99.98 % |
The design consequence is worth stating plainly: all latency is the speech model. Micro-optimising the filters, the grammar, or the injector would not be measurable. What is measurable is which model you choose — so that is the knob the docs push you toward, and the only one worth turning.
Most of these stages are configurable or can be turned off; see the configuration reference.
What a burst costs¶
| Model | WER on LibriSpeech test-clean | Cold start | Decode, median | Decode, p95 | On disk |
|---|---|---|---|---|---|
tiny.en | 4.82 % | 0.60 s | 0.89 s | 1.61 s | 78 MB |
base.en (default) | 4.07 % | 0.85 s | 1.56 s | 3.53 s | 148 MB |
small.en | 2.59 % | 1.63 s | 5.05 s | 8.97 s | 486 MB |
base.en is the default because it is the compromise that survives contact with real use: noticeably more accurate than tiny.en, and still back inside a second and a half. small.en is the most accurate and the right choice for yazses transcribe and Meeting Mode — but a 5-second median breaks the flow of live dictation, which is a different job.
These are read audiobook clips in clean conditions, so your dictation WER will be worse; treat them as a comparison between models, not a promise about your desk. Full method, hardware and the commands to reproduce every number are on the benchmarks page.
Your machine, not this one¶
Every number above is one laptop. yazses status reports p50 and p95 decode latency per model from your own dictation — percentiles rather than a mean, because decode time is right-skewed and the slow tail is what you actually wait through, and with the sample count attached because a p95 over six utterances is not a p95. stt/latency.py keeps a bounded in-memory window per model; nothing is written to disk and no audio or text is involved, so it does not depend on the opt-in learning corpus and is available exactly when it is wanted.
Wall-clock is not the only cost. A decode taking a second of your time spends around five seconds of CPU, because the work spreads across cores — the right trade on mains power and the wrong one on battery. [stt] cpu_threads caps it (0, the default, leaves the library alone). Measured on this machine, capping at 4 changed nothing; the trade only begins below that. See CPU and battery.
The daemon and its states¶
The orchestrator is a single long-lived process, yazses-daemon (src/yazses/core/daemon.py). It wires the whole pipeline together, owns the hotkey listener, and runs an IPC server for the CLI and tray.
Internally it is a state machine. The ordinary dictation loop is the spine down the middle; everything else is a mode you enter deliberately and return from.
- LOADING — the daemon is up but the speech model is still loading.
- IDLE — ready and waiting for the hotkey.
- RECORDING — the key is held; audio is being captured.
- TRANSCRIBING — the key was released; the engine is decoding.
- INJECTING — the result is being typed into the focused app.
- MEETING — hands-free whole-meeting capture, driven over IPC rather than by the hotkey; stopping it runs the batch diarization pass through TRANSCRIBING.
- REMOTE_SETUP / REMOTE_ACTIVE — establishing or using the SSH forwarding path.
- ENROLLING — the accessibility / mic calibration wizard is running.
- READBACK — speaking a transcript back via offline TTS.
Two more states exist in the type and are worth being precise about, because a tidier diagram would misrepresent them. ERROR is not a daemon state at all — the tray synthesises it when the daemon stops answering IPC, which is why the icon can go red while the daemon itself has simply died. PAUSED is defined, and Meeting Mode and enrollment both accept it as a legal starting point, but nothing in the daemon currently sets it.
CLI ↔ daemon IPC¶
The CLI and system tray never touch the pipeline directly — they talk to the daemon over a small JSON-RPC 2.0 channel (newline-delimited JSON). On Linux and macOS this is a Unix domain socket; on Windows it is a named pipe (src/yazses/ipc/). This is how yazses status, yazses stop, and yazses mark-wrong reach a running daemon, and how the daemon reports live state (model, PID, ready/loading, mic level) back to callers.
Platform abstraction¶
YazSes runs on Linux, macOS, and Windows from one codebase. It does this by defining a set of Protocol interfaces — abstract contracts — that each OS implements in its own way. The contracts live in src/yazses/platform/base.py; the implementations live in src/yazses/platform/linux/, .../macos/, and .../windows/. A factory (platform/factory.py) inspects the running OS and returns a Platform bundle of the right concrete backends, so the daemon and CLI are written once against the interfaces and never branch on the operating system.
| Interface | Responsibility | Linux | macOS | Windows |
|---|---|---|---|---|
HotkeyBackend | Detect the hold-to-talk key, emit start/end callbacks | evdev | Quartz event tap | keyboard hook |
InjectorBackend | Type text / send key sequences into the focused app | ydotool / xdotool / wtype | Quartz | SendInput |
LifecycleBackend | Start/stop the daemon, manage the PID file, register autostart | systemd user unit | launchd | Service Control Manager |
IpcServer / IpcClient | JSON-RPC transport for CLI ↔ daemon | Unix socket | Unix socket | named pipe |
PermissionsBackend | Probe keyboard/microphone permissions, explain how to grant them | input group | TCC prompts | (n/a) |
TrayBackend | Tray / menu-bar UI | PySide6 QSystemTrayIcon | rumps | pystray |
The EMG hotkey backend (platform/emg/backend.py) is a platform-independent HotkeyBackend implementation — it reads squeeze events from a USB serial device (YESP protocol). Since v2.14.0 the daemon constructs it through a pluggable activation-source seam (core/daemon.py::_build_activation_sources) whenever [emg] device_port is set: a squeeze drives the command-key callbacks by default (mode = "command"), or plain hold-to-talk dictation (mode = "full_text"). Each activation source runs in its own background thread beside the keyboard hook and is stopped at shutdown; the same seam is where future non-keyboard triggers (wake word, switch access) plug in.
Bluetooth is the second transport through the same seam. platform/emg/ble_backend.py (BLEEMGBackend) speaks the same YESP protocol over the Nordic UART Service and duck-types the same HotkeyBackend with the same two callbacks, so [emg] ble_address builds a source exactly as device_port does, and [emg] mode routes both identically — deliberately one decision shared rather than one per transport, since a squeeze that dictates over Bluetooth and runs a command over USB is the kind of drift nobody would think to test. Setting both is allowed and logs a warning: they are two physical devices, and choosing one for the user would be a guess.
⚠ Until v2.36 ble_address was read by nothing. It was a documented config key that _build_activation_sources never looked at, and yazses doctor printed it as a flat OK — so a paired armband was configured, reported healthy, and never connected. There is no symptom to notice, because a hotkey that fails is silent and so is one you are not pressing. doctor now reports WARN when bleak is absent, since an address without the transport library cannot connect.
Reaching the app grid¶
A .deb or snap can write into /usr/share/applications; a pipx or uv tool install owns nothing outside its own virtualenv, which left those users with a Settings window reachable only by typing a command. system/launcher.py is the per-user equivalent — it writes the .desktop entry and the icon set into $XDG_DATA_HOME, which is what XDG intends and needs no privileged step (yazses settings --install-launcher). The Exec= line is the bare command rather than an absolute path: hard-coding today's interpreter is how a launcher breaks at the next upgrade.
Adding a new operating system¶
Because every OS-specific concern is behind a Protocol, porting YazSes to a new platform is well-scoped: implement each interface in src/yazses/platform/<os>/, then register the new sys.platform value in platform/factory.py. No changes to the daemon, pipeline, or CLI are required.
Text injection backends¶
Getting characters into the focused window is surprisingly OS- and display-server-specific, so on Linux YazSes ships several injector backends and picks one automatically (src/yazses/inject/):
- ydotool — types via the kernel
uinputdevice. This works everywhere, including on Wayland and inside terminals, which is why it is the default where available. - xdotool — the classic X11 typing tool; fine on plain X11 sessions.
- wtype — a Wayland typing tool, but it is blocked on GNOME and KDE Wayland, so it can't be relied on there.
- clipboard — copies the text and pastes it. A pragmatic fallback, but a no-op inside terminals (paste means something different there) and it clobbers your clipboard.
At startup, auto.py probes the environment and selects the best available backend; you can override the choice with the [injection] backend config key (auto | type | clipboard | wtype).
Why Wayland needs ydotool. Wayland deliberately isolates applications from one another for security, so an app generally can't synthesise input into a different window. wtype relies on a protocol that GNOME and KDE do not expose, so it fails silently there. ydotool sidesteps this by injecting at the kernel level through uinput (via its helper daemon ydotoold), which is why it is the one reliable path for keystroke injection on modern Wayland desktops. yazses setup provisions ydotoold for you; see the Linux install guide.
Why the keycodes are a committed table. ydotool key accepts only numeric <keycode>:<state> tokens and silently ignores symbolic names — ydotool key ctrl+v emits no events at all — so a combo like ctrl+v has to be turned into 29:1 47:1 47:0 29:0 somewhere. That resolution used to happen by importing evdev inside ydotool_key_args. evdev is a C extension compiled against <linux/input.h>, Linux-only, and deliberately not installed on the BSDs — which the BSD backend reaches, since it composes the Linux injectors. The result was that every spoken command, every backspace correction and the clipboard paste raised ModuleNotFoundError there. src/yazses/inject/keycodes.py now holds the whole keyboard range (codes 1–255) as a table; codes above 255 (media, brightness, vendor keys) still resolve through evdev where it exists, so nothing that worked on Linux stopped working. Freezing them is safe because input-event-codes.h is a kernel ABI whose numbers cannot change without breaking every existing binary, and tests/test_ydotool_keycodes.py re-derives the table from evdev on any machine that has it, so it cannot drift unnoticed.
The remote path¶
YazSes can dictate into an application running on a different machine over SSH (src/yazses/remote/, yazses remote <host>). The speech is still captured and transcribed locally — only the final text is sent onward, so your audio never leaves your machine.
At a high level: the local daemon opens an SSH reverse tunnel (remote/forwarder.py), a lightweight injection agent runs on the remote host (yazses-agent, remote/agent.py), and the daemon's normal injector is swapped for a proxy (remote/local_proxy.py) that forwards typed text over the tunnel to that agent. From your point of view it feels identical to local dictation; the last mile just happens over the network.
What is built, and what is designed¶
YazSes carries a large catalogue of capabilities, and it is deliberately honest about which of them are real. Every capability is a row in one registry (src/yazses/system/features.py), and each row knows whether it is actually wired into the build. yazses features prints that distinction, and features enable refuses a capability that isn't wired rather than writing a config key nothing will read.
It also prints what a capability costs. system/depsize.py reads a committed table (system/feature_sizes.json, regenerated by scripts/gen-feature-sizes.py) and reports the download size before anything is fetched — the marginal size, so a feature whose packages you already have reports as free rather than as its full weight. The table holds fully resolved dependency closures, not wheel sizes, because the two differ by orders of magnitude where it matters: speechbrain is a few MB of its own and 3.1 GB resolved, since it pulls PyTorch and the NVIDIA CUDA stack that a CPU-only tool never uses. Model files are added on top of the pip figure, because the generator resolves packages and a model is not one: the catalogue priced read-back at 12 MB while tts/download.py said in its own docstring that the voice it fetches is 340 MB, and the loud-download warning could never fire on it. _MODEL_FETCHERS holds the two together and tests/test_feature_size_counts_model_files.py derives both sides from the tree, so a new download.py cannot ship unpriced. Sizes come from a committed file rather than a live query so the catalogue renders offline and instantly (ADR-011), and an unknown size prints nothing rather than a guess. See ADR-018, which also records why third-party plug-ins are declined: a plug-in would sit on the dictation hot path with the microphone, the transcript and the injector.
| Category | Wired | Designed, not wired | Total |
|---|---|---|---|
| Formatting & structure | 20 | 11 | 31 |
| Core dictation | 13 | 7 | 20 |
| Accuracy & correction | 11 | 9 | 20 |
| Accessibility & input modalities | 2 | 18 | 20 |
| Editing & navigation | 10 | 3 | 13 |
| Commands & automation | 5 | 7 | 12 |
| Learning, memory & analytics | 5 | 4 | 9 |
| Conversation & recording capture | 4 | 4 | 8 |
| Multilingual | 3 | 4 | 7 |
| Total | 73 | 67 | 140 |
Two readings of that chart matter.
The core is dense and the frontier is thin. The categories a daily user touches — core dictation, formatting, editing — are majority-wired. The one category that is almost entirely designed is accessibility and input modalities (2 of 20), and that is not an accident: those are the capabilities gated on hardware nobody has yet (sEMG wristbands, switch access rigs) or on a platform capability that isn't universal (gaze on Wayland). They are specified so that when the hardware arrives the work is integration, not invention.
A designed row is a contribution-shaped hole. Several of these have a tested, dependency-free pure core and no caller — the design is done and the wiring is not. Five such capabilities were wired by outside contributors in a single week, which is why this number moves. If you want to help, this chart is the map: see find a task that fits you.
The optional learning loop¶
YazSes can improve its accuracy for your voice and vocabulary over time — but only if you opt in. This is off by default.
When enabled ([learning] enabled = true), the daemon records one event per hold-release into an encrypted, machine-bound local corpus (src/yazses/learning/). The corpus is a local SQLite database at ~/.local/share/yazses/; the transcript text and any stored audio are encrypted with a key derived from the machine, while only coarse metadata is kept in the clear. Capture happens on a background thread so it never slows down dictation, and nothing is ever uploaded.
Later, yazses tune analyses that corpus offline and proposes concrete improvements — new vocabulary entries, a better VAD threshold, a different model, disfluency tweaks — which you review and approve before anything is written to your config. Each proposal is checked against held-out data it wasn't derived from, so a change has to survive evidence it hasn't already seen. You can inspect or delete the corpus at any time with yazses corpus status, and flag a bad transcription with yazses mark-wrong to feed the loop a correction signal.
The v2 cognitive layer¶
Beyond core dictation, YazSes includes an experimental v2 cognitive layer: a set of opt-in perceptual and personalization features such as a target-speaker "cocktail" filter, look-to-pane gaze targeting, corpus-driven vocabulary biasing, and code-switch language routing. Every one of them is OFF by default, and their heavy machine-learning dependencies (speaker embeddings, gaze models, and so on) are isolated behind optional install extras — the base install never pulls them in, and the pure logic stays dependency-free and dormant until you explicitly enable a feature (manage them with yazses features enable/disable). See the v2 features preview.
Staying up: the reliability layer¶
A dictation daemon is only useful if it is running and correct at the moment you reach for the key, and the failures that matter most are the ones with no visible symptom. Five mechanisms exist for that, all on by default.
Config can never stop startup. configcheck.py reads the config dataclasses' own annotations and repairs what it can ("0.004" → 0.004), falls back to documented defaults otherwise, drops unknown keys and sections, and survives unparseable TOML. Nothing is swallowed: every decision becomes a ConfigProblem, listed at startup and shown by yazses doctor as a Config validity check. Previously a single quoted number loaded cleanly and then failed every dictation burst inside numpy, naming neither the file nor the key.
One authority on "is it running". A PID file survives kill -9 and is not recreated if deleted under a live daemon; the single-instance lock is held by the OS for the process lifetime and so is exact in both directions. pid.is_running() consults the lock first, so yazses status and yazses start cannot give opposite answers — a state this project reached in practice.
It comes back. yazses autostart enable installs a systemd user service pointing at the current install (platform/linux/autostart.py is its single source of truth) with Restart=on-failure and a StartLimit crash-loop bound. A clean exit — what yazses stop produces — is deliberately not restarted.
The gate follows your voice. audio/adaptive_vad.py watches outcomes: a run of discards with no successful transcription between them means the silence threshold sits above the speaker, so it is lowered to pass those bursts, persisted, and announced. Only downward, because a gate that is too high fails invisibly while one that is too low only adds noise the user can see.
The icon stays. The tray was once launched at startup and never checked, so a crash left dictation running unobserved. tray/supervisor.py re-checks every 20 s using the tray lock as the liveness signal, bounded at five relaunches.
And so does the daemon — supervision runs both ways. systemd and launchd restart a crashed daemon; Windows autostart is an HKCU\Run value that fires once at login, so there a crash meant no dictation until the next sign-in while the tray watched and did nothing. The tray's poller now restarts it, under the same shape as the supervisor above: bounded attempts, spaced by a cooldown, then stop and say how to see the real error. The one distinction worth the code is dead versus wedged — a daemon that is alive but not answering still holds the single-instance lock, so replacing it is impossible and red is the honest answer.
The user-facing inventory of every one of these — what triggers it, what bounds it, and the failures deliberately left alone — is Reliability: what recovers by itself. Keep the two in step: that page states the actual constants (MAX_DAEMON_RELAUNCHES, RELAUNCH_COOLDOWN_S, silent_streak_threshold), so changing one here without changing it there publishes a number that is no longer true.
A self-healing action nobody sees is only half of one. system/notify.py speaks notify-send, which exists on Linux and nowhere else, so on Windows and macOS the mic auto-heal and the VAD retune repaired capture and told the user nothing. The daemon now queues those toasts onto its status reply — the channel the tray already polls — and the tray shows them with the OS's own notifier. Deliberately not a new IPC direction: the daemon never calls the tray, so a tray that has quit costs at most a bounded queue.
And it says the same thing on every OS. The badge's colour and tooltip come from one pure function, tray/menu.py::icon_spec, fed by one TrayModel→status bridge, status_from_model. That is not decoration: the Windows tray once kept a private colour table of its own, so the same daemon state was a different colour on Windows than on Linux, command mode and "no text field focused" had no colour at all, and five of the twelve tray states — Meeting Mode among them — fell through to idle blue. A second implementation of a policy is a second policy.
One renderer draws the mark everywhere. brandmark.py redraws contrib/icons/yazses.svg in Pillow, and both the live tray badge and the packaged .ico/.icns (scripts/gen-icons.py) come out of it, so the shortcut icon and the tray glyph cannot diverge. The build now fails when the icon file is absent. It used to fall back to icon=None, which is why every Windows release shipped PyInstaller's default artwork on the desktop shortcut: the asset had never existed, and a silent fallback meant no build, test, or CI run ever mentioned it.
Two commands expose the result: yazses verify runs the real chain and names the first broken link, and yazses report writes a redacted diagnostic bundle locally — never uploaded, per the privacy posture below.
Where this is going¶
The architecture is built so the next era is an extension rather than a rewrite. Three seams carry most of the future work, and each already exists in the shipping code:
- The activation seam.
_build_activation_sourcestreats "what starts a burst" as a plug-in. A keyboard key and an EMG squeeze are already two implementations of the same contract; a wake word, a switch, or a brain-computer interface would be a third without touching the pipeline. - The engine seam.
SttEnginemade a second engine (Parakeet) a config change rather than a fork. Anything that turns audio into words plugs in here. - The platform seam. Every OS-specific concern is a Protocol, which is what makes the Android programme a port rather than a rewrite.
What is deliberately not being built is as much a part of the design as what is. Cloud escalation is fully specified and stays unimplemented so the offline promise cannot erode by increments; personal speech adapters wait on a measured win against held-out data rather than shipping on the strength of the idea. See the roadmap for the full account, including the speculative directions — spoken recall over your own corpus is the one the project exists for.
Privacy posture¶
YazSes is offline by design. Push-to-talk means it only records while you hold the key; transcription runs locally on your CPU; and no audio, text, or telemetry is sent anywhere by default. The only outbound path is one you turn on yourself — the SSH remote injection above, and even then only the final text is forwarded, not your audio. The learning corpus is opt-in, encrypted, and local.
For the full commitment and details, see the privacy statement.
How these figures are made¶
The four figures on this page are generated, not drawn:
The generator reads the live capability registry (yazses.system.features — the same one yazses features prints), the generated command index, and the measured timings parsed out of the benchmarks page. It fails rather than guessing: if a benchmark table is reworded it stops, and if a label would overflow the shape holding it the figure is refused. So a figure claiming a capability is wired is making the same claim the CLI makes, and a figure quoting a latency is quoting a number you can reproduce with the commands on the benchmarks page.
The figures are plain SVG with no script and no external reference — nothing is fetched from a third party when you load this page, which is the same commitment the privacy statement makes about the software itself. They animate from the site's stylesheet and stop animating entirely if your system asks for reduced motion; every value they show is also in a table on this page.