wspr is a push-to-talk voice dictation and transcription tool for Linux/X11.
It provides local voice-to-text as well as voice-to-command (currently for i3-wm) and a socket interface for piping transcribed text to a listener.
You hold a hotkey (default Super+Space), speak, and release. The audio is transcribed locally with faster-whisper and routed to the hotkey's configured sink:
- type - types the transcript into the focused window with
xdotool(classic dictation behavior). - socket - sends the transcript over a Unix socket to any listener: a note taker, prompt-feeder, or home automation script.
- command - hands the transcript to a command plugin, which routes it through a local LLM (Ollama) onto a whitelisted window-manager action, validates it, and executes it. The i3 plugin, wspr-i3, ships in this repo as
wspr_i3/.
The core daemon is window-manager-agnostic. It knows hotkeys, audio, and transcription. All i3-command processing lives in the plugin, which core loads by name from config; dictation-only setups never import it.
For plain dictation, I probably wouldn't. Plenty of tools already exist. However, I couldn't find anything that matched my exact needs or interests.
- Model choice. I wanted fully local and accurate. faster-whisper is noticably better than some of the alternatives packaged with existing tools.
- Socket sink. My original plan was to control i3 through a socket. I could not find a tool that gave me streaming over a Unix socket to any listener. I wanted composability to not re-invent the wheel each time I have an idea. I've left the socket functionality, but have move onto plugin-based voice commands.
- Voice commands. I wanted the same push-to-talk flow to drive my window manager through a local LLM, with a whitelist between the model and my machine.
- Linux with X11. wspr grabs global hotkeys through Xlib (
XGrabKey), so it needs an Xorg session, not Wayland. - Python 3.11+ for the standard-library
tomllibconfig parser. - Python packages (see
requirements.txt), all for the dictation core; the command plugin is stdlib-only:faster-whisper- local transcriptionsounddevice- microphone capture (needs the PortAudio system library)numpy- audio buffer handlingpython-xlib- global hotkey grabs and the X event loop
- System tools:
xdotool- types transcripts into the focused window (thetypesink)notify-send(libnotify) - desktop notifications
- For the command sink (the wspr-i3 plugin, installed via
./install.sh --with-i3; dictation works without these):- i3 - actions execute through
i3-msg(or optionally thepython-i3ipcpackage, see[i3]in the config) - Ollama running locally with the configured routing model (default
gemma4:latest) - rofi - the confirm gate, the window picker, and the
wspr prompttext box
- i3 - actions execute through
xdotool and notify-send are not pip-installable; install them from your distro's package manager. wspr warns at startup if either is missing rather than failing silently, and --with-i3 warns about missing rofi, i3-msg, or an unreachable Ollama.
The recommended way to install wspr for everyday use is install.sh:
./install.sh # dictation-only core
./install.sh --with-i3 # core + the wspr-i3 voice-command plugin
./install.sh --remove-i3 # drop the plugin, keep the core
./install.sh --uninstall # remove everything except your configIt sets everything up under your home directory (no root needed):
| What | Where |
|---|---|
| App + private venv | ~/.local/share/wspr/ |
wspr-i3 plugin (--with-i3) |
~/.local/share/wspr/wspr_i3/ |
| Launcher executable | ~/.local/bin/wspr |
| Default config | ~/.config/wspr/wspr.toml (only if absent) |
| systemd user service | ~/.config/systemd/user/wspr.service |
It creates the venv, installs the dependencies from requirements.txt, and (if
nvidia-smi is present) adds the CUDA cuBLAS/cuDNN wheels for device = "cuda".
The installer is safe to re-run: it upgrades the code, dependencies, and
service, and refreshes an installed plugin so core and plugin never skew. It
never overwrites an existing config. The plugin is sticky: only an explicit
--remove-i3 removes it, and --uninstall keeps your config for the next
install. If you're already in a graphical session it (re)starts the service
immediately.
The service is deliberately not enabled: wspr needs the session's
DISPLAY/XAUTHORITY, which don't exist until X starts, so systemd must not
launch it at boot. Instead, have your window manager start it once the session
is up. For i3:
exec --no-startup-id systemctl --user import-environment DISPLAY XAUTHORITY && systemctl --user restart wspr.service
The service restarts automatically on failure, and its output goes to the journal:
systemctl --user status wspr # is it running?
journalctl --user -u wspr -f # follow logs
systemctl --user stop wspr # stop itIf ~/.local/bin isn't on your PATH, the installer prints a note; add it to
run wspr directly.
To run without installing (e.g. for development), use a local venv instead:
uv venv .venv
source .venv/bin/activate
uv pip install -r requirements.txt
python wspr.pywspr # run the push-to-talk daemon (what the service does)
wspr COMMAND [...] # anything else is forwarded to the command plugin
Core owns no subcommands; the vocabulary belongs to the configured plugin. With wspr-i3 that is currently:
wspr prompt # rofi text box: type a command instead of speaking it
wspr route switch to workspace three # dry run: print the routed action, execute nothing
wspr exec switch to workspace three # route one transcript and execute it
wspr context # show what wspr-i3 knows about this machine
wspr windows # list the windows wspr-i3 sees (debug)route shows what the router decided - source (fast/llm), confidence, and
whether it would confirm - without side effects; exec is the same pipeline
and actually executes. prompt is the no-microphone entry: a rofi text box
whose contents go through the same routing, gates, and whitelist as speech.
Bind it to a key (e.g. bindsym $mod+Ctrl+d exec ~/.local/bin/wspr prompt).
It runs in its own process, outside the daemon's routing lock - fine for one
human at one keyboard. wspr -h prints usage; an unrecognized command prints
the plugin's own usage.
wspr reads the first wspr.toml found in: $WSPR_CONFIG, the app directory,
~/.config/wspr/wspr.toml. Each [[hotkeys]] entry is one push-to-talk
binding. The shipped default is dictation-only:
[[hotkeys]]
combo = "super+space"
sink = "type"
[model]
size = "base.en" # tiny.en, base.en, small.en, medium, large-v3
device = "cpu" # cpu, cuda
compute_type = "int8" # int8 (CPU), float16 (GPU)To enable voice commands, install the plugin (./install.sh --with-i3) and
uncomment the command binding and [command] section that the shipped config
carries:
[[hotkeys]]
combo = "super+ctrl+space"
sink = "command"
[command]
module = "wspr_i3" # the plugin that handles command bindings[model] is the speech-to-text stage, used by every binding. A socket binding
takes an optional socket key to override the default path
($XDG_RUNTIME_DIR/wspr.sock). Two optional keys tune transcription per
binding: vocab_bias (default true) primes whisper with the plugin's command
vocabulary on command bindings, so command words ("workspace", app names)
transcribe reliably; initial_prompt replaces that with literal bias text and
works on any binding.
The remaining sections are read only by the plugin:
[ollama]- the routing stage:url,model,timeout,keep_alive, andconfidence_threshold(below it,uncertainmode asks first). Dictation never touches Ollama.[confirm]- when a routed command asks (rofi yes/no) before executing:alwaysevery command,uncertainonly privileged actions and shaky routings (the default),neveronly privileged actions.[i3]- how the plugin talks to i3:i3msgshells out toi3-msg(the default, zero dependencies);i3ipcuses thepython-i3ipcpackage and fails loud at startup if it isn't installed.
Measured on the repo's experiments/eval.py battery, 36 cases (2026-07-19):
| Model | Score | Warm latency | Footprint |
|---|---|---|---|
gemma4:latest |
36/36 | ~1.0 s mean | 9.6 GB VRAM held warm |
gemma3:4b |
32/36 | ~500 ms mean | 3.3 GB |
gemma3:1b |
24/29 (older battery) | ~490 ms | 815 MB |
The smaller models' misses are wrong actions, not refusals: "move to
workspace five" moves the window instead of switching, and questions
hallucinate actions. The move-vs-switch object-word distinction and
none-discipline on questions are what demand the default gemma4:latest;
pick a smaller model only if you accept occasional wrong actions.
A command binding hands the transcript to the plugin named under
[command]. In wspr-i3 the transcript goes through this pipeline:
- Fast path. Two anchored regex rules route the highest-frequency commands - workspace switches ("workspace three", "go to workspace 5") and curated app launches ("open kitty") - instantly, offline, and deterministically. Anything with extra words falls through to the model.
- LLM routing. A local Ollama model gets a system prompt grounded in this machine - its hostname, declared packages, i3 workspace names, and curated app aliases - and must answer with constrained JSON naming a whitelisted action. Descriptive requests work ("open my photo editor" resolves to gimp if installed); apps that aren't installed are refused.
- Validation. Only a whitelisted action with validated args becomes executable. App names are re-resolved locally against the machine's own package list - the command that runs is never taken from the model.
- Confirm gate. Privileged actions (lock screen, system updates) always
ask first; in
uncertainmode, shaky routings (fuzzy app resolution, low confidence) ask too. The rofi yes/no lists No first, so a reflexive Enter cancels. - Execution, with a desktop notification reporting the outcome: executed, refused, no matching command, or routing failed.
The whitelist currently holds: switch workspace, move the focused window to a workspace, move a named window to a workspace ("put chrome on workspace three"), focus a window, launch an app (optionally on a workspace), lock the screen, and run system updates. Window queries match against class and title with tiered narrowing; if several windows still match, a rofi picker asks which one you meant.
If Ollama is down, the fast-path vocabulary keeps working and fuzzier phrasings fail with a clear "routing failed" notification - the daemon degrades to a useful core instead of dying.
The plugin seam is generic: core imports whatever module [command] names,
from the app directory or ~/.local/share/wspr/plugins, so plugins for other
window managers can exist without any core changes. A plugin is a module
exposing up to four functions; only handle is required:
handle(text, cfg)- route and execute one transcriptprepare(cfg)- startup init, called once before any keys are grabbed; fail loud here, not on the first utterancevocabulary() -> str- short bias text for whisper'sinitial_prompton command bindings (seevocab_biasabove)cli(argv, cfg) -> int- the forwarded subcommands
test_listener.py is a minimal consumer for the socket sink. It binds the
same Unix socket ($XDG_RUNTIME_DIR/wspr.sock) that wspr connects to and prints
each transcript it receives, so you can confirm the sink works before wiring up
a real application. It uses only the standard library, so no venv is needed.
You need a hotkey routed to the socket sink; add one to your config with a combo that isn't already bound:
[[hotkeys]]
combo = "super+shift+s"
sink = "socket"Then, in two terminals:
# terminal 1 - start the listener
python3 test_listener.py
# -> Listening on /run/user/1000/wspr.sock. Ctrl-C to quit.
# terminal 2 - start wspr
wspr # or: python wspr.pyHold the socket hotkey, speak, and release. The transcript is sent over the socket and printed by the listener:
received: 'hello from the socket sink'
Ctrl-C stops the listener and removes the socket file.