dictate

push-to-talk dictation for windows in about 1,500 lines

hold a key, speak, release, and the text lands wherever you were typing. no window and no webview: one toml file, a tray icon, and a bubble that follows the microphone level without taking focus. transcription runs locally.

rust, win32, cpal, rubato, transcribe-cpp, win-text-inject

the level bubble, a dark pill with five vertical bars
the whole ui while the key is held, captured live at its real size of 78 by 30 pixels

Dictate is my own dictation tool. It is a single Windows binary, about 1,500 lines of Rust in six files, and it stays that small because it has no window. Configuration is one TOML file beside the exe. Feedback is a tray icon and a pill-shaped bubble whose bars follow the live microphone level. Speech recognition runs locally from a GGUF model you place yourself, and text delivery is delegated to win-text-inject.

architecture

which thread does what between key press and pasted text?

four threads, each isolated so none can stall another: the hook, the coordinator, the audio callback, and the UI pump.

stored state
keyboard hook thread handy-keys, press + release coordinator loop win-text-inject focused app UI thread tray + level bubble WS_EX_NOACTIVATE audio thread cpal, native rate resample + trim rubato to 16 kHz transcribe-cpp model held resident dictate.toml beside the exe held / released start / stop samples transcript paste peak level (atomic)

the bubble is a layered WS_POPUP window shown with SW_SHOWNOACTIVATE, so the caret never leaves the app being dictated into. the target window is captured after key release, and the model stays loaded between dictations because loading dominates latency.

Four threads, each isolated so none can stall another. The keyboard hook thread (Windows kills hooks that stall). The audio callback thread (drops samples if late, and the cpal stream is not Send on WASAPI, so it never crosses threads). The UI message pump (freezes the bubble if busy). The main loop coordinates.

On key release the pipeline runs in order: the audio thread has been capturing at the device’s native rate and averaging channels to mono, publishing a fast-attack, slow-release peak level through an atomic for the bubble. The recording is resampled to 16 kHz with rubato in 1,024-sample chunks, gated on peak level, guarded by a minimum length, and trimmed of leading and trailing silence in 20 ms frames with a 120 ms pad. transcribe-cpp holds the session resident and returns the transcript, with one automatic retry because the audio exists only in RAM. The foreground window is captured after release, and inject delivers the text, falling back to a private clipboard write and then to printing the transcript.

technical decisions

  • No window and no webview. A GUI toolkit for a pill overlay would be the largest dependency in the tree. A UI is also the single largest source of complexity in every dictation app that has one, so configuration is TOML and the first run writes a commented starter file and exits.
  • handy-keys for the hotkey. RegisterHotKey cannot report release and cannot express a modifier-only binding such as Ctrl+Win. The low-level hook can, and it blocks the registered chord from reaching the focused app.
  • Local models, placed by the user. Nothing is uploaded and nothing is downloaded behind your back. Pre-roll is off by default because an open microphone lights the Windows indicator.
  • Model held resident. Loading dominates latency, so idle RAM is traded for seconds per dictation.
  • Focus is never taken. The bubble is a layered WS_POPUP with WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW, WS_EX_TOPMOST and WS_EX_TRANSPARENT, shown with SW_SHOWNOACTIVATE. The one deliberate SetForegroundWindow is the user-initiated tray menu.
  • Text delivery extracted into a crate. The clipboard-restore race, held-modifier sanitising and clipboard-history opt-outs are not specific to dictation, and this app hits all three by construction.

hard problems

  • The paste chord firing while the hotkey modifier is still physically held. The crate releases held modifiers before the chord and never re-presses them.
  • Anti-aliased resampling instead of decimation, with a zero-padded final block.
  • The tray icon is a 32 by 32 premultiplied BGRA circle built in memory, with no .ico resource, and it is re-added on the TaskbarCreated broadcast so it survives an Explorer restart.
  • Commands are posted as window messages rather than thread messages, because modal loops drop thread messages.
  • The HKCU Run entry is re-asserted every launch, because Task Manager can veto it.
  • A windowless release build has nowhere to print a fatal error, so it uses MessageBoxW.

numbers

measurevalue
source1,492 lines across six files
target sample rate16 kHz
minimum recording400 ms
bubble78 by 30 px, five bars, redrawn every 45 ms
modelscanary-180m-flash at about 200 MB, parakeet-tdt-0.6b-v3 at about 740 MB, both Q8_0