Hold a key, talk, let go. Clean written text appears wherever your cursor is.
Everything runs on your machine. No account, no subscription, no audio leaving the laptop.
Status. Linux works end to end and is used daily. Windows and macOS have full platform layers that compile and pass tests in CI, but have never been run on real hardware — treat them as unverified. See Platforms.
There are a lot of Whisper wrappers. They split into two groups that never overlap:
- Solved Linux — voxtype, vocalinux, nerd-dictation. Kernel-level hotkeys, Wayland text injection, real hotplug handling. No AI cleanup.
- Solved cleanup — whisper-local, localflow, FluidVoice. They run the transcript through an LLM to strip fillers and fix punctuation. Windows and macOS only.
Freeflow is the intersection: Linux-first, with the cleanup pass.
The cleanup is the point. Raw speech-to-text is a faithful record of what left your mouth — the "um", the false start, the sentence you abandoned. Nobody wants to paste that. A local model rewrites it into what you meant:
you say um so i was thinking we should uh ship it friday no actually monday
you get I think we should ship it on Monday.
hold key ──► ring buffer ──► whisper.cpp ──► local LLM ──► clipboard ──► paste
(500 ms of (fillers,
audio from punctuation,
before you self-corrections)
pressed)
The pre-roll buffer matters more than it sounds. People start talking a beat before their finger lands, so a naive recorder clips the first word every time.
The LLM step never blocks. If it is slow, missing, or produces something that does not look like a cleanup, the raw transcript goes out instead.
- Linux with PipeWire or PulseAudio
- Wayland or X11
- ~4 GB of disk for models
- Ollama for the cleanup pass (optional — dictation works without it)
git clone https://github.com/andriidrok1/freeflow
cd freeflow
./scripts/setup-linux.sh # packages, udev rule, input group, ydotoold
./scripts/download-model.sh # whisper small, 190 MB
# Optional: the AI cleanup pass. Dictation works without it.
ollama pull qwen2.5:7b-instruct
bun install
bun run tauri buildThat produces a real binary at src-tauri/target/release/freeflow, plus a .deb and an AppImage under src-tauri/target/release/bundle/. The first build compiles whisper.cpp and takes several minutes; later ones are quick.
For development, bun run tauri dev instead — same app, with hot reload and logs on stdout.
Freeflow has no window. It sits in the background waiting for the hotkey, so you want it started at login:
mkdir -p ~/.config/systemd/user
cat > ~/.config/systemd/user/freeflow.service <<EOF
[Unit]
Description=Freeflow dictation
After=graphical-session.target
[Service]
ExecStart=$PWD/src-tauri/target/release/freeflow
Restart=on-failure
[Install]
WantedBy=graphical-session.target
EOF
systemctl --user enable --now freeflow
journalctl --user -u freeflow -f # logsLog out and back in after setup-linux.sh. It adds you to the input group, and group membership is only read at login. Until you do, Freeflow cannot see the keyboard — it will say so in the pill rather than failing silently.
The setup script also writes a systemd user unit for ydotoold. Debian and Ubuntu ship the daemon with no unit of their own, so without this there is nothing to start and text insertion fails with a socket error.
Hold Right Ctrl, speak, release. Press Esc while recording to throw the take away.
There is no window and no tray icon. The overlay appears while you dictate and goes away afterwards; that is the whole interface. To check it is alive, look at the logs.
Configuration lives at ~/.config/freeflow/config.toml:
[hotkey]
key = "KEY_RIGHTCTRL" # or rctrl, right-ctrl, capslock, f13, keycode_190
cancel_key = "KEY_ESC"
[audio]
preroll_ms = 500 # audio kept from before the keypress
silence_stop_ms = 1500 # stop on this much silence even if held
[stt]
language = "en" # or "auto" — see Language support below
[polish]
enabled = true
model = "qwen2.5:7b-instruct"
timeout_ms = 3000 # past this, use the raw transcriptThe cleanup prompt is a plain file at src-tauri/prompts/polish.md. Edit it without rebuilding.
| Model | Size | Notes |
|---|---|---|
tiny |
75 MB | testing only |
base |
57 MB | quantised, usable |
small |
190 MB | default — good accuracy, fast on CPU |
medium |
539 MB | |
large-v3-turbo |
574 MB | best, wants a GPU |
./scripts/download-model.sh large-v3-turboWhisper currently runs on CPU. GPU builds are a whisper-rs feature flag away and are not wired up yet.
The default is language = "en". Set "auto" for detection, but know the trade-off first.
Pinning a language is both faster and more accurate: whisper skips its detection pass — measured at 1.7 s per take instead of 3.1 s — and short clips stop being misidentified. A two-second English clip has come back tagged as Norwegian, after which the transcript is nonsense.
Whisper handles 99 languages. The cleanup pass is a separate question, because it depends on how well a small instruct model follows instructions in that language:
| Language | Transcription | Cleanup |
|---|---|---|
| English | good | good |
| Russian | good | good on qwen2.5:7b. On 3b it translates to English and inverts self-corrections |
| Ukrainian | good | unreliable — even 7b inverts self-corrections: "Friday, no, Monday" comes back as Friday |
| Others | works | untested |
The language is pinned into the LLM prompt by name. Without that, qwen hands back an English translation of Russian input — the "do not translate" instruction alone is not enough.
When the cleanup produces something that is not a cleanup — an answer to a dictated question, a translation, a sentence collapsed into a single word — it is discarded and the raw transcript is used instead. An unreliable language therefore degrades to plain transcription, never to nonsense.
Linux is the reference implementation because its constraints are the tightest:
| Linux (Wayland) | Windows | macOS | |
|---|---|---|---|
| Hotkey with release | evdev, needs input group |
WH_KEYBOARD_LL |
CGEventTap |
| Text injection | clipboard + ydotool |
SendInput + KEYEVENTF_UNICODE |
CGEventKeyboardSetUnicodeString |
| Focused app | needs a GNOME extension | GetForegroundWindow |
NSWorkspace |
| Status | works, used daily | compiles and tests in CI, never run | compiles and tests in CI, never run |
Anything that works within the Linux limits works everywhere. Building the other way round would mean rewriting the core.
Curiously, the other two platforms are better at the parts Linux makes hard. Both inject Unicode directly into the focused window, so there is no clipboard to clobber and no keyboard layout to mistranslate. Both identify the focused application with one system call instead of a shell extension — which means per-app behaviour, still stubbed on Linux, comes almost free there.
What they are worse at, and what nobody has verified on hardware yet:
- Windows antivirus. A low-level keyboard hook plus
SendInputis the signature of a keylogger. Unsigned builds will be quarantined; get a code-signing certificate before distributing binaries. - macOS permissions. Input Monitoring and Accessibility are granted separately and both are keyed to the code signature, so an unsigned build loses its grants on every rebuild. Set up signing before debugging anything there.
- macOS Secure Input. While focus is in a password field the system silences every event tap, and it occasionally stays stuck that way.
secure_input_active()detects this and the pill says so, rather than the app going quiet for no visible reason. - Modifier chords are not implemented on either. A config asking for one fails with a clear message instead of being silently ignored.
- The macOS build uses private Apple APIs. A transparent window is only available behind Tauri's
macos-private-api, which the overlay needs. That rules out the Mac App Store — fine for a GitHub release, disqualifying for a store one.
Every one of these cost real time to diagnose, and none of them announces itself.
The pill never appears.
In Tauri v2, capabilities are granted per window label. If a window is missing from src-tauri/capabilities/default.json, its event subscription is denied — with no error in the log, no exception in the console, and a window that renders an empty transparent page. The overlay window's label is overlay; it must be listed there.
Digits get typed into your document instead of your text.
ydotool key changed argument format between versions and they are mutually unintelligible: 0.1.8 wants ctrl+v, 1.x wants 29:1 47:1 47:0 29:0. Handed the wrong one, 0.1.8 parses the digits as key names, types them, and exits successfully. Freeflow probes ydotool key --help at startup and picks the right form; if you see this, check the detected syntax in the log.
The app aborts at startup with a backtrace pointing into tao.
set_ignore_cursor_events reaches through to the underlying gdk_window, which does not exist until the window is mapped. Calling it on a hidden window aborts the process from inside the GTK event loop.
Nothing happens when you hold the key.
Check groups for input. If it is missing you have not logged out since running the setup script. If it is there, check the log for which keyboards were opened — the ydotoold virtual device is deliberately skipped, so if it is the only one listed, no real keyboard was found.
Text is transcribed but never inserted.
systemctl --user status ydotoold. On Debian and Ubuntu there is no packaged unit, so this is only running if the setup script wrote one.
The first dictation after a reboot loses its cleanup. Ollama takes about 30 seconds to pull a model into VRAM the first time; after that it answers in 200-400 ms. Freeflow warms it at startup, but the warm-up needs to finish. Later dictations are unaffected.
- Hotkey needs a logout after install for the
inputgroup to apply. - The overlay runs through XWayland (
GDK_BACKEND=x11), because a Wayland client cannot position its own window and Mutter does not implementzwlr_layer_shell_v1. - Injection always goes through the clipboard.
ydotool typemaps to a US layout and drops non-Latin characters, so Cyrillic would come out as garbage. - The clipboard is restored ~250 ms after pasting. Copy something in that window and it will be clobbered.
- Terminals need Ctrl+Shift+V. The paste style is implemented but not yet chosen automatically, because that needs focused-window detection.
cd src-tauri && cargo test && cargo clippy --all-targets
./scripts/check-cross.sh # lint the Windows and macOS layers from Linuxcheck-cross.sh compiles the platform layers for foreign targets against stub
config and error types. cargo check does not link, so this needs no cross
toolchain — it catches a typo in thirty seconds instead of eight minutes of
CI. It is not a substitute for CI, which builds and tests the real crate on
real runners.
The overlay can be developed without building Tauri at all:
bun run dev
# then open http://localhost:1420/overlay.html?demo=cycle?demo= accepts listening, processing, done, error, or cycle for
the whole sequence on a loop.
The evdev device manager — hotplug, suspend/resume and screen-lock handling — is adapted from voxtype (MIT, Copyright (c) 2025 Peter Jackson). See src-tauri/src/platform/linux/NOTICE.
Speech recognition is whisper.cpp via whisper-rs.
MIT
