Voice-to-text for Linux terminals. Speak instead of type — into opencode, claude-code, neovim, or any focused window.
A lightweight dictation tool that captures microphone audio, streams it through whisper.cpp for local speech recognition, and routes the transcribed text to a configurable output: stdout, files, or direct keystroke injection.
Primary use cases:
- Dictate to a terminal running an AI coding assistant (opencode, claude-code, gpt-codex)
- Dictate into neovim in insert mode
- Dictate directly into the focused terminal or text box on Wayland
Constraints:
- Local inference only (whisper.cpp, no cloud APIs)
- Low latency, streaming transcription
- Works on Wayland (sway, GNOME, KDE)
- Must work on both powerful workstations (Ryzen 9, 24C) and old laptops (ThinkPad T460, 2C/4T)
┌─────────────────────────────────────────┐
│ whisper.cpp `whisper-stream` │
│ SDL2 captures mic → sliding window │
│ inference → text on stdout │
└──────────────────┬──────────────────────┘
│ pipe (stdout)
┌──────────────────▼──────────────────────┐
│ dictate (Go binary) │
│ - parses stream stdout (strips ANSI) │
│ - filters hallucinations │
│ - keeps raw + annotated text streams │
│ - SIGUSR1 toggles start/stop │
│ - routes text to output sink(s) │
└──────────────────┬──────────────────────┘
│
┌────────┼─────────┐
▼ ▼ ▼
stdout raw-file wtype
(annotated) (raw) (keys)
+ optional --file tee
Two processes connected by a pipe. whisper-stream handles all audio capture (via SDL2, which uses PipeWire as its backend on modern Linux) and inference. The Go binary manages lifecycle, parses output, and routes text.
- whisper-stream does the hard work: audio capture, sliding-window inference, streaming output. No reason to reimplement any of it.
- Go for orchestration: subprocess management, signal handling, output routing. Fast iteration, no CGo needed.
- SDL2 for audio: whisper-stream uses SDL2, which auto-detects PipeWire/PulseAudio/ALSA at runtime. No audio code on our side.
- PipeWire-native mic detection:
pw-dumpto enumerate sources, heuristic scoring (USB > BT > DMIC > Analog), per-process routing viaPIPEWIRE_NODEenv var (no system-wide side effects). - Hallucination filtering: whisper hallucinates on silence (CJK text, "thank you for watching", etc.). Go parser drops non-Latin script and known phantom phrases.
Docker multi-stage build. All build-time dependencies (gcc, cmake, libsdl2-dev, libvulkan-dev, glslc, Go) live in the container. Host needs only Docker to build.
Output: two binaries (whisper-stream ~45MB with Vulkan shaders, dictate ~2.5MB static Go).
Normal app builds live in bin/. CPU-comparison variants from make whisper-native and
make whisper-generic live in .build/ so the main app path stays unambiguous.
whisper-stream dynamically links against SDL2 and Vulkan. The Go binary is statically linked.
| Dep | Debian/Ubuntu | Fedora | Arch |
|---|---|---|---|
| SDL2 (audio capture) | libsdl2-2.0-0 |
SDL2 |
sdl2 |
| Vulkan (GPU, optional) | libvulkan1 |
vulkan-loader |
vulkan-icd-loader |
| wtype (typed output) | wtype |
wtype |
wtype |
Debian/Ubuntu: sudo ./scripts/install-runtime.sh
Fedora: sudo dnf install SDL2 vulkan-loader
Arch: sudo pacman -S sdl2 vulkan-icd-loader wtype
whisper-stream uses ANSI escape codes (\033[2K\r) to overwrite lines as transcription refines. Every N steps it emits \n to finalize.
The Go parser:
- Reads stdout line-by-line (split on
\n) - Splits each line by
\r, takes the last segment (final refinement) - Strips ANSI escape codes
- Drops whisper special tokens (
[BLANK_AUDIO],[Start speaking], etc.) - Drops hallucination text (non-Latin scripts, known phrases)
- Prepends timestamp with cycle delta:
[MM:SS.s Δ3.2s] transcribed text
- Balanced default profile:
--step 2500 --length 5000 --keep 0 --ac 1280 --step 2500(2.5s): inference runs every 2.5 seconds--length 5000(5s): each inference window targets 5 seconds of audio--keep 0: rely on text-side overlap trimming instead of forced audio overlap--ac 1280: slightly reduced audio context, faster than full-context1500with little quality loss- Median encode time on Radeon 890M Vulkan: ~1106ms/step
- Median headroom per cycle: ~1394ms (no audio drops in overnight sweep)
Effective latency: ~2.5-3 seconds from speech to text on stdout.
Unix signals. Sway keybinding sends pkill -USR1 dictate:
- SIGUSR1: toggle recording on/off (kills/restarts whisper-stream)
- SIGTERM/SIGINT: clean shutdown
dictate [--model path] [--lang auto|en|pt] [--device ID|name] [--file path]
[--cpu] [--list-devices] [--pw-node ID]
[--step ms] [--length ms] [--keep ms] [--ac N]
--model: path to ggml model. Default: largestggml-*.bininmodels/--lang: language for transcription. Default:auto--device: PipeWire node ID or name substring. Default: auto-detect best mic--output:stdoutortype. Default:stdout--pw-node: PipeWire node ID, bypasses mic detection (for benchmarks / direct control)--file: also tee output to a file (append mode)--raw-file: also tee raw text chunks to a file (append mode, no timestamps)--cpu: disable GPU (Vulkan) inference, use CPU only--list-devices: print audio sources and exit--step: inference interval in ms. Default:2500--length: audio window length in ms. Default:5000--keep: audio context kept between windows in ms. Default:0--ac: audio context limit (0 = whisper default). Default:1280--silence-timeout: stop after this much transcription silence. Example:15s
Output goes to stdout by default. All log/diagnostic output goes to stderr.
Typing into the focused window on Wayland:
dictate --output type --silence-timeout 15sOne-key toggle for a focused input box:
scripts/toggle-dictate.sh start --lang en
scripts/toggle-dictate.sh stop
scripts/toggle-dictate.sh statusAll development and testing was done on sway (Regolith/Wayland). The core pipeline (whisper-stream → dictate → wtype) works on any Wayland compositor. Only the keybinding setup differs per desktop environment.
bindsym $mod+d exec --no-startup-id "$HOME/play/dictate/scripts/toggle-dictate.sh" toggleFor laptop media keys (e.g. the display-toggle button on F9), the firmware
often translates the keypress into a modifier combo rather than an XF86
keysym. On Framework laptops with fn-row defaulting to media functions,
F9 (display icon) sends Super+P:
bindsym $mod+p exec --no-startup-id "$HOME/play/dictate/scripts/toggle-dictate.sh" toggleIf your firmware emits a raw XF86 keysym instead, use that directly:
bindsym XF86Display exec --no-startup-id "$HOME/play/dictate/scripts/toggle-dictate.sh" toggleTo check what your key actually sends, use wev -f wl_keyboard and look
at the sym: line for the pressed event.
bind = SUPER, P, exec, ~/play/dictate/scripts/toggle-dictate.sh toggle
Settings → Keyboard → Custom Shortcuts, or via CLI:
# create the shortcut
dconf write /org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/dictate/name "'Dictate Toggle'"
dconf write /org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/dictate/command "'/path/to/dictate/scripts/toggle-dictate.sh toggle'"
dconf write /org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/dictate/binding "'<Super>p'"
# register it
dconf write /org/gnome/settings-daemon/plugins/media-keys/custom-keybindings "['/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/dictate/']"System Settings → Shortcuts → Custom Shortcuts → Add new → Command/URL.
Set the trigger key and point the action at toggle-dictate.sh toggle.
wtype only works on Wayland. X11 would need xdotool type as the
keystroke backend, which is not yet implemented.
The toggle script writes state under ${XDG_RUNTIME_DIR}/dictate/ (dictate.pid, dictate.log).
Use the shortcut again for a hard stop. This is more reliable than silence timeout when nearby voices keep the model active.
Recommended model targets:
make model-gpu # ggml-large-v3-turbo-q5_0.bin
make model-cpu-light # ggml-medium-q5_0.bin
make models-recommendedSingle-command regression check for the default profile:
make integ-testThis runs a Go integration test that exercises the production-path benchmark with the current default settings and fails if:
- median WER across repeated runs exceeds the configured threshold (default
22%) - any run has to be force-killed instead of exiting cleanly
- median headroom drops below the configured floor (default
500ms)
Override knobs if needed:
DICTATE_INTEG_REPEATS=3 DICTATE_INTEG_MAX_MEDIAN_WER=22 DICTATE_INTEG_MIN_MEDIAN_HEADROOM_MS=500 make integ-testMultilingual models (not .en variants) — supports Portuguese, English, and auto-detect.
| Model | Size | Vulkan (890M) | CPU (24T) | Quality | Notes |
|---|---|---|---|---|---|
ggml-tiny.bin |
75MB | fast | fast | poor | Low-power machines |
ggml-base.bin |
142MB | fast | fast | fair | Fast iteration/testing |
ggml-small.bin |
466MB | fast | ~real-time | good | General use |
ggml-large-v3-turbo-q5_0.bin |
548MB | Δ2.5s/step | too slow | best | Best current default on GPU |
Default: auto-selects the largest model present in models/.
- Turbo q5 on Vulkan is the sweet spot: balanced default is
2500/5000 keep=0 ac=1280; accuracy-first can push to2250/6750 keep=100 ac=1500. - CPU cannot keep up with turbo: even with a native-optimized build, turbo q5 CPU stayed far from realtime on this 24-thread laptop.
- Medium q5 on CPU is only borderline:
ggml-medium-q5_0.binwithac=768is the first CPU config that looked remotely plausible, but GPU still wins comfortably. - Language auto-detect is unreliable for Portuguese: 25-30% misdetection (Spanish, French, German, English). Use
--lang ptexplicitly. - Performance power profile recommended: laptop mode works but performance profile gives the iGPU more thermal headroom.
- GPU at 100% is expected: Vulkan shaders (clip rectangle, shader interpolator) saturate the iGPU during encode. This is fine — the 890M is shared with display compositor but doesn't cause visible issues in performance profile.
- Project scaffold (dirs, go.mod, .gitignore, LICENSE)
- DESIGN.md
- Dockerfile (multi-stage: whisper.cpp with Vulkan + Go)
- Makefile (all, build, models, lint, run, clean)
- scripts/install-runtime.sh
- Go code: main.go, process.go, file.go, detect.go
- Docker build produces both binaries (CPU + Vulkan)
- Download multilingual models (tiny, base, small, large-v3-turbo-q5_0)
- PipeWire mic auto-detection with heuristic scoring
- Per-process audio routing via PIPEWIRE_NODE (no system-wide mutation)
- Hallucination filtering (non-Latin, known phrases)
- Timestamp prefixes on output for latency visibility
- Tuned streaming params (balanced default: 2.5s step, 5s window, 0ms keep,
ac=1280) - Tested on hardware — transcription works (en + pt)
- GitHub repo + license
- Test large-v3-turbo-q5_0 model — good quality, Δ3.0s on Vulkan
- Test Vulkan GPU vs CPU — Vulkan required for turbo, CPU too slow (12-15s/step)
- Tune step/length/keep/ac — overnight sweep picked balanced, accuracy-first, and conservative profiles
- Per-step delta timing (Δ) in output for latency visibility
-
--cpuflag to disable Vulkan when needed - Better Portuguese accuracy —
--lang ptexplicit, auto-detect too unreliable - Understand step/length/keep interaction well enough to choose balanced and conservative defaults
- Test
-acsweep and rank candidate context caps - Explore alternative/newer whisper models
- Try quantized medium model (medium-q5 ~250MB, possibly faster than turbo-q5)
- Evaluate whisper-stream VAD mode vs step mode
Automated search for optimal model + settings. Exercises the exact production code path (whisper-stream + dictate via virtual PipeWire source), not whisper-cli.
- Parameterize streaming params (step/length/keep/ac) as CLI flags
-
--pw-nodeflag to bypass mic detection (direct PipeWire node ID) - Virtual PipeWire source:
pactl load-module module-null-sinkcreates virtual sink,pw-cat --playbackinjects WAV, whisper-stream captures from monitor - Benchmark runner (
cmd/bench): sweep step × length × keep × ac combos - WER scoring against reference transcripts
- WAV test corpus: JFK sample from whisper.cpp (
make corpus) - Test the pipeline end-to-end (virtual source → whisper-stream → dictate → WER)
- Add Portuguese corpus (record or download from Common Voice)
- Equivalence validation: compare virtual replay vs live mic on same audio
- Rank combos by accuracy × speed, find Pareto frontier
- SIGUSR1 toggle documented
- Sway keybinding config example
- Desktop notification on toggle (via
notify-send)
-
--output typemode usingwtype(Wayland keystroke injection) - Works with terminals (types directly into focused window)
- Works with neovim in insert mode
- Config file (~/.config/dictate/config.toml)
- Visual indicator (sway bar or desktop notification)
- Packaging: portable tarball (binaries + models)
- Test on ThinkPad T460 with tiny model
- Nix flake (future, for hermetic deployment)
whisper.cpp only supports whisper-family models. The ASR landscape has moved past whisper — these are worth exploring as a replacement for whisper-stream:
- NVIDIA Parakeet TDT v3 (0.6B, CC-BY-4.0): 6.3% WER, built-in punctuation & capitalization, 25 EU languages (EN + PT), runs via ONNX on CPU at RTFx ~3000+. Community consensus as the current sweet spot. Has chunked streaming support.
- NVIDIA Nemotron Speech Streaming (0.6B, NVIDIA Open): 6.9% WER, cache-aware native streaming down to 80ms chunks. EN-only. Purpose-built for real-time dictation. Released March 2026.
- IBM Granite 4.0 1B Speech (2B total, Apache 2.0): 5.5% WER, lowest on Open ASR leaderboard. Batch only (no streaming). Has keyword biasing for names/acronyms. EN, PT, FR, DE, ES, JA.
Integration path: replace whisper-stream with an ONNX or NeMo-based streaming subprocess. The Go binary and pipe architecture stay the same. Open whisper.cpp issues (#1732, #3118) requesting Parakeet support have no implementation.
dictate/
├── README.md
├── AGENTS.md
├── LICENSE
├── Dockerfile
├── Makefile
├── go.mod
├── .gitignore
├── scripts/
│ └── install-runtime.sh
├── cmd/
│ ├── dictate/
│ │ └── main.go
│ └── bench/
│ └── main.go # benchmark runner (WER scoring, param sweep)
├── audio/
│ └── detect.go # PipeWire mic detection
├── whisper/
│ └── process.go # whisper-stream subprocess + parser
├── output/
│ └── file.go # sink fanout: stdout, file, wtype
├── integ/
│ └── bench_test.go # production-path integration test
├── bench/
│ └── corpus/ # test WAV + reference transcripts (WAVs gitignored)
├── bin/ # main app binaries
├── .build/ # benchmark-only binary variants
└── models/ # downloaded models (gitignored)