Caps Lock is my on-air light now
Claude Code has a built-in dictation feature: hold the spacebar, talk, release. I use it a lot, and it works well, with one recurring exception. It cannot spell the words I say most. "Odoo", the ERP system we work with daily, came out as "o do" or "ODU" every single time. "Solin", my own company name, fared no better. Every dictated sentence needed manual repair, always in the same places.
The obvious question: can the built-in feature be trained? Answer, after checking the docs and the issue tracker: no. The transcription runs in the cloud, is tuned for coding vocabulary, and takes exactly two automatic hints, the project name and the git branch name. There is no custom vocabulary. An open feature request asks for exactly this; until it lands, there is nothing to configure.
So I asked Claude Code to build me a dictation system that can be trained. The machine already had the ingredients: faster-whisper on an RTX 3090, installed a while ago for transcribing meeting recordings. One working day later I had something better than what I started with, and along the way we hit four bugs that are each worth telling. This note is the history of that day.
The design: three small pieces and two text files
The whole thing is about 400 lines of plain Python and bash:
- A daemon (a systemd user service) that keeps a Whisper model parked in VRAM and listens on a unix socket for four commands: toggle, cancel, status, quit.
- A one-line client bound to hotkeys in i3.
- Two config files, reloaded on every dictation so edits apply immediately:
vocabulary.txt, a list of terms fed to Whisper as an initial prompt, biasing the decoder toward "Odoo, Solin, Moodle, Totara, ..." before it hears a word.replacements.txt, regex rules applied after transcription. This layer is the guarantee: even when Whisper mishears,\bodu+\b→Odoofixes it deterministically before the text goes anywhere.
Press the hotkey: recording starts. Press again: the model transcribes, the corrections run, and xdotool type puts the text into whatever window has focus. That last part is the quiet superpower. It works in every application, Claude Code, Gmail, Vim, a browser form, not just the one tool that happened to ship a dictation feature.
The two-layer correction earned its keep in the very first test:
raw: ... words like Solin, our company name, and Odu, our ERP system.
corrected: ... words like Solin, our company name, and Odoo, our ERP system.
The vocabulary prompt got "Solin" right at the decoder level; the replacement rule caught "Odu" afterwards. Both names, correct, on day one. The built-in feature never managed that once.
Bug #1: ffmpeg eats your keystrokes
The first interactive test appeared to crash. Enter did nothing; Ctrl+C produced Error writing trailer: Immediate exit requested. The cause is a classic: ffmpeg reads the terminal's stdin by default, for its interactive "press q to stop" feature, so it swallowed the Enter keypress the wrapper script was waiting for. Ctrl+C then hit both processes at once, and ffmpeg treats a second interrupt as "exit now", corrupting the WAV file on the way out.
Fix: -nostdin, stdin from /dev/null, and a separate process session so the recorder never sees the terminal's signals and is always stopped gracefully with a deliberate SIGINT.
Bug #2: Shift turns Pause into Break
The hotkey plan was Pause for English, Shift+Pause for Dutch. English worked; Dutch produced garbled Anglo-mush, and the daemon log showed why: the Dutch recording had run with lang=en. The Shift binding never fired at all.
xmodmap -pke explained it: keycode 127 = Pause Break. With Shift held, the Pause key does not send Shift plus Pause; its keysym becomes Break, a relic of the teletype era, when Break physically held the line to interrupt the machine at the other end. An i3 binding on the keysym Shift+Pause can therefore never match what the keyboard actually sends. The fix is to bind the physical keycode instead of the keysym: bindcode 127, bindcode Shift+127. Keycodes do not change when modifiers enter the room.
Bug #3: the fast model only speaks English
With the binding fixed, the log dutifully reported lang=nl, and Dutch still came out as garbled English-ish text with the tell-tale repetition loops of wrong-language decoding. The model was the culprit this time: distil-large-v3, chosen for speed, is an English-only distillation. The Distil-Whisper models are trained exclusively on English data; no language flag can change what a model was taught. Swapping to the multilingual large-v3, already cached from the meeting-transcription tool, fixed Dutch completely, and on a 3090 the speed difference is imperceptible. One related refinement: the vocabulary prompt now gets a language-matched prefix ("Woordenlijst: ..." for Dutch), because an English prompt with language=nl quietly pushes the decoder back toward English.
Lesson: when a bilingual pipeline misbehaves, check which component is actually bilingual.
The laptop, and bug #4
The same setup went onto the travel laptop, which has no GPU, with two environment variables: model small, device cpu. It transcribes at roughly 0.7 times real time on a 12-core CPU, a few seconds of wait, fully offline, which beats any cloud dictation service on hotel Wi-Fi. The replacement table guarantees the brand names regardless of model quality.
Everything lives in the private config repo I already sync between machines: scripts, the systemd unit, the i3 bindings, and the two text files. Editing the vocabulary in the repo is editing the live config; commit, pull on the other machine, done. Bug #4 came out of this phase, and it is a small one with a good moral: the bootstrap script backs up any pre-existing file as <name>.prelink-backup, next to the original. My desktop's window manager includes every file in its config directory, backup or not, so the backup copy of the bindings was read as a second config fragment and i3 raised a red "you have an error in your config file" bar over duplicate bindings. Never leave backup files in a directory that a config system globs wholesale.
The finishing touch: an on-air light
The Pause key worked, but it sits in a far corner of the keyboard, and a toggle you cannot see is a toggle you will get wrong. Every recording studio solves this with a red lamp over the door. My keyboard, it turns out, has one built in: the Caps Lock LED. Nobody needs Caps Lock's actual function, the key sits in prime position under the left pinky, and it comes with its own indicator light. So: Caps Lock is the record button, and its LED is the on-air light.
Two things had to be true for this to work, and both tested out. First, i3 can grab the Caps Lock keycode and run a command on it. Second, and this is the lucky part, the grab does not suppress the key's lock action: the caps state still toggles on the press, so the LED lights up by itself when recording starts and goes off on the stop press. No LED driver code needed. (Driving the LED directly via xset led turned out not to work at all; the light is slaved to the modifier state and ignores outside opinions. The free behavior was the only practical path.)
There was one subtle way for the light to lie. Start a recording with Caps Lock, stop it with the Pause key, and the LED, and the caps state with it, would stay on. The recording ends and suddenly everything you type IS IN CAPITALS, which is the opposite of a status indicator. So the daemon now owns the state machine: on every record start it forces caps lock on, and on every stop, cancel, and error path it forces it off, through a direct call into libX11. Whichever key starts or stops the recording, the light tells the truth, and stray caps typing is impossible.
The final key map, on both machines:
| Key | Action |
|---|---|
| Caps Lock | record / stop (English); LED = recording |
| Shift+Caps Lock | record in Dutch |
| Ctrl+Caps Lock | discard the recording |
| Pause | same as Caps Lock, for old times' sake |
The key that starts a recording picks the language.
The one wish that did not survive
I wanted to keep the spacebar habit inside Claude Code, wired to the new system. Not possible: its keybindings can only invoke its own internal actions, and there is no "run external command" action. The built-in feature can safely overload the spacebar because it knows its own input-box state; an external tool cannot see that. A window-scoped key grab with tap-versus-hold detection is technically buildable, but it would make every typed space appear on key release, a permanent rubber-band feel in my main working environment. Not worth it. The built-in voice feature is now disabled, and Caps Lock does the job everywhere, Claude Code included.
Numbers
- Desktop:
large-v3on an RTX 3090 transcribes 21.5 seconds of speech in 0.7 seconds. About 1.5 GB of VRAM parked, essentially zero compute between dictations, no fans, no heat. - Laptop: multilingual
smallon a 12-core CPU, roughly 0.7 times real time, fully offline. - Code: one Python daemon, one bash client, one i3 config, one systemd unit, two text files. About 400 lines.
- Time: one working day, four bugs included.
What I make of it
A vocabulary problem that a cloud service cannot solve for me turned out to be a solved problem locally: Whisper's initial prompt plus a deterministic correction table, both in plain text files I can edit in ten seconds. Typing the result into the focused window upgraded a single-app feature into a system-wide one. And the best interface element of the day cost nothing: a status light that was already on the keyboard, waiting since the eighties for something worth indicating.
Tools: Claude Code, faster-whisper, ffmpeg, xdotool, i3, systemd. The four bugs were diagnosed the boring way, by reading logs and xmodmap output, which is exactly how I like my bugs diagnosed.