Skip to content
andriidrok1Public

About

Hold a key, talk, let go. Local voice dictation with AI cleanup — Linux first, no cloud, no subscription.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

Freeflow

CI

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.

The overlay: a waveform writing itself right to left while you speak, then collapsing into a word count

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.

Why another dictation app

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.

How it works

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.

Requirements

  • Linux with PipeWire or PulseAudio
  • Wayland or X11
  • ~4 GB of disk for models
  • Ollama for the cleanup pass (optional — dictation works without it)

Install

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 build

That 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.

Run it in the background

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     # logs

Log 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.

Use

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 transcript

The cleanup prompt is a plain file at src-tauri/prompts/polish.md. Edit it without rebuilding.

Models

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-turbo

Whisper currently runs on CPU. GPU builds are a whisper-rs feature flag away and are not wired up yet.

Language support

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.

Platforms

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 SendInput is 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.

Troubleshooting

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.

Known rough edges

  • Hotkey needs a logout after install for the input group to apply.
  • The overlay runs through XWayland (GDK_BACKEND=x11), because a Wayland client cannot position its own window and Mutter does not implement zwlr_layer_shell_v1.
  • Injection always goes through the clipboard. ydotool type maps 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.

Developing

cd src-tauri && cargo test && cargo clippy --all-targets
./scripts/check-cross.sh          # lint the Windows and macOS layers from Linux

check-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.

Credits

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.

License

MIT

About

Hold a key, talk, let go. Local voice dictation with AI cleanup — Linux first, no cloud, no subscription.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages