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
+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 } }
}
}
}
}