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.
| 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) |
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/rwedFrom source:
cargo build --release
install -m755 target/release/rwed ~/.local/bin/rwedIf 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.)
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 trigger2. 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 listLanguage 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$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_classesis the config-side map for Ctrl+Shift+V terminals.[llm]— optional refine pass through an OpenAI-compatible endpoint (enabled,url,model, …). Off by default; seeconfig/rwed.toml.example.--no-llmskips it for a single run.
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.
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| 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 |
- 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
MIT