Push-to-talk speech-to-text for Linux.
The daemon owns the hotkey via /dev/input/event*. Recognition is
scribe. This process records,
shows a HUD, and injects text into the focused window.
scribe fetch-model
scribe serve &
voicein daemonHold Shift+Alt+V and speak. Tap (<300ms) latches; press again to
stop. Hold past 300ms and release to stop. voicein cancel discards
the take.
- Linux (
x86_64/aarch64) - PipeWire (
pw-record) - group
input(hotkey reads/dev/input/event*) - scribe on the same machine
- Wayland:
wl-copy,wtype(HUD needs layer-shell) - X11:
xclip,xdotool(no HUD)
Not tied to niri or any compositor. A compositor bind is optional: only needed if the chord would otherwise type into the focused window.
Tag a v* release and GitHub Actions uploads:
| Arch | Archive |
|---|---|
x86_64 |
voicein-linux-amd64.tar.gz |
aarch64 |
voicein-linux-arm64.tar.gz |
curl -fsSL -o voicein.tar.gz \
https://github.com/maplevoid/voicein/releases/latest/download/voicein-linux-amd64.tar.gz
tar -xzf voicein.tar.gz
sudo install -m 755 voicein-linux-amd64/voicein /usr/local/bin/voiceinvoicein is a static Go binary. It still needs scribe
on the same machine (binary or source), PipeWire, group input, and
the inject tools below.
# 1. scribe (transcriber) — binary or:
git clone https://github.com/maplevoid/scribe.git
cd scribe
scripts/fetch-sherpa.sh x86_64-unknown-linux-gnu
go build -o scribe ./cmd/scribe
sudo install -m 755 scribe /usr/local/bin/scribe
# 2. voicein
git clone https://github.com/maplevoid/voicein.git
cd voicein
go build -mod=vendor -o voicein ./cmd/voicein
sudo install -m 755 voicein /usr/local/bin/voiceinPackages to have on PATH:
| Distro | Packages |
|---|---|
| Debian / Ubuntu | pipewire-bin wl-clipboard wtype xclip xdotool libnotify-bin |
| Fedora | pipewire-utils wl-clipboard wtype xclip xdotool libnotify |
| Arch | pipewire wl-clipboard wtype xclip xdotool libnotify |
Add yourself to group input, then log out and back in:
sudo usermod -aG input "$USER"
id # must list inputOn Debian/Ubuntu the device nodes are often root:input with mode
660. On some setups they stay root:root 600 — then the hotkey
will not work even after joining the group. Check:
ls -l /dev/input/event0scribe fetch-model # SenseVoice into ~/.local/share/scribe/models
scribe serve & # or the systemd socket below
voicein daemon
voicein status # idleWithout a local model, use Groq or OpenAI in scribe's config instead
of fetch-model. Without scribe serve (or scribe.socket), a take
ends with a HUD flash and no text.
systemd user units, if you are not using Nix:
# ~/.config/systemd/user/scribe.socket
[Socket]
ListenStream=%t/scribe.sock
SocketMode=0600
[Install]
WantedBy=sockets.target# ~/.config/systemd/user/scribe.service
[Service]
ExecStart=/usr/local/bin/scribe serve
Type=simple# ~/.config/systemd/user/voicein.service
[Unit]
After=graphical-session.target scribe.socket
Wants=scribe.socket
PartOf=graphical-session.target
[Service]
ExecStart=/usr/local/bin/voicein daemon
Restart=on-failure
[Install]
WantedBy=graphical-session.targetsystemctl --user daemon-reload
systemctl --user enable --now scribe.socket
systemctl --user enable --now voiceinIf this graphical session was already running, start the units by hand after enabling them.
nix run github:maplevoid/scribe -- fetch-model
nix run github:maplevoid/voicein -- daemonHome Manager (NixOS or standalone):
{
inputs.scribe.url = "github:maplevoid/scribe";
inputs.scribe.inputs.nixpkgs.follows = "nixpkgs";
inputs.voicein.url = "github:maplevoid/voicein";
inputs.voicein.inputs.nixpkgs.follows = "nixpkgs";
}{
imports = [
inputs.scribe.homeManagerModules.default
inputs.voicein.homeManagerModules.default
];
services.scribe.enable = true;
services.voicein.enable = true;
}On NixOS, still add group input yourself:
users.users.<name>.extraGroups = [ "input" ];Then rebuild and log out. id must show input. The Home Manager
modules do not add the group, do not download models, and wrap
wl-copy / wtype / xclip / xdotool / notify-send. They use
the host's pw-record.
hybrid(default): press starts recording. A tap shorter thantap(300ms) latches like toggle; hold past that threshold stops on release. A later press stops a latched take.toggle: press to start, press again (or hit 60s) to transcribe.hold: hold; release (or hit 60s) to transcribe.
Thinking pauses do not end the take. The first keypress also warms
scribe (empty PCM). The hotkey does nothing while transcribing; use
cancel.
hotkey = "" disables evdev. toggle / down / up still work
from a terminal or a compositor bind.
The compositor does not start recording. The daemon reads the chord
from evdev with no EVIOCGRAB, so an unbound Shift+Alt+V still
reaches the focused window (Ghostty / terminals often paste). Bind the
same keys to a no-op to swallow them.
Do not bind V to voicein toggle: evdev already latches, and a
compositor spawn would fire twice. Bind C to voicein cancel; the
daemon has no evdev cancel chord.
Hyprland:
bind = SHIFT ALT, V, exec, true
bind = SHIFT ALT, C, exec, voicein cancelniri (default mod-key Super):
binds {
Shift+Alt+V repeat=false { spawn "true"; }
Shift+Alt+C { spawn "voicein" "cancel"; }
}If mod-key "Alt", physical Shift+Alt+V / Shift+Alt+C are
Mod+Shift+V / Mod+Shift+C. Write Mod+, not Shift+Alt+:
binds {
Mod+Shift+V repeat=false { spawn "true"; }
Mod+Shift+C { spawn "voicein" "cancel"; }
}Sway / i3: bind the same keys to true / voicein cancel.
Optional. Missing files use built-in defaults.
voicein reads ~/.config/voicein/config.toml. Engine, language, and
idle live in ~/.config/scribe/config.toml.
voicein config > ~/.config/voicein/config.toml
scribe config > ~/.config/scribe/config.toml
voicein config set hotkey "shift+alt+v"
scribe config set engine sensevoicesocket = "" # default: $XDG_RUNTIME_DIR/voicein.sock
sample_rate = 16000
max_record = "60s"
notify = true
mode = "hybrid" # hybrid | toggle | hold
tap = "300ms"
hotkey = "shift+alt+v" # evdev; empty disables
[scribe]
socket = "" # default: $XDG_RUNTIME_DIR/scribe.sockSame daemons. In ~/.config/scribe/config.toml:
[model]
engine = "groq" # or "openai"export GROQ_API_KEY=gsk_...
# already-running user unit:
# systemctl --user edit scribe
# [Service]
# Environment=GROQ_API_KEY=gsk_...
systemctl --user restart scribe.socketopenai uses OPENAI_API_KEY. Either engine also accepts
SCRIBE_API_KEY. Do not put keys in the toml.
Session type picks the default tools. A focused XWayland window
(Flatpak QQ / WeChat via xwayland-satellite) switches to xclip +
xdotool even on a Wayland session. wtype into those clients can
quit Electron.
- Wayland (
wl-copy+wtype): copy, then type-from-stdin; fallback is Ctrl+V. - X11 / XWayland (
xclip+xdotool): native X11 session, or the focused window's pid comm containsxwayland.
Text is always copied first. Type failure falls back to Ctrl+V; that failure still leaves the text on the clipboard.
HUD needs a Wayland layer-shell compositor. Plain X11 can inject, with no bar.
id # must list input
voicein status # idle
journalctl --user -u scribe -u voicein -eA healthy start looks like:
# scribe
engine sensevoice model=/home/you/.local/share/scribe/models/model.int8.onnx
listening on /run/user/UID/scribe.sock idle=10m0s
# voicein
scribe socket /run/user/UID/scribe.sock
listening on /run/user/UID/voicein.sock
hotkey shift+alt+v via evdev; compositor bind optional (swallow key)
hud: wayland ready 77x36 layer=overlay
After a take, journal inject type is wtype on Wayland clients and
xdotool on XWayland (Flatpak QQ / WeChat). inject paste means type
failed and Ctrl+V ran.
| Symptom | Cause |
|---|---|
model .../tokens.txt: no such file or directory |
no scribe fetch-model |
daemon not running (.../voicein.sock) |
daemon / unit not started |
hotkey does nothing, journal hotkey listen: |
not in group input, or no logout |
| letter still types into the window | no compositor no-op bind |
| QQ / WeChat quits on inject | wtype hit XWayland; journal should show inject type (xdotool) |
pw-record: command not found |
PipeWire not on PATH |
| HUD flashes red, no notification | decode / record failed; check scribe logs |
text on clipboard, one notify-send |
inject failed |
| take ends, no text, scribe not listening | scribe serve / scribe.socket not running |
go test ./...
go build -o voicein ./cmd/voiceinOr nix develop.