Skip to content
dilidin2Public

About

Local voice dictation for Sway. Press a key, speak, and the transcript is pasted at the cursor of the focused window. One Rust binary, no daemon, 100% local Whisper STT (Vulkan/CPU), uinput+clipboard injection, optional LLM refine pass. Zero cloud.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

6 Commits

Folders and files

Repository files navigation

rwed

On-demand, local voice dictation for Sway. Press a key, speak, and the transcript lands at the cursor of whatever window is focused — terminal, browser, editor, anywhere.

Sway only. rwed is built on Sway's keybinds, swaymsg IPC, and its window naming — it works on Sway and is very unlikely to work on any other compositor.

  • No daemon, no tray, no subscription. One binary. The process only exists while a dictation session is in flight; Sway spawns it on a keybind and it exits after pasting.
  • 100% local STT — whisper.cpp compiled into the binary via whisper-rs, with a Vulkan backend and automatic CPU-int8 fallback.
  • End-of-speech: re-press the same key (tap-to-commit, the primary flow) or let the energy VAD commit after a silence window (hands-free).
  • Injection: virtual keyboard via /dev/uinput + Wayland clipboard — works in every surface, with the right paste combo per app (Ctrl+V vs Ctrl+Shift+V in terminals).
  • Anti-hallucination: VAD trims to the speech span, upstream suppress_nst/suppress_blank, and a data-file blacklist of known Whisper silence artifacts (assets/hallucinations.txt).
  • Optional LLM refine pass (off by default): any OpenAI-compatible endpoint (llama-server, Ollama, LM Studio, vLLM, …) fixes obvious speech-recognition errors — garbled words, broken punctuation, stutters — and resolves self-corrections ("mi chiamo Matteo, no, Marco" → "mi chiamo Marco"). Never translates: English words stay in English. Best-effort: if the server is down or slow, the raw transcript is pasted instead.

rwed = one honest binary, zero cloud.


Requirements

What Why
Sway required — keybinds + focused-window detection (see above)
PipeWire/Pulse microphone capture (cpal → native PipeWire, shared source — works even while OBS/browser hold the mic)
curl model downloads
Rust stable + CMake only if building from source (whisper.cpp is compiled in)
ffmpeg optional, only for rwed test file.wav
udev rule + input group write access to /dev/uinput (one-time, sudo)

Install

The binary lives at ~/.local/bin/rwed — make sure ~/.local/bin is in your $PATH. First run auto-creates an annotated default config at ~/.config/rwed/rwed.toml.

Prebuilt — grab the latest release from the GitHub repo and put it there:

curl -LO https://github.com/dilidin2/rwed/releases/latest/download/rwed
install -Dm755 rwed ~/.local/bin/rwed

From source:

cargo build --release
install -m755 target/release/rwed ~/.local/bin/rwed

If the build fails on missing PipeWire/ALSA/Vulkan headers or glslc (no libpipewire-0.3-dev/libasound2-dev/libvulkan-dev with sudo), recreate the vendored header tree without sudo:

scripts/fetch-headers.sh   # apt-get download + dpkg -x into third_party/headers

(That tree is in .gitignore by design; see the comments in .cargo/config.toml for the details of the link/build shims.)

First-time setup

1. uinput permissions (one-time; lets the virtual keyboard work):

sudo cp scripts/90-rwed-uinput.rules /etc/udev/rules.d/
sudo usermod -aG input "$USER"        # log out/in afterwards
sudo udevadm control --reload && sudo udevadm trigger

2. Download a model (default: small.en, ~250 MB):

rwed model download            # = rwed model download small.en
rwed model download it         # Italian: small (multilingual)
rwed model list

Language aliases: <lang> picks the multilingual small model (Italian, French, …), <lang>-<size> picks another size (it-turbo → turbo). After downloading one, set model and language in the config (the command prints the exact lines).

For better accuracy: rwed model download large-v3-turbo, then set model = "large-v3-turbo" in the config.

3. Sway keybind — append scripts/rwed-sway.conf to ~/.config/sway/config (or copy it into ~/.config/sway/config.d/), then swaymsg reload. $mod+Space toggles dictation, $mod+Shift+Escape cancels.

4. Wayland indicator (optional) — paste the two snippets from scripts/rwed-waybar.jsonc into ~/.config/waybar/config.json and restart waybar. The mic glyph lights up only while a session is actually recording (and clicking it toggles).

5. Check the environment:

rwed doctor     # uinput, mic, Vulkan dGPU, swaymsg, model, clipboard…
rwed bench      # per-device load/infer times — verify the dGPU is picked

Usage

$mod+Space            toggle (start / commit)        — the Sway keybind
$mod+Shift+Escape     cancel (drop the recording)

CLI:

Command Behavior
rwed Toggle: start a session, or commit the running one
rwed start Explicit start (--exclusive: error if one is already running)
rwed stop Explicit commit; no-op with a warning while idle
rwed cancel Abort: drop the audio, paste nothing
rwed status idle, or recording: pid=… model=… elapsed=…s device=…
rwed test [file] Transcribe a file (or 5 s of silence) without injecting — pipeline check
rwed bench Model load + RTF per device (M0 artifact)
rwed doctor Environment self-check; non-zero exit on hard failures
rwed model list | download | verify | remove Model management (sha256 cached)

Global flags on every subcommand: --model NAME, --device vulkan\|cpu, --verbose.

Config lives at ~/.config/rwed/rwed.toml — auto-created with sensible defaults (fully annotated) on first run; every key is optional. The knobs that matter in practice:

  • model / device / gpu_device — which model and where it runs.
  • input_device — substring of the mic name (wpctl status); needed when the ALSA "default" points at a dead dsnoop slave.
  • silence_commit_s — hands-free commit delay (default 5; use 2 for a snappier hands-free feel).
  • paste_mode — auto (default) / clipboard / type; terminal_classes is the config-side map for Ctrl+Shift+V terminals.
  • [llm] — optional refine pass through an OpenAI-compatible endpoint (enabled, url, model, …). Off by default; see config/rwed.toml.example. --no-llm skips it for a single run.

How it works

keypress → rwed (flock on $XDG_RUNTIME_DIR/rwed/rwed.lock)
  lock busy  → SIGUSR1 to the session → commit
  lock free  → fork; child = session (setsid), parent exits in < 1 ms
session: mic (cpal, 16 kHz mono) → energy VAD → trim to speech span
  → whisper.cpp (Vulkan dGPU / CPU fallback) → hallucination filter
  → [optional LLM refine via OpenAI-compatible endpoint]
  → clipboard + synthetic Ctrl+V / Ctrl+Shift+V (uinput) → exit

The lock is a flock, so the kernel releases it on any death (crash, kill -9, OOM) — a stale lockfile can never fake a running session, and two fast keypresses cannot spawn two recorders. The active state file (for waybar) is written only after the microphone has actually opened: the icon is a promise.

Testing

cargo test                 # VAD invariants, hallucination filter, sway tree
rwed test                   # 5 s of silence must be discarded (guard check)
rwed test recording.wav     # real clip, raw + filtered transcript
scripts/race-test.sh       # M1 exit gate: 20× double-toggles + kill -9
rwed doctor

Troubleshooting

Symptom Fix
Icon never lights up / "mic failed" rwed doctor; set input_device in rwed.toml; check PipeWire
"paste blocked: … udev rule" install the udev rule + input group (above), re-login; text stays on the clipboard
Typing lands in the wrong window / not at all try paste_mode = "type" in the config (universal, slower)
Transcription is slow rwed bench — make sure gpu_device points at the dGPU; else device = "vulkan" with the right index
Whisper "hears" silence silence never reaches Whisper (VAD trims); if it still happens, add the phrase to extra_hallucinations in rwed.toml
Session feels dead after a pause that's the silence_commit_s window — set 2 for hands-free, or just re-tap the key

Roadmap (M3, stretch)

  • in-binary wlr-layer-shell overlay dot (rwed --overlay) instead of waybar
  • Silero VAD via ONNX (ort) for noisy rooms
  • optional local-LLM cleanup pass → implemented as the [llm] refine pass (any OpenAI-compatible endpoint, off by default)
  • personal-dictionary bias via initial_prompt
  • filler-word removal in the refine prompt ("uh", "e quindi"…) — one-line config change to system_prompt

License

MIT

About

Local voice dictation for Sway. Press a key, speak, and the transcript is pasted at the cursor of the focused window. One Rust binary, no daemon, 100% local Whisper STT (Vulkan/CPU), uinput+clipboard injection, optional LLM refine pass. Zero cloud.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages