Speech to Text: Omarchy bar plugin for dictation

A bar widget plus a stdlib-only Python daemon. Press a language's key to
record, press it again to stop: the text is pasted at the cursor (or handed
to the default coding agent with a second key). While recording the bar
shows a waveform (yellow while the microphone opens, green while listening)
and the words as they are recognised; the recording is transcribed at every
pause, so stopping only transcribes the last phrase. Every recording and its
text are kept in a searchable history with playback, copy, paste and delete.

Uses Omarchy's own dictation engine (voxtype, local Whisper) by default and
downloads any model a language needs by itself; whisper.cpp or a custom
command can be picked instead. Key bindings are applied at runtime through
Hyprland's Lua API and never touch a key something else already uses.
This commit is contained in:
2026-09-06 23:55:22 +01:00
commit f2ed66ed17
10 changed files with 3378 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
__pycache__/
*.pyc
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Alan Silva (alanfortlink)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+53
View File
@@ -0,0 +1,53 @@
# Speech to Text
Dictation for Omarchy: press a key, talk, press it again, and the words are pasted where your cursor is. A live waveform and the words as they are recognised show in the bar while you talk; every recording is kept with its text so you can play it back, copy or paste it again.
> Tested only on **Omarchy 4** (Arch Linux, Hyprland, omarchy-shell).
## What you get
- **One key per language** (default: `SUPER ALT D` for English; set your own in Settings). Press to start, press again to stop and paste. Esc discards.
- **+ Return** per language: also press Return after pasting (for chat boxes and prompts).
- **Ask your agent**: a second key per language hands the text to Omarchy's default coding agent (`omarchy default agent`) instead of pasting it.
- **Live waveform and live text** in the bar: yellow while the microphone connects, green while it listens; the bar goes back to the icon the moment the text is pasted.
- **Stop is instant**: the recording is transcribed at every pause while you talk, so only the last phrase is left when you stop.
- **History** of every recording (text + audio) with play, copy, paste, paste-and-send and delete, searchable, in the bar popup.
- **Languages** picked from Whisper's list; the model a language needs is downloaded by itself the first time (about 150 MB for the default model; the bar shows the progress). English-only models are swapped for the multilingual one.
- **Engine**: Omarchy's own dictation engine, [voxtype](https://github.com/peteonrails/voxtype) (local Whisper), by default, so there is nothing new to install. whisper.cpp (`whisper-cli`) and any custom command are also supported (Settings → Advanced).
- **Safe key bindings**: a key that anything else already uses is refused, never taken over.
- Local only. Nothing leaves your machine.
## Install
```bash
omarchy plugin add https://github.com/alanfortlink/speech-to-text.git --enable
```
That is all: the microphone icon appears in the bar, the daemon starts with the shell and applies the key bindings itself (nothing in `~/.config/hypr` is touched).
Optional: `~/.config/omarchy/plugins/alanfortlink.speech-to-text/install.sh` puts the `stt` command on your PATH. From a checkout anywhere else, `./install.sh` also links the checkout into the plugins directory (handy for development).
Requires `voxtype` (Omarchy ships it: `omarchy-voxtype-install`), `pipewire`, `wtype`, `wl-clipboard`, `curl`. All present on a stock Omarchy.
## Use
- Press the language's key, talk, press it again. `Esc` discards while recording. The first press for a new language downloads its model; the bar shows "Getting ready…" until it is there.
- Click the microphone icon for History and Settings. Right-click it to start recording; while recording, click the waveform to stop, right-click to discard.
- Settings: one row per language: the Dictate key (click it, press the key; Backspace clears), the + Return switch, and the Ask-agent key. The first row is the default language; use the arrows to reorder. Add languages from the picker.
- `stt` from a terminal: `stt toggle --lang en [--enter]`, `stt status --follow`, `stt history`, `stt paste ID`, `stt set liveText false`.
## Files
| What | Where |
|---|---|
| Config | `~/.config/speech-to-text/config.json` |
| History database and audio | `~/.local/share/speech-to-text/` |
| Models | `~/.local/share/voxtype/models/` (shared with voxtype) |
| Daemon socket and log | `$XDG_RUNTIME_DIR/speech-to-text/` |
## Uninstall
```bash
~/.config/omarchy/plugins/alanfortlink.speech-to-text/install.sh --uninstall
omarchy plugin remove alanfortlink.speech-to-text
```
Executable
+170
View File
@@ -0,0 +1,170 @@
#!/usr/bin/env python3
"""Command-line client for the speech-to-text daemon (sttd).
stt toggle [--lang CODE] [--enter] [--agent] start recording, or stop + transcribe + paste (--agent: hand the text to the default agent)
stt start [--lang CODE]
stt stop [--enter]
stt cancel
stt status [--json] [--follow]
stt history [-n N] [-q QUERY] [--json]
stt copy ID | paste ID [--enter] | play ID | delete ID
stt set KEY JSON_VALUE change one config key (e.g. stt set liveText false)
stt rebind re-apply the Hyprland key bindings
stt quit
"""
import json
import os
import shutil
import socket
import subprocess
import sys
import time
SOCK = os.path.join(os.environ.get("STT_RUNTIME_DIR") or os.path.join(os.environ.get("XDG_RUNTIME_DIR", "/tmp"), "speech-to-text"), "ctl.sock")
def die(msg, notify=True):
print(msg, file=sys.stderr)
if notify:
cmd = "omarchy-notification-send" if shutil.which("omarchy-notification-send") else "notify-send"
try:
subprocess.Popen([cmd, "Speech to text", msg], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except OSError:
pass
sys.exit(1)
def connect():
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
try:
s.connect(SOCK)
except OSError:
die("The speech-to-text daemon is not running (is the plugin enabled in the bar?)")
return s
def send(s, obj):
s.sendall((json.dumps(obj) + "\n").encode())
def lines(s):
buf = b""
while True:
data = s.recv(65536)
if not data:
return
buf += data
while b"\n" in buf:
line, buf = buf.split(b"\n", 1)
if line.strip():
yield json.loads(line)
def first(s, type_):
for msg in lines(s):
if msg.get("type") == type_:
return msg
def flag(args, name):
if name in args:
args.remove(name)
return True
return False
def opt(args, name, default=None):
if name in args:
i = args.index(name)
if i + 1 < len(args):
v = args[i + 1]
del args[i:i + 2]
return v
return default
def fmt_state(st):
s = st.get("state", "?")
if s == "recording":
return f"recording ({st.get('langLabel')}) {st.get('elapsed', 0)}s: {st.get('partial', '')}"
if s == "transcribing":
return "transcribing"
last = st.get("last") or {}
return "idle" + (f" · last: {last.get('text', '')}" if last else "")
def main(argv):
args = list(argv)
if not args or args[0] in ("-h", "--help", "help"):
print(__doc__.strip())
return
cmd = args.pop(0)
s = connect()
s.settimeout(None if cmd == "status" and "--follow" in args else 15) # never leave a key binding's process hanging
try:
initial = first(s, "state") # the daemon greets with its state
except socket.timeout:
die("The speech-to-text daemon is not answering")
if cmd in ("toggle", "start"):
send(s, {"cmd": cmd, "lang": opt(args, "--lang"), "enter": flag(args, "--enter"), "agent": flag(args, "--agent")})
try:
st = first(s, "state")
except socket.timeout:
st = None
print(fmt_state(st) if st else "ok")
elif cmd == "stop":
send(s, {"cmd": "stop", "enter": flag(args, "--enter"), "agent": flag(args, "--agent")})
print("stopping")
elif cmd in ("cancel", "rebind", "quit", "stopPlay", "clearError"):
send(s, {"cmd": cmd})
print("ok")
elif cmd == "status":
as_json = flag(args, "--json")
follow = flag(args, "--follow")
st = initial
while True:
print(json.dumps(st) if as_json else fmt_state(st), flush=True)
if not follow:
break
st = first(s, "state")
if st is None:
break
elif cmd == "history":
n = int(opt(args, "-n", "20"))
q = opt(args, "-q", "")
as_json = flag(args, "--json")
send(s, {"cmd": "history", "limit": n, "query": q})
h = first(s, "history")
if as_json:
print(json.dumps(h, indent=2))
else:
for t in h.get("items", []):
when = time.strftime("%Y-%m-%d %H:%M", time.localtime(t["createdAt"]))
print(f"{t['id']:>5} {when} {t['lang']:<3} {t['duration']:>5.1f}s {t['text']}")
total = h.get("total", 0)
print(f"({total} recording{'' if total == 1 else 's'})")
elif cmd in ("copy", "paste", "play", "delete"):
if not args:
die(f"usage: stt {cmd} ID", notify=False)
send(s, {"cmd": cmd, "id": int(args[0]), "enter": flag(args, "--enter")})
time.sleep(0.2)
print("ok")
elif cmd == "set":
if len(args) < 2:
die("usage: stt set KEY JSON_VALUE", notify=False)
try:
value = json.loads(args[1])
except ValueError:
value = args[1]
send(s, {"cmd": "set", "config": {args[0]: value}})
st = first(s, "state")
print(json.dumps(st.get("config", {}).get(args[0])))
else:
die(f"unknown command: {cmd}", notify=False)
s.close()
if __name__ == "__main__":
main(sys.argv[1:])
Executable
+1555
View File
File diff suppressed because it is too large Load Diff
Executable
+55
View File
@@ -0,0 +1,55 @@
#!/bin/bash
# Install the Speech to Text plugin for the current user.
# ./install.sh link the plugin into ~/.config/omarchy/plugins (when run from a checkout elsewhere),
# put the `stt` CLI on PATH, enable the bar widget
# ./install.sh --uninstall
# `omarchy plugin add <git-url> --enable` alone is enough for normal use: the key
# bindings call the CLI by absolute path. This script only adds conveniences.
set -euo pipefail
HERE=$(cd "$(dirname "$0")" && pwd)
ID=alanfortlink.speech-to-text
PLUGIN=$HOME/.config/omarchy/plugins/$ID
BIN=$HOME/.local/bin
MODE=${1:-}
if [[ $MODE == --uninstall ]]; then
omarchy-plugin-disable "$ID" >/dev/null 2>&1 || true
rm -f "$BIN/stt"
[[ -L $PLUGIN ]] && rm -f "$PLUGIN"
pkill -f "daemon/sttd.py" 2>/dev/null || true
echo "uninstalled (history in ~/.local/share/speech-to-text and config in ~/.config/speech-to-text were left alone)"
echo "If the plugin was added with 'omarchy plugin add', also run: omarchy plugin remove $ID"
exit 0
fi
missing=()
for c in python3 pw-record pw-play wtype wl-copy wl-paste hyprctl; do
command -v "$c" >/dev/null 2>&1 || missing+=("$c")
done
if ((${#missing[@]})); then
echo "error: missing commands: ${missing[*]} (pipewire, wtype, wl-clipboard, hyprland)" >&2
exit 1
fi
if ! command -v voxtype >/dev/null 2>&1; then
echo "note: voxtype (Omarchy's dictation engine) is not installed; install it with 'omarchy-voxtype-install' or pick another engine in Settings." >&2
fi
mkdir -p "$BIN" "$(dirname "$PLUGIN")"
ln -sfn "$HERE/bin/stt" "$BIN/stt"
chmod +x "$HERE/bin/stt" "$HERE/daemon/sttd.py"
# A checkout somewhere else is linked in; a checkout that already lives in the
# plugins dir (omarchy plugin add) is used in place.
if [[ $HERE != "$PLUGIN" ]]; then
if [[ -e $PLUGIN && ! -L $PLUGIN ]]; then
echo "error: $PLUGIN exists and is not a symlink; remove it first (omarchy plugin remove $ID)" >&2
exit 1
fi
ln -sfn "$HERE" "$PLUGIN"
fi
case ":$PATH:" in *":$BIN:"*) ;; *) echo "note: $BIN is not on your PATH; the key bindings run 'stt' from there." >&2 ;; esac
omarchy-shell shell rescanPlugins >/dev/null 2>&1 || true
omarchy-plugin-enable "$ID" --section right >/dev/null 2>&1 || omarchy-plugin-enable "$ID" >/dev/null 2>&1 || true
echo "installed: the Speech to Text icon is in the bar. Default keys: F13 (dictate), CTRL+F13 (dictate and send). Click the icon → Settings to change them."
+42
View File
@@ -0,0 +1,42 @@
{
"schemaVersion": 1,
"id": "alanfortlink.speech-to-text",
"name": "Speech to Text",
"version": "0.1.0",
"author": "alanfortlink",
"license": "MIT",
"description": "Dictation with a live waveform and live text in the bar, a browsable history of every recording and its transcript, and configurable keys per language (toggle, and toggle + Return). Uses the same local Whisper engine Omarchy ships (voxtype) by default; other engines are configurable.",
"kinds": [
"service",
"bar-widget"
],
"keepLoaded": true,
"entryPoints": {
"service": "plugin/Service.qml",
"barWidget": "plugin/Panel.qml"
},
"barWidget": {
"displayName": "Speech to Text",
"description": "Microphone icon that turns into a live waveform with the words as you say them; click for history and settings.",
"category": "Media",
"allowMultiple": false,
"defaultSection": "right",
"defaults": {
"alwaysShow": true
},
"schema": [
{
"key": "alwaysShow",
"type": "boolean",
"label": "Show the microphone icon while idle",
"defaultValue": true
},
{
"key": "maxTextWidth",
"type": "number",
"label": "Maximum width of the live text in the bar (px)",
"defaultValue": 360
}
]
}
}
+1234
View File
File diff suppressed because it is too large Load Diff
+185
View File
@@ -0,0 +1,185 @@
import QtQuick
import Quickshell
import Quickshell.Io
// Headless service: runs the speech-to-text daemon (daemon/sttd.py), restarts
// it if it dies, and keeps one control connection to it. The bar widget reads
// everything from here and sends every action through here.
Item {
id: root
property var shell: null
property var manifest: null
// ---- daemon state (mirrors the JSON the daemon pushes) ----
property var state: ({})
readonly property bool connected: sockConnected
readonly property string status: state.state || "idle" // idle | recording | transcribing
readonly property bool recording: status === "recording"
readonly property bool listening: recording && !!state.listening // audio is flowing; before that the mic is still connecting
readonly property bool transcribing: status === "transcribing"
readonly property bool busy: recording || transcribing
readonly property string lang: state.lang || ""
readonly property string langLabel: state.langLabel || ""
readonly property real elapsed: state.elapsed || 0
readonly property var levels: state.levels || []
readonly property string partial: state.partial || ""
readonly property var last: state.last || null
readonly property string error: state.error || ""
readonly property int playing: state.playing || 0
readonly property var config: state.config || ({})
readonly property var engines: state.engines || []
readonly property var binds: state.binds || [] // keys actually applied
readonly property var conflicts: state.conflicts || [] // keys refused because something else owns them
readonly property var languages: config.languages || []
// code -> name (whisper's list). Static, so the daemon sends it only with the
// greeting and on `get`; keep the last copy across the 20 Hz state ticks.
property var languageNames: ({})
readonly property var download: state.download || null // {model, pct} while a model is fetched
readonly property string agentName: state.agentName || "" // omarchy's default coding agent
readonly property bool agentMode: !!state.agentMode // this recording goes to the agent
// ---- history (fetched on demand; the daemon says when it changed) ----
property var historyItems: []
property int historyTotal: 0
property string historyQuery: ""
signal historyChanged()
property string daemonLog: ""
property int restarts: 0
property string daemonError: ""
readonly property string runtimeDir: (Quickshell.env("XDG_RUNTIME_DIR") || "/tmp") + "/speech-to-text"
readonly property string socketPath: runtimeDir + "/ctl.sock"
readonly property string repoDir: decodeURIComponent(String(Qt.resolvedUrl("..")).replace(/^file:\/\//, "").replace(/\/$/, ""))
readonly property string daemonScript: repoDir + "/daemon/sttd.py"
// ---- commands ----
function send(obj) {
if (!sockConnected) return false
sock.write(JSON.stringify(obj) + "\n")
sock.flush()
return true
}
function toggle(langCode, enter, agent) { return send({ cmd: "toggle", lang: langCode || null, enter: !!enter, agent: !!agent }) }
function start(langCode) { return send({ cmd: "start", lang: langCode || null }) }
function stop(enter) { return send({ cmd: "stop", enter: !!enter }) }
function cancel() { return send({ cmd: "cancel" }) }
function refresh() { return send({ cmd: "get" }) }
function clearError() { return send({ cmd: "clearError" }) }
function setConfig(patch) { return send({ cmd: "set", config: patch }) }
function setSetting(key, value) { var p = {}; p[key] = value; return setConfig(p) }
function rebind() { return send({ cmd: "rebind" }) }
function setLang(code) { return send({ cmd: "setLang", lang: code }) }
// While the panel captures a key, our own binds must not fire on it.
function suspendBinds() { return send({ cmd: "suspendBinds" }) }
function resumeBinds() { return send({ cmd: "resumeBinds" }) }
function loadHistory(query, limit) {
historyQuery = query || ""
return send({ cmd: "history", query: historyQuery, limit: limit || 200, offset: 0 })
}
function deleteTake(id) { return send({ cmd: "delete", id: id }) }
function clearHistory() { return send({ cmd: "clearHistory" }) }
function copyTake(id) { return send({ cmd: "copy", id: id }) }
function pasteTake(id, enter) { return send({ cmd: "paste", id: id, enter: !!enter }) }
function editTake(id, text) { return send({ cmd: "edit", id: id, text: text }) }
function play(id) { return send({ cmd: "play", id: id }) }
function stopPlay() { return send({ cmd: "stopPlay" }) }
// ---- daemon lifecycle ----
Process {
id: daemon
command: ["python3", root.daemonScript]
running: false
stderr: SplitParser {
onRead: function(line) {
var l = root.daemonLog + line + "\n"
if (l.length > 4000) l = l.slice(l.length - 4000)
root.daemonLog = l
}
}
onExited: function(code, status) {
root.state = ({})
root.restarts += 1
if (code !== 3 && root.restarts >= 3) root.daemonError = "daemon keeps exiting (code " + code + ") — check the log"
restartTimer.interval = code === 3 ? 5000 : Math.min(10000, 1000 + root.restarts * 1000) // 3 = another instance holds the lock
restartTimer.restart()
}
}
// Set while we wait for an orphaned daemon (from a previous shell) to honour
// our quit; the socket dropping is the signal to start our own.
property bool orphanQuit: false
Timer {
id: restartTimer
interval: 1000
repeat: false
onTriggered: {
if (daemon.running) { daemon.signal(15); return }
if (root.sockConnected) { root.send({ cmd: "quit" }); root.orphanQuit = true; interval = 5000; restart(); return }
daemon.running = true
}
}
Timer { interval: 60000; running: daemon.running; repeat: false; onTriggered: root.restarts = 0 }
// ---- control connection ----
// Quickshell's Socket cannot recover from a refused connection, so a new
// Socket object is created for every attempt.
property var sock: null
readonly property bool sockConnected: sock ? sock.connected === true : false
Component {
id: sockComp
Socket {
path: root.socketPath
connected: true
parser: SplitParser {
onRead: function(line) {
var msg
try { msg = JSON.parse(line) } catch (e) { return }
if (!msg) return
if (msg.type === "state") {
if (msg.languageNames) root.languageNames = msg.languageNames
root.state = msg
if (root.daemonError !== "") root.daemonError = ""
} else if (msg.type === "history") {
if (String(msg.query || "") === root.historyQuery) {
root.historyItems = msg.items || []
root.historyTotal = msg.total || 0
}
} else if (msg.type === "history-changed") {
root.historyChanged()
root.loadHistory(root.historyQuery)
}
}
}
onConnectionStateChanged: {
root.sockConnectedChanged()
if (connected) root.loadHistory(root.historyQuery)
if (!connected) {
root.state = ({})
if (root.orphanQuit) { root.orphanQuit = false; restartTimer.interval = 1000; restartTimer.restart() }
reconnectTimer.restart()
}
}
onError: function(err) { reconnectTimer.restart() }
}
}
function connectSocket() {
if (sock) { sock.destroy(); sock = null }
sock = sockComp.createObject(root)
sockConnectedChanged()
}
Timer { id: reconnectTimer; interval: 800; repeat: false; onTriggered: if (!root.sockConnected) root.connectSocket() }
Timer { interval: 3000; running: !root.sockConnected; repeat: true; onTriggered: if (!root.sockConnected) root.connectSocket() }
Component.onCompleted: {
daemon.running = true
connectSocket()
}
Component.onDestruction: {
if (daemon.running) daemon.signal(15)
}
}
+61
View File
@@ -0,0 +1,61 @@
import QtQuick
// A row of thin bars mirrored around the middle. `levels` holds 0..1 values,
// newest last; the bars show the most recent `bars` of them. With `idle` on
// (nothing to show yet, or transcribing) the bars breathe gently instead.
Item {
id: root
property var levels: []
property int bars: 20
property real barWidth: 2
property real gap: 2
property color color: "white"
property bool idle: false // gentle breathing (nothing to show yet, or transcribing)
property bool sine: false // a travelling sine wave (the microphone is still connecting)
property real minHeight: 2
implicitWidth: bars * (barWidth + gap) - gap
implicitHeight: 16
property real phase: 0
Timer {
interval: 40
repeat: true
running: (root.idle || root.sine) && root.visible
onTriggered: root.phase += root.sine ? 0.35 : 0.2
}
function levelAt(i) {
var lv = levels || []
var offset = lv.length - bars
var v = offset + i >= 0 ? Number(lv[offset + i]) : 0
return isFinite(v) ? Math.max(0, Math.min(1, v)) : 0
}
function breath(i) {
return 0.12 + 0.1 * (1 + Math.sin(phase + i * 0.45)) / 2
}
function wave(i) {
return 0.15 + 0.75 * (1 + Math.sin(i * 0.7 - phase)) / 2
}
Row {
anchors.centerIn: parent
spacing: root.gap
Repeater {
model: root.bars
Rectangle {
required property int index
readonly property real lv: root.sine ? root.wave(index) : root.idle ? root.breath(index) : root.levelAt(index)
width: root.barWidth
height: Math.max(root.minHeight, Math.round(lv * root.height))
radius: root.barWidth / 2
color: root.color
anchors.verticalCenter: parent.verticalCenter
Behavior on height { NumberAnimation { duration: 70 } }
}
}
}
}