Local dictation for Wayland that types while you speak. Hold a key, talk, and the words appear in whatever you were typing into, a beat behind your voice.
There is no window, no panel, no overlay. The text arriving is the interface.
Archived. utter works, but it is no longer maintained: its author dictates with voxtype now. The code, tests and numbers below stay as a reference.
utter daemon # keeps the model resident, arms the trigger
utter keys --watch # confirm your trigger key is detected
Nothing leaves your machine. No account, no API key, no subscription.
Measured, dictating an 11-second sentence (audio starts at t=0):
t+2.2s And so,
t+2.6s my
t+4.6s fellow
t+5.0s Americans,
t+7.0s not what
t+7.4s your country
t+10.6s you, ask what you can do for
t+11.4s your country.
Words land roughly 1–2 seconds behind your voice and are never taken back.
Paid dictation apps charge $10–15/month, and the good ones are macOS-only. The surprise is that the subscription was never buying transcription — transcription is nearly free now. It buys the client: the hotkey, the live typing, the cleanup pass, the dictionary. That part simply does not exist on Linux, so this is that part.
The other surprise is speed. A resident local model beats the cloud on latency, because the network round-trip alone costs more than the inference:
| latency | |
|---|---|
| utter, local, resident model | 190–250 ms |
| Aqua Voice (cloud, fastest paid product) | ~450 ms claimed |
| Wispr Flow (cloud) | 700 ms claimed, 1–2 s reported |
- Hold Right Ctrl (or any key that types nothing). Read straight from the kernel's input devices, so it works in every application without a compositor keybind.
- Talk. Every 400 ms the audio so far is re-recognised. Words that two consecutive passes agree on get typed; the rest is held back until it stops changing.
- Release. A final pass adds whatever is still unsaid.
Text is only ever appended, never rewritten. Injection is fire-and-forget — a tool cannot read back the window it typed into, so "delete the last 17 characters" is a guess that corrupts real text the moment you move the caret or an autocomplete fires. Instead, a word is typed only once it has stopped changing.
That policy is LocalAgreement-2 from Macháček et al., plus two words of deliberate lag — measured as the point where whisper stops committing the punctuation it invents at the edge of a partial.
The trigger is a passive read of the keyboard, not a grab, so the key still reaches your application. Holding Space would autorepeat spaces into your document at the compositor's repeat rate, and the count cannot be derived reliably. Alt+Space is no better — measured, it inserted 2 characters out of 5 presses.
So hold mode wants an inert key: RIGHTCTRL, SCROLLLOCK, PAUSE, MENU, F13.
Prefer a chord you tap instead? mode = "chord" with modifiers = ["ALT"].
mode = "double_tap" taps one key twice — by default space. Double-spacing is
something people genuinely type, so those taps are ignored if any other key was pressed
in the previous 500 ms; pause briefly first and it opens. Its two spaces reach the
window and are deleted afterwards.
[trigger]
mode = "hold"
key = "SCROLLLOCK" # hold to talk, release to insert
hold_ms = 220 # ignore an accidental brushHold mode is classic push-to-talk: the microphone opens while the key is down and commits the moment you let go, so silence detection is switched off — pausing mid-sentence while still holding the key is not the end of your dictation.
Pick an inert key for this. The trigger is a passive read, so the key still reaches your application: holding a key that types something inserts that character over and over while held, at the compositor's autorepeat rate. Deleting those would mean guessing that rate, and guessing one too many eats your actual text — so utter does not try. It warns instead.
| hold (default) | chord | double-tap | |
|---|---|---|---|
| example | RIGHTCTRL |
Alt+Space |
SPACE twice |
| how it ends | you release | silence, or the chord again | silence, or two more taps |
| stray characters | none, on an inert key | none | 2 spaces, deleted after |
| fires by accident | when you hold the key for a shortcut | no | only if you pause then double-space |
On a German layout ALT means left Alt only: AltGr+Space inserts a non-breaking
space, so binding it would type an invisible character every time. Ask for ALTGR
explicitly if you want it anyway.
Two details that make this possible rather than fiddly:
- The trigger is a passive read, not a grab — your keystrokes still reach the application, which is why the two stray spaces need deleting afterwards. Grabbing the keyboard would avoid that but would mean intercepting every keystroke on the system.
- Synthesised keystrokes are invisible to it.
wtypeuses the Wayland virtual-keyboard protocol rather than creating a kernel device, so utter typing its own output can never retrigger itself. No echo suppression needed.
Silence detection looks at a ~380 ms window rather than a single audio chunk. Without that it cuts people off mid-sentence: the first version committed after 3.4 s because it mistook a dramatic pause for the end of the sentence.
On one machine — Ryzen 7 7700X, Radeon RX 9070 XT (gfx1201), RADV Vulkan,
whisper.cpp 1.9.3, large-v3-turbo q8:
| what | measured |
|---|---|
| stop talking → text delivered | 196–246 ms (11.5 s of audio) |
| model load, per process | 243 ms — which is why the daemon exists |
| 66 s of audio, inference only | 880 ms (≈75× realtime) |
| same model, CPU only (16 threads) | 4.39 s |
Parakeet 0.6B via parakeet-cli |
691–1068 ms (reloads the model each time) |
| optional LLM cleanup pass, warm | 482–924 ms |
GPU returns to idle clocks with VRAM released between utterances.
- A Wayland compositor implementing
zwp_virtual_keyboard_manager_v1— wlroots-based ones do (niri, Sway, Hyprland, river). GNOME and KDE do not, so typing will fall back to the clipboard there. whisper-cpp,wtype, PipeWire, and a GGML model.- PyGObject and dbus-python from your distribution. No Python packages to install.
On Arch:
sudo pacman -S whisper-cpp ggml-vulkan wtype wl-clipboard \
python-gobject python-dbus python-pipxTake ggml-vulkan, not ggml-hip: on RDNA4, Vulkan measures faster than ROCm, and
the ROCm path has an open bug where the GPU never drops back to idle clocks until the
process exits — bad for something that sits resident all day.
A model, either one:
mkdir -p ~/ai/stt && cd ~/ai/stt
# fast, resident, the default
curl -LO https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo-q8_0.bin
# more accurate on English, but reloads every time (~700 ms)
curl -L -o ggml-parakeet-v3-q8.bin \
https://huggingface.co/ggml-org/parakeet-GGUF/resolve/main/ggml-parakeet-tdt-0.6b-v3-q8_0.bingit clone https://github.com/one-more-refactor/utter && cd utter
pipx install --system-site-packages . # PyGObject/dbus come from the distro
utter init # writes ~/.config/utter/config.toml
utter check # verifies models, binaries, mic, and the trigger
utter sources # lists capture devices that actually work as a target
utter keys --watch # confirm the trigger fires on your keyboardReading input devices normally needs the input group, but systemd-logind usually
grants the active seat's user an ACL on local keyboards, so the trigger often works
with no setup at all. If utter keys finds nothing:
sudo usermod -aG input $USER # then log out and back inThen run the daemon, and bind a key. For niri, in binds { }:
Mod+D { spawn "utter" "toggle"; }
Mod+Shift+D { spawn "utter" "cancel"; }A user service is in contrib/utter.service:
cp contrib/utter.service ~/.config/systemd/user/
systemctl --user enable --now utterEverything is optional; ~/.config/utter/config.toml only needs what you change.
[asr]
backend = "whisper-server" # resident, ~220 ms
model = "~/ai/stt/ggml-large-v3-turbo-q8_0.bin"
language = "en"
[trigger]
mode = "hold" # or "chord" / "double_tap"
key = "RIGHTCTRL" # SCROLLLOCK or PAUSE if you use Right Ctrl for shortcuts
hold_ms = 220
# mode = "chord"; modifiers = ["ALT"]; key = "SPACE"
[stream]
enabled = true # type words while you speak
interval_ms = 400 # re-recognise the audio so far this often
[output]
mode = "type" # or "clipboard"
[output.replacements]
"see three do" = "cr3do" # deterministic, applied before anything else
[cleanup]
enabled = false # the optional LLM pass; needs stream.enabled = false
intensity = "light" # off | light | heavy
vocabulary = ["Authentik", "Proxmox", "niri"]
[audio]
auto_stop = true # not used in hold mode: releasing the key ends it
silence_ms = 1500
silence_level = 0.02
[ui]
tray = true
sounds = true
sound_volume = 0.25stream.enabled is on by default and is what makes words appear while you speak. Turn
it off to get the whole utterance typed at once when you stop.
Raw recognition keeps every "um", every false start, and has no paragraphs. A local
LLM pass fixes that. Point [cleanup] at any Ollama-compatible endpoint.
It only runs with stream.enabled = false. Live typing commits words as you speak,
and the cleanup pass would have to rewrite them afterwards, so with streaming on (the
default) cleanup and two_stage are switched off at startup.
Start with it off. Plain dictation is ~220 ms; the cleanup pass roughly triples that, and it introduces an over-editing failure mode where the model "improves" what you actually said. Turn it on once the fillers start bothering you.
With [output] two_stage = true (the default) the raw text is typed immediately and replaced when the
cleaned version returns, so a ~1.2 s pipeline still feels like a ~0.2 s one.
Two things worth knowing, both learned the hard way:
- Use an instruct model, never a reasoning model. A reasoning model spends its
whole token budget thinking and returns an empty string.
uttersends"think": falseand strips<think>blocks, but the model choice is still yours. - Custom vocabulary belongs here, not in the recogniser. Feeding a word list to
Whisper's
initial_promptis measurably harmful: around −24% rare-word error for +17% overall error.initial_promptis previous-transcript conditioning, not a vocabulary channel.
evdev trigger ──► pw-record ──raw s16──► WAV every 400 ms
│
whisper-server (resident, 127.0.0.1) ──► agree + tidy
│
printable text only ──► wtype ──► focused window
The daemon holds the model and starts its own whisper-server; it refuses to start if
something else already holds the port. The CLI is a thin client over a unix socket.
Things that are the way they are on purpose:
wtype, notydotool.wtypeuploads its own keymap, so it is immune to your keyboard layout.ydotoolemits raw US scancodes, which on a German QWERTZ layout swaps y/z and mangles every punctuation mark — fatal when dictating into a terminal. It also needs nouinputaccess and no group membership.max_duration_secsis a hard stop. A missed toggle otherwise becomes a multi-minute recording of near-silence, which recognisers hallucinate over cheerfully —[BLANK_AUDIO], or "Thank you. Thank you." Those are filtered out.- evdev, not a compositor keybind. niri fires keybinds on press only; there is no release event available to config, so hold-to-talk is not possible through a keybind at all. Reading evdev directly sidesteps the compositor entirely and gives a trigger that works everywhere. If you do bind a compositor key instead, use a single modifier: holding a chord while text is being typed combines the held modifier with every character and fires compositor shortcuts instead of inserting text.
- Typing into whatever has focus is inherent to the approach. Focus is not always
the window you think it is.
uttertypes printable text only: control characters (newline, tab, escape) are stripped beforewtypesees them, so it never presses Return. Still, point it somewhere deliberate. - The default trigger is Right Ctrl. Holding it for 220 ms opens the microphone
even if you meant a Ctrl shortcut. If you use Right Ctrl, set
key = "SCROLLLOCK". - GNOME and KDE do not implement the virtual-keyboard protocol; use
mode = "clipboard"there. pw-record --targetaccepts a PipeWirenode.nameor a numeric serial, and nothing else. The PulseAudio-style names frompactlwork only when they happen to match. Runutter sources.- Tested on exactly one machine, one compositor, one GPU.
- The double-tap trigger's logic is unit-tested, and the keyboards are confirmed
readable, but a uinput device cannot be used to test it end-to-end (uinput nodes get
no seat ACL), so the final link — a human actually tapping space twice — is verified
by
utter keys --watchrather than automatically. - Live typing re-recognises the whole utterance on each pass. Fine at conversational lengths, wasteful for very long ones.
MIT.
Every line of this repository — the code, the config, the commit messages, and this README — was written by Claude, an AI, working from a human's instructions on a single Arch Linux machine.
What that means for you in practice:
- The measurements above are real, taken on real hardware, and reproducible. They are not estimates.
- The pipeline has been tested end-to-end: capture, recognition, cleanup, tray and keystroke injection all verified working.
- But it has been tested on one machine, by a process with no stake in your setup.
The unit tests (
pytest, run in CI) stub out the microphone, the recogniser andwtype, so they cover the logic, not the hardware. Nobody has run this on Sway, Hyprland, an NVIDIA GPU, or a non-German keyboard layout. - This program synthesises keystrokes into your focused window. Read
inject.pybefore you trust it with that. It is about a hundred lines and deliberately boring.
Review it like you would review code from a stranger who is very fast, very literal, and occasionally confident about things it has not checked.
part of one more — small tools that keep their data in plain files and never ask you to make an account.