System-wide dictation for Linux. One service, one model, every application.
Written in Go. Pure static binary, no runtime dependencies beyond a speech engine of your choosing.
Status: early. The service, engines, capture and injection all work. See Roadmap.
Most dictation setups end up per-project: a virtualenv here, a model there, several hundred megabytes duplicated for each thing that wants to hear you.
vox is a single long-lived user service. The model loads once. Your editor, your browser, your window manager keybinding and your tablet's microphone button all talk to the same thing, and there is exactly one copy of the model on disk.
┌──────────────┐
keybinding ───► │ │
tablet UI ───► │ vox service │ ──► types into whatever has focus
script ───► │ │
└──────────────┘
one model, loaded once
./install.sh
vox doctor # what is present, what is missing
systemctl --user enable --now vox
vox toggle # speak; run again to stop and typeBind vox-hotkey to a key for push-to-talk, and you will see the on-screen
indicator from your next login (or run vox-overlay & now).
Two small companions are installed alongside the service.
vox-hotkey is vox toggle for a keyboard shortcut. Stopping types the
transcript within a second, and a shortcut such as Super+H usually still has
Super held down at that moment, which turns every typed letter into a
Super+letter shortcut. vox-hotkey waits (for at most two seconds) until the
modifiers are released. The wait needs X11 and python3-xlib; without them it
is a plain toggle.
vox-overlay is a small pill near the bottom of the screen: red with a
running timer while listening, amber while the last words are typed, grey if
the service is down, and gone while idle. In the last thirty seconds before
the recording limit it turns orange and counts down. It never takes focus and
clicks pass straight through it, so it cannot steal the window you are
dictating into. It follows the subscribe stream, so it does nothing while
idle. It needs GTK 3 with PyGObject and cairo (python3-gi,
python3-gi-cairo, gir1.2-gtk-3.0), and on Wayland it runs through XWayland.
vox drives every engine as an external command, and ships profiles for the common ones. That means a new engine is a config entry rather than a release, and you pick your own accuracy/size/latency tradeoff instead of inheriting someone else's.
| Profile | Needs | Notes |
|---|---|---|
faster-whisper |
the faster-whisper Python package |
Fast and accurate; a wrapper ships with vox |
whisper-cpp |
whisper-cli |
Single binary, ggml-*.bin models |
whisper |
the reference whisper CLI |
Slowest |
vosk |
vosk-transcriber |
Small and fully offline, lower accuracy |
vox daemon autodetects the first usable one. Override with
-engine NAME -model NAME.
Defaults favour accuracy over speed, because dictation has latency to spare. Measured on an i5-8250U, transcribing a 22-second utterance:
| Model | Decoding | Speed | |
|---|---|---|---|
base.en |
greedy (beam=1) |
20x realtime | fast and noticeably worse |
base.en |
beam=5 |
10x realtime | default |
small.en |
beam=5 |
4x realtime | better; still imperceptible for dictation |
beam_size=5 is faster-whisper's own default and cut word error rate by about
a fifth in testing against greedy decoding. Running 10x faster than realtime
means a ten-second utterance transcribes in one second, so there is no reason
to spend that headroom on speed you cannot feel.
If accuracy is not good enough, get a larger model -- that is the bigger lever:
vox models get small.en
vox daemon -model small.enA caveat on the numbers above: they are speed measurements. Attempts to score accuracy with synthetic speech were not meaningful -- every configuration transcribed espeak output perfectly, because synthetic speech is far more regular than the human speech these models are trained on. Only the same-model beam comparison is a real accuracy result. Judge models with your own voice; nothing else generalises.
What you wait for is a chunk's fixed cost, not its length: Whisper pads every
input to a thirty-second window, so two seconds of speech take nearly as long
as six. On the i5-8250U above, base.en:
| per chunk, and after tapping stop | |
|---|---|
| one process per chunk (vox before serving) | 2.2 s |
| model kept loaded, physical cores only | 0.75 s |
Two things make the difference. vox keeps the model loaded in one
long-lived process (vox-faster-whisper --serve), started and warmed when the
service starts, instead of paying Python's imports and a model load -- well
over a second -- on every chunk. That costs about 240 MB of memory for as long
as the service runs. And it uses one thread per physical core: Whisper's
arithmetic saturates a core, so hyperthreads only contend, and 4 threads beat
8 on a 4-core laptop. Override with VOX_WHISPER_THREADS.
If the engine cannot serve (an engine without a serve mode, or an older wrapper), vox says so in its log and starts a process per chunk as before.
vox looks for an existing virtualenv before asking you to make a new one — it
checks $VOX_WHISPER_VENV, then ~/.local/share/vox/venv, then
~/.local/share/voice-dictation/venv. Point it at an existing model cache with:
export VOX_MODEL_DIR=~/.cache/faster-whisperNothing is re-downloaded.
vox models # shows the directory and its contents$XDG_DATA_HOME/vox/models, overridable with VOX_MODEL_DIR. Bare model names
resolve there; absolute paths are used as given. Downloading a model once and
having every tool on the machine use it is the entire point.
By default vox creates a uinput virtual keyboard. This is worth explaining,
because most tools reach for xdotool or wtype.
A virtual keyboard is a kernel input device, so text arrives by the same path as
a real keypress. It works identically on X11 and on every Wayland compositor,
needs no per-compositor protocol, and does not care which toolkit owns the
focused window. By contrast xdotool is X11-only, and wtype needs
virtual-keyboard-unstable-v1, which GNOME and KDE do not implement.
The cost is that a keyboard emits keycodes, not characters, so characters are
mapped through a US-QWERTY layout. On a different layout the keycodes are
still valid but the characters differ. vox reports characters it cannot type
rather than typing something else, and wtype/ydotool/xdotool remain
available as fallbacks. Reading the active keymap is on the roadmap.
Security, plainly: write access to /dev/uinput is the ability to
synthesize arbitrary keystrokes into your session. That is inherent to what
dictation does, but it is a real capability — the shipped udev rule scopes it
to the active local session via uaccess rather than granting it to a group
permanently. Skip it with install.sh --no-udev and vox falls back to an
external tool.
Text appears while you speak, the way phone and desktop dictation does, rather than arriving all at once when you stop. The wait after tapping stop is a second or two regardless of how long you spoke.
This is the default. To transcribe in a single pass instead:
vox daemon -stream=falsePauses never stop the recording. Silence is used to decide where to cut a chunk, never whether to keep listening. People pause to think, and dictation that shuts off when they do is worse than dictation that is slow. Tap stop when you are finished.
The one exception is a recording someone forgot about. After five minutes vox
stops and types what it has, exactly as if you had tapped stop: left running,
it would keep typing into whatever has focus, and room noise that clears the
silence threshold invites Whisper to invent phrases. Change the limit with
vox daemon -max-record 10m, or remove it with -max-record 0.
Your pauses are what make it work well: a chunk is cut at the quietest moment near the target length, so a natural pause becomes a clean boundary. Talk without pausing and it still cuts, just at the least-bad point it can find.
Chunking is an implementation detail and should not be visible in the transcript. Three things would otherwise give it away:
Ellipses. Whisper writes ... when speech trails off or starts
mid-utterance, which is exactly what a chunk boundary looks like to it. Left
alone this litters dictation with pauses the speaker never made — and they land
precisely where the speaker was most fluent, since chunks are cut at the
quietest moment. They are stripped.
Restarted sentences. Every chunk looks like the beginning of an utterance,
so the engine capitalises it and re-punctuates. Each chunk is given the
preceding text as an initial_prompt, so the model knows it is continuing, and
a leading capital mid-sentence is lowered again — except for I, acronyms and
mixed-case names.
False full stops. Audio that stops after a word sounds like an ending, so
Whisper closes almost every chunk with a full stop — measured, about half of
all cuts that fell mid-sentence. vox measures the pause each cut landed in:
between words it is tens of milliseconds, between sentences it is longer. Under
0.3 s, or after a word no sentence ends on ("the", "to", "and"), the closing
. ? or ! is dropped and the next chunk carries on in lowercase. The
engine still sees its own punctuation as context, so its accuracy is
untouched; only what is typed changes. Each chunk's pause is in the log
(pause=, end_dropped=), which is what to look at if it guesses wrong for
your voice.
The remaining tradeoff is real: Whisper is more accurate with more context, so
a six-second chunk is slightly worse than the same words inside a thirty-second
utterance. Longer chunks recover accuracy and give back latency;
-chunk-seconds 10 is a reasonable middle.
Detected, not assumed: pw-record (PipeWire), then parec (PulseAudio), then
arecord (ALSA). Recording is 16 kHz mono s16, which is what the engines want.
The recorder is stopped with SIGINT rather than SIGKILL so it can finish writing a valid WAV header — a truncated file makes every engine fail with a confusing error.
A Unix socket at $XDG_RUNTIME_DIR/vox.sock, newline-delimited. No D-Bus
dependency, works with no session bus, and is trivial to drive from a script.
| Command | |
|---|---|
toggle |
Start, or stop and type |
start / stop |
Explicit control |
cancel |
Abandon without transcribing |
state |
ready | listening | transcribing |
subscribe |
Stream state changes, for building a UI |
key NAME |
Press a single key: Return, Tab, BackSpace, Escape, arrows |
info |
Which engine, recorder and injector are in use, and the recording limit |
echo toggle | nc -U $XDG_RUNTIME_DIR/vox.socksubscribe is what a microphone button, or vox-overlay, uses to show
listening and transcribing without polling.
key exists because a touch panel has no physical keyboard to submit with.
Pressing a key and typing text are the same capability -- synthetic input into
the focused window -- so it reuses the virtual keyboard rather than growing a
second mechanism with its own device and permissions.
- Pluggable engines, autodetection, shared model directory
- uinput injection working on X11 and Wayland alike
- Socket API with state subscription
- Read the active keymap instead of assuming US-QWERTY
- Streaming transcription, so text appears while you speak
- Voice commands (punctuation, newline, corrections)
- A model downloader, so
vox models get base.enjust works
- hinged — tablet mode for Linux convertibles. Its on-screen input panel calls vox for dictation, which is what a tablet with no keyboard needs.
MIT