-
Notifications
You must be signed in to change notification settings - Fork 3
routing
Crate: crates/voxctrl-routing/
VoxCtrl's routing system decouples what you say from where it goes. You define:
-
Output Commands (
targets.toml) — named delivery destinations -
Hotkey Bindings (
bindings.toml) — which keys trigger which commands
Note
Output Commands are called targets everywhere below the UI: the file is
targets.toml, each block is [[target]], bindings reference target_id,
and the Tauri commands are get_targets / save_targets. Only the name the
app shows you changed; nothing on disk did.
Both files are hot-reloaded when changed on disk.
Defined in ~/.config/voxctrl/targets.toml. Each [[target]] block describes one destination.
Saying one by name. Start dictation and say "Hey Vox" (or "Vox Control"), then the command's
name, then the text: "Hey Vox, notes, remember to call the plumber" (or "Vox Control notes, remember to call the plumber") delivers
remember to call the plumber to the command named notes, whatever the
active hotkey was pointed at. Everything after the name is the payload. The full
matching rules — conversational lead-ins, fuzzy name matching, the overlay —
are under command — Voice Command Router.
| Field | Type | Default | Description |
|---|---|---|---|
id |
string | required | Unique identifier, referenced by bindings |
label |
string | required | The target's name — shown in the UI, and spoken to route dictation here through a command target (see Voice Command Router) |
delivery |
string | required | Delivery type (see below) |
strip_newlines |
bool | false |
Replace newlines (\n) with spaces and strip carriage returns (\r). Honored by the inject and command targets |
processing |
object | (inherit) | Per-target post-processing overrides |
response_pipe |
string | null | FIFO path for TTS response output |
Note
Two targets carry a conversation with an LLM. Use chat when the model is reachable
over an OpenAI-compatible HTTP API; use pipe + response_pipe when you are driving a
local agent process over FIFOs.
Simulates typing into the currently focused window.
[[target]]
id = "default"
label = "Focused Window"
delivery = "inject"Delivered text always ends with a single space, so consecutive dictations do not run their last and first words together; text that already ends in whitespace is left alone. The clipboard target does the same. Nothing appends a newline.
Linux injection priority:
-
wtype(Wayland) -
xdotool type --clearmodifiers(X11) - Clipboard + Ctrl+V fallback
Windows injection:
- Native synthesised keystrokes via
SendInput(KEYEVENTF_UNICODE) implemented invoxctrl-winput. - Automatically falls back to an atomic clipboard paste (safely restoring prior clipboard contents) for long dictations exceeding 2,000 characters.
Copies text to the system clipboard. Does not paste.
[[target]]
id = "clipboard"
label = "Copy to Clipboard"
delivery = "clipboard"Writes text to a file on disk.
[[target]]
id = "notes"
label = "Meeting Notes"
delivery = "file"
file_path = "~/Documents/notes.md"
file_prefix = "- " # Prepend to each entry
file_timestamp = true # Prepend a timestamp (default: true)
file_timestamp_format = "%Y-%m-%dT%H:%M:%SZ" # strftime pattern, UTC
file_mode = "append" # "append" or "write" (default: "append")With file_timestamp on, each line is prefixed with [<timestamp>] .
file_timestamp_format is a chrono
strftime
pattern rendered in UTC — %Y year, %m month, %d day, %H hour, %M
minute, %S second, %b month name, %a weekday, %p AM/PM, %Z zone,
%% a literal percent; anything else is written as typed. The target editor
previews the pattern as you type and flags one it cannot render. A pattern that
is unusable at delivery time falls back to the default,
%Y-%m-%dT%H:%M:%SZ, with a warning — a bad format never costs you the
dictation.
Runs a shell command. The transcribed text is passed as an argument.
[[target]]
id = "cmd"
label = "Custom Script"
delivery = "exec"
command = "/home/user/scripts/handle-voice.sh"POSTs the text as a JSON body to an HTTP endpoint.
[[target]]
id = "api"
label = "My API"
delivery = "http"
http_url = "http://localhost:8080/voice"
http_method = "POST" # Default: "POST"Request body:
{"text": "transcribed text here"}Optional: http_headers (table) and http_json_template (JSON value).
Like http but uses webhook_url and adds an HMAC-SHA256 signature header.
[[target]]
id = "secure_hook"
label = "Signed Webhook"
delivery = "webhook"
webhook_url = "https://example.com/hook"
webhook_secret = "your-shared-secret"Adds header: X-VoxCtrl-Signature: sha256=<hex>
Optional: webhook_json_template (JSON value) to customize the payload shape.
Verify on your server:
import hmac, hashlib
def verify(payload: bytes, secret: str, signature: str) -> bool:
expected = 'sha256=' + hmac.new(
secret.encode(), payload, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)Sends text (newline-terminated) to a socket.
[[target]]
id = "sock"
label = "Unix Socket"
delivery = "socket"
socket_unix = "/tmp/myapp.sock" # Unix domain socket path
# OR for TCP:
socket_host = "127.0.0.1"
socket_port = 9000Writes to a named FIFO pipe.
[[target]]
id = "fifo"
label = "FIFO Output"
delivery = "pipe"
pipe_path = "/tmp/voice.fifo"Emits the text as a text_injected signal on the ai.voxctrl.Dictation interface.
[[target]]
id = "dbus"
label = "DBus Output"
delivery = "dbus"
dbus_signal = "text_injected" # Optional: override signal nameEnqueues the text as a response to a pending transcribe_voice tool call from an MCP client.
[[target]]
id = "mcp_out"
delivery = "mcp"
mcp_path = "/tmp/voxctrl-mcp.sock" # Optional socket path override
mcp_tool = "transcribe_voice" # Optional tool name hintPlays the transcribed text aloud via the globally configured Text-to-Speech (TTS) engine. This works offline and does not require an active MCP client or server to be enabled.
[[target]]
id = "tts_out"
label = "Read Transcription Aloud"
delivery = "speak"Dynamically routes dictated text to other targets based on spoken trigger phrases.
[[target]]
id = "cmd_router"
label = "Voice Command Router"
delivery = "command"On a new install the first-run wizard creates this target, named "Command", and
binds the first hotkey to it. Until a second target exists it behaves exactly
like inject — a transcription with no trigger keyword in it falls through to
typing into the focused window — so voice command routing works the day another
target is added, with no re-binding.
How Voice Command Routing Works:
-
Trigger Keyword: Listens for the
"Hey Vox"or"Vox Control"keywords (case-insensitive, supportingHey Vox,Hey, Vox,Hey-Vox,VoxCtrl,voxctrl,vox ctrl,vox-ctrl,vox control, and trailing punctuation likeHey Vox:). -
Target Resolution: Matches spoken target names against all configured target IDs and Labels (case-insensitively). Longest candidate target names take precedence (e.g.
"Personal Notes"is matched before"Notes"). - Conversational Lead-in Support: Supports natural lead-in command phrases between the trigger keyword and the target name, such as "Hey Vox, send this to my notes. I love you.", "Hey Vox, add this to my personal notes, help", "Vox Control put this into my Notes: hello", or "Hey, Vox, send us to my notes. I love you.".
-
Payload Extraction: Strips transition punctuation (
.,:,,,;) and connector words (saying,that,with text) and routes the remaining text payload to the matched target. - Command UI Overlay: When a voice command trigger is matched and executed, VoxCtrl automatically displays a temporary purple/indigo HUD overlay pill displaying the command name and text payload summary for a configurable duration (default: 3 seconds).
-
Auto-Trigger Detection: Dictating with the
"Hey Vox"or"Vox Control"keyword (e.g. "Hey Vox, notes Help me!" or "VoxCtrl notes Help me!") automatically activates voice command routing and surfaces the overlay pill regardless of whether the active hotkey target is set tocommandorinject. -
Fallback: If no trigger keyword is spoken or if no target matches, it falls back to direct text injection into the active application (identical to the
injecttarget).
Sends each dictation as a turn in an ongoing conversation to an OpenAI-compatible
/v1/chat/completions endpoint — Hermes, Ollama, llama.cpp, LM Studio, vLLM, or a
remote provider — and surfaces the model's reply.
Unlike http, this target reads the response and remembers the exchange, so the
model keeps its context across turns. This makes VoxCtrl a voice front end for the same
API that Open WebUI talks to.
[[target]]
id = "hermes"
label = "Hermes"
delivery = "chat"
chat_url = "http://localhost:8080" # /v1 suffix optional
chat_model = "hermes-4-14b"
chat_system_prompt = "You are a concise voice assistant. Answer in one or two spoken sentences."
chat_reply_mode = "speak" # speak | inject | clipboard | none
chat_max_history = 20 # messages sent per turn; 0 = whole conversation
chat_timeout_secs = 120
chat_reset_phrase = "new conversation" # optional; clears history instead of asking
# chat_api_key = "sk-..." # optional; usually unnecessary locally| Field | Type | Default | Description |
|---|---|---|---|
chat_url |
string | required | Server base URL. A missing /v1 suffix is added automatically |
chat_model |
string | required | Model id as reported by GET /v1/models
|
chat_api_key |
string | null | Sent as Authorization: Bearer … when set |
chat_system_prompt |
string | null | Prepended to every request; never trimmed from the window |
chat_max_history |
int | 20 |
Most recent messages sent per turn. 0 sends everything |
chat_timeout_secs |
int | 120 |
Per-request timeout — local models can be slow to first token |
chat_reply_mode |
string | "speak" |
speak, inject, clipboard, or none
|
chat_reset_phrase |
string | null | Saying this clears the conversation instead of sending it |
Conversation lifetime. History is held in memory, keyed by target id, and survives saving settings (which rebuilds every target). It is cleared by the spoken reset phrase, by the Reset conversation button in the target editor, or by restarting VoxCtrl. It is never written to disk.
Reply handling. speak requires TTS to be enabled under Settings → TTS. inject
types the reply into the focused window and honours the target's strip_newlines
setting. none runs the conversation without surfacing replies.
Failure behaviour. If the request fails, times out, or returns an empty completion, the unanswered turn is rolled back so it is not resent on the next dictation.
Tip
Point chat_url at whatever base URL you gave Open WebUI. If Open WebUI can reach the
server, so can this target — use its model list to find the exact chat_model id, or
press Fetch models in the target editor.
Each target can override global post-processing settings. All fields are optional (null = inherit global config). Snippet expansion is not among them — snippets always apply, and are switched off only by defining none.
[[target]]
id = "code_editor"
label = "Code Editor"
delivery = "inject"
[code_editor.processing]
code_mode = true
remove_fillers = false
spoken_punctuation = true
auto_format_lists = falseNote
LLM post-processing parameters (openai_enabled, openai_model, openai_mode, openai_prompt, openai_system_prompt) are defined per-hotkey binding in bindings.toml (or via the Hotkeys UI tab) instead of per-target. This allows you to apply different LLM rewriting styles (e.g. formal, bullet-points) using different hotkeys targeting the same destination. The model is served over the OpenAI API (configured under Settings → OpenAI API). The field names use the openai_ prefix; the legacy ollama_ names are still accepted for backwards compatibility.
Defined in ~/.config/voxctrl/bindings.toml. Each [[binding]] block maps a key combo + gesture to one or more targets.
| Field | Type | Default | Description |
|---|---|---|---|
id |
string | required | Unique identifier |
label |
string | "" |
Display name in UI |
keys |
string[] | required | Key names (evdev format, on every platform). Any number of modifiers plus exactly one regular key — see Hotkeys |
gesture |
string | required |
"hold", "toggle", "double_tap", or "double_tap_hold"
|
target_ids |
string[] | required | Ordered list of targets to route to |
target_id |
string | Single target (legacy; resolved if target_ids is empty) |
|
hold_threshold_ms |
integer | 200 |
Minimum hold / double-tap-hold duration in ms |
tap_ms |
integer | 300 |
Longest gap between the first tap's release and the second tap's press, in ms |
disabled |
bool | false |
Disable without removing |
| Gesture | Behavior |
|---|---|
hold |
Recording starts on press, stops on release |
toggle |
First press starts, second press stops |
double_tap |
Two taps within tap_ms toggle a session. Starts on the second press, unless a double_tap_hold shares the same keys — then it resolves on the release, to tell the two apart |
double_tap_hold |
Double-tap and keep held on the second press (hold_threshold_ms before recording starts). Releasing always stops it; a 2-minute timeout is a backstop for a release that never arrives |
chord was removed — see Hotkeys → Migrating from chord. Existing bindings load as hold automatically.
Common keys:
-
KEY_LEFTMETA— Left Super/Windows key -
KEY_LEFTCTRL— Left Ctrl -
KEY_LEFTSHIFT— Left Shift -
KEY_LEFTALT— Left Alt -
KEY_SPACE— Space bar -
KEY_F1–KEY_F12— Function keys -
KEY_A–KEY_Z— Letter keys -
KEY_ESCAPE— Escape key
[[binding]]
id = "dictate_hold"
label = "Dictate to Cursor (Hold)"
keys = ["KEY_LEFTMETA", "KEY_SPACE"]
gesture = "hold"
target_ids = ["default"]
[[binding]]
id = "dictate_notes"
label = "Dictate to Notes File"
keys = ["KEY_LEFTCTRL", "KEY_LEFTSHIFT", "KEY_N"]
gesture = "toggle"
target_ids = ["notes", "clipboard"] # Routes to both sequentially
[[binding]]
id = "quick_copy"
label = "Copy to Clipboard"
keys = ["KEY_LEFTMETA", "KEY_V"]
gesture = "double_tap"
target_ids = ["clipboard"]
tap_ms = 300
[[binding]]
id = "double_tap_hold_dictate"
label = "Double-Tap & Hold to Dictate"
keys = ["KEY_LEFTMETA", "KEY_SPACE"]
gesture = "double_tap_hold"
tap_ms = 300
hold_threshold_ms = 200
target_ids = ["default"]
[[binding]]
id = "toggle_dictate"
label = "Toggle Dictation"
keys = ["KEY_LEFTCTRL", "KEY_LEFTMETA", "KEY_SPACE"]
gesture = "toggle"
target_ids = ["default"]When target_ids contains multiple entries, VoxCtrl delivers to each target sequentially in the listed order after a single recording session. This lets you, for example, inject text into a window AND log it to a file simultaneously.
If binding A's keys are a proper subset of binding B's keys (e.g. META+SPACE vs CTRL+META+SPACE), and both are pressed, only binding B fires. This prevents shorter combos from accidentally triggering when a longer combo is intended.
Shadowing is resolved when a key goes down. Releasing CTRL part-way through a CTRL+META+SPACE gesture therefore does not start a META+SPACE recording.
OutputTargetRouter::route(text, target_id, targets):
- Look up target by
target_idfrom the in-memory cache - Apply per-target
processingoverrides (inheriting globals for null fields) - Build delivery payload (single-line flattening, trailing space, prefix, timestamp)
- Dispatch to the appropriate delivery handler
- On error (socket unavailable, file unwritable, etc.), log the failure and continue — never crashes or drops the UI
The router is hot-reloadable: save_targets() via IPC updates both the TOML file and the in-memory cache, and spawns any new FIFO response pipe listeners.