Files
alan b93327dc26 Ask for the headset profile at capture start; relaunch the daemon on update
The daemon now requests the Bluetooth headset profile itself (pactl
set-card-profile) the moment a take starts, instead of waiting for
WirePlumber to notice the stream and run its switch timer. The microphone
link comes up 0.15-0.18 s after the key instead of 0.21-0.27 s; WirePlumber
still restores A2DP when the stream goes away. First real audio is now at
0.82-0.86 s, which is the headset's own floor.

When the plugin is updated while the daemon runs, the daemon sees its source
change, exits with code 4 once idle, and the shell service relaunches it
without counting that as a crash.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xoc9DJCViR7dg9eKzzAcfQ
2026-09-09 12:17:06 +01:00

1918 lines
82 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 hashlib
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",
}
# What the bar says while recording, in the language being dictated.
UI_STRINGS = {
"en": {"listening": "Listening…", "opening": "Opening microphone…", "transcribing": "Transcribing…"},
"pt": {"listening": "Ouvindo…", "opening": "Abrindo o microfone…", "transcribing": "Transcrevendo…"},
"es": {"listening": "Escuchando…", "opening": "Abriendo el micrófono…", "transcribing": "Transcribiendo…"},
"fr": {"listening": "À l’écoute…", "opening": "Ouverture du micro…", "transcribing": "Transcription…"},
"de": {"listening": "Ich höre zu…", "opening": "Mikrofon wird geöffnet…", "transcribing": "Transkribiere…"},
"it": {"listening": "In ascolto…", "opening": "Apertura del microfono…", "transcribing": "Trascrizione…"},
"nl": {"listening": "Luistert…", "opening": "Microfoon openen…", "transcribing": "Transcriberen…"},
"pl": {"listening": "Słucham…", "opening": "Otwieranie mikrofonu…", "transcribing": "Transkrypcja…"},
"sv": {"listening": "Lyssnar…", "opening": "Öppnar mikrofonen…", "transcribing": "Transkriberar…"},
"da": {"listening": "Lytter…", "opening": "Åbner mikrofonen…", "transcribing": "Transskriberer…"},
"no": {"listening": "Lytter…", "opening": "Åpner mikrofonen…", "transcribing": "Transkriberer…"},
"fi": {"listening": "Kuuntelee…", "opening": "Avataan mikrofonia…", "transcribing": "Litteroidaan…"},
"tr": {"listening": "Dinliyor…", "opening": "Mikrofon açılıyor…", "transcribing": "Yazıya dökülüyor…"},
"ru": {"listening": "Слушаю…", "opening": "Открываю микрофон…", "transcribing": "Расшифровка…"},
"uk": {"listening": "Слухаю…", "opening": "Відкриваю мікрофон…", "transcribing": "Розшифровка…"},
"cs": {"listening": "Poslouchám…", "opening": "Otevírám mikrofon…", "transcribing": "Přepisuji…"},
"el": {"listening": "Ακούω…", "opening": "Άνοιγμα μικροφώνου…", "transcribing": "Απομαγνητοφώνηση…"},
"he": {"listening": "מקשיב…", "opening": "פותח מיקרופון…", "transcribing": "מתמלל…"},
"ar": {"listening": "أستمع…", "opening": "جارٍ فتح الميكروفون…", "transcribing": "جارٍ التفريغ…"},
"hi": {"listening": "सुन रहा है…", "opening": "माइक्रोफ़ोन खुल रहा है…", "transcribing": "लिख रहा है…"},
"ja": {"listening": "聞いています…", "opening": "マイクを開いています…", "transcribing": "文字起こし中…"},
"zh": {"listening": "正在聆听…", "opening": "正在打开麦克风…", "transcribing": "正在转写…"},
"ko": {"listening": "듣고 있습니다…", "opening": "마이크 여는 중…", "transcribing": "받아쓰는 중…"},
"id": {"listening": "Mendengarkan…", "opening": "Membuka mikrofon…", "transcribing": "Menyalin…"},
"vi": {"listening": "Đang nghe…", "opening": "Đang mở micrô…", "transcribing": "Đang ghi lại…"},
"th": {"listening": "กำลังฟัง…", "opening": "กำลังเปิดไมโครโฟน…", "transcribing": "กำลังถอดความ…"},
"hu": {"listening": "Hallgatom…", "opening": "Mikrofon megnyitása…", "transcribing": "Átírás…"},
"ro": {"listening": "Ascult…", "opening": "Deschid microfonul…", "transcribing": "Transcriu…"},
"ca": {"listening": "Escoltant…", "opening": "Obrint el micròfon…", "transcribing": "Transcrivint…"},
}
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")
# Models come from one pinned commit of ggerganov/whisper.cpp on Hugging Face, and a
# download is only used when its size and SHA-256 match this table (taken from that
# commit's LFS pointers). An unlisted model name is refused rather than fetched.
MODEL_COMMIT = "5359861c739e955e79d9a303bcbc70fb988958b1"
MODEL_URL = "https://huggingface.co/ggerganov/whisper.cpp/resolve/" + MODEL_COMMIT + "/ggml-{model}.bin"
MODEL_DIGESTS = { # name: (sha256, bytes)
"base": ("60ed5bc3dd14eea856493d334349b405782ddcaf0028d4b5df4088345fba2efe", 147951465),
"base-q5_1": ("422f1ae452ade6f30a004d7e5c6a43195e4433bc370bf23fac9cc591f01a8898", 59707625),
"base-q8_0": ("c577b9a86e7e048a0b7eada054f4dd79a56bbfa911fbdacf900ac5b567cbb7d9", 81768585),
"base.en": ("a03779c86df3323075f5e796cb2ce5029f00ec8869eee3fdfb897afe36c6d002", 147964211),
"base.en-q5_1": ("4baf70dd0d7c4247ba2b81fafd9c01005ac77c2f9ef064e00dcf195d0e2fdd2f", 59721011),
"base.en-q8_0": ("a4d4a0768075e13cfd7e19df3ae2dbc4a68d37d36a7dad45e8410c9a34f8c87e", 81781811),
"large-v1": ("7d99f41a10525d0206bddadd86760181fa920438b6b33237e3118ff6c83bb53d", 3094623691),
"large-v2": ("9a423fe4d40c82774b6af34115b8b935f34152246eb19e80e376071d3f999487", 3094623691),
"large-v2-q5_0": ("3a214837221e4530dbc1fe8d734f302af393eb30bd0ed046042ebf4baf70f6f2", 1080732091),
"large-v2-q8_0": ("fef54e6d898246a65c8285bfa83bd1807e27fadf54d5d4e81754c47634737e8c", 1656129691),
"large-v3": ("64d182b440b98d5203c4f9bd541544d84c605196c4f7b845dfa11fb23594d1e2", 3095033483),
"large-v3-q5_0": ("d75795ecff3f83b5faa89d1900604ad8c780abd5739fae406de19f23ecd98ad1", 1081140203),
"large-v3-turbo": ("1fc70f774d38eb169993ac391eea357ef47c88757ef72ee5943879b7e8e2bc69", 1624555275),
"large-v3-turbo-q5_0": ("394221709cd5ad1f40c46e6031ca61bce88931e6e088c188294c6d5a55ffa7e2", 574041195),
"large-v3-turbo-q8_0": ("317eb69c11673c9de1e1f0d459b253999804ec71ac4c23c17ecf5fbe24e259a1", 874188075),
"medium": ("6c14d5adee5f86394037b4e4e8b59f1673b6cee10e3cf0b11bbdbee79c156208", 1533763059),
"medium-q5_0": ("19fea4b380c3a618ec4723c3eef2eb785ffba0d0538cf43f8f235e7b3b34220f", 539212467),
"medium-q8_0": ("42a1ffcbe4167d224232443396968db4d02d4e8e87e213d3ee2e03095dea6502", 823369779),
"medium.en": ("cc37e93478338ec7700281a7ac30a10128929eb8f427dda2e865faa8f6da4356", 1533774781),
"medium.en-q5_0": ("76733e26ad8fe1c7a5bf7531a9d41917b2adc0f20f2e4f5531688a8c6cd88eb0", 539225533),
"medium.en-q8_0": ("43fa2cd084de5a04399a896a9a7a786064e221365c01700cea4666005218f11c", 823382461),
"small": ("1be3a9b2063867b937e64e2ec7483364a79917e157fa98c5d94b5c1fffea987b", 487601967),
"small-q5_1": ("ae85e4a935d7a567bd102fe55afc16bb595bdb618e11b2fc7591bc08120411bb", 190085487),
"small-q8_0": ("49c8fb02b65e6049d5fa6c04f81f53b867b5ec9540406812c643f177317f779f", 264464607),
"small.en": ("c6138d6d58ecc8322097e0f987c32f1be8bb0a18532a3f88f734d1bbf9c41e5d", 487614201),
"small.en-q5_1": ("bfdff4894dcb76bbf647d56263ea2a96645423f1669176f4844a1bf8e478ad30", 190098681),
"small.en-q8_0": ("67a179f608ea6114bd3fdb9060e762b588a3fb3bd00c4387971be4d177958067", 264477561),
"tiny": ("be07e048e1e599ad46341c8d2a135645097a538221678b7acdd1b1919c6e1b21", 77691713),
"tiny-q5_1": ("818710568da3ca15689e31a743197b520007872ff9576237bda97bd1b469c3d7", 32152673),
"tiny-q8_0": ("c2085835d3f50733e2ff6e4b41ae8a2b8d8110461e18821b09a15c40c42d1cca", 43537433),
"tiny.en": ("921e4cf8686fdd993dcd081a5da5b6c365bfde1162e72b08d75ac75289920b1f", 77704715),
"tiny.en-q5_1": ("c77c5766f1cef09b6b7d47f21b546cbddd4157886b3b5d6d4f709e91e66c7c2b", 32166155),
"tiny.en-q8_0": ("5bc2b3860aa151a4c6e7bb095e1fcce7cf12c7b020ca08dcec0c6d018bb7dd94", 43550795),
}
MODEL_DOWNLOAD_DEADLINE = 3600 # seconds for the whole transfer, the largest model is ~3 GB
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 recording next to its text
"historyDays": 30, # delete recordings older than this (0 = keep forever)
"outputMode": "paste", # paste | type | clipboard
"pasteKeys": "auto", # auto | ctrl+v | ctrl+shift+v | shift+insert
"restoreClipboard": True,
"maxDurationSecs": 300,
"device": "default",
"animation": "bars", # what the bar shows while recording: bars | wave | pulse | dots
"warmMic": False, # keep the microphone stream open between recordings: instant start + pre-roll
"warmHoldSecs": 0, # with warmMic: close the stream this long after the last recording (0 = keep it open)
"prerollMs": 600, # audio from just before the key press that a warm microphone keeps
"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.
SOURCE = os.path.abspath(__file__)
SOURCE_MTIME = os.stat(SOURCE).st_mtime
EXIT_RELAUNCH = 4 # the source changed underneath us (plugin update): the shell service starts the new one
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):
"""A language may appear several times (one entry that sends, one that
does not…): every entry gets its own id (en, en-2, …) that the key
bindings and `stt toggle --lang` refer to."""
out, ids = [], set()
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:
continue # unknown codes would reach `voxtype --language` and the bind's shell line
id = str(l.get("id", "") or "").strip().lower()
if not re.fullmatch(r"[a-z]{2,3}(-\d+)?", id) or not id.startswith(code) or id in ids:
n, id = 1, code
while id in ids:
n += 1
id = f"{code}-{n}"
ids.add(id)
out.append({
"id": id,
"code": code,
"label": LANGUAGES.get(code, 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, lid = lang["code"], lang["label"], lang.get("id") or lang["code"]
if not code:
continue
if "-" in lid: # a second entry for the same language: tell them apart in the keybindings list
label = f"{label} {lid.split('-')[1]}"
if lang.get("autoSend"):
label += ", sends"
for field, desc, cmd in (
("key", f"Dictate ({label}){MARK}", f"{shlex.quote(STT_CLI)} toggle --lang {lid}"),
("agentKey", f"Ask agent ({label}){MARK}", f"{shlex.quote(STT_CLI)} toggle --lang {lid} --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
# ---------------------------------------------------------------------------
def bt_card_for(device):
"""bluez_input.88:C9:E8:A7:EC:7E (or bluez_input.88_C9_....0) -> bluez_card.88_C9_E8_A7_EC_7E, else None."""
if not device.startswith("bluez_input."):
return None
return "bluez_card." + device[len("bluez_input."):].split(".")[0].replace(":", "_")
def request_headset_profile(device):
"""Ask for the headset (HFP) profile the moment capture starts. WirePlumber would do the same
once it notices the stream, plus its switch timer: asking first saves ~0.15 s on the microphone
link. Fire and forget: no-op if the profile is already active, and WirePlumber still restores
A2DP when the stream goes away, whoever switched."""
card = bt_card_for(device)
if not card:
return
try:
subprocess.Popen(["pactl", "set-card-profile", card, "headset-head-unit"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except OSError:
pass
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 # real audio is arriving (a Bluetooth mic sends silence while it switches profile)
self.chunks = 0
self.nonzero_run = 0 # consecutive chunks that were not digital silence
self.standby = False # warm mode: the stream runs but only the last second is kept
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
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
self.chunks += 1
# Green means "your voice is getting through". Clear input (speech, room noise on most
# mics) turns it on at once. Otherwise wait for three consecutive chunks that are not
# digital silence: a Bluetooth headset delivers exact zeros until its microphone link
# is up, the virtual mic emits one stray nonzero chunk right after start (processing
# residue), and the headset's own floor then ramps in from a few LSB with the odd
# all-zero chunk. No time-based fallback: a mic that only sends zeros is not listening.
self.nonzero_run = self.nonzero_run + 1 if rms > 0 else 0
if rms > 0.0005 or self.nonzero_run >= 3:
self.listening = True
with self.lock:
self.buf += data
self.levels.append(round(level, 3)) # one per 50 ms; indexed by absolute offset, never trimmed while recording
if self.standby and len(self.buf) > 3 * RATE * 2: # warm: keep only the last 1.5 s
drop = len(self.buf) - int(1.5 * RATE) * 2
drop -= drop % CHUNK
del self.buf[:drop]
del self.levels[: drop // CHUNK]
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):
"""Blocking (up to ~1 s): call it from an executor, never on the event loop."""
if self.proc and self.proc.poll() is None:
self.proc.terminate()
try:
self.proc.wait(timeout=0.5)
except subprocess.TimeoutExpired:
self.proc.kill()
self.proc.wait()
if self.thread:
self.thread.join(timeout=1)
@property
def alive(self):
return self.proc is not None and self.proc.poll() is None
def park(self):
"""Warm mode: keep the stream open between recordings, remembering only the last moment."""
with self.lock:
self.standby = True
def begin(self, preroll_secs):
"""Turn a parked stream into a recording, keeping `preroll_secs` of what was just heard."""
keep = int(preroll_secs * RATE) * 2
keep -= keep % CHUNK
with self.lock:
self.standby = False
if keep and len(self.buf) > keep:
drop = len(self.buf) - keep
del self.buf[:drop]
del self.levels[: drop // CHUNK]
kept = len(self.buf)
self.started = time.time() - kept / 2 / RATE
self.error = ""
@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")
def sha256_file(path):
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
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 audio_sources():
"""Microphones PipeWire offers: [{name, label}] (a virtual one like Microphone Effects included)."""
try:
r = subprocess.run(["pw-dump"], capture_output=True, text=True, timeout=5)
nodes = json.loads(r.stdout) if r.returncode == 0 else []
except (OSError, subprocess.TimeoutExpired, ValueError):
return []
out = []
for n in nodes:
props = (n.get("info") or {}).get("props") or {}
if props.get("media.class") != "Audio/Source":
continue
name = str(props.get("node.name") or "")
if not name or name.startswith("alsa_output") or "monitor" in name:
continue
out.append({"name": name, "label": str(props.get("node.description") or props.get("node.nick") or name)})
return out
def missing_tools(cfg):
"""What a stock machine may still lack; the panel offers to install it."""
out = []
if not which("pw-record"):
out.append("pipewire")
if not which("wtype"):
out.append("wtype")
if cfg.get("engine", "voxtype") == "voxtype" and not which("voxtype"):
out.append("voxtype")
if not which("wl-copy"):
out.append("wl-clipboard")
return out
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()
def prune(self, days):
"""Delete recordings older than `days` (0: keep everything). Returns how many went."""
if not days or days <= 0:
return 0
cutoff = time.time() - days * 86400
rows = self.db.execute("SELECT id, audio FROM takes WHERE created_at < ?", (cutoff,)).fetchall()
for id, audio in rows:
if audio:
try:
os.remove(audio)
except OSError:
pass
self.db.execute("DELETE FROM takes WHERE created_at < ?", (cutoff,))
self.db.commit()
return len(rows)
# ---------------------------------------------------------------------------
# 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.sources = audio_sources()
self.missing = missing_tools(self.cfg)
self.error_clear = None # timer handle: errors fade by themselves
self.warm = None # a parked Recorder (warmMic): the stream is already open when the key is pressed
self.warm_close = None # timer handle: with warmHoldSecs, the parked stream closes after a quiet spell
self.loop = None
self.stopping = False
self.stop_event = None
self.exit_code = 0
# ---- 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,
"missing": self.missing,
"strings": UI_STRINGS.get(self.lang["code"], UI_STRINGS["en"]),
"warm": bool(self.warm and self.warm.alive),
}
if full:
msg["languageNames"] = LANGUAGES
msg["sources"] = self.sources
return msg
def refresh_environment(self):
"""Things that change rarely and cost a subprocess: only on demand."""
self.engines = available_engines()
self.agent = self.agent_name()
self.sources = audio_sources()
self.missing = missing_tools(self.cfg)
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, ref):
"""The entry with this id (en-2), else the first with this code, else the default (the first in the list)."""
usable = [l for l in self.cfg["languages"] if l["code"]]
if ref:
for l in usable:
if l.get("id") == ref:
return l
for l in usable:
if l["code"] == ref:
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):
if model not in MODEL_DIGESTS:
self.fail(f"No pinned checksum for the {model} model; put ggml-{model}.bin in {VOXTYPE_MODELS} yourself")
return
want_sha, want_size = MODEL_DIGESTS[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:
proc = await asyncio.create_subprocess_exec(
"curl", "-sSL", "--fail", "--proto", "=https",
"--max-filesize", str(want_size), "--max-time", str(MODEL_DOWNLOAD_DEADLINE),
"--speed-limit", "1000", "--speed-time", "60", # give up when stalled for a minute
"-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 / want_size)
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 != want_size:
raise RuntimeError(f"unexpected size ({size} bytes, expected {want_size})")
got_sha = await self.loop.run_in_executor(None, sha256_file, part)
if got_sha != want_sha:
raise RuntimeError("checksum mismatch; the file was discarded")
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
# ---- warm microphone ----
def ensure_warm(self):
"""With warmMic on, keep a parked stream ready whenever nothing is recording."""
if self.stopping or self.state != "idle":
return
if not self.cfg.get("warmMic"):
if self.warm:
w, self.warm = self.warm, None
self.loop.run_in_executor(None, w.stop)
return
if self.warm and self.warm.alive:
return
if self.warm:
self.loop.run_in_executor(None, self.warm.stop)
if self.missing or not which("pw-record"):
self.warm = None
return
if self.cfg.get("warmHoldSecs", 0) and self.cfg.get("warmHoldSecs", 0) > 0 and not self.rec:
# With a hold time the stream is only kept open *after* a recording, not opened ahead of one.
self.warm = None
return
rec = Recorder(self.cfg.get("device", "default"))
try:
rec.start()
except (OSError, ValueError, TypeError) as e:
log("warm microphone failed:", e)
self.warm = None
return
rec.park()
self.warm = rec
def _arm_warm_close(self):
"""With a hold time, a parked stream is closed once nothing has been recorded for that long
(a Bluetooth headset then drops back to its music profile)."""
if self.warm_close:
self.warm_close.cancel()
self.warm_close = None
hold = self.cfg.get("warmHoldSecs", 0)
if self.cfg.get("warmMic") and hold and hold > 0:
self.warm_close = self.loop.call_later(hold, self._close_warm)
def _close_warm(self):
self.warm_close = None
if not self.warm:
return
if self.state != "idle": # still transcribing: look again in a moment
self.warm_close = self.loop.call_later(2, self._close_warm)
return
w, self.warm = self.warm, None
self.loop.run_in_executor(None, w.stop)
self.broadcast()
async def release_rec(self, rec):
"""A recording is over: park the stream (warm) or close it (off the loop: pw-record can take a moment to die)."""
if self.cfg.get("warmMic") and rec.alive and not self.stopping:
rec.park()
self.warm = rec
self._arm_warm_close()
else:
if self.warm is rec:
self.warm = None
await self.loop.run_in_executor(None, rec.stop)
async def start(self, code=None, agent=False):
if self.state != "idle":
self.broadcast()
return
self.missing = missing_tools(self.cfg)
if self.missing:
self.fail("Dictation needs " + ", ".join(self.missing) + " — open the microphone icon to install it")
self.broadcast()
return
self.agent_mode = bool(agent)
self.lang = self.find_lang(code)
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 = ""
if self.warm and self.warm.alive and self.warm.device == self.cfg.get("device", "default"):
# The stream is already open: no start-up gap, and the moment before the key press comes along.
self.rec = self.warm
self.warm = None
self.rec.begin(max(0, int(self.cfg.get("prerollMs", 600))) / 1000)
else:
if self.warm:
self.warm.stop()
self.warm = None
self.rec = Recorder(self.cfg.get("device", "default"))
request_headset_profile(self.rec.device or "")
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, audio, a, b, prefix):
"""Run the engine on audio[a:b] (under the engine lock). Returns text ('' for noise) or None on error."""
pcm = bytes(audio[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()
audio = rec.snapshot()
cut = self._find_cut(levels)
if cut is not None and cut > self.committed_off:
a = self.committed_off
text = await self._transcribe_range(audio, 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, len(audio)
if b - a > int(0.8 * RATE) * 2 and self._voiced(levels, a, b):
text = await self._transcribe_range(audio, 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
pcm = rec.snapshot() # frozen now: a parked stream keeps only its last moment
levels = rec.levels_copy()
started = rec.started
self.state = "transcribing"
await self.release_rec(rec)
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:
duration = len(pcm) / 2 / RATE
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(pcm, a, b, "final-") if self._voiced(levels, 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)
if self.history.prune(self.cfg.get("historyDays", 30)):
log("history pruned")
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.ensure_warm()
self.broadcast()
@staticmethod
def _discard(path):
try:
os.remove(path)
except OSError:
pass
async def cancel(self):
if self.state == "idle":
self.broadcast()
return
rec = self.rec
self.state = "idle"
self.rec = None
self.partial = ""
EngineRun.kill() # a transcription in flight is for a recording nobody wants
self.binds.set_cancel(self.cfg, False)
self.broadcast()
if rec:
await self.release_rec(rec)
self.ensure_warm()
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.get("id") not in [l.get("id") 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.refresh_environment()
if "historyDays" in (patch or {}) and self.history.prune(self.cfg.get("historyDays", 30)):
self.broadcast({"type": "history-changed"})
if self.warm and ("device" in (patch or {}) or not self.cfg.get("warmMic")):
w, self.warm = self.warm, None
self.loop.run_in_executor(None, w.stop)
self.ensure_warm()
if self.warm:
self._arm_warm_close()
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":
self.refresh_environment()
writer.write((json.dumps(self.state_msg(full=True)) + "\n").encode())
elif cmd == "install":
# Omarchy's own installer (asks for confirmation and the password in a floating terminal).
self.spawn(self.loop.run_in_executor(None, lambda: subprocess.Popen(
["omarchy-launch-floating-terminal-with-presentation", "omarchy-voxtype-install"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)))
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 warm_watch(self):
"""A parked stream can die (device unplugged, headset off): reopen it when it does."""
while not self.stopping:
await asyncio.sleep(3)
if self.cfg.get("warmMic") and self.state == "idle" and (not self.warm or not self.warm.alive) and not self.cfg.get("warmHoldSecs", 0):
self.ensure_warm()
async def source_watch(self):
"""Plugin updated while running: exit when idle so the shell service relaunches the new code."""
while not self.stopping:
await asyncio.sleep(5)
try:
changed = os.stat(SOURCE).st_mtime != SOURCE_MTIME
except OSError:
changed = False
if changed and self.state == "idle" and not (self.download_task and not self.download_task.done()):
log("daemon source changed; relaunching")
self.exit_code = EXIT_RELAUNCH
await self.shutdown()
return
async def shutdown(self):
if self.stopping:
return
self.stopping = True
if self.rec:
self.rec.stop()
if self.warm:
self.warm.stop()
self.warm = None
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)
if self.history.prune(self.cfg.get("historyDays", 30)):
log("history pruned")
self.ensure_warm()
self.ensure_models()
self.loop.create_task(self.hypr_events())
self.loop.create_task(self.warm_watch())
self.loop.create_task(self.source_watch())
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.
daemon = Daemon()
asyncio.run(daemon.run())
sys.exit(daemon.exit_code)
if __name__ == "__main__":
main()