f2ed66ed17
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.
1556 lines
62 KiB
Python
Executable File
1556 lines
62 KiB
Python
Executable File
#!/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 <language> · 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()
|