Push-to-talk dictation for Wayland, in the spirit of Wispr Flow. Hold a key, say what you mean, let go — the words land in whatever window you were using. Built for niri; powered by OpenRouter.
A small pill tracks the state — recording (with a live waveform), transcribing, cleaning up, typing — and closes completely when idle. The hotkey keeps working without an invisible window intercepting clicks or focus.
- Hotkey down — recording starts, with a subtle beep.
- Hotkey up — the audio is encoded to WAV and sent to OpenRouter's transcription endpoint.
- Cleanup pass (optional) — a second model pass fixes punctuation, capitalization, and obvious recognition errors without touching your meaning. If it fails, the raw transcript is used instead.
- Delivery — the result is typed into the focused window through a Unicode Wayland virtual keyboard and also placed on the clipboard, so nothing is lost if a field refuses synthetic input.
paru -S dictationapp-bin # prebuilt binary (fast)
paru -S dictationapp # build from sourcegit clone https://github.com/Derpedyea/niri-wispr
cd niri-wispr
cargo install --path . # installs to ~/.cargo/bin/dictationapp
cd ui && bun install && bun run install-local # the windows: ~/.cargo/bin/dictationapp-uiThe pill and Settings are GPUIX (React on
Zed's GPUI) apps in ui/, compiled with Bun into one
dictationapp-ui executable that the daemon starts per window.
To start it with niri:
spawn-at-startup "~/.cargo/bin/dictationapp"- Wayland with layer-shell (niri, sway, Hyprland, …) — the pill is a bottom-anchored overlay surface
- An OpenRouter API key — set it in the settings window, in
config.toml, or viaOPENROUTER_API_KEY - Read access to
/dev/input/event*— for the global hotkey - A compositor supporting the Wayland virtual keyboard protocol (niri, sway, Hyprland, …) — for Unicode text insertion
wl-clipboard— publishes transcripts without focusing the pill; niri's data-control protocol lets the intended text field keep focuspw-play,paplay, oraplay— for the start/stop cues (any one of them;beeps = falsesilences this entirely)- A microphone reachable through cpal's default input device
Granting /dev/input access is distro-specific — typically
the input group plus a udev rule, or logind ACLs.
Run dictationapp once — it stays resident without an idle window and listens for
the hotkey and for commands on a Unix socket
($XDG_RUNTIME_DIR/dictationapp-$USER.sock):
dictationapp --toggle # start/stop dictation
dictationapp --start # start recording
dictationapp --stop # stop and transcribe
dictationapp --cancel # throw the current recording away
dictationapp --settings # open the settings window
dictationapp --reload # re-read config.toml
dictationapp --quit # exit
dictationapp --record <out.wav> [secs] # save mic audio to a WAV (debug)Clicking the pill also toggles recording. When the pill has focus:
Space/Enter toggles, Esc cancels, Ctrl-Q quits.
A microphone icon sits in your bar's tray (Noctalia, Waybar, KDE — anything
that hosts StatusNotifierItems) and turns red while you're recording. Click
it, or run dictationapp --settings, to open Settings: changes apply as you
make them, and you set the hotkey by pressing it. Packaged installs also
ship a Dictation Settings launcher entry, so it shows up in app
launchers like fuzzel or the noctalia launcher. For a source install, copy
it yourself:
install -Dm644 packaging/dictationapp-settings.desktop \
~/.local/share/applications/The config lives at ~/.config/dictationapp/config.toml:
api_key = "sk-or-..." # or set OPENROUTER_API_KEY (env wins)
model = "fish-audio/transcribe-1"
language = "en" # optional; omit to auto-detect
mode = "hold" # "hold" or "toggle"
hotkey = "KEY_RIGHTCTRL" # any evdev key name, e.g. KEY_CAPSLOCK
# mic = "USB Microphone" # input device name (exact or substring);
# unset = system default. Pick it in Settings.
type_text = true # false = clipboard only
beeps = true # start/stop/error cues
cleanup = true # LLM pass that tidies the transcript
cleanup_model = "inclusionai/ling-3.0-flash"hold— press and hold the hotkey; release to transcribe.toggle— tap once to start, again to stop.
Output changes apply immediately, including transcripts still processing. Disabling typing stops any remaining insertion and keeps the full transcript on the clipboard. Changing the hotkey or mode discards an active recording. Clipboard-only completion briefly shows Copied. Paste to insert. A failed clipboard copy stops delivery and shows Copy failed. Check wl-clipboard.
In hold mode, taps under ¼ second are ignored. In either mode, using the hotkey in a shortcut (Right Ctrl+C) never starts a dictation.
The pill needs no rules: it's a layer-shell overlay (namespace
dictationapp-pill) anchored to the bottom of the focused output, it never
takes keyboard focus, and it exists only while active or showing a message.
Settings is an ordinary window; this rule floats it:
window-rule {
match app-id="dictationapp" title="Dictation Settings"
open-floating true
min-width 460
max-width 460
min-height 760
max-height 760
}When idle there is no pill process at all, so nothing can cover buttons underneath it.
An optional compositor-level toggle:
binds {
Super+D { spawn "dictationapp" "--toggle"; }
}- Nothing types — check that your compositor supports the Wayland
virtual keyboard protocol. Transcripts still reach the clipboard;
type_text = falsesilences the startup warning. - Copy failed — install
wl-clipboardand check the Wayland session, then dictate again. This also supplies the fallback copy if typing fails. - Hotkey does nothing — check read access to
/dev/input/event*. IPC (--toggle, clicking a visible pill) works regardless. The watcher picks up newly connected devices within about a second. - The hotkey isn't swallowed — there's no device grab (grabbing would break normal typing), so the key also reaches the focused app. Pick an inert key like Right Ctrl.
- Hear what the model hears —
dictationapp --record /tmp/speech.wav 5and play it back. - Runtime logs go to stderr:
recording started,transcript: ...,typed N chars, errors.
The tag is the release — CI does the rest:
# bump `version` in Cargo.toml first, then:
git tag v0.2.0
git push origin v0.2.0.github/workflows/release.yml builds the binary on Ubuntu, verifies the tag
matches Cargo.toml, and publishes a GitHub release with a
x86_64-linux-gnu tarball and checksums.
The same workflow then updates two AUR packages — dictationapp (built from
source) and dictationapp-bin (the release tarball) — via
packaging/aur/publish.sh. One-time setup:
-
Create an account at https://aur.archlinux.org/register.
-
Generate a dedicated key pair:
ssh-keygen -t ed25519 -f ~/.ssh/aur -
In your AUR account settings, add
~/.ssh/aur.pubunder SSH Public Key. -
In this repo's Settings → Secrets and variables → Actions:
- Secret
AUR_SSH_PRIVATE_KEY— contents of~/.ssh/aur(required) - Secret
AUR_USERNAME— your AUR username (optional; commit author) - Variable
AUR_EMAIL— your AUR email (optional; commit author)
- Secret
-
Push a tag. On the first run the packages are created automatically (pushing to a nonexistent pkgbase creates it), so
dictationappanddictationapp-binget claimed under your account.
If AUR_SSH_PRIVATE_KEY isn't set, the AUR job skips itself — the GitHub
release still happens.
cargo build # debug binary at target/debug/dictationapp
cargo test # deterministic tests; no desktop input
cargo test -- --ignored # opt-in hardware/input + network tests
cd ui && bun install && bun run typecheck # debug builds run ui/src with bun
python3 scripts/test-aur-publish.py # mocked publishing; no network or credentialsRust daemon: cpal (audio), hound (WAV), evdev/uinput (hotkey + typing), ureq
(HTTP). Windows: GPUIX React apps in
ui/, one process per window, driven over stdin/stdout. See
docs/window-lifecycle.md for native verification.
| File | Role |
|---|---|
main.rs |
CLI dispatch, wiring |
app.rs |
command loop + state machine (Idle → Recording → Transcribing → Cleaning → Typing), pill view |
ui.rs |
starts window processes, JSON views in / events out |
ui/src/ |
the pill and Settings in React (GPUIX) |
audio.rs |
mic capture + WAV encode |
api.rs |
OpenRouter transcription + cleanup pass |
hotkey.rs |
evdev global hotkey watcher |
typer.rs |
Unicode insertion through a Wayland virtual keyboard |
ipc.rs |
Ordered CLI commands, acknowledgements, and lifetime-held instance lock |
settings.rs |
settings model: saves, reloads, key capture |
tray.rs |
tray icon in the bar |
beep.rs |
audio cues |
niri.rs |
focuses Settings via niri msg |
config.rs |
config.toml load/save |
packaging/aur/ |
PKGBUILDs + the AUR publish script |
MIT © Derpedyea
dictationapp-ui bundles GPUIX (Apache-2.0, which includes Zed's GPUI),
React (MIT) and the Bun runtime.