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:
+1234
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user