Skip to content
one-more-refactorPublic

About

Local push-to-talk dictation for Wayland. Resident Whisper/Parakeet, overlay meter, tray, sound cues. No cloud, no subscription.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

9 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

utter

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.

kind: tool status: archived

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.

How it feels

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.

Why this exists

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

How it works

  1. 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.
  2. 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.
  3. 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.

Why hold a key that types nothing

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"].

Other triggers

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.

Holding a different key

[trigger]
mode = "hold"
key = "SCROLLLOCK"      # hold to talk, release to insert
hold_ms = 220           # ignore an accidental brush

Hold 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. wtype uses 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.

Measured

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.

Requirements

  • 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-pipx

Take 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.bin

Install

git 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 keyboard

Reading 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 in

Then 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 utter

Configuration

Everything 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.25

stream.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.

The optional cleanup pass

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. utter sends "think": false and 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_prompt is measurably harmful: around −24% rare-word error for +17% overall error. initial_prompt is previous-transcript conditioning, not a vocabulary channel.

How it works

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.

Design notes

Things that are the way they are on purpose:

  • wtype, not ydotool. wtype uploads its own keymap, so it is immune to your keyboard layout. ydotool emits 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 no uinput access and no group membership.
  • max_duration_secs is 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.

Limitations

  • Typing into whatever has focus is inherent to the approach. Focus is not always the window you think it is. utter types printable text only: control characters (newline, tab, escape) are stripped before wtype sees 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 --target accepts a PipeWire node.name or a numeric serial, and nothing else. The PulseAudio-style names from pactl work only when they happen to match. Run utter 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 --watch rather than automatically.
  • Live typing re-recognises the whole utterance on each pass. Fine at conversational lengths, wasteful for very long ones.

License

MIT.


⚠️ Made by a clanka

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 and wtype, 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.py before 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.


one more

part of one more — small tools that keep their data in plain files and never ask you to make an account.

About

Local push-to-talk dictation for Wayland. Resident Whisper/Parakeet, overlay meter, tray, sound cues. No cloud, no subscription.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages