commit f2ed66ed17a236fef21b53789a2d6cfcef818f7a Author: Alan Silva Date: Sun Sep 6 23:55:22 2026 +0100 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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a60b85 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d72a969 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a7b05e3 --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/bin/stt b/bin/stt new file mode 100755 index 0000000..09c57f5 --- /dev/null +++ b/bin/stt @@ -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:]) diff --git a/daemon/sttd.py b/daemon/sttd.py new file mode 100755 index 0000000..a2deb22 --- /dev/null +++ b/daemon/sttd.py @@ -0,0 +1,1555 @@ +#!/usr/bin/env python3 +"""Speech-to-text daemon for the Omarchy shell plugin. + +Owns the microphone (pw-record), the transcription engine, the history +database, the Hyprland key bindings and the paste step. Talks JSON lines over +a unix socket at $XDG_RUNTIME_DIR/speech-to-text/ctl.sock; the shell plugin +and the `stt` CLI are its clients. Standard library only. +""" + +import asyncio +import fcntl +import json +import math +import os +import re +import shlex +import shutil +import signal +import sqlite3 +import struct +import subprocess +import sys +import tempfile +import threading +import time +import wave + +VERSION = "0.1.0" +HOME = os.path.expanduser("~") +RUNTIME = os.environ.get("STT_RUNTIME_DIR") or os.path.join(os.environ.get("XDG_RUNTIME_DIR", "/tmp"), "speech-to-text") +SOCK = os.path.join(RUNTIME, "ctl.sock") +LOCK = os.path.join(RUNTIME, "lock") +BINDS_STATE = os.path.join(RUNTIME, "binds.json") +DATA = os.environ.get("STT_DATA_DIR") or os.path.join(os.environ.get("XDG_DATA_HOME", os.path.join(HOME, ".local", "share")), "speech-to-text") +TAKES = os.path.join(DATA, "takes") +DB = os.path.join(DATA, "history.db") +CONFIG_DIR = os.environ.get("STT_CONFIG_DIR") or os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.join(HOME, ".config")), "speech-to-text") +CONFIG = os.path.join(CONFIG_DIR, "config.json") +VOXTYPE_CONFIG = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.join(HOME, ".config")), "voxtype", "config.toml") + +RATE = 16000 +CHUNK = 1600 # bytes = 800 samples = 50 ms of s16 mono +LEVELS = 48 # bars pushed to the bar widget + +# Whisper's languages (code -> name). "auto" lets the model detect the language per take. +LANGUAGES = { + "auto": "Auto-detect", "en": "English", "pt": "Portuguese", "es": "Spanish", "fr": "French", "de": "German", + "it": "Italian", "ja": "Japanese", "zh": "Chinese", "ko": "Korean", "ru": "Russian", "nl": "Dutch", "pl": "Polish", + "tr": "Turkish", "sv": "Swedish", "uk": "Ukrainian", "ar": "Arabic", "hi": "Hindi", "cs": "Czech", "da": "Danish", + "fi": "Finnish", "el": "Greek", "he": "Hebrew", "hu": "Hungarian", "id": "Indonesian", "no": "Norwegian", + "ro": "Romanian", "th": "Thai", "vi": "Vietnamese", "ca": "Catalan", "bg": "Bulgarian", "hr": "Croatian", + "sk": "Slovak", "sl": "Slovenian", "lt": "Lithuanian", "lv": "Latvian", "et": "Estonian", "fa": "Persian", + "ms": "Malay", "ta": "Tamil", "ur": "Urdu", "bn": "Bengali", "tl": "Tagalog", "sw": "Swahili", "af": "Afrikaans", + "cy": "Welsh", "is": "Icelandic", "gl": "Galician", "eu": "Basque", "sr": "Serbian", "mk": "Macedonian", + "sq": "Albanian", "az": "Azerbaijani", "ka": "Georgian", "kk": "Kazakh", "hy": "Armenian", "ne": "Nepali", + "si": "Sinhala", "km": "Khmer", "lo": "Lao", "my": "Burmese", "mn": "Mongolian", "mr": "Marathi", "te": "Telugu", + "kn": "Kannada", "ml": "Malayalam", "gu": "Gujarati", "pa": "Punjabi", "am": "Amharic", "yo": "Yoruba", + "ha": "Hausa", "so": "Somali", "uz": "Uzbek", "tg": "Tajik", "be": "Belarusian", "bs": "Bosnian", "mt": "Maltese", + "ga": "Irish", "la": "Latin", "yi": "Yiddish", "mi": "Maori", "haw": "Hawaiian", "jw": "Javanese", "su": "Sundanese", +} +VOXTYPE_MODELS = os.environ.get("STT_MODELS_DIR") or os.path.join(os.environ.get("XDG_DATA_HOME", os.path.join(HOME, ".local", "share")), "voxtype", "models") +MODEL_URL = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-{model}.bin" + +DEFAULT_CONFIG = { + "engine": "voxtype", # voxtype | whisper-cpp | command + "engineCommand": "", # command engine: shell line, {file} and {lang} are replaced, stdout is the text + "whisperModel": "", # whisper-cpp engine: path to a ggml model + "languages": [ # the first one is the default; autoSend: press Return after pasting; + {"code": "en", "key": "SUPER ALT D", "autoSend": False, "agentKey": "", "engineArgs": ""}, # agentKey: send the text to the default agent + ], + "agentCommand": "omarchy-agent-prompt {text}", # how a transcription is handed to the agent ({text} is shell-quoted) + "liveText": True, # transcribe while recording and show it in the bar + "liveIntervalMs": 1500, + "liveWindowSecs": 30, + "keepAudio": True, # keep the wav of every take next to its text + "outputMode": "paste", # paste | type | clipboard + "pasteKeys": "auto", # auto | ctrl+v | ctrl+shift+v | shift+insert + "restoreClipboard": True, + "maxDurationSecs": 300, + "device": "default", + "cancelKey": "ESCAPE", + "notify": True, +} + +MARK = " · stt" # suffix on every bind description this daemon creates +MODMASK = {"SHIFT": 1, "CAPS": 2, "CTRL": 4, "ALT": 8, "MOD2": 16, "MOD3": 32, "SUPER": 64, "MOD5": 128} +BARE_OK = {"ESCAPE", "PAUSE", "PRINT", "SCROLL_LOCK", "INSERT", "MENU", "CAPS_LOCK", "HOME", "END", "PAGE_UP", "PAGE_DOWN"} +KEY_ALIASES = { # keysyms some keyboards send instead of the plain F-key + "F13": "XF86Tools", "F14": "XF86Launch5", "F15": "XF86Launch6", "F16": "XF86Launch7", + "F17": "XF86Launch8", "F18": "XF86Launch9", "F19": "XF86Launch1", "F20": "XF86Launch2", + "F21": "XF86Launch3", "F22": "XF86Launch4", +} +TERMINAL_CLASSES = {"alacritty", "kitty", "foot", "com.mitchellh.ghostty", "org.omarchy.terminal", "wezterm", + "org.wezfurlong.wezterm", "xterm", "konsole", "org.kde.konsole", "gnome-terminal", + "org.gnome.terminal", "tilix", "st", "urxvt", "rio", "ptyxis", "org.gnome.ptyxis"} + + +LOG_FILE = os.path.join(RUNTIME, "daemon.log") +# The CLI next to this daemon: key bindings call it by absolute path, so they work whatever Hyprland's PATH is. +STT_CLI = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "bin", "stt") + + +def log(*a): + line = time.strftime("%H:%M:%S") + " " + " ".join(str(x) for x in a) + print(line, file=sys.stderr, flush=True) + try: + with open(LOG_FILE, "a") as f: + f.write(line + "\n") + except OSError: + pass + + +def which(name): + return shutil.which(name) is not None + + +def notify(title, body): + cmd = "omarchy-notification-send" if which("omarchy-notification-send") else "notify-send" + try: + subprocess.Popen([cmd, title, body], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except OSError: + pass + + +# --------------------------------------------------------------------------- +# config +# --------------------------------------------------------------------------- + +def load_config(): + cfg = json.loads(json.dumps(DEFAULT_CONFIG)) + try: + with open(CONFIG) as f: + user = json.load(f) + for k, v in user.items(): + if k in DEFAULT_CONFIG: + cfg[k] = v + if user.get("autoReturn"): # older config: one global switch -> per-language autoSend + for l in cfg.get("languages") or []: + if isinstance(l, dict): + l["autoSend"] = True + except FileNotFoundError: + pass + except (OSError, ValueError) as e: + log("config unreadable, using defaults:", e) + cfg["languages"] = normalize_languages(cfg.get("languages")) + return cfg + + +def normalize_languages(langs): + out = [] + for l in langs or []: + if not isinstance(l, dict): + continue + code = str(l.get("code", "")).strip().lower().replace("_", "-") + if code not in LANGUAGES: # "pt-BR", "ptbr", "en_US" -> whisper's two-letter code + base = code.split("-")[0] + code = base if base in LANGUAGES else (base[:2] if base[:2] in LANGUAGES else code) + if code not in LANGUAGES or any(o["code"] == code for o in out): + continue # unknown codes would reach `voxtype --language` and the bind's shell line + out.append({ + "code": code, + "label": LANGUAGES.get(code, str(l.get("label", "") or code)), + "key": str(l.get("key", "") or "").strip(), + "autoSend": bool(l.get("autoSend", False)), + "agentKey": str(l.get("agentKey", "") or "").strip(), + "engineArgs": str(l.get("engineArgs", "") or ""), + }) + if not out: + return normalize_languages(json.loads(json.dumps(DEFAULT_CONFIG["languages"]))) + return out + + +def save_config(cfg): + os.makedirs(CONFIG_DIR, exist_ok=True) + tmp = CONFIG + ".tmp" + with open(tmp, "w") as f: + json.dump(cfg, f, indent=2) + os.replace(tmp, CONFIG) + + +# --------------------------------------------------------------------------- +# key bindings (Hyprland) +# --------------------------------------------------------------------------- + +def parse_key(spec): + """'CTRL SHIFT F13' / 'ctrl+f13' / 'F13' -> ('CTRL SHIFT', 'F13'); '' -> None.""" + parts = [p for p in re.split(r"[\s+,]+", spec.strip()) if p] + if not parts: + return None + key = parts[-1] + mods = " ".join(p.upper() for p in parts[:-1]) + mods = mods.replace("CONTROL", "CTRL").replace("META", "SUPER").replace("WIN", "SUPER").replace("MOD4", "SUPER") + if re.fullmatch(r"f\d{1,2}", key, re.I): + key = key.upper() + elif len(key) == 1: + key = key.upper() + # A bare letter/digit/symbol would hijack that key in every app: require a modifier. + if not mods and not (re.fullmatch(r"F\d{1,2}", key) or key.startswith("XF86") or key.upper() in BARE_OK): + return None + return mods, key + + +def hyprctl(*args): + try: + return subprocess.run(["hyprctl", *args], capture_output=True, text=True, timeout=5) + except (OSError, subprocess.TimeoutExpired) as e: + log("hyprctl failed:", e) + return None + + +def lua_str(s): + return '"' + str(s).replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n").replace("\r", "") + '"' + + +class Binds: + """Applies the configured toggle keys as Hyprland binds, remembers what it + applied so a config change or a clean exit can take them back, and + re-applies after Hyprland reloads its own config (which drops runtime binds). + + Hyprland with the Lua config (Omarchy 4) takes runtime binds through + `hyprctl eval` and the o.bind/hl.unbind helpers; the legacy parser takes + `hyprctl --batch keyword bindd/unbind`. Which one works is probed once.""" + + def __init__(self): + self.applied = [] # [(mods, key)] + self.cancel_applied = None + self.conflicts = [] # keys refused because something else is bound to them + self.lua = None # unknown until probed + try: + with open(BINDS_STATE) as f: + saved = json.load(f) + if isinstance(saved, dict): + self.applied = [tuple(x) for x in saved.get("applied", [])] + self.cancel_applied = tuple(saved["cancel"]) if saved.get("cancel") else None + else: # older format: a bare list + self.applied = [tuple(x) for x in saved] + except (OSError, ValueError, TypeError): + pass + + @property + def enabled(self): + return "HYPRLAND_INSTANCE_SIGNATURE" in os.environ + + def probe(self): + if self.lua is None: + r = hyprctl("eval", "return type(o) == 'table' and type(o.bind) == 'function'") + if r is not None: # a timeout / missing hyprctl is not an answer: ask again next time + self.lua = r.returncode == 0 and r.stdout.strip() in ("ok", "true") + return bool(self.lua) + + def _save(self): + try: + os.makedirs(RUNTIME, exist_ok=True) + with open(BINDS_STATE, "w") as f: + json.dump({"applied": self.applied, "cancel": self.cancel_applied}, f) + except OSError: + pass + + def forget(self): + """Hyprland reloaded its config: every runtime bind is gone, whatever we remember.""" + self.applied = [] + self.cancel_applied = None + + def sweep(self): + """Startup: drop binds a previous daemon left behind (crash mid-recording), on keys nobody else uses.""" + if not self.enabled: + return + existing = self.current() or [] + stale = {(b[0], b[1]) for b in existing if self.ours(b[2])} + unbinds = [] + for mask, key in stale: + mods = " ".join(name for name, bit in MODMASK.items() if mask & bit) + if not self.foreign(existing, mods, key): + unbinds.append((mods, key)) + if unbinds: + self._run(unbinds, []) + self.applied = [] + self.cancel_applied = None + + @staticmethod + def specs(cfg): + out = [] + for lang in cfg["languages"]: + code, label = lang["code"], lang["label"] + if not code: + continue + for field, desc, cmd in ( + ("key", f"Dictate ({label}){MARK}", f"{shlex.quote(STT_CLI)} toggle --lang {code}"), + ("agentKey", f"Ask agent ({label}){MARK}", f"{shlex.quote(STT_CLI)} toggle --lang {code} --agent"), + ): + pk = parse_key(lang.get(field, "")) + if not pk: + continue + mods, key = pk + out.append((mods, key, desc, cmd)) + alias = KEY_ALIASES.get(key) + if alias: + out.append((mods, alias, desc, cmd)) + return out + + @staticmethod + def current(): + """Every bind Hyprland has right now: [(modmask, key_lower, description, dispatcher)].""" + r = hyprctl("binds", "-j") + if not r or r.returncode != 0: + return None + try: + return [(int(b.get("modmask", 0)), str(b.get("key", "")).lower(), str(b.get("description", "")), + str(b.get("dispatcher", ""))) for b in json.loads(r.stdout)] + except ValueError: + return None + + @staticmethod + def modmask(mods): + return sum(MODMASK.get(m, 0) for m in mods.split()) + + @staticmethod + def foreign(existing, mods, key): + """Binds on (mods, key) that are not ours. hl.unbind / keyword unbind + remove *every* bind on a key, so a key with a foreign bind is never + touched: neither bound (both would fire) nor unbound (theirs would go).""" + mask = Binds.modmask(mods) + return [b for b in existing if b[0] == mask and b[1] == key.lower() and not Binds.ours(b[2])] + + @staticmethod + def ours(desc): + # Current binds carry MARK; the patterns cover binds left by a daemon from before the marker. + return desc.endswith(MARK) or desc == "Cancel dictation" or re.fullmatch(r"(Dictate( and send)?|Ask agent) \(.+\)", desc) is not None + + def _run(self, unbinds, binds): + """unbinds: [(mods, key)], binds: [(mods, key, desc, cmd)].""" + if not unbinds and not binds: + return + # Unbinds and binds go in separate calls: within one eval Hyprland + # applies the unbind of a key after the bind of the same key, which + # would remove what was just added. + results = [] + if self.probe(): + if unbinds: + results.append(hyprctl("eval", "\n".join( + f"pcall(hl.unbind, {lua_str(' + '.join(m.split() + [k]))})" for m, k in unbinds))) + if binds: + results.append(hyprctl("eval", "\n".join( + f"o.bind({lua_str(' + '.join(m.split() + [k]))}, {lua_str(desc)}, {lua_str(cmd)})" + for m, k, desc, cmd in binds))) + else: + if unbinds: + results.append(hyprctl("--batch", " ; ".join(f"keyword unbind {m},{k}" for m, k in unbinds))) + if binds: + results.append(hyprctl("--batch", " ; ".join( + f"keyword bindd {m},{k},{desc.replace(',', ' ')},exec,{cmd}" for m, k, desc, cmd in binds))) + for r in results: + if r is None or r.returncode != 0 or "error" in r.stdout.lower(): + log("binds failed:", r.stdout.strip() if r else "", r.stderr.strip() if r else "") + return + log("binds:", "lua" if self.lua else "keyword", f"-{len(unbinds)} +{len(binds)}") + + suspended = False + + def apply(self, cfg): + if not self.enabled or self.suspended: + return + specs = self.specs(cfg) + existing = self.current() + if existing is None: + log("binds: cannot list current binds; not touching anything") + return + wanted, conflicts = [], [] + for m, k, desc, cmd in specs: + others = self.foreign(existing, m, k) + if others: + if k not in KEY_ALIASES.values(): # a refused alias is not worth a message + conflicts.append({"mods": m, "key": k, "desc": desc[: -len(MARK)], + "takenBy": others[0][2] or others[0][3] or "another bind"}) + continue + wanted.append((m, k, desc, cmd)) + # Refresh: drop what we applied before plus the keys about to be (re)bound — + # but only keys nothing else uses. + keys = list(self.applied) + [(m, k) for m, k, _, _ in wanted if (m, k) not in self.applied] + unbinds = [(m, k) for m, k in keys if not self.foreign(existing, m, k)] + self._run(unbinds, wanted) + self.applied = [(m, k) for m, k, _, _ in wanted] + self.conflicts = conflicts + for c in conflicts: + log("bind refused:", (c["mods"] + " " if c["mods"] else "") + c["key"], "is taken by", c["takenBy"]) + self._save() + + def clear(self): + if not self.enabled or (not self.applied and not self.cancel_applied): + return + existing = self.current() or [] + unbinds = list(self.applied) + if self.cancel_applied: + unbinds.append(self.cancel_applied) + self._run([(m, k) for m, k in unbinds if not self.foreign(existing, m, k)], []) + self.applied = [] + self.cancel_applied = None + self._save() + + def set_cancel(self, cfg, on): + if not self.enabled: + return + pk = parse_key(cfg.get("cancelKey", "") or "") + if on and pk: + if self.cancel_applied == pk: + return + existing = self.current() or [] + if self.foreign(existing, pk[0], pk[1]): + log("cancel key", pk, "is taken; Esc will not cancel this take") + return + self._run([self.cancel_applied] if self.cancel_applied else [], [(pk[0], pk[1], "Cancel dictation" + MARK, f"{shlex.quote(STT_CLI)} cancel")]) + self.cancel_applied = pk + self._save() + elif self.cancel_applied: + existing = self.current() or [] + if not self.foreign(existing, *self.cancel_applied): + self._run([self.cancel_applied], []) + self.cancel_applied = None + self._save() + + +# --------------------------------------------------------------------------- +# recorder +# --------------------------------------------------------------------------- + +class Recorder: + def __init__(self, device): + self.device = device + self.proc = None + self.thread = None + self.buf = bytearray() + self.levels = [] + self.lock = threading.Lock() + self.started = 0.0 + self.error = "" + self.listening = False # first audio chunk has arrived (pw-record takes a moment to connect) + + def start(self): + fake = os.environ.get("STT_FAKE_INPUT") # tests: stream a 16 kHz mono wav at real-time pace instead of the mic + if fake: + cmd = ["python3", "-c", + "import sys,time\nd=open(sys.argv[1],'rb').read()[44:]\n" + "for i in range(0,len(d),3200):\n sys.stdout.buffer.write(d[i:i+3200]); sys.stdout.buffer.flush(); time.sleep(0.1)\n" + "time.sleep(600)", fake] + else: + cmd = ["pw-record", "--raw", "--format=s16", f"--rate={RATE}", "--channels=1"] + if self.device and self.device != "default": + cmd += ["--target", self.device] + cmd.append("-") + self.proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + self.started = time.time() + self.thread = threading.Thread(target=self._pump, daemon=True) + self.thread.start() + + def _pump(self): + out = self.proc.stdout + while True: + data = out.read(CHUNK) + if not data: + break + self.listening = True + n = len(data) // 2 + samples = struct.unpack(f"<{n}h", data[: n * 2]) + rms = math.sqrt(sum(s * s for s in samples) / max(1, n)) / 32768.0 + level = min(1.0, math.sqrt(rms * 12.0)) # perceptual-ish: speech at normal level fills most of the bar + with self.lock: + self.buf += data + self.levels.append(round(level, 3)) # one per 50 ms; indexed by absolute offset, so never trimmed + err = self.proc.stderr.read().decode(errors="replace").strip() + rc = self.proc.wait() + if rc not in (0, -15, -9) and err: + self.error = err.splitlines()[-1] + + def stop(self): + if self.proc and self.proc.poll() is None: + self.proc.terminate() + try: + self.proc.wait(timeout=2) + except subprocess.TimeoutExpired: + self.proc.kill() + self.proc.wait() + if self.thread: + self.thread.join(timeout=2) + + @property + def duration(self): + with self.lock: + return len(self.buf) / 2 / RATE + + def snapshot(self, last_secs=None): + with self.lock: + if last_secs is None: + return bytes(self.buf) + n = int(last_secs * RATE) * 2 + return bytes(self.buf[-n:]) + + @property + def size(self): + with self.lock: + return len(self.buf) + + def snapshot_range(self, a, b): + with self.lock: + return bytes(self.buf[a:b]) + + def levels_copy(self): + with self.lock: + return list(self.levels) + + def recent_levels(self, n=LEVELS): + with self.lock: + lv = self.levels[-n:] + return [0.0] * (n - len(lv)) + lv + + +def write_wav(path, pcm): + with wave.open(path, "wb") as w: + w.setnchannels(1) + w.setsampwidth(2) + w.setframerate(RATE) + w.writeframes(pcm) + + +# --------------------------------------------------------------------------- +# engines +# --------------------------------------------------------------------------- + +def available_engines(): + out = [] + if which("voxtype"): + out.append("voxtype") + if which("whisper-cli") or which("whisper-cpp") or which("whisper"): + out.append("whisper-cpp") + out.append("command") + return out + + +def voxtype_model(): + try: + with open(VOXTYPE_CONFIG) as f: + m = re.search(r'^\s*model\s*=\s*"([^"]+)"', f.read(), re.M) + return m.group(1) if m else "base.en" + except OSError: + return "base.en" + + +VOXTYPE_QUIET_CONFIG = os.path.join(RUNTIME, "voxtype.toml") + + +def voxtype_config(): + """voxtype's config with pause_media, audio feedback and typing turned off: + transcribing a file must not pause the music or beep. Rebuilt whenever the + user's config changes; falls back to the user's own file.""" + try: + src = os.stat(VOXTYPE_CONFIG) + except OSError: + return None + try: + if os.stat(VOXTYPE_QUIET_CONFIG).st_mtime >= src.st_mtime: + return VOXTYPE_QUIET_CONFIG + except OSError: + pass + try: + with open(VOXTYPE_CONFIG) as f: + text = f.read() + text = re.sub(r"^(\s*pause_media\s*=\s*)true", r"\1false", text, flags=re.M) + text = re.sub(r"^(\s*state_file\s*=\s*).*$", r'\1"disabled"', text, flags=re.M) + if "pause_media" not in text: + text += "\n[audio]\npause_media = false\n" + tmp = VOXTYPE_QUIET_CONFIG + ".tmp" + with open(tmp, "w") as f: + f.write(text) + os.replace(tmp, VOXTYPE_QUIET_CONFIG) + return VOXTYPE_QUIET_CONFIG + except OSError: + return VOXTYPE_CONFIG + + +def model_for(cfg, lang): + """The whisper model a take in `lang` will use with the voxtype engine (None for other engines).""" + if cfg.get("engine", "voxtype") != "voxtype": + return None + m = voxtype_model() + if lang["code"] != "en" and m.endswith(".en"): + m = m[:-3] # an English-only model cannot do other languages + if "/" in m: # a custom path in voxtype's config + return None + return m + + +def model_path(model): + return os.path.join(VOXTYPE_MODELS, f"ggml-{model}.bin") + + +class EngineRun: + """The transcription process now running (if any), so cancel/shutdown can kill it.""" + proc = None + lock = threading.Lock() + + @classmethod + def run(cls, args, shell=False): + proc = subprocess.Popen(args, shell=shell, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + with cls.lock: + cls.proc = proc + try: + out, err = proc.communicate(timeout=600) + except subprocess.TimeoutExpired: + proc.kill() + proc.communicate() + raise + finally: + with cls.lock: + if cls.proc is proc: + cls.proc = None + return proc.returncode, out, err + + @classmethod + def kill(cls): + with cls.lock: + proc = cls.proc + if proc and proc.poll() is None: + proc.kill() + + +def run_engine(cfg, lang, path): + """Blocking. Returns (text, error).""" + engine = cfg.get("engine", "voxtype") + code = lang["code"] + try: + extra = shlex.split(lang.get("engineArgs", "") or "") + except ValueError as e: + return "", f"bad engine arguments: {e}" + try: + if engine == "voxtype": + if not which("voxtype"): + return "", "voxtype is not installed — run omarchy-voxtype-install" + args = ["voxtype", "-q"] + quiet = voxtype_config() + if quiet: + args += ["-c", quiet] + args += ["--language", code] + model = model_for(cfg, lang) + if model and model != voxtype_model(): + args += ["--model", model] + args += extra + ["transcribe", path] + rc, out, err = EngineRun.run(args) + if rc != 0: + return "", (err.strip().splitlines() or ["voxtype failed"])[-1] + lines = out.splitlines() + for i, l in enumerate(lines): + if l.startswith("Processing ") and "samples" in l: + lines = lines[i + 1:] + break + return " ".join(l.strip() for l in lines if l.strip()).strip(), "" + if engine == "whisper-cpp": + binary = next((b for b in ("whisper-cli", "whisper-cpp", "whisper") if which(b)), None) + if not binary: + return "", "whisper-cli is not installed" + model = cfg.get("whisperModel") or "" + if not model: + return "", "set the whisper.cpp model path in Settings" + args = [binary, "-m", os.path.expanduser(model), "-l", code, "-nt", "-np"] + extra + ["-f", path] + rc, out, err = EngineRun.run(args) + if rc != 0: + return "", (err.strip().splitlines() or ["whisper failed"])[-1] + return " ".join(l.strip() for l in out.splitlines() if l.strip()).strip(), "" + if engine == "command": + tmpl = cfg.get("engineCommand") or "" + if not tmpl.strip(): + return "", "set the recognition command in Settings" + line = tmpl.replace("{file}", shlex.quote(path)).replace("{lang}", shlex.quote(code)) + if extra: + line += " " + " ".join(shlex.quote(a) for a in extra) + rc, out, err = EngineRun.run(line, shell=True) + if rc != 0: + return "", (err.strip().splitlines() or ["command failed"])[-1] + return out.strip(), "" + return "", f"unknown engine {engine}" + except subprocess.TimeoutExpired: + return "", "transcription timed out" + except OSError as e: + return "", str(e) + + +# --------------------------------------------------------------------------- +# output (paste / type / clipboard) +# --------------------------------------------------------------------------- + +def clipboard_text(): + try: + types = subprocess.run(["wl-paste", "--list-types"], capture_output=True, text=True, timeout=2).stdout + if "text/plain" not in types: + return None + r = subprocess.run(["wl-paste", "--no-newline", "--type", "text/plain"], capture_output=True, timeout=2) + return r.stdout if r.returncode == 0 else None + except (OSError, subprocess.TimeoutExpired): + return None + + +def wl_copy(data): + if isinstance(data, str): + data = data.encode() + try: + subprocess.run(["wl-copy"], input=data, timeout=5) + except (OSError, subprocess.TimeoutExpired) as e: + notify("Speech to text", f"wl-copy failed: {e}") + + +def active_is_terminal(): + r = hyprctl("activewindow", "-j") + if not r or r.returncode != 0: + return False + try: + w = json.loads(r.stdout) + except ValueError: + return False + for tag in w.get("tags") or []: + if str(tag).rstrip("*") == "terminal": + return True + cls = str(w.get("class") or w.get("initialClass") or "").lower() + return cls in TERMINAL_CLASSES + + +def wtype(*args): + try: + subprocess.run(["wtype", *args], timeout=10) + except (OSError, subprocess.TimeoutExpired) as e: + notify("Speech to text", f"wtype failed: {e}") + + +def press_paste(keys): + if keys == "auto": + keys = "ctrl+shift+v" if active_is_terminal() else "ctrl+v" + if keys == "ctrl+shift+v": + wtype("-M", "ctrl", "-M", "shift", "-k", "v", "-m", "shift", "-m", "ctrl") + elif keys == "shift+insert": + wtype("-M", "shift", "-k", "Insert", "-m", "shift") + else: + wtype("-M", "ctrl", "-k", "v", "-m", "ctrl") + + +def deliver(cfg, text, enter): + """Blocking. Puts the text where the cursor is, then optionally presses Return.""" + mode = cfg.get("outputMode", "paste") + saved = None + if mode in ("paste", "clipboard"): + if mode == "paste" and cfg.get("restoreClipboard", True): + saved = clipboard_text() + wl_copy(text) + if mode == "paste": + time.sleep(0.08) + press_paste(cfg.get("pasteKeys", "auto")) + elif mode == "type": + wtype("-d", "1", "--", text) + if enter: + time.sleep(0.15) + wtype("-k", "Return") + if saved is not None: + time.sleep(0.4) + wl_copy(saved) + + +def deliver_agent(cfg, text): + """Blocking. Hands the text to the default coding agent (a new terminal) instead of pasting it.""" + tmpl = cfg.get("agentCommand") or "omarchy-agent-prompt {text}" + line = tmpl.replace("{text}", shlex.quote(text)) if "{text}" in tmpl else tmpl + " " + shlex.quote(text) + try: + subprocess.Popen(["sh", "-c", line], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True) + except OSError as e: + notify("Speech to text", f"Could not launch the agent: {e}") + + +# --------------------------------------------------------------------------- +# history +# --------------------------------------------------------------------------- + +class History: + def __init__(self): + os.makedirs(TAKES, exist_ok=True) + self.db = sqlite3.connect(DB) + self.db.execute( + "CREATE TABLE IF NOT EXISTS takes (id INTEGER PRIMARY KEY, created_at REAL, duration REAL," + " lang TEXT, engine TEXT, text TEXT, audio TEXT, delivered INTEGER)" + ) + self.db.commit() + + def add(self, created_at, duration, lang, engine, text, audio, delivered): + cur = self.db.execute( + "INSERT INTO takes (created_at, duration, lang, engine, text, audio, delivered) VALUES (?,?,?,?,?,?,?)", + (created_at, duration, lang, engine, text, audio, int(delivered)), + ) + self.db.commit() + return cur.lastrowid + + @staticmethod + def row(r): + return {"id": r[0], "createdAt": r[1], "duration": r[2], "lang": r[3], "engine": r[4], "text": r[5], + "audio": r[6] or "", "delivered": bool(r[7])} + + def get(self, id): + r = self.db.execute("SELECT * FROM takes WHERE id=?", (id,)).fetchone() + return self.row(r) if r else None + + def list(self, query="", limit=50, offset=0): + q = f"%{query}%" + rows = self.db.execute( + "SELECT * FROM takes WHERE text LIKE ? ORDER BY id DESC LIMIT ? OFFSET ?", (q, limit, offset) + ).fetchall() + total = self.db.execute("SELECT COUNT(*) FROM takes WHERE text LIKE ?", (q,)).fetchone()[0] + return [self.row(r) for r in rows], total + + def delete(self, id): + t = self.get(id) + if not t: + return + if t["audio"]: + try: + os.remove(t["audio"]) + except OSError: + pass + self.db.execute("DELETE FROM takes WHERE id=?", (id,)) + self.db.commit() + + def clear(self): + for (audio,) in self.db.execute("SELECT audio FROM takes").fetchall(): + if audio: + try: + os.remove(audio) + except OSError: + pass + self.db.execute("DELETE FROM takes") + self.db.commit() + + def update_text(self, id, text): + self.db.execute("UPDATE takes SET text=? WHERE id=?", (text, id)) + self.db.commit() + + +# --------------------------------------------------------------------------- +# daemon +# --------------------------------------------------------------------------- + +class Daemon: + def __init__(self): + self.cfg = load_config() + self.binds = Binds() + self.history = History() + self.clients = set() + self.state = "idle" # idle | recording | transcribing + self.lang = self.cfg["languages"][0] + self.rec = None + self.partial = "" + self.last = None + self.error = "" + self.playing = 0 + self.play_proc = None + self.enter_pending = False + self.agent_mode = False # this recording goes to the agent, not the cursor + # Incremental transcription of the running take: text for audio before + # committed_off is final; tail_text is the live guess for what follows. + self.committed = [] + self.committed_off = 0 + self.tail_text = "" + self.engine_lock = None + self.download = None # {"model", "pct", "error"} while a model is being fetched + self.download_task = None + self.wanted_model = "" # model a refused recording was waiting for + self.engines = available_engines() # cached: state is pushed 20×/s while recording + self.agent = self.agent_name() + self.error_clear = None # timer handle: errors fade by themselves + self.loop = None + self.stopping = False + self.stop_event = None + + # ---- state ---- + def state_msg(self, full=False): + """full: include the static language table (initial greeting and explicit `get`).""" + rec = self.rec if self.state != "idle" else None + msg = { + "type": "state", + "version": VERSION, + "state": self.state, + "lang": self.lang["code"], + "langLabel": self.lang["label"], + "startedAt": rec.started if rec else 0, + "elapsed": round(rec.duration, 1) if rec else 0, + "listening": bool(rec and rec.listening), + "levels": rec.recent_levels() if rec and self.state == "recording" else [], + "partial": self.partial, + "last": self.last, + "error": self.error, + "playing": self.playing, + "config": self.cfg, + "engines": self.engines, + "binds": [{"mods": m, "key": k, "desc": d[: -len(MARK)]} for m, k, d, _ in Binds.specs(self.cfg) + if k not in KEY_ALIASES.values() and (m, k) in self.binds.applied], + "conflicts": self.binds.conflicts, + "download": self.download, + "agentName": self.agent, + "agentMode": self.agent_mode if self.state != "idle" else False, + } + if full: + msg["languageNames"] = LANGUAGES + return msg + + def broadcast(self, msg=None): + line = (json.dumps(msg or self.state_msg()) + "\n").encode() + for w in list(self.clients): + try: + if w.transport.get_write_buffer_size() > 1_000_000: # a client that stopped reading + raise ConnectionError("client not draining") + w.write(line) + except Exception: + self.clients.discard(w) + try: + w.close() + except Exception: + pass + + # ---- recording ---- + def find_lang(self, code): + """The language with this code, else the default (the first in the list).""" + usable = [l for l in self.cfg["languages"] if l["code"]] + for l in usable: + if code and l["code"] == code: + return l + return usable[0] if usable else self.cfg["languages"][0] + + @staticmethod + def agent_name(): + try: + with open(os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.join(HOME, ".config")), "omarchy", "defaults", "agent")) as f: + return f.read().strip() + except OSError: + return "" + + # ---- models ---- + def missing_models(self): + out = [] + for l in self.cfg["languages"]: + m = model_for(self.cfg, l) + if m and not os.path.exists(model_path(m)) and m not in out: + out.append(m) + return out + + def ensure_models(self): + """Fetch every model the configured languages need, one after the other, in the background.""" + if self.download_task and not self.download_task.done(): + return + missing = self.missing_models() + if missing: + self.download_task = self.loop.create_task(self.fetch_model(missing[0])) + + def lang_for_model(self, model): + for l in self.cfg["languages"]: + if model_for(self.cfg, l) == model: + return l["label"] + return model + + async def fetch_model(self, model): + os.makedirs(VOXTYPE_MODELS, exist_ok=True) + dest = model_path(model) + fd, part = tempfile.mkstemp(prefix=f"ggml-{model}.", suffix=".part", dir=VOXTYPE_MODELS) + os.close(fd) + url = MODEL_URL.format(model=model) + self.download = {"model": model, "lang": self.lang_for_model(model), "pct": 0, "error": ""} + self.broadcast() + log("downloading model", model) + proc = None + try: + head = await self.loop.run_in_executor(None, lambda: subprocess.run( + ["curl", "-sIL", url], capture_output=True, text=True, timeout=60)) + total = 0 + for line in head.stdout.splitlines(): + if line.lower().startswith("content-length:"): + total = int(line.split(":", 1)[1].strip() or 0) + proc = await asyncio.create_subprocess_exec( + "curl", "-sSL", "--fail", "-o", part, url, stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.PIPE) + while proc.returncode is None: + try: + await asyncio.wait_for(proc.wait(), timeout=0.5) + except asyncio.TimeoutError: + pass + try: + done = os.path.getsize(part) + except OSError: + done = 0 + self.download["pct"] = int(done * 100 / total) if total else 0 + self.broadcast() + err = (await proc.stderr.read()).decode(errors="replace").strip() + if proc.returncode != 0: + raise RuntimeError(err.splitlines()[-1] if err else f"curl exited {proc.returncode}") + size = os.path.getsize(part) + if size < 1_000_000 or (total and size != total): + raise RuntimeError(f"incomplete file ({size} of {total} bytes)") + os.chmod(part, 0o644) # mkstemp makes it private; models are plain shared files + os.replace(part, dest) + log("model ready", dest) + self.download = None + if self.wanted_model == model: # a recording was refused while waiting for this one + self.wanted_model = "" + self.error = "" + if self.cfg.get("notify", True): + label = self.lang_for_model(model) + key = next((l["key"] for l in self.cfg["languages"] if l["label"] == label and l["key"]), "") + notify("Speech to text", f"Ready for {label}" + (f" — press {key} to dictate" if key else "")) + except asyncio.CancelledError: + if proc and proc.returncode is None: + proc.kill() + self.download = None + raise + except Exception as e: # noqa: BLE001 - anything here is "the download failed" + self.download = None + self.fail(f"Could not download the {model} model: {e}") + finally: + try: + os.remove(part) + except OSError: + pass + self.broadcast() + self.download_task = None + self.ensure_models() # next one, if any + + async def start(self, code=None, agent=False): + if self.state != "idle": + self.broadcast() + return + if not which("pw-record"): + self.fail("pw-record (PipeWire) is not installed") + self.broadcast() + return + self.agent_mode = bool(agent) + self.lang = self.find_lang(code) + if self.cfg.get("engine", "voxtype") == "voxtype" and not which("voxtype"): + self.fail("voxtype is not installed — run omarchy-voxtype-install") + self.broadcast() + return + model = model_for(self.cfg, self.lang) + if model and not os.path.exists(model_path(model)): + # Not an error: the bar shows "Getting ready for · N%" while it downloads. + self.ensure_models() + self.wanted_model = model + self.broadcast() + return + self.error = "" + self.partial = "" + self.committed = [] + self.committed_off = 0 + self.tail_text = "" + self.rec = Recorder(self.cfg.get("device", "default")) + try: + self.rec.start() + except (OSError, ValueError, TypeError) as e: + self.rec = None + self.fail(f"cannot record: {e}") + self.broadcast() + return + self.state = "recording" + self.binds.set_cancel(self.cfg, True) + self.broadcast() + self.loop.create_task(self.pump_levels()) + if self.cfg.get("liveText", True): + self.loop.create_task(self.live_loop()) + + async def pump_levels(self): + rec = self.rec + while self.state == "recording" and self.rec is rec: + if rec.proc.poll() is not None: + self.fail(rec.error or "The microphone stopped") + await self.cancel() + return + if rec.duration >= self.cfg.get("maxDurationSecs", 300): + await self.stop(False) + return + self.broadcast() + await asyncio.sleep(0.05) + + # ---- incremental transcription ---- + # Whisper has no streaming mode, so the take is cut at pauses: everything + # up to the last pause is transcribed once and kept ("committed"), and only + # the part after it is re-transcribed on every tick and again at stop. That + # keeps the live text cheap on long takes and makes stop fast: it only has + # to transcribe the last phrase. + LEVEL_SECS = CHUNK / 2 / RATE # one level per chunk (50 ms) + + def _thresholds(self, levels): + """(silence, voice) thresholds adapted to the take's noise floor.""" + if len(levels) < 10: + return 0.10, 0.14 + floor = sorted(levels)[len(levels) // 4] + return floor + 0.04, max(0.14, floor + 0.10) + + def _voiced(self, levels, a, b): + lv = levels[a // CHUNK: max(a // CHUNK + 1, b // CHUNK)] + return bool(lv) and max(lv) > self._thresholds(levels)[1] + + def _find_cut(self, levels): + """Byte offset in the middle of the last pause after committed_off, or None. + A pause is >= 0.6 s under the silence threshold, ending >= 0.3 s ago, + with >= 1 s of audio before it. Very long uncommitted stretches are cut + at their quietest recent point so the tail never grows unbounded.""" + n = len(levels) + first = self.committed_off // CHUNK + silent, _ = self._thresholds(levels) + gap, settle, min_chunk = 12, 6, 20 + i = n - settle + while i - gap >= first + min_chunk: + if max(levels[i - gap:i]) < silent: + return (i - gap // 2) * CHUNK + i -= 1 + if n - first > 25 * 20: + window = levels[n - 100:n - settle] + j = min(range(len(window)), key=lambda k: window[k]) + return (n - 100 + j) * CHUNK + return None + + async def _transcribe_range(self, rec, a, b, prefix): + """Run the engine on buf[a:b] (under the engine lock). Returns text ('' for noise) or None on error.""" + pcm = rec.snapshot_range(a, b) + if len(pcm) < int(0.3 * RATE) * 2: + return "" + fd, tmp = tempfile.mkstemp(prefix=prefix, suffix=".wav", dir=RUNTIME) + os.close(fd) + write_wav(tmp, pcm) + try: + async with self.engine_lock: + text, err = await self.loop.run_in_executor(None, run_engine, self.cfg, self.lang, tmp) + finally: + try: + os.remove(tmp) + except OSError: + pass + if err: + self.error = err + return None + return text if re.search(r"\w", text) else "" # whisper answers noise with lone punctuation + + def _partial_text(self): + return " ".join(self.committed + ([self.tail_text] if self.tail_text else [])) + + async def live_loop(self): + rec = self.rec + interval = max(0.4, self.cfg.get("liveIntervalMs", 1500) / 1000) + await asyncio.sleep(interval) + while self.state == "recording" and self.rec is rec: + levels = rec.levels_copy() + cut = self._find_cut(levels) + if cut is not None and cut > self.committed_off: + a = self.committed_off + text = await self._transcribe_range(rec, a, cut, "live-") if self._voiced(levels, a, cut) else "" + if self.rec is not rec: + return + if text is not None: # on an engine error the segment stays uncommitted and is retried + if text: + self.committed.append(text) + self.committed_off = cut + self.tail_text = "" + else: + a, b = self.committed_off, rec.size + if b - a > int(0.8 * RATE) * 2 and self._voiced(levels, a, b): + text = await self._transcribe_range(rec, a, b, "live-") + if self.rec is not rec: + return + if text: + self.tail_text = text + self.partial = self._partial_text() + self.broadcast() + await asyncio.sleep(interval) + + async def stop(self, enter=False, agent=False): + if self.state != "recording": + self.broadcast() + return + rec = self.rec + rec.stop() + self.state = "transcribing" + self.enter_pending = bool(enter) + agent = bool(agent) or self.agent_mode + lang = self.lang + self.binds.set_cancel(self.cfg, False) + self.broadcast() + path = "" + try: + pcm = rec.snapshot() + duration = len(pcm) / 2 / RATE + started = rec.started + stamp = time.strftime("%Y%m%d-%H%M%S", time.localtime(started)) + path = os.path.join(TAKES, f"{stamp}-{lang['code']}.wav") + write_wav(path, pcm) + if duration < 0.3: + text, err = "", "nothing recorded" + elif self.cfg.get("liveText", True) and (self.committed or self.committed_off): + # The live loop already transcribed everything before committed_off + # (a call in flight finishes under the engine lock and commits); + # only the tail after the last pause is left. + async with self.engine_lock: + pass + if self.rec is not rec: + raise asyncio.CancelledError + committed, off = list(self.committed), self.committed_off + a, b = off, len(pcm) + tail = await self._transcribe_range(rec, a, b, "final-") if self._voiced(rec.levels_copy(), a, b) else "" + if tail is None: + text, err = "", self.error + else: + text, err = " ".join(committed + ([tail] if tail else [])), "" + else: + text, err = await self.loop.run_in_executor(None, run_engine, self.cfg, lang, path) + if self.rec is not rec: # cancelled (or restarted) meanwhile: this recording is void + raise asyncio.CancelledError + if err: + self._discard(path) + self.fail(err) + elif not re.search(r"\w", text): # empty, or whisper's lone punctuation for noise + self._discard(path) + self.fail("Nothing heard") + else: + if not self.cfg.get("keepAudio", True): + self._discard(path) + path = "" + id = self.history.add(started, duration, lang["code"], self.cfg.get("engine", "voxtype"), + text, path, self.cfg.get("outputMode", "paste") != "clipboard") + self.last = {"id": id, "text": text, "lang": lang["code"], "duration": round(duration, 1), + "createdAt": started, "enter": self.enter_pending, "agent": agent} + self.error = "" + if agent: + await self.loop.run_in_executor(None, deliver_agent, self.cfg, text) + else: + await self.loop.run_in_executor(None, deliver, self.cfg, text, self.enter_pending) + self.broadcast({"type": "history-changed"}) + except asyncio.CancelledError: + self._discard(path) + return + except Exception as e: # noqa: BLE001 - whatever failed, the daemon must not get stuck in "transcribing" + self._discard(path) + log("stop failed:", repr(e)) + self.fail(f"Transcription failed: {e}") + finally: + if self.rec is rec: + self.state = "idle" + self.rec = None + self.partial = "" + self.broadcast() + + @staticmethod + def _discard(path): + try: + os.remove(path) + except OSError: + pass + + async def cancel(self): + if self.state == "idle": + self.broadcast() + return + if self.rec: + self.rec.stop() + EngineRun.kill() # a transcription in flight is for a recording nobody wants + self.state = "idle" + self.rec = None + self.partial = "" + self.binds.set_cancel(self.cfg, False) + self.broadcast() + + async def toggle(self, code=None, enter=False, agent=False): + if self.state == "idle": + await self.start(code, agent) + elif self.state == "recording": + await self.stop(enter or bool(self.lang.get("autoSend")), agent) + else: + self.broadcast() # transcribing: nothing to do, but answer whoever asked + + def fail(self, msg): + self.error = msg + log("error:", msg) + if self.cfg.get("notify", True): + notify("Speech to text", msg) + # The bar and the popup show it for a moment; it must not stay lit until someone dismisses it. + if self.error_clear: + self.error_clear.cancel() + self.error_clear = self.loop.call_later(8, self._clear_error, msg) + + def _clear_error(self, msg): + if self.error == msg: + self.error = "" + self.broadcast() + + # ---- history actions ---- + async def play(self, id): + self.stop_play() + t = self.history.get(id) + if not t or not t["audio"] or not os.path.exists(t["audio"]): + self.fail("This recording has no audio") + self.broadcast() + return + try: + self.play_proc = subprocess.Popen(["pw-play", t["audio"]], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except OSError as e: + self.fail(str(e)) + self.broadcast() + return + self.playing = id + self.broadcast() + proc = self.play_proc + while proc.poll() is None: + await asyncio.sleep(0.2) + if self.play_proc is proc: + self.play_proc = None + self.playing = 0 + self.broadcast() + + def stop_play(self): + if self.play_proc and self.play_proc.poll() is None: + self.play_proc.terminate() + self.play_proc = None + self.playing = 0 + + # ---- config ---- + def set_config(self, patch): + for k, v in (patch or {}).items(): + if k not in DEFAULT_CONFIG: + continue + want = type(DEFAULT_CONFIG[k]) + if want is bool and isinstance(v, bool): + pass + elif want in (int, float) and isinstance(v, (int, float)) and not isinstance(v, bool): + v = want(v) + elif want is str and isinstance(v, str): + pass + elif want is list and isinstance(v, list): + pass + elif want is int and isinstance(v, str) and v.strip().lstrip("-").isdigit(): + v = int(v) + else: + self.fail(f"{k}: expected {want.__name__}, got {type(v).__name__}") + continue + self.cfg[k] = v + if not (5 <= self.cfg.get("maxDurationSecs", 300) <= 7200): + self.cfg["maxDurationSecs"] = DEFAULT_CONFIG["maxDurationSecs"] + self.cfg["languages"] = normalize_languages(self.cfg.get("languages")) + if self.lang["code"] not in [l["code"] for l in self.cfg["languages"]]: + self.lang = self.cfg["languages"][0] + try: + save_config(self.cfg) + except OSError as e: + self.fail(f"cannot save config: {e}") + self.binds.apply(self.cfg) + self.engines = available_engines() + self.agent = self.agent_name() + self.ensure_models() + + # ---- socket ---- + async def handle(self, reader, writer): + self.clients.add(writer) + try: + writer.write((json.dumps(self.state_msg(full=True)) + "\n").encode()) + while True: + try: + line = await reader.readline() + except (ValueError, asyncio.LimitOverrunError): # a line longer than the limit + break + if not line: + break + try: + msg = json.loads(line) + except ValueError: + continue + if not isinstance(msg, dict): + continue + try: + await self.dispatch(msg, writer) + except Exception as e: # noqa: BLE001 - one bad request must not take the connection down + log("request failed:", repr(msg)[:200], repr(e)) + except (ConnectionError, asyncio.CancelledError): + pass + finally: + self.clients.discard(writer) + try: + writer.close() + except Exception: + pass + + def spawn(self, coro): + """Slow work (a recording's stop, a paste) runs as its own task so the + connection keeps answering — the shell's cancel/get must not queue + behind a transcription.""" + task = self.loop.create_task(coro) + task.add_done_callback(lambda t: log("task failed:", repr(t.exception())) if not t.cancelled() and t.exception() else None) + return task + + @staticmethod + def _id(msg): + try: + return int(msg.get("id") or 0) + except (TypeError, ValueError): + return 0 + + @staticmethod + def _lang(msg): + v = msg.get("lang") + return str(v) if isinstance(v, str) and v else None + + async def dispatch(self, msg, writer): + cmd = msg.get("cmd") + if cmd == "get": + writer.write((json.dumps(self.state_msg(full=True)) + "\n").encode()) + elif cmd == "toggle": + self.spawn(self.toggle(self._lang(msg), bool(msg.get("enter")), bool(msg.get("agent")))) + elif cmd == "start": + self.spawn(self.start(self._lang(msg), bool(msg.get("agent")))) + elif cmd == "stop": + self.spawn(self.stop(bool(msg.get("enter")) or bool(self.lang.get("autoSend")), bool(msg.get("agent")))) + elif cmd == "suspendBinds": # the panel is capturing a key: ours must not fire + self.binds.clear() + self.binds.suspended = True + elif cmd == "resumeBinds": + self.binds.suspended = False + self.binds.apply(self.cfg) + self.broadcast() + elif cmd == "cancel": + await self.cancel() + elif cmd == "history": + query = str(msg.get("query", "") or "")[:200] + limit = max(1, min(1000, self._id({"id": msg.get("limit", 50)}) or 50)) + offset = max(0, self._id({"id": msg.get("offset", 0)})) + items, total = self.history.list(query, limit, offset) + writer.write((json.dumps({"type": "history", "items": items, "total": total, + "query": query, "offset": offset}) + "\n").encode()) + elif cmd == "delete": + self.history.delete(self._id(msg)) + self.broadcast({"type": "history-changed"}) + elif cmd == "clearHistory": + self.history.clear() + self.broadcast({"type": "history-changed"}) + elif cmd == "copy": + t = self.history.get(self._id(msg)) + if t: + self.spawn(self.loop.run_in_executor(None, wl_copy, t["text"])) + elif cmd == "paste": + t = self.history.get(self._id(msg)) + if t: + self.spawn(self.loop.run_in_executor(None, deliver, self.cfg, t["text"], bool(msg.get("enter")))) + elif cmd == "edit": + self.history.update_text(self._id(msg), str(msg.get("text", ""))[:100_000]) + self.broadcast({"type": "history-changed"}) + elif cmd == "play": + self.spawn(self.play(self._id(msg))) + elif cmd == "stopPlay": + self.stop_play() + self.broadcast() + elif cmd == "set": + patch = msg.get("config") + self.set_config(patch if isinstance(patch, dict) else {}) + self.broadcast() + elif cmd == "setLang": + if self.state == "idle": + self.lang = self.find_lang(str(msg.get("lang", ""))) + self.broadcast() + elif cmd == "rebind": + self.binds.apply(self.cfg) + self.broadcast() + elif cmd == "clearError": + self.error = "" + self.broadcast() + elif cmd == "quit": + self.loop.create_task(self.shutdown()) + + # ---- hyprland events: re-apply binds after a config reload ---- + async def hypr_events(self): + sig = os.environ.get("HYPRLAND_INSTANCE_SIGNATURE") + if not sig: + return + path = os.path.join(os.environ.get("XDG_RUNTIME_DIR", "/tmp"), "hypr", sig, ".socket2.sock") + while not self.stopping: + try: + reader, writer = await asyncio.open_unix_connection(path) + while True: + line = await reader.readline() + if not line: + break + if line.startswith(b"configreloaded"): + await asyncio.sleep(0.3) + self.binds.forget() # the reload dropped every runtime bind + self.binds.apply(self.cfg) + if self.state == "recording": + self.binds.set_cancel(self.cfg, True) + writer.close() + except OSError: + pass + await asyncio.sleep(5) + + async def shutdown(self): + if self.stopping: + return + self.stopping = True + if self.rec: + self.rec.stop() + EngineRun.kill() + if self.download_task and not self.download_task.done(): + self.download_task.cancel() + try: + await self.download_task + except (asyncio.CancelledError, Exception): # noqa: BLE001 + pass + self.stop_play() + self.binds.clear() + for w in list(self.clients): + try: + w.close() + except Exception: + pass + self.stop_event.set() + + async def run(self): + self.loop = asyncio.get_running_loop() + self.stop_event = asyncio.Event() + self.engine_lock = asyncio.Lock() + os.makedirs(RUNTIME, exist_ok=True) + try: + os.remove(SOCK) + except OSError: + pass + server = await asyncio.start_unix_server(self.handle, path=SOCK, limit=1 << 20) + self.binds.sweep() + self.binds.apply(self.cfg) + self.ensure_models() + self.loop.create_task(self.hypr_events()) + for s in (signal.SIGTERM, signal.SIGINT): + self.loop.add_signal_handler(s, lambda: self.loop.create_task(self.shutdown())) + log(f"sttd {VERSION} listening on {SOCK}") + async with server: + await server.start_serving() + await self.stop_event.wait() + + +def main(): + os.makedirs(RUNTIME, exist_ok=True) + try: + if os.path.getsize(LOG_FILE) > 200_000: + os.remove(LOG_FILE) + except OSError: + pass + lockf = open(LOCK, "w") + try: + fcntl.flock(lockf, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + log("another sttd holds the lock; exiting") + sys.exit(3) + # The socket is not removed on exit: the shell may already have started a + # replacement daemon that listens on the same path. + asyncio.run(Daemon().run()) + + +if __name__ == "__main__": + main() diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..852c918 --- /dev/null +++ b/install.sh @@ -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 --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." diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..6f87619 --- /dev/null +++ b/manifest.json @@ -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 + } + ] + } +} diff --git a/plugin/Panel.qml b/plugin/Panel.qml new file mode 100644 index 0000000..14c870c --- /dev/null +++ b/plugin/Panel.qml @@ -0,0 +1,1234 @@ +import QtQuick +import QtQuick.Controls +import Quickshell +import Quickshell.Io +import qs.Commons +import qs.Ui + +// Bar widget + popup for Speech to Text. In the bar: a microphone icon that, +// while a recording runs, turns into a waveform (yellow sine while the microphone +// connects, green voice levels once it listens) with the words as they are +// recognised; it folds back the moment the text has been pasted. The popup +// has two tabs: History (every recording, with play / copy / paste / delete) and +// Settings (one key and an auto-send switch per language; the rest is +// tucked under Advanced). All state lives in the daemon (see Service.qml); +// this file renders and forwards. +Panel { + id: root + moduleName: "alanfortlink.speech-to-text" + ipcTarget: "alanfortlink.speech-to-text" + manageIpc: false + + readonly property var svc: bar && bar.shell ? bar.shell.serviceFor("alanfortlink.speech-to-text") : null + readonly property bool connected: svc ? svc.connected : false + readonly property bool recording: svc ? svc.recording : false + readonly property bool transcribing: svc ? svc.transcribing : false + readonly property bool listening: svc ? svc.listening : false // audio is flowing + readonly property bool connecting: recording && !listening // pw-record is still opening the microphone + readonly property bool busy: recording || transcribing + readonly property var cfg: svc ? svc.config : ({}) + readonly property var download: svc ? svc.download : null + readonly property bool alwaysShow: setting("alwaysShow", true) + readonly property int maxTextWidth: Style.space(setting("maxTextWidth", 360)) + readonly property color fg: bar ? bar.foreground : Color.foreground + readonly property color dim: Qt.darker(fg, 1.4) + readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family + readonly property bool vertical: bar ? bar.vertical : false + readonly property int barSize: bar ? bar.barSize : Style.bar.sizeHorizontal + readonly property int rowH: Style.spacing.controlHeight + readonly property int trailInset: Style.space(6) // ToggleSwitch's hover ring pad; trailing controls line up with it + readonly property int labelW: Style.space(104) // one label column for every form row + + // Traffic-light colours for the recording: yellow while the microphone connects, + // green once it listens. The shell's Color singleton does not expose them, + // so they come from the current theme's colors.toml (with fallbacks). + property color yellow: "#f9e2af" + property color green: "#a6e3a1" + FileView { + id: themeFile + path: Color.currentThemePath + "/colors.toml" + watchChanges: true + onLoaded: root.readThemeColors() + onFileChanged: reload() + onTextChanged: root.readThemeColors() + } + function readThemeColors() { + var t = String(themeFile.text() || "") + var y = t.match(/^\s*yellow\s*=\s*"(#[0-9a-fA-F]{6})"/m), g = t.match(/^\s*green\s*=\s*"(#[0-9a-fA-F]{6})"/m) + if (y) yellow = y[1] + if (g) green = g[1] + } + readonly property color takeColor: connecting ? yellow : recording ? green : fg + + // A failed recording shows its reason in the bar for a moment; a successful one + // shows nothing — the text is already where it belongs. + property bool showError: false + Timer { id: errorTimer; interval: 4000; onTriggered: root.showError = false } + onTranscribingChanged: { + if (transcribing || !svc) return + if (svc.error !== "") { showError = true; errorTimer.restart() } + } + onRecordingChanged: if (recording) { showError = false; errorTimer.stop() } + + readonly property bool expanded: !vertical && (busy || showError || download !== null) + visible: alwaysShow || expanded + implicitWidth: expanded ? strip.implicitWidth : button.implicitWidth + implicitHeight: button.implicitHeight + Behavior on implicitWidth { NumberAnimation { duration: 180; easing.type: Easing.OutCubic } } + + // Idle: left = popup, right = dictate. While recording the strip is the + // control: left = stop, right = discard. + function pressed(b) { + if (!svc) return + if (recording) { + if (b === Qt.RightButton) svc.cancel() + else if (b === Qt.LeftButton) svc.toggle(null, false) + return + } + if (b === Qt.RightButton) svc.toggle(null, false) + else if (b === Qt.MiddleButton) svc.cancel() + else root.toggle() + } + + // ---- text helpers ---- + function langName(code) { + if (svc && svc.languageNames && svc.languageNames[code]) return svc.languageNames[code] + for (var i = 0; i < langs.length; i++) if (langs[i].code === code) return langs[i].label || code + return code + } + function keyFor(code, kind) { // the applied key for one language ("" if none); kind "Dictate" (default) or "Ask agent" + if (!svc) return "" + var want = (kind || "Dictate") + " (" + langName(code) + ")", b = svc.binds || [] + for (var i = 0; i < b.length; i++) { + if (String(b[i].desc || "") !== want) continue + return (b[i].mods ? b[i].mods + " " : "") + b[i].key + } + return "" + } + readonly property string defaultLang: langs.length ? langs[0].code : "" + readonly property string agentName: svc && svc.agentName !== "" ? svc.agentName : "agent" + function clock(secs) { + var s = Math.floor(secs || 0) + return Math.floor(s / 60) + ":" + (s % 60 < 10 ? "0" : "") + (s % 60) + } + function when(ts) { + var d = new Date((ts || 0) * 1000), now = new Date() + var hm = Qt.formatTime(d, "HH:mm") + if (d.toDateString() === now.toDateString()) return hm + var y = new Date(now.getTime() - 86400000) + if (d.toDateString() === y.toDateString()) return "Yesterday " + hm + return Qt.formatDate(d, "d MMM") + " " + hm + } + function subtitle() { + if (!svc) return "Service not loaded" + if (!connected) return svc.daemonError !== "" ? svc.daemonError : "Starting…" + if (connecting) return "Opening the microphone…" + if (recording) return "Listening · " + svc.langLabel + " · " + clock(svc.elapsed) + (svc.agentMode ? " · to " + agentName : "") + if (transcribing) return "Transcribing…" + if (download) return "Getting ready for " + (download.lang || download.model) + " · " + download.pct + "%" + return "Idle · " + svc.langLabel + } + readonly property string liveText: { + if (!svc) return "" + if (connecting) return "Opening microphone…" + if (recording) return svc.partial !== "" ? svc.partial : "Listening…" + if (transcribing) return svc.partial !== "" ? svc.partial : (svc.agentMode ? "Sending to " + agentName + "…" : "Transcribing…") + if (showError) return svc.error + if (download) return "Getting ready for " + (download.lang || download.model) + " · " + download.pct + "%" + return "" + } + readonly property bool livePlaceholder: recording ? (svc && svc.partial === "") : (transcribing || (download !== null && !showError)) + // The popup shows the end of a long transcript (Text cannot left-elide wrapped text). + readonly property string liveTail: liveText.length > 420 ? "…" + liveText.slice(-420) : liveText + + // ---------- bar: idle icon ---------- + BarIconButton { + id: button + anchors.fill: parent + visible: !root.expanded + bar: root.bar + text: "󰍬" + active: root.busy || root.showError + useActiveColor: true + activeColor: root.busy ? root.takeColor : Color.urgent + tooltipText: (root.connected ? "Speech to text" : "Speech to text · starting") + + (root.svc && root.svc.error !== "" ? " · " + root.svc.error : "") + + (root.keyFor(root.defaultLang) !== "" ? " · " + root.keyFor(root.defaultLang) + ": dictate " + root.langName(root.defaultLang) : "") + + " · right-click: dictate" + onPressed: function(b) { root.pressed(b) } + } + + // ---------- bar: live strip ---------- + Item { + id: strip + anchors.fill: parent + visible: root.expanded + implicitWidth: stripRow.implicitWidth + Style.spacing.xxxl + + Row { + id: stripRow + anchors.centerIn: parent + spacing: Style.spacing.md + + Waveform { + anchors.verticalCenter: parent.verticalCenter + height: Style.bar.iconCanvas + bars: 18 + levels: root.svc ? root.svc.levels : [] + sine: root.connecting || (root.download !== null && !root.busy) + idle: root.transcribing || (!root.recording && root.showError) + color: root.showError && !root.busy ? Color.urgent : root.download !== null && !root.busy ? root.yellow : root.takeColor + Behavior on color { ColorAnimation { duration: 250 } } + } + + TextMetrics { id: liveMetrics; text: root.liveText; font.family: root.fontFamily; font.pixelSize: Style.font.body } + Text { + anchors.verticalCenter: parent.verticalCenter + text: root.liveText + color: root.showError && !root.busy ? Color.urgent : root.livePlaceholder ? root.dim : root.fg + font.family: root.fontFamily + font.pixelSize: Style.font.body + font.italic: root.livePlaceholder + elide: Text.ElideLeft + width: Math.min(liveMetrics.advanceWidth + 2, root.maxTextWidth) + visible: text !== "" + } + + Text { + anchors.verticalCenter: parent.verticalCenter + visible: root.recording + text: root.clock(root.svc ? root.svc.elapsed : 0) + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } + } + MouseArea { + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + onClicked: function(mouse) { root.pressed(mouse.button) } + onEntered: if (root.bar && root.recording) root.bar.showTooltip(strip, "Click: stop and paste · right-click: discard") + onExited: if (root.bar) root.bar.hideTooltip(strip) + } + } + + IpcHandler { + target: root.ipcTarget + function open() { root.open() } + function close() { root.close() } + function show() { root.open() } + function hide() { root.close() } + function toggle() { root.toggle() } + // omarchy-shell alanfortlink.speech-to-text dictate (same as `stt toggle`) + function dictate() { if (root.svc) root.svc.toggle(null, false) } + function dictateSend() { if (root.svc) root.svc.toggle(null, true) } + function cancel() { if (root.svc) root.svc.cancel() } + function history() { root.tab = "history"; root.open() } + function settings() { root.tab = "settings"; root.open() } + // Arm key capture for the language at `index` (what clicking its key button does). + function capture(index: int) { root.tab = "settings"; root.open(); root.startCapture(index, "key") } + function captureAgent(index: int) { root.tab = "settings"; root.open(); root.startCapture(index, "agentKey") } + } + + onOpenedChanged: { + endCapture() + if (opened && svc) { svc.refresh(); svc.loadHistory(""); expandedTake = 0 } + } + onTabChanged: endCapture() + + // Anything that pastes or starts typing must run after the popup has given + // the keyboard back to the window underneath, or the text lands in here. + property var deferredFn: null + Timer { id: deferred; interval: 220; onTriggered: { var f = root.deferredFn; root.deferredFn = null; if (f) f() } } + function closeThen(fn) { deferredFn = fn; close(); deferred.restart() } + + // ---------- popup ---------- + readonly property var tabs: [ { key: "history", label: "History" }, { key: "settings", label: "Settings" } ] + property string tab: "history" + property bool clearConfirm: false + property int expandedTake: 0 + property bool advancedOpen: false + property int captureIndex: -1 // language row waiting for a key press (-1: none) + property string captureField: "key" // "key" (dictate) or "agentKey" (send to the agent) + property string captureNote: "" + Timer { id: noteClear; interval: 3500; onTriggered: root.captureNote = "" } + function note(t) { captureNote = t; noteClear.restart() } + + // The daemon pushes state ~20×/s while recording; derive the lists from + // JSON text so the rows are not rebuilt on every push. + readonly property string langsJson: JSON.stringify(svc ? svc.languages : []) + readonly property var langs: JSON.parse(langsJson) + readonly property string namesJson: JSON.stringify(svc ? svc.languageNames : ({})) + readonly property var addableLangs: { + var names = JSON.parse(namesJson), have = {}, out = [] + for (var i = 0; i < langs.length; i++) have[langs[i].code] = true + for (var code in names) if (!have[code]) out.push({ value: code, label: names[code] + " (" + code + ")" }) + out.sort(function(a, b) { return a.value === "auto" ? -1 : b.value === "auto" ? 1 : a.label.localeCompare(b.label) }) + return out + } + readonly property string enginesJson: JSON.stringify(svc ? svc.engines : []) + readonly property var engineOpts: JSON.parse(enginesJson).map(function(e) { + return { value: e, label: ({ voxtype: "Omarchy built-in (voxtype)", "whisper-cpp": "whisper.cpp", command: "Custom command" })[e] || e } + }) + readonly property var langOpts: langs.map(function(l) { return { value: l.code, label: l.label || l.code } }) + readonly property string conflictsJson: JSON.stringify(svc ? svc.conflicts : []) + readonly property var conflicts: JSON.parse(conflictsJson) + function conflictText() { + return conflicts.map(function(c) { + return (c.mods ? c.mods + " " : "") + c.key + " is already used by “" + c.takenBy + "” — pick another key" + }).join("\n") + } + + function saveLangs(list) { if (svc) svc.setSetting("languages", list) } + function patchLang(index, key, value) { + var list = JSON.parse(langsJson) + if (index < 0 || index >= list.length) return false + list[index][key] = value + saveLangs(list) + return true + } + function addLang(code) { + if (!code) return + var list = JSON.parse(langsJson) + for (var i = 0; i < list.length; i++) if (list[i].code === code) return + list.push({ code: code, key: "", autoSend: false, agentKey: "", engineArgs: "" }) + saveLangs(list) + } + function moveLang(index, delta) { // the first language is the default + var list = JSON.parse(langsJson), j = index + delta + if (index < 0 || index >= list.length || j < 0 || j >= list.length) return + var t = list[index]; list[index] = list[j]; list[j] = t + saveLangs(list) + } + function removeLang(index) { + var list = JSON.parse(langsJson) + if (list.length <= 1) return + list.splice(index, 1) + saveLangs(list) + } + + // ---- key capture ---- + // While a row is armed the daemon takes our own binds down (so pressing the + // current key is captured instead of starting a recording) and puts them back after. + function startCapture(index, field) { + if (index < 0 || index >= langs.length) return + captureIndex = index + captureField = field || "key" + if (svc) svc.suspendBinds() + keyCatcher.forceActiveFocus() + } + function endCapture() { + if (captureIndex === -1) return + captureIndex = -1 + if (svc) svc.resumeBinds() + } + // Qt key event -> Hyprland bind spec ("CTRL SHIFT F13"). "" for a lone modifier; + // null for a bare key that must not become a global bind (a letter, digit…). + function keySpec(event) { + var k = event.key + if (k === Qt.Key_Control || k === Qt.Key_Shift || k === Qt.Key_Alt || k === Qt.Key_Meta || k === Qt.Key_AltGr || k === Qt.Key_Super_L || k === Qt.Key_Super_R) return "" + var mods = [] + if (event.modifiers & Qt.ControlModifier) mods.push("CTRL") + if (event.modifiers & Qt.ShiftModifier) mods.push("SHIFT") + if (event.modifiers & Qt.AltModifier) mods.push("ALT") + if (event.modifiers & Qt.MetaModifier) mods.push("SUPER") + var name = "", bareOk = false + // F13–F24 reach Qt as XF86Tools/XF86Launch* keysyms; their xkb key codes are fixed (evdev KEY_F13 = 183, +8). + var code = event.nativeScanCode + if (code >= 191 && code <= 202) { name = "F" + (13 + code - 191); bareOk = true } + else if (k >= Qt.Key_F1 && k <= Qt.Key_F35) { name = "F" + (k - Qt.Key_F1 + 1); bareOk = true } + else { + var special = {} + special[Qt.Key_Pause] = "PAUSE"; special[Qt.Key_Print] = "PRINT"; special[Qt.Key_ScrollLock] = "SCROLL_LOCK" + special[Qt.Key_Insert] = "INSERT"; special[Qt.Key_Menu] = "MENU"; special[Qt.Key_CapsLock] = "CAPS_LOCK" + special[Qt.Key_Home] = "HOME"; special[Qt.Key_End] = "END"; special[Qt.Key_PageUp] = "PAGE_UP"; special[Qt.Key_PageDown] = "PAGE_DOWN" + var table = {} + table[Qt.Key_Space] = "SPACE"; table[Qt.Key_Return] = "RETURN"; table[Qt.Key_Enter] = "KP_Enter"; table[Qt.Key_Tab] = "TAB" + table[Qt.Key_Backspace] = "BACKSPACE"; table[Qt.Key_Delete] = "DELETE" + table[Qt.Key_Left] = "LEFT"; table[Qt.Key_Right] = "RIGHT"; table[Qt.Key_Up] = "UP"; table[Qt.Key_Down] = "DOWN" + table[Qt.Key_Minus] = "MINUS"; table[Qt.Key_Equal] = "EQUAL"; table[Qt.Key_Comma] = "COMMA"; table[Qt.Key_Period] = "PERIOD" + table[Qt.Key_Slash] = "SLASH"; table[Qt.Key_Semicolon] = "SEMICOLON"; table[Qt.Key_Apostrophe] = "APOSTROPHE" + table[Qt.Key_BracketLeft] = "BRACKETLEFT"; table[Qt.Key_BracketRight] = "BRACKETRIGHT"; table[Qt.Key_Backslash] = "BACKSLASH"; table[Qt.Key_QuoteLeft] = "GRAVE" + if (special[k] !== undefined) { name = special[k]; bareOk = true } + else if (table[k] !== undefined) name = table[k] + else if (k >= Qt.Key_A && k <= Qt.Key_Z) name = String.fromCharCode(65 + (k - Qt.Key_A)) + else if (k >= Qt.Key_0 && k <= Qt.Key_9) name = String.fromCharCode(48 + (k - Qt.Key_0)) + else if (event.text && event.text.length === 1 && event.text.charCodeAt(0) > 32) name = event.text.toUpperCase() + } + if (name === "") return code > 0 ? "?" : "" + if (!mods.length && !bareOk) return null + return (mods.length ? mods.join(" ") + " " : "") + name + } + function finishCapture(event) { + if (captureIndex === -1) return false + if (event.key === Qt.Key_Escape && !(event.modifiers & ~Qt.KeypadModifier)) { endCapture(); return true } + if ((event.key === Qt.Key_Backspace || event.key === Qt.Key_Delete) && !(event.modifiers & ~Qt.KeypadModifier)) { + var i = captureIndex, f = captureField + captureIndex = -1 + patchLang(i, f, "") + if (svc) svc.resumeBinds() + return true + } + var spec = keySpec(event) + if (spec === "") return true // a lone modifier: keep waiting + if (spec === "?") { note("That key has no name I can bind — try another"); return true } + if (spec === null) { note("Add a modifier (CTRL, SUPER…): a bare key would be taken from every app"); return true } + var index = captureIndex, field = captureField + captureIndex = -1 + patchLang(index, field, spec) + if (svc) svc.resumeBinds() + return true + } + + KeyboardPanel { + id: panel + anchorItem: root + owner: root + bar: root.bar + open: root.opened + focusTarget: keyCatcher + contentWidth: panel.fittedContentWidth(Style.space(520)) + contentHeight: panel.fittedContentHeight(topBlock.implicitHeight + Style.space(10) + body.implicitHeight + Style.space(4)) + + PanelKeyCatcher { + id: keyCatcher + anchors.fill: parent + blocked: root.clearConfirm || root.captureIndex !== -1 || (settingsLoader.item ? settingsLoader.item.pickerOpen === true : false) + onCloseRequested: root.close() + // Tab moves between the panel's controls; with nothing focused it switches panels like elsewhere. + onTabRequested: function(direction) { + var n = keyCatcher.activeFocus ? null : keyCatcher.nextItemInFocusChain(direction > 0) + if (n && n !== keyCatcher) n.forceActiveFocus(Qt.TabFocusReason) + else root.switchPanel(direction) + } + onMoveRequested: function(dx, dy) { + var n = keyCatcher.nextItemInFocusChain(dy > 0 || dx > 0) + if (n && n !== keyCatcher) n.forceActiveFocus(Qt.TabFocusReason) + } + Keys.onPressed: function(event) { + if (root.finishCapture(event)) { event.accepted = true; return } + if (clearDialog.handleKey(event)) event.accepted = true + } + + ConfirmDialog { + id: clearDialog + anchors.fill: parent + z: 10 + opened: root.clearConfirm + message: "Delete every recording and its text?" + confirmText: "Delete all" + selectedIndex: 0 + foreground: root.fg + fontFamily: root.fontFamily + onCanceled: root.clearConfirm = false + onConfirmed: { root.clearConfirm = false; if (root.svc) root.svc.clearHistory() } + } + Connections { target: root; function onClearConfirmChanged() { if (root.clearConfirm) { clearDialog.selectedIndex = 0; keyCatcher.forceActiveFocus() } } } + + // ---------- Fixed top: hero · live text · error · language · tabs ---------- + Column { + id: topBlock + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + spacing: Style.space(10) + + PanelHero { + width: parent.width + title: "Speech to Text" + meta: root.subtitle() + detail: root.connected ? root.keyFor(root.defaultLang) : "" + foreground: root.fg + fontFamily: root.fontFamily + iconComponent: Component { + Text { + text: "󰍬" + color: root.recording ? root.takeColor : root.fg + font.family: root.fontFamily + font.pixelSize: Style.font.display + } + } + trailingControl: Component { + Row { + spacing: Style.space(4) + PanelActionButton { + visible: root.busy + iconText: "󰜺" + tooltipText: "Discard this recording" + foreground: root.fg + hoverColor: Color.urgent + fontFamily: root.fontFamily + onClicked: if (root.svc) root.svc.cancel() + } + Button { + text: root.recording ? "Stop" : root.transcribing ? "Working…" : "Dictate" + enabled: root.connected && !root.transcribing + foreground: root.fg + fontFamily: root.fontFamily + bordered: true + // The text is pasted into the focused window, so the popup gets out of the way first. + onClicked: root.closeThen(function() { if (root.svc) root.svc.toggle(null, false) }) + } + } + } + } + + Text { + width: parent.width + visible: root.busy + text: root.liveTail + color: root.livePlaceholder ? root.dim : root.fg + font.family: root.fontFamily + font.pixelSize: Style.font.body + font.italic: root.livePlaceholder + wrapMode: Text.Wrap + } + + Row { + width: parent.width + visible: root.svc && root.svc.error !== "" + spacing: Style.space(6) + Text { + width: parent.width - errClear.width - parent.spacing + text: root.svc ? root.svc.error : "" + color: Color.urgent + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + wrapMode: Text.Wrap + anchors.verticalCenter: parent.verticalCenter + } + PanelActionButton { + id: errClear + iconText: "󰅖" + tooltipText: "Dismiss" + foreground: root.fg + fontFamily: root.fontFamily + onClicked: if (root.svc) root.svc.clearError() + } + } + + // ---------- Tabs: History · Settings ---------- + Item { + id: tabStrip + width: parent.width + height: Style.space(26) + PanelSeparator { // baseline the active tab's underline sits on + anchors.bottom: parent.bottom + foreground: root.fg + } + Row { + anchors.fill: parent + Repeater { + model: root.tabs + delegate: Item { + id: tabItem + required property var modelData + readonly property bool current: root.tab === tabItem.modelData.key + width: tabStrip.width / root.tabs.length + height: tabStrip.height + Text { + id: tabLabel + anchors.horizontalCenter: parent.horizontalCenter + anchors.verticalCenter: parent.verticalCenter + anchors.verticalCenterOffset: -Style.space(1) + text: tabItem.modelData.label + color: tabItem.current ? Color.accent : (tabArea.containsMouse ? root.fg : root.dim) + font.family: root.fontFamily + font.pixelSize: Style.font.body + font.bold: tabItem.current + } + Rectangle { // a dot on Settings while a key could not be applied + anchors.left: tabLabel.right + anchors.leftMargin: Style.space(3) + anchors.top: tabLabel.top + anchors.topMargin: Style.space(2) + width: Style.space(4); height: width + radius: width / 2 + color: Color.urgent + visible: tabItem.modelData.key === "settings" && root.conflicts.length > 0 && !tabItem.current + } + Rectangle { // 2px accent underline under the active label + anchors.bottom: parent.bottom + anchors.horizontalCenter: parent.horizontalCenter + width: Math.round(tabLabel.implicitWidth) + Style.space(12) + height: Style.space(2) + radius: height / 2 + color: Color.accent + visible: tabItem.current + } + MouseArea { + id: tabArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.tab = tabItem.modelData.key + } + } + } + } + } + } + + // ---------- Scrolling body: the active tab ---------- + ScrollView { + id: scrollArea + anchors.top: topBlock.bottom + anchors.topMargin: Style.space(10) + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + clip: true + readonly property bool overflows: body.implicitHeight > height + 1 + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + ScrollBar.vertical.policy: overflows ? ScrollBar.AsNeeded : ScrollBar.AlwaysOff + // The Flickable takes the wheel only while there is something to + // scroll, so a short tab never bounces; no fling inertia, so the wheel + // moves the page and stops when the finger does. + Binding { target: scrollArea.contentItem; property: "interactive"; value: scrollArea.overflows } + Binding { target: scrollArea.contentItem; property: "flickDeceleration"; value: 20000 } + Binding { target: scrollArea.contentItem; property: "maximumFlickVelocity"; value: 1500 } + Binding { target: scrollArea.contentItem; property: "boundsBehavior"; value: Flickable.StopAtBounds } + + Item { + id: body + width: scrollArea.availableWidth + implicitWidth: width + implicitHeight: historyLoader.active ? historyLoader.implicitHeight : settingsLoader.implicitHeight + Loader { id: historyLoader; width: body.width; active: root.opened && root.tab === "history"; sourceComponent: historyView; visible: active } + Loader { id: settingsLoader; width: body.width; active: root.opened && root.tab === "settings"; sourceComponent: settingsView; visible: active } + } + } + } + } + + // ======================= shared rows ======================= + // One compact settings row: label on the left, switch on the right. + component SwitchRow: Item { + id: sw + property string label: "" + property string summary: "" // dim caption before the switch + property bool checked: false + property bool enabled: root.connected + signal toggled() + width: parent ? parent.width : 200 + height: root.rowH + opacity: enabled ? 1 : 0.5 + Text { + id: swSummary + anchors.right: swToggle.left + anchors.rightMargin: Style.space(8) + anchors.verticalCenter: parent.verticalCenter + visible: sw.summary !== "" + text: sw.summary + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.caption + elide: Text.ElideRight + width: Math.min(implicitWidth, sw.width * 0.5) + horizontalAlignment: Text.AlignRight + } + Text { + anchors.left: parent.left + anchors.right: sw.summary !== "" ? swSummary.left : swToggle.left + anchors.rightMargin: Style.space(8) + anchors.verticalCenter: parent.verticalCenter + text: sw.label + color: root.fg + font.family: root.fontFamily + font.pixelSize: Style.font.body + elide: Text.ElideRight + } + ToggleSwitch { + id: swToggle + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + cursorPad: root.trailInset + checked: sw.checked + interactive: sw.enabled + foreground: root.fg + onToggled: sw.toggled() + } + MouseArea { // the whole row toggles, like a real settings list + anchors.fill: parent + anchors.rightMargin: swToggle.width + enabled: sw.enabled + onClicked: sw.toggled() + } + } + + component RowLabel: Text { + anchors.verticalCenter: parent ? parent.verticalCenter : undefined + width: root.labelW + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.body + elide: Text.ElideRight + } + + component Note: Text { + width: parent ? parent.width : 200 + color: root.fg + opacity: 0.6 + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + wrapMode: Text.Wrap + } + + component Section: Column { + property string title: "" + width: parent ? parent.width : 200 + spacing: Style.space(10) + PanelSeparator { width: parent.width; foreground: root.fg } + PanelSectionHeader { text: parent.title; foreground: root.fg; fontFamily: root.fontFamily } + } + + // A text field that follows a config value but never overwrites what the + // user is typing: it re-reads the value only while unfocused, commits on + // Enter / focus loss, Esc puts the old value back, Tab moves on. + component ConfigField: TextField { + id: cf + property string key: "" + property var current: root.cfg[key] + property var commit: null // function(text) -> bool; default: setSetting(key, text) + foreground: root.fg + enabled: root.connected + opacity: enabled ? 1 : 0.5 + onCurrentChanged: if (!activeFocus) text = current === undefined || current === null ? "" : String(current) + Component.onCompleted: text = current === undefined || current === null ? "" : String(current) + function revert() { text = current === undefined || current === null ? "" : String(current) } + onEditingFinished: { + var v = text + if (String(current === undefined || current === null ? "" : current) === v) return + var ok = commit ? commit(v) : (root.svc ? root.svc.setSetting(key, v) : false) + if (ok === false) revert() + } + Keys.onEscapePressed: function(e) { revert(); keyCatcher.forceActiveFocus(); e.accepted = true } + Keys.onTabPressed: function(e) { var n = cf.nextItemInFocusChain(true); if (n) n.forceActiveFocus(Qt.TabFocusReason); e.accepted = true } + Keys.onBacktabPressed: function(e) { var n = cf.nextItemInFocusChain(false); if (n) n.forceActiveFocus(Qt.BacktabFocusReason); e.accepted = true } + } + + // ======================= History ======================= + Component { + id: historyView + Column { + spacing: Style.space(8) + + TextField { + id: search + width: parent.width - root.trailInset + placeholderText: "Search recordings…" + foreground: root.fg + onTextEdited: searchDebounce.restart() + Timer { id: searchDebounce; interval: 250; onTriggered: if (root.svc) root.svc.loadHistory(search.text) } + Keys.onEscapePressed: function(e) { if (text !== "") { text = ""; if (root.svc) root.svc.loadHistory(""); e.accepted = true } } + } + + ListView { + id: list + width: parent.width + height: Math.max(Style.space(60), Math.min(contentHeight, Style.space(400))) + clip: true + spacing: Style.space(6) + boundsBehavior: Flickable.StopAtBounds + interactive: contentHeight > height + flickDeceleration: 20000 + maximumFlickVelocity: 1500 + model: root.svc ? root.svc.historyItems : [] + ScrollBar.vertical: ScrollBar { policy: list.interactive ? ScrollBar.AsNeeded : ScrollBar.AlwaysOff } + // The daemon replaces the whole list on every change; keep the scroll position. + property real keepY: 0 + onModelChanged: Qt.callLater(function() { if (keepY <= contentHeight - height) contentY = keepY }) + onContentYChanged: if (!moving || dragging || flicking) keepY = contentY + + Text { + anchors.centerIn: parent + width: parent.width - Style.space(20) + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.Wrap + visible: list.count === 0 + text: !root.connected ? "Waiting for the daemon…" + : root.svc && root.svc.historyQuery !== "" ? "No matches" + : root.keyFor(root.defaultLang) !== "" ? "No recordings yet — press " + root.keyFor(root.defaultLang) + " and talk" + : "No recordings yet — click Dictate, or set a key in Settings" + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.body + } + + delegate: Rectangle { + id: row + required property var modelData + required property int index + readonly property bool expandedRow: root.expandedTake === modelData.id + readonly property bool playingRow: root.svc && root.svc.playing === modelData.id + width: list.width - (list.interactive ? Style.space(8) : root.trailInset) + height: rowCol.implicitHeight + Style.space(12) + radius: Style.cornerRadius + color: rowHover.hovered ? Style.hoverFillFor(root.fg, Color.accent) : Style.normalFillFor(root.fg, Color.accent) + HoverHandler { id: rowHover } + property bool copied: false + Timer { id: copiedTimer; interval: 1200; onTriggered: row.copied = false } + + Column { + id: rowCol + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: Style.space(6) + spacing: Style.space(3) + + Row { + width: parent.width + spacing: Style.space(4) + Text { + anchors.verticalCenter: parent.verticalCenter + width: parent.width - actions.width - parent.spacing + text: root.when(row.modelData.createdAt) + " · " + root.langName(row.modelData.lang) + " · " + root.clock(row.modelData.duration) + + (row.modelData.audio === "" ? " · no audio" : "") + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.caption + elide: Text.ElideRight + } + Row { + id: actions + spacing: 0 + PanelActionButton { + iconText: row.playingRow ? "󰓛" : "󰐊" + tooltipText: row.playingRow ? "Stop" : "Play" + enabled: row.modelData.audio !== "" + opacity: enabled ? 1 : 0.35 + foreground: row.playingRow ? Color.accent : root.fg + fontFamily: root.fontFamily + onClicked: if (root.svc) { if (row.playingRow) root.svc.stopPlay(); else root.svc.play(row.modelData.id) } + } + PanelActionButton { + iconText: row.copied ? "󰄬" : "󰆏" + tooltipText: "Copy" + foreground: row.copied ? Color.accent : root.fg + fontFamily: root.fontFamily + onClicked: { if (root.svc) root.svc.copyTake(row.modelData.id); row.copied = true; copiedTimer.restart() } + } + PanelActionButton { + iconText: "󰆒" + tooltipText: "Paste into the focused window" + foreground: root.fg + fontFamily: root.fontFamily + onClicked: { var id = row.modelData.id; root.closeThen(function() { if (root.svc) root.svc.pasteTake(id, false) }) } + } + PanelActionButton { + iconText: "󰌑" + tooltipText: "Paste and press Return" + foreground: root.fg + fontFamily: root.fontFamily + onClicked: { var id = row.modelData.id; root.closeThen(function() { if (root.svc) root.svc.pasteTake(id, true) }) } + } + PanelActionButton { + iconText: "󰆴" + tooltipText: "Delete" + foreground: root.fg + hoverColor: Color.urgent + fontFamily: root.fontFamily + onClicked: if (root.svc) root.svc.deleteTake(row.modelData.id) + } + } + } + + Text { + id: takeText + width: parent.width + text: row.modelData.text + color: root.fg + font.family: root.fontFamily + font.pixelSize: Style.font.body + wrapMode: Text.Wrap + maximumLineCount: row.expandedRow ? 400 : 3 + elide: Text.ElideRight + MouseArea { + anchors.fill: parent + cursorShape: (takeText.truncated || row.expandedRow) ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: root.expandedTake = row.expandedRow ? 0 : row.modelData.id + } + } + Text { + visible: takeText.truncated || row.expandedRow + text: row.expandedRow ? "less" : "more…" + color: Color.accent + font.family: root.fontFamily + font.pixelSize: Style.font.caption + MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor; onClicked: root.expandedTake = row.expandedRow ? 0 : row.modelData.id } + } + } + } + } + + Row { + width: parent.width - root.trailInset + spacing: Style.space(6) + Text { + anchors.verticalCenter: parent.verticalCenter + width: parent.width - clearBtn.width - parent.spacing + text: { + var total = root.svc ? root.svc.historyTotal : 0, shown = list.count + if (root.svc && root.svc.historyQuery !== "") return shown + " of " + total + " recordings match" + if (shown < total) return "showing " + shown + " of " + total + " recordings" + return total + " recording" + (total === 1 ? "" : "s") + } + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.caption + elide: Text.ElideRight + } + Button { + id: clearBtn + text: "Clear all" + enabled: root.svc && root.svc.historyTotal > 0 + foreground: root.fg + fontFamily: root.fontFamily + onClicked: root.clearConfirm = true + } + } + } + } + + // ======================= Settings ======================= + Component { + id: settingsView + Column { + id: settings + spacing: Style.space(14) + readonly property bool pickerOpen: addPicker.popupOpen + + // ---------- Languages: one row each; the first is the default ---------- + Column { + width: parent.width + spacing: Style.space(10) + PanelSectionHeader { text: "LANGUAGES"; foreground: root.fg; fontFamily: root.fontFamily } + + readonly property int keyW: Style.space(112) + readonly property int sendW: Style.space(70) + readonly property int actW: Style.space(72) + + Row { // column headings + width: parent.width - root.trailInset + spacing: Style.space(8) + Item { width: parent.width - parent.parent.keyW * 2 - parent.parent.sendW - parent.parent.actW - parent.spacing * 4; height: 1 } + Text { width: parent.parent.keyW; text: "DICTATE"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption } + Text { width: parent.parent.sendW; text: "+ RETURN"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption } + Text { width: parent.parent.keyW; text: "ASK " + root.agentName; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; elide: Text.ElideRight } + } + + Repeater { + model: root.langs + Rectangle { + id: langRow + required property var modelData + required property int index + readonly property bool armedKey: root.captureIndex === index && root.captureField === "key" + readonly property bool armedAgent: root.captureIndex === index && root.captureField === "agentKey" + readonly property var cols: parent + width: parent.width - root.trailInset + height: root.rowH + Style.space(8) + radius: Style.cornerRadius + color: (armedKey || armedAgent) ? Style.selectedFillFor(root.fg, Color.accent) : langHover.hovered ? Style.hoverFillFor(root.fg, Color.accent) : Style.normalFillFor(root.fg, Color.accent) + HoverHandler { id: langHover } + + Row { + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: Style.space(8) + anchors.rightMargin: Style.space(2) + spacing: Style.space(8) + + Column { + anchors.verticalCenter: parent.verticalCenter + width: parent.width - langRow.cols.keyW * 2 - langRow.cols.sendW - langRow.cols.actW - parent.spacing * 4 + Text { + width: parent.width + text: root.langName(langRow.modelData.code) + color: root.fg + font.family: root.fontFamily + font.pixelSize: Style.font.body + elide: Text.ElideRight + } + Text { + visible: langRow.index === 0 + text: "default" + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } + } + + // Keys: click to capture the next key press, right-click to clear. + Button { + anchors.verticalCenter: parent.verticalCenter + width: langRow.cols.keyW + text: langRow.armedKey ? "Press…" : (langRow.modelData.key ? langRow.modelData.key : "Set") + selected: langRow.armedKey + enabled: root.connected + foreground: langRow.modelData.key || langRow.armedKey ? root.fg : root.dim + fontFamily: root.fontFamily + bordered: true + tooltipText: langRow.armedKey ? "Press a key · Backspace clears · Esc cancels" : "Dictate: press to start, again to stop and paste" + onClicked: { if (langRow.armedKey) root.endCapture(); else root.startCapture(langRow.index, "key") } + onRightClicked: { root.endCapture(); root.patchLang(langRow.index, "key", "") } + } + + Item { + width: langRow.cols.sendW + height: root.rowH + ToggleSwitch { + anchors.centerIn: parent + cursorPad: root.trailInset + checked: !!langRow.modelData.autoSend + interactive: root.connected + foreground: root.fg + onToggled: root.patchLang(langRow.index, "autoSend", !langRow.modelData.autoSend) + } + } + + Button { + anchors.verticalCenter: parent.verticalCenter + width: langRow.cols.keyW + text: langRow.armedAgent ? "Press…" : (langRow.modelData.agentKey ? langRow.modelData.agentKey : "Set") + selected: langRow.armedAgent + enabled: root.connected + foreground: langRow.modelData.agentKey || langRow.armedAgent ? root.fg : root.dim + fontFamily: root.fontFamily + bordered: true + tooltipText: langRow.armedAgent ? "Press a key · Backspace clears · Esc cancels" : "Ask " + root.agentName + ": the text opens your default coding agent instead of being pasted" + onClicked: { if (langRow.armedAgent) root.endCapture(); else root.startCapture(langRow.index, "agentKey") } + onRightClicked: { root.endCapture(); root.patchLang(langRow.index, "agentKey", "") } + } + + Row { + anchors.verticalCenter: parent.verticalCenter + width: langRow.cols.actW + spacing: 0 + PanelActionButton { + iconText: "󰅃" + tooltipText: langRow.index === 1 ? "Make default" : "Move up" + enabled: langRow.index > 0 + opacity: enabled ? 1 : 0.25 + foreground: root.fg + fontFamily: root.fontFamily + onClicked: root.moveLang(langRow.index, -1) + } + PanelActionButton { + iconText: "󰅀" + tooltipText: "Move down" + enabled: langRow.index < root.langs.length - 1 + opacity: enabled ? 1 : 0.25 + foreground: root.fg + fontFamily: root.fontFamily + onClicked: root.moveLang(langRow.index, 1) + } + PanelActionButton { + iconText: "󰆴" + tooltipText: "Remove language" + enabled: root.langs.length > 1 + opacity: enabled ? 1 : 0.25 + foreground: root.fg + hoverColor: Color.urgent + fontFamily: root.fontFamily + onClicked: root.removeLang(langRow.index) + } + } + } + } + } + + SearchableDropdown { + id: addPicker + width: parent.width - root.trailInset + showLabel: false + triggerLabel: "Add a language…" + placeholderText: "Search languages…" + value: "" + options: root.addableLangs + foreground: root.fg + fontFamily: root.fontFamily + onChanged: function(v) { root.addLang(v); value = "" } + } + + Note { + visible: root.captureNote !== "" || root.conflicts.length > 0 + text: root.captureNote !== "" ? root.captureNote : root.conflictText() + color: Color.urgent + opacity: 1 + } + Note { text: "The first language is the default. + Return also presses Return after pasting. Esc discards while recording." } + } + + // ---------- The two switches that matter ---------- + Section { + title: "RECORDING" + SwitchRow { + label: "Live text while talking" + summary: checked ? "uses CPU while recording" : "" + checked: root.cfg.liveText !== false + onToggled: if (root.svc) root.svc.setSetting("liveText", root.cfg.liveText === false) + } + SwitchRow { + label: "Keep the audio of every recording" + checked: root.cfg.keepAudio !== false + onToggled: if (root.svc) root.svc.setSetting("keepAudio", root.cfg.keepAudio === false) + } + } + + // ---------- Advanced (collapsed) ---------- + Column { + width: parent.width + spacing: Style.space(10) + PanelSeparator { width: parent.width; foreground: root.fg } + Item { + width: parent.width + height: root.rowH + Text { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + text: (root.advancedOpen ? "󰅀 " : "󰅂 ") + "Advanced" + color: advHover.hovered ? root.fg : root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.body + } + HoverHandler { id: advHover } + MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor; onClicked: root.advancedOpen = !root.advancedOpen } + } + + Column { + width: parent.width + spacing: Style.space(10) + visible: root.advancedOpen + + Row { + width: parent.width + spacing: Style.space(8) + RowLabel { text: "Recognition" } + Dropdown { + width: parent.width - root.labelW - parent.spacing - root.trailInset + showLabel: false + enabled: root.connected + value: String(root.cfg.engine || "voxtype") + options: root.engineOpts + foreground: root.fg + fontFamily: root.fontFamily + onChanged: function(v) { if (root.svc) root.svc.setSetting("engine", v); value = Qt.binding(function() { return String(root.cfg.engine || "voxtype") }) } + } + } + Note { + visible: root.cfg.engine === "voxtype" || !root.cfg.engine + text: "Uses Omarchy's dictation model (change it with omarchy-voxtype-model). English-only models are swapped for the multilingual one automatically." + } + Row { + width: parent.width + spacing: Style.space(8) + visible: root.cfg.engine === "command" + RowLabel { text: "Command" } + ConfigField { + width: parent.width - root.labelW - parent.spacing - root.trailInset + placeholderText: "whisper-cli -m model.bin -l {lang} -nt -np -f {file}" + key: "engineCommand" + } + } + Note { visible: root.cfg.engine === "command"; text: "{file} is a 16 kHz mono WAV, {lang} the language code; stdout is the text." } + Row { + width: parent.width + spacing: Style.space(8) + visible: root.cfg.engine === "whisper-cpp" + RowLabel { text: "Model" } + ConfigField { + width: parent.width - root.labelW - parent.spacing - root.trailInset + placeholderText: "~/.local/share/voxtype/models/ggml-base.bin" + key: "whisperModel" + } + } + + Row { + width: parent.width + spacing: Style.space(8) + RowLabel { text: "After recording" } + Dropdown { + width: parent.width - root.labelW - parent.spacing - root.trailInset + showLabel: false + enabled: root.connected + value: String(root.cfg.outputMode || "paste") + options: [ { value: "paste", label: "Paste at the cursor" }, { value: "type", label: "Type it out key by key" }, { value: "clipboard", label: "Copy to the clipboard only" } ] + foreground: root.fg + fontFamily: root.fontFamily + onChanged: function(v) { if (root.svc) root.svc.setSetting("outputMode", v); value = Qt.binding(function() { return String(root.cfg.outputMode || "paste") }) } + } + } + Row { + width: parent.width + spacing: Style.space(8) + visible: (root.cfg.outputMode || "paste") === "paste" + RowLabel { text: "Paste keys" } + Dropdown { + width: parent.width - root.labelW - parent.spacing - root.trailInset + showLabel: false + enabled: root.connected + value: String(root.cfg.pasteKeys || "auto") + options: [ { value: "auto", label: "Auto (Ctrl+Shift+V in terminals, Ctrl+V elsewhere)" }, { value: "ctrl+v", label: "Ctrl+V" }, { value: "ctrl+shift+v", label: "Ctrl+Shift+V" }, { value: "shift+insert", label: "Shift+Insert" } ] + foreground: root.fg + fontFamily: root.fontFamily + onChanged: function(v) { if (root.svc) root.svc.setSetting("pasteKeys", v); value = Qt.binding(function() { return String(root.cfg.pasteKeys || "auto") }) } + } + } + SwitchRow { + visible: (root.cfg.outputMode || "paste") === "paste" + label: "Restore the clipboard after pasting" + checked: root.cfg.restoreClipboard !== false + onToggled: if (root.svc) root.svc.setSetting("restoreClipboard", root.cfg.restoreClipboard === false) + } + SwitchRow { + label: "Notify when a recording fails" + checked: root.cfg.notify !== false + onToggled: if (root.svc) root.svc.setSetting("notify", root.cfg.notify === false) + } + Row { + width: parent.width + spacing: Style.space(8) + RowLabel { text: "Max recording (s)" } + NumberField { + anchors.verticalCenter: parent.verticalCenter + enabled: root.connected + value: Number(root.cfg.maxDurationSecs || 300) + from: 5 + to: 3600 + stepSize: 5 + foreground: root.fg + fontFamily: root.fontFamily + onModified: function(v) { if (root.svc) root.svc.setSetting("maxDurationSecs", v) } + } + } + Row { + width: parent.width + spacing: Style.space(8) + RowLabel { text: "Microphone" } + ConfigField { + width: parent.width - root.labelW - parent.spacing - root.trailInset + placeholderText: "default" + key: "device" + } + } + Row { + width: parent.width + spacing: Style.space(8) + RowLabel { text: "Agent command" } + ConfigField { + width: parent.width - root.labelW - parent.spacing - root.trailInset + placeholderText: "omarchy-agent-prompt {text}" + key: "agentCommand" + } + } + Note { text: "Runs with {text} replaced by the (quoted) transcription. The default opens Omarchy's default agent (" + root.agentName + ", set with: omarchy default agent )." } + Note { text: "Saved to ~/.config/speech-to-text/config.json; key bindings apply immediately." } + } + } + } + } +} diff --git a/plugin/Service.qml b/plugin/Service.qml new file mode 100644 index 0000000..953b2e4 --- /dev/null +++ b/plugin/Service.qml @@ -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) + } +} diff --git a/plugin/Waveform.qml b/plugin/Waveform.qml new file mode 100644 index 0000000..f19a825 --- /dev/null +++ b/plugin/Waveform.qml @@ -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 } } + } + } + } +}