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:
@@ -0,0 +1,2 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
Executable
+55
@@ -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."
|
||||||
@@ -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
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