sayd

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.sh from 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.

SettingDefaultWhat it does
voice"af_heart" The voice to speak with. 29 packs ship with the model download. The settings window lists them all.
speed1.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.
threads8 ONNX Runtime threads. Measured peak at 8; more does not help.
idle_unload_secs600 Seconds of silence before the ~1.27 GB engine session is dropped from memory. 0 means never unload.
mutedfalse While muted, sayd accepts submissions and silently discards them. Nothing piles up to be spoken later.
max_chars20000 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.

SettingDefaultWhat it does
enabledtrue 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_whitespacetrue Fold runs of spaces and newlines into one space.
rejoin_hyphenationtrue 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_markdowntrue Remove markdown markup — asterisks, backticks, headings — so it is not read aloud.
drop_code_blockstrue Skip fenced code blocks entirely. Code is not worth hearing character by character.
spell_acronymstrue 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.

SettingDefaultWhat it does
target_chars400 How much text goes into one synthesis chunk. Smaller starts sooner; larger flows better.
lookahead_chunks2 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.

SettingDefaultWhat it does
enabledfalse 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_secs30 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_nametrue Prefix the announcement with the application's name: "Signal: Alice sent a message".
speak_bodyfalse 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.

SettingDefaultWhat it does
enabledtrue The master switch. Off means nothing is rewritten, not even an explicit --reword. On means nothing yet — a provider is still needed.
notificationsfalse 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.
providerunset "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_ms1500 Deadline for a notification rewrite, in milliseconds. Past it the original is spoken. Minimum 200, no maximum.
request_timeout_ms25000 Deadline for an explicit --reword. Longer, because you may point it at a whole document and a local model needs time to read it.
streamfalse 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.
promptunset 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_promptunset The same, for explicit --reword requests. A different instruction, because summarising a document is a different job from rephrasing one line.
max_chars400 Longest notification to rewrite (32–2000). Anything longer is spoken as written.
request_max_chars8000 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