Dictate is my push-to-talk dictation tool for Windows. Hold a key, speak, release, and the transcript lands wherever the caret was. Transcription runs locally from a model file you place next to the executable. The whole thing is 1,492 lines of Rust across six files, and the reason it stays that small is that it has no window.
No settings screen means configuration is one TOML file. No onboarding means the defaults have to be right. No webview means no browser engine, no bundler, no front-end build, and no framework that would be the largest dependency in the tree. What is left as interface is a tray icon and a 78 by 30 pixel bubble whose bars follow the live microphone level, so silence is flat and speech moves.
four threads, one job each
Three jobs must never block each other, and each has a reason:
- The keyboard hook. Windows kills a low-level hook that stalls. It gets its own thread and does nothing but forward key events.
- The audio callback. It drops samples if it is late. The WASAPI stream is not
Sendin the audio library I use, so it could not cross threads anyway. - The UI message pump. A busy pump freezes the bubble. It owns the tray icon and the overlay window and nothing else.
The main loop coordinates the three: it receives key events, starts and stops capture, runs inference, and hands the text to the injection crate. Inference is the one slow thing, and it runs on the main loop with the model held resident between dictations, because loading the model dominates latency. That is an explicit RAM-for-latency trade, stated in the config comments so nobody wonders why the process is large at idle.
the bubble must never take focus
The one hard requirement: if the overlay steals focus, the text lands in the overlay instead of the app you were dictating into. That is enforced structurally rather than by care. The bubble is a layered popup window with the no-activate, tool-window, topmost, and transparent extended styles, shown with the no-activate flag. The only deliberate foreground call in the codebase is the user-initiated tray menu, which Windows requires.
Commands to the UI thread are posted as window messages rather than thread messages, because a modal loop drops thread messages and the tray menu is a modal loop.
the parts nobody thinks about until they break
The tray icon is a 32 by 32 premultiplied BGRA circle built in memory, with no icon resource file. It is re-added on the TaskbarCreated broadcast so it survives an Explorer restart, which otherwise leaves the app running with no way to reach it. The autostart entry in the current-user Run key is re-asserted on every launch, because Task Manager can veto it and does.
A windowless release build has nowhere to print a fatal error. It uses a message box, which is the one window the app will ever show.
why a hook and not a hotkey
RegisterHotKey is the documented way to bind a global shortcut and it cannot do two things this app needs: report the key’s release, which is what ends the recording, and bind a modifier-only chord such as Ctrl+Win. A low-level keyboard hook can do both and can also block the chord from reaching the focused application, so holding the push-to-talk key does not type anything into the app underneath.
audio
Capture is downmixed and resampled to 16 kHz in 1,024-sample chunks, because that is what the model wants. Recordings under 400 ms are discarded as accidental presses. Silence is trimmed in 20 ms frames with a 120 ms pad on each side, so the model does not spend time on the second before you started talking. The bubble is redrawn every 45 ms from the level meter, which is the only thing about the app you can see.
Text delivery is not in this codebase at all. It is win-text-inject, a separate crate, because putting text where the caret is turned out to be its own problem with its own post.