Install and configure.
sayd is the daemon. say is the command that drives it.
This page shows how to install both, and what every setting does.
Installation
sayd needs four native libraries: espeak-ng, ONNX Runtime, GTK4 and libadwaita. Nix installs them for you. On other systems you install them first, then build with cargo.
With Nix
The flake carries every dependency. Nothing to install first.
# run it straight from the repository nix run github:elsirion/sayd # or build from a checkout nix build
The flake also exposes an overlay. Use it to add sayd to
environment.systemPackages on NixOS.
With a package manager
Install the four libraries with your system's package manager. Then build.
# Debian/Ubuntu — names differ on other distributions apt install libespeak-ng-dev libonnxruntime-dev \ libgtk-4-dev libadwaita-1-dev export ORT_DYLIB_PATH=/usr/lib/x86_64-linux-gnu/libonnxruntime.so cargo build --release
ORT_DYLIB_PATH matters: ONNX Runtime is loaded at run time,
not linked at build time. Without it the build succeeds but the daemon
will not start. Put both binaries — target/release/sayd and
target/release/say — on your $PATH.
Then start the daemon and bind keys in your sway config:
exec sayd bindsym $mod+Shift+s exec say selection bindsym $mod+Shift+v exec say clipboard bindsym $mod+Shift+m exec say stop
Reloading sway is safe. sayd is single-instance: a second start hands its arguments to the running daemon and exits.
Voices
The Kokoro-82M weights and voice packs are not shipped. They are 341 MB and come from Hugging Face. Two ways to get them:
- The settings window. Open Settings from the tray. On a machine with no voices it shows a Download voices button that says what it will fetch before you press it.
- The shell. Run
./scripts/fetch-models.shfrom a checkout.
This download is one of only two network calls sayd can make. It fetches files and sends nothing. The other is the rewording endpoint, which you configure yourself — see Rewording.
The config file
Configuration lives in ~/.config/sayd/config.toml
($XDG_CONFIG_HOME is honoured). You rarely edit it by hand:
the settings window writes it for you. If you do edit it by hand, sayd
picks up the change at once — no restart needed.
Every setting has a default. A missing file, or a missing key, is not an error. The tables below list each setting with its default value.
General settings
Top-level keys, not in any table.
| Setting | Default | What it does |
|---|---|---|
| voice | "af_heart" | The voice to speak with. 29 packs ship with the model download. The settings window lists them all. |
| speed | 1.0 | Playback speed multiplier for every utterance. |
| speed_mode | "model" | "model" or "stretch". model hands the speed to Kokoro itself. stretch synthesises at 1.0 and time-stretches the audio afterwards. model can swallow the first word at some speeds and does not hit the exact tempo; stretch avoids both but has its own artifacts. |
| model | "fp32" | "fp32", "fp16" or "q8". fp32 sounds best. q8 is about three times faster and sounds worse. Switching reloads the engine. |
| threads | 8 | ONNX Runtime threads. Measured peak at 8; more does not help. |
| idle_unload_secs | 600 | Seconds of silence before the ~1.27 GB engine session is dropped from memory. 0 means never unload. |
| muted | false | While muted, sayd accepts submissions and silently discards them. Nothing piles up to be spoken later. |
| max_chars | 20000 | Submissions longer than this are refused. |
Text cleanup
The [cleanup] table. Cleanup rewrites text before it is
spoken, so screen artifacts do not reach the speaker. It runs before
rewording too, so URLs and code never reach the endpoint either.
| Setting | Default | What it does |
|---|---|---|
| enabled | true | Master switch. Off means text is spoken exactly as submitted, and every switch below is ignored (but kept, so turning it back on restores them). |
| collapse_whitespace | true | Fold runs of spaces and newlines into one space. |
| rejoin_hyphenation | true | Rejoin words that were split across lines with a hyphen. |
| urls | "link" | "link" speaks a URL as the word "link". "domain" speaks just the host, like "example.com". "keep" leaves it alone. |
| strip_markdown | true | Remove markdown markup — asterisks, backticks, headings — so it is not read aloud. |
| drop_code_blocks | true | Skip fenced code blocks entirely. Code is not worth hearing character by character. |
| spell_acronyms | true | Read all-caps words letter by letter: "URL" becomes "U R L". |
Chunking
The [chunking] table. sayd synthesises long text in chunks,
so playback starts before the whole text is rendered.
| Setting | Default | What it does |
|---|---|---|
| target_chars | 400 | How much text goes into one synthesis chunk. Smaller starts sooner; larger flows better. |
| lookahead_chunks | 2 | How many chunks are synthesised ahead of the one playing. |
Notifications
The [notifications] table. sayd can speak desktop
notifications. It watches the session bus passively, so it works next to
any notification daemon — mako, dunst — without replacing it.
| Setting | Default | What it does |
|---|---|---|
| enabled | false | Off by default. Speaking notifications changes how your desktop behaves, so you have to ask for it. |
| allow | [] | Application names to speak. Matched exactly, ignoring case — no globs. Empty means nothing is spoken. sayd logs every name it declines, once per run, so you can find the right ones; the settings window offers every name it has seen. |
| cooldown_secs | 30 | At most one utterance per application in this window. Extra notifications are counted and read out as one follow-up ("Signal: 3 more notifications") when the window closes. 0 turns rate limiting off entirely. |
| speak_app_name | true | Prefix the announcement with the application's name: "Signal: Alice sent a message". |
| speak_body | false | Also read the notification's body after its summary. Off by default — bodies are often long and often repeat the summary. |
Two switches to hear anything: set enabled = true
and put at least one name in allow. With rewording
on, a non-zero cooldown_secs is raised to one second past
reword.timeout_ms, so a follow-up can never overtake the
announcement it counts from.
Rewording
The [reword] table. A small language model rewrites text into
something written for the ear before it is spoken. Off until you set a
provider. When anything goes wrong — a dead, slow or missing endpoint —
the original text is spoken instead. No notification is ever lost.
| Setting | Default | What it does |
|---|---|---|
| enabled | true | The master switch. Off means nothing is rewritten, not even an explicit --reword. On means nothing yet — a provider is still needed. |
| notifications | false | Rewrite notification announcements automatically. Requires provider to be set. |
| base_url | "http://localhost:11434/v1" | Where the text goes. Any OpenAI-compatible endpoint. The default is a local Ollama. Text goes here and nowhere else — no proxies, no redirects. |
| model | "llama3.2:3b" | The model name sent with each request. |
| provider | unset | "llama-cpp" or "generic". llama-cpp also tells the server not to "think" — a reasoning model cannot answer inside the deadline. Use generic for Ollama, LM Studio, vLLM and hosted APIs. Required when notifications = true. |
| api_key | "" | Sent as a bearer token. Local servers ignore it. If set, the config file is written with mode 0600. |
| api_key_env | "SAYD_REWORD_API_KEY" | Environment variable to read the key from. If it is set and non-empty, it wins over api_key. Prefer it — it keeps the key out of the config file. |
| timeout_ms | 1500 | Deadline for a notification rewrite, in milliseconds. Past it the original is spoken. Minimum 200, no maximum. |
| request_timeout_ms | 25000 | Deadline for an explicit --reword. Longer, because you may point it at a whole document and a local model needs time to read it. |
| stream | false | Speak a --reword answer sentence by sentence as it arrives, instead of waiting for the whole thing. Starts much sooner, but a spoken sentence cannot be unsaid, so the whole-answer safety checks cannot run. |
| prompt | unset | The instruction sent with a notification rewrite. Unset means the built-in prompt, which also improves with new releases. An edited prompt is stored; a cleared one returns to the built-in. |
| request_prompt | unset | The same, for explicit --reword requests. A different instruction, because summarising a document is a different job from rephrasing one line. |
| max_chars | 400 | Longest notification to rewrite (32–2000). Anything longer is spoken as written. |
| request_max_chars | 8000 | Longest text an explicit --reword will rewrite (32–20000). |
Privacy: with rewording configured, the text about to be spoken is
sent to base_url. Point it at localhost — the default — and
nothing leaves your machine. Cleanup runs first, so URLs, code blocks and
markdown are already gone from what is sent. The destination is logged
once per run.
A working local setup
This is a real configuration against a llama.cpp server on the same machine:
[reword] enabled = true notifications = true base_url = "http://localhost:8080/v1" provider = "llama-cpp" timeout_ms = 10000