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 - Ollama running locally with the configured routing model (default
gemma3:1b)
- 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.
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 route switch to workspace three # dry run: print the routed action, execute nothing
wspr exec switch to workspace three # route one transcript and execute itroute shows what the router decided without side effects; exec is the
same pipeline and actually executes. 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. [ollama]
(url, model, timeout, keep_alive) tunes the routing stage and is only read by
command bindings. A socket binding takes an optional socket key to override
the default path ($XDG_RUNTIME_DIR/wspr.sock).
A command binding hands the transcript to the plugin named under
[command]. wspr-i3 sends the text to a local Ollama model, which must answer
with constrained JSON naming a whitelisted action. The reply is validated and
then executed with i3-msg. A desktop notification reports the outcome:
executed, refused, no matching command, or routing failed.
The whitelist currently holds workspace switching ("switch to workspace three", 1-10). More actions can and will be added.
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 handle(text, cfg) for transcripts and cli(argv, cfg) for 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.