Revert "Add pipeline engine and remove legacy compatibility paths"

This commit is contained in:
Thales Maciel 2026-02-26 12:54:47 -03:00
parent e221d49020
commit 5b38cc7dcd
18 changed files with 399 additions and 1523 deletions

View file

@ -3,7 +3,6 @@ from __future__ import annotations
import argparse
import errno
import hashlib
import inspect
import json
import logging
@ -13,21 +12,12 @@ import sys
import threading
import time
from pathlib import Path
from typing import Any, Callable
from typing import Any
from aiprocess import LlamaProcessor, SYSTEM_PROMPT
from aiprocess import LlamaProcessor
from config import Config, load, redacted_dict
from constants import (
CONFIG_RELOAD_POLL_INTERVAL_SEC,
DEFAULT_CONFIG_PATH,
RECORD_TIMEOUT_SEC,
STT_LANGUAGE,
)
from constants import RECORD_TIMEOUT_SEC, STT_LANGUAGE
from desktop import get_desktop_adapter
from engine import Engine, PipelineBinding, PipelineLib, PipelineOptions
from notify import DesktopNotifier
from pipelines_runtime import DEFAULT_PIPELINES_PATH, fingerprint as pipelines_fingerprint
from pipelines_runtime import load_bindings
from recorder import start_recording as start_audio_recording
from recorder import stop_recording as stop_audio_recording
from vocabulary import VocabularyEngine
@ -65,52 +55,6 @@ def _compute_type(device: str) -> str:
return "int8"
def _replacement_view(cfg: Config) -> list[tuple[str, str]]:
return [(item.source, item.target) for item in cfg.vocabulary.replacements]
def _config_fields_changed(current: Config, candidate: Config) -> list[str]:
changed: list[str] = []
if current.daemon.hotkey != candidate.daemon.hotkey:
changed.append("daemon.hotkey")
if current.recording.input != candidate.recording.input:
changed.append("recording.input")
if current.stt.model != candidate.stt.model:
changed.append("stt.model")
if current.stt.device != candidate.stt.device:
changed.append("stt.device")
if current.injection.backend != candidate.injection.backend:
changed.append("injection.backend")
if (
current.injection.remove_transcription_from_clipboard
!= candidate.injection.remove_transcription_from_clipboard
):
changed.append("injection.remove_transcription_from_clipboard")
if _replacement_view(current) != _replacement_view(candidate):
changed.append("vocabulary.replacements")
if current.vocabulary.terms != candidate.vocabulary.terms:
changed.append("vocabulary.terms")
return changed
CONTEXT_SYSTEM_PROMPT = (
"You infer concise writing context from transcript text.\n"
"Return a short plain-text hint (max 12 words) that describes the likely domain and style.\n"
"Do not add punctuation, quotes, XML tags, or markdown."
)
def _extract_cleaned_text_from_llm_raw(raw: str) -> str:
parsed = json.loads(raw)
if isinstance(parsed, str):
return parsed
if isinstance(parsed, dict):
cleaned_text = parsed.get("cleaned_text")
if isinstance(cleaned_text, str):
return cleaned_text
raise RuntimeError("unexpected ai output format: missing cleaned_text")
class Daemon:
def __init__(self, cfg: Config, desktop, *, verbose: bool = False):
self.cfg = cfg
@ -122,34 +66,17 @@ class Daemon:
self.stream = None
self.record = None
self.timer: threading.Timer | None = None
self._config_apply_in_progress = False
self._active_hotkey: str | None = None
self._pending_worker_hotkey: str | None = None
self._pipeline_bindings: dict[str, PipelineBinding] = {}
self.model = _build_whisper_model(cfg.stt.model, cfg.stt.device)
self.model = _build_whisper_model(
cfg.stt.model,
cfg.stt.device,
)
logging.info("initializing ai processor")
self.ai_processor = LlamaProcessor(verbose=self.verbose)
logging.info("ai processor ready")
self.log_transcript = verbose
self.vocabulary = VocabularyEngine(cfg.vocabulary)
self.vocabulary = VocabularyEngine(cfg.vocabulary, cfg.domain_inference)
self._stt_hint_kwargs_cache: dict[str, Any] | None = None
self.lib = PipelineLib(
transcribe_fn=self._transcribe_with_options,
llm_fn=self._llm_with_options,
)
self.engine = Engine(self.lib)
self.set_pipeline_bindings(
{
cfg.daemon.hotkey: PipelineBinding(
hotkey=cfg.daemon.hotkey,
handler=self.build_reference_pipeline(),
options=PipelineOptions(failure_policy="best_effort"),
)
}
)
def set_state(self, state: str):
with self.lock:
prev = self.state
@ -166,74 +93,16 @@ class Daemon:
def request_shutdown(self):
self._shutdown_requested.set()
def current_hotkeys(self) -> list[str]:
with self.lock:
return list(self._pipeline_bindings.keys())
def set_pipeline_bindings(self, bindings: dict[str, PipelineBinding]) -> None:
if not bindings:
raise ValueError("at least one pipeline binding is required")
with self.lock:
self._pipeline_bindings = dict(bindings)
if self._active_hotkey and self._active_hotkey not in self._pipeline_bindings:
self._active_hotkey = None
def build_hotkey_callbacks(
self,
hotkeys: list[str],
*,
dry_run: bool,
) -> dict[str, Callable[[], None]]:
callbacks: dict[str, Callable[[], None]] = {}
for hotkey in hotkeys:
if dry_run:
callbacks[hotkey] = (lambda hk=hotkey: logging.info("hotkey pressed: %s (dry-run)", hk))
else:
callbacks[hotkey] = (lambda hk=hotkey: self.handle_hotkey(hk))
return callbacks
def apply_pipeline_bindings(
self,
bindings: dict[str, PipelineBinding],
callbacks: dict[str, Callable[[], None]],
) -> tuple[str, str]:
with self.lock:
if self._shutdown_requested.is_set():
return "deferred", "shutdown in progress"
if self._config_apply_in_progress:
return "deferred", "reload in progress"
if self.state != State.IDLE:
return "deferred", f"daemon is busy ({self.state})"
self._config_apply_in_progress = True
try:
self.desktop.set_hotkeys(callbacks)
with self.lock:
if self._shutdown_requested.is_set():
return "deferred", "shutdown in progress"
if self.state != State.IDLE:
return "deferred", f"daemon is busy ({self.state})"
self._pipeline_bindings = dict(bindings)
return "applied", ""
except Exception as exc:
return "error", str(exc)
finally:
with self.lock:
self._config_apply_in_progress = False
def handle_hotkey(self, hotkey: str):
def toggle(self):
should_stop = False
with self.lock:
if self._shutdown_requested.is_set():
logging.info("shutdown in progress, trigger ignored")
return
if self._config_apply_in_progress:
logging.info("reload in progress, trigger ignored")
return
if self.state == State.IDLE:
self._active_hotkey = hotkey
self._start_recording_locked()
return
if self.state == State.RECORDING and self._active_hotkey == hotkey:
if self.state == State.RECORDING:
should_stop = True
else:
logging.info("busy (%s), trigger ignored", self.state)
@ -244,9 +113,6 @@ class Daemon:
if self.state != State.IDLE:
logging.info("busy (%s), trigger ignored", self.state)
return
if self._config_apply_in_progress:
logging.info("reload in progress, trigger ignored")
return
try:
stream, record = start_audio_recording(self.cfg.recording.input)
except Exception as exc:
@ -267,17 +133,10 @@ class Daemon:
def _timeout_stop(self):
self.stop_recording(trigger="timeout")
def _start_stop_worker(
self,
stream: Any,
record: Any,
trigger: str,
process_audio: bool,
):
active_hotkey = self._pending_worker_hotkey or self.cfg.daemon.hotkey
def _start_stop_worker(self, stream: Any, record: Any, trigger: str, process_audio: bool):
threading.Thread(
target=self._stop_and_process,
args=(stream, record, trigger, process_audio, active_hotkey),
args=(stream, record, trigger, process_audio),
daemon=True,
).start()
@ -286,8 +145,6 @@ class Daemon:
return None
stream = self.stream
record = self.record
active_hotkey = self._active_hotkey or self.cfg.daemon.hotkey
self._active_hotkey = None
self.stream = None
self.record = None
if self.timer:
@ -301,17 +158,9 @@ class Daemon:
logging.warning("recording resources are unavailable during stop")
self.state = State.IDLE
return None
return stream, record, active_hotkey
return stream, record
def _stop_and_process(
self,
stream: Any,
record: Any,
trigger: str,
process_audio: bool,
active_hotkey: str | None = None,
):
hotkey = active_hotkey or self.cfg.daemon.hotkey
def _stop_and_process(self, stream: Any, record: Any, trigger: str, process_audio: bool):
logging.info("stopping recording (%s)", trigger)
try:
audio = stop_audio_recording(stream, record)
@ -330,17 +179,44 @@ class Daemon:
return
try:
logging.info("pipeline started (%s)", hotkey)
text = self._run_pipeline(audio, hotkey).strip()
self.set_state(State.STT)
logging.info("stt started")
text = self._transcribe(audio)
except Exception as exc:
logging.error("pipeline failed: %s", exc)
logging.error("stt failed: %s", exc)
self.set_state(State.IDLE)
return
text = (text or "").strip()
if not text:
self.set_state(State.IDLE)
return
if self.log_transcript:
logging.debug("stt: %s", text)
else:
logging.info("stt produced %d chars", len(text))
domain = self.vocabulary.infer_domain(text)
if not self._shutdown_requested.is_set():
self.set_state(State.PROCESSING)
logging.info("ai processing started")
try:
processor = self._get_ai_processor()
ai_text = processor.process(
text,
lang=STT_LANGUAGE,
dictionary_context=self.vocabulary.build_ai_dictionary_context(),
domain_name=domain.name,
domain_confidence=domain.confidence,
)
if ai_text and ai_text.strip():
text = ai_text.strip()
except Exception as exc:
logging.error("ai process failed: %s", exc)
text = self.vocabulary.apply_deterministic_replacements(text).strip()
if self.log_transcript:
logging.debug("processed: %s", text)
else:
@ -366,23 +242,13 @@ class Daemon:
finally:
self.set_state(State.IDLE)
def _run_pipeline(self, audio: Any, hotkey: str) -> str:
with self.lock:
binding = self._pipeline_bindings.get(hotkey)
if binding is None and self._pipeline_bindings:
binding = next(iter(self._pipeline_bindings.values()))
if binding is None:
return ""
return self.engine.run(binding, audio)
def stop_recording(self, *, trigger: str = "user", process_audio: bool = True):
payload = None
with self.lock:
payload = self._begin_stop_locked()
if payload is None:
return
stream, record, active_hotkey = payload
self._pending_worker_hotkey = active_hotkey
stream, record = payload
self._start_stop_worker(stream, record, trigger, process_audio)
def cancel_recording(self):
@ -404,129 +270,40 @@ class Daemon:
time.sleep(0.05)
return self.get_state() == State.IDLE
def build_reference_pipeline(self) -> Callable[[Any, PipelineLib], str]:
def _reference_pipeline(audio: Any, lib: PipelineLib) -> str:
text = (lib.transcribe(audio) or "").strip()
if not text:
return ""
dictionary_context = self.vocabulary.build_ai_dictionary_context()
context_prompt = {
"transcript": text,
"dictionary": dictionary_context,
}
context = (
lib.llm(
system_prompt=CONTEXT_SYSTEM_PROMPT,
user_prompt=json.dumps(context_prompt, ensure_ascii=False),
llm_opts={"temperature": 0.0},
)
.strip()
)
payload: dict[str, Any] = {
"language": STT_LANGUAGE,
"transcript": text,
"context": context,
}
if dictionary_context:
payload["dictionary"] = dictionary_context
ai_raw = lib.llm(
system_prompt=SYSTEM_PROMPT,
user_prompt=json.dumps(payload, ensure_ascii=False),
llm_opts={"temperature": 0.0, "response_format": {"type": "json_object"}},
)
cleaned_text = _extract_cleaned_text_from_llm_raw(ai_raw)
return self.vocabulary.apply_deterministic_replacements(cleaned_text).strip()
return _reference_pipeline
def _transcribe(self, audio) -> str:
return self._transcribe_with_options(audio)
def _transcribe_with_options(
self,
audio: Any,
*,
hints: list[str] | None = None,
whisper_opts: dict[str, Any] | None = None,
) -> str:
kwargs: dict[str, Any] = {
"language": STT_LANGUAGE,
"vad_filter": True,
}
kwargs.update(self._stt_hint_kwargs(hints=hints))
if whisper_opts:
kwargs.update(whisper_opts)
kwargs.update(self._stt_hint_kwargs())
segments, _info = self.model.transcribe(audio, **kwargs)
parts = []
for seg in segments:
text = (seg.text or "").strip()
if text:
parts.append(text)
out = " ".join(parts).strip()
if self.log_transcript:
logging.debug("stt: %s", out)
else:
logging.info("stt produced %d chars", len(out))
return out
def _llm_with_options(
self,
*,
system_prompt: str,
user_prompt: str,
llm_opts: dict[str, Any] | None = None,
) -> str:
if self.get_state() != State.PROCESSING:
self.set_state(State.PROCESSING)
logging.info("llm processing started")
processor = self._get_ai_processor()
return processor.chat(
system_prompt=system_prompt,
user_prompt=user_prompt,
llm_opts=llm_opts,
)
return " ".join(parts).strip()
def _get_ai_processor(self) -> LlamaProcessor:
if self.ai_processor is None:
raise RuntimeError("ai processor is not initialized")
return self.ai_processor
def _stt_hint_kwargs(self, *, hints: list[str] | None = None) -> dict[str, Any]:
if not hints and self._stt_hint_kwargs_cache is not None:
def _stt_hint_kwargs(self) -> dict[str, Any]:
if self._stt_hint_kwargs_cache is not None:
return self._stt_hint_kwargs_cache
hotwords, initial_prompt = self.vocabulary.build_stt_hints()
extra_hints = [item.strip() for item in (hints or []) if isinstance(item, str) and item.strip()]
if extra_hints:
words = [item.strip() for item in hotwords.split(",") if item.strip()]
words.extend(extra_hints)
deduped: list[str] = []
seen: set[str] = set()
for item in words:
key = item.casefold()
if key in seen:
continue
seen.add(key)
deduped.append(item)
merged = ", ".join(deduped)
hotwords = merged[:1024]
if hotwords:
initial_prompt = f"Preferred vocabulary: {hotwords}"[:600]
if not hotwords and not initial_prompt:
if not hints:
self._stt_hint_kwargs_cache = {}
return self._stt_hint_kwargs_cache
return {}
self._stt_hint_kwargs_cache = {}
return self._stt_hint_kwargs_cache
try:
signature = inspect.signature(self.model.transcribe)
except (TypeError, ValueError):
logging.debug("stt signature inspection failed; skipping hints")
if not hints:
self._stt_hint_kwargs_cache = {}
return self._stt_hint_kwargs_cache
return {}
self._stt_hint_kwargs_cache = {}
return self._stt_hint_kwargs_cache
params = signature.parameters
kwargs: dict[str, Any] = {}
@ -536,288 +313,8 @@ class Daemon:
kwargs["initial_prompt"] = initial_prompt
if not kwargs:
logging.debug("stt hint arguments are not supported by this whisper runtime")
if not hints:
self._stt_hint_kwargs_cache = kwargs
return self._stt_hint_kwargs_cache
return kwargs
def try_apply_config(self, candidate: Config) -> tuple[str, list[str], str]:
with self.lock:
if self._shutdown_requested.is_set():
return "deferred", [], "shutdown in progress"
if self._config_apply_in_progress:
return "deferred", [], "config reload already in progress"
if self.state != State.IDLE:
return "deferred", [], f"daemon is busy ({self.state})"
self._config_apply_in_progress = True
current_cfg = self.cfg
try:
changed = _config_fields_changed(current_cfg, candidate)
if not changed:
return "applied", [], ""
next_model = None
if (
current_cfg.stt.model != candidate.stt.model
or current_cfg.stt.device != candidate.stt.device
):
try:
next_model = _build_whisper_model(candidate.stt.model, candidate.stt.device)
except Exception as exc:
return "error", [], f"stt model reload failed: {exc}"
try:
next_vocab = VocabularyEngine(candidate.vocabulary)
except Exception as exc:
return "error", [], f"vocabulary reload failed: {exc}"
with self.lock:
if self._shutdown_requested.is_set():
return "deferred", [], "shutdown in progress"
if self.state != State.IDLE:
return "deferred", [], f"daemon is busy ({self.state})"
self.cfg = candidate
if next_model is not None:
self.model = next_model
self.vocabulary = next_vocab
self._stt_hint_kwargs_cache = None
return "applied", changed, ""
finally:
with self.lock:
self._config_apply_in_progress = False
class ConfigReloader:
def __init__(
self,
*,
daemon: Daemon,
config_path: Path,
notifier: DesktopNotifier | None = None,
poll_interval_sec: float = CONFIG_RELOAD_POLL_INTERVAL_SEC,
):
self.daemon = daemon
self.config_path = config_path
self.notifier = notifier
self.poll_interval_sec = poll_interval_sec
self._stop_event = threading.Event()
self._request_lock = threading.Lock()
self._pending_reload_reason = ""
self._pending_force = False
self._last_seen_fingerprint = self._fingerprint()
self._thread: threading.Thread | None = None
def start(self) -> None:
if self._thread is not None:
return
self._thread = threading.Thread(target=self._run, daemon=True, name="config-reloader")
self._thread.start()
def stop(self, timeout: float = 2.0) -> None:
self._stop_event.set()
if self._thread is not None:
self._thread.join(timeout=timeout)
self._thread = None
def request_reload(self, reason: str, *, force: bool = False) -> None:
with self._request_lock:
if self._pending_reload_reason:
self._pending_reload_reason = f"{self._pending_reload_reason}; {reason}"
else:
self._pending_reload_reason = reason
self._pending_force = self._pending_force or force
logging.debug("config reload requested: %s (force=%s)", reason, force)
def tick(self) -> None:
self._detect_file_change()
self._apply_if_pending()
def _run(self) -> None:
while not self._stop_event.wait(self.poll_interval_sec):
self.tick()
def _detect_file_change(self) -> None:
fingerprint = self._fingerprint()
if fingerprint == self._last_seen_fingerprint:
return
self._last_seen_fingerprint = fingerprint
self.request_reload("file change detected", force=False)
def _apply_if_pending(self) -> None:
with self._request_lock:
reason = self._pending_reload_reason
force_reload = self._pending_force
if not reason:
return
if self.daemon.get_state() != State.IDLE:
logging.debug("config reload deferred; daemon state=%s", self.daemon.get_state())
return
try:
candidate = load(str(self.config_path))
except Exception as exc:
self._clear_pending()
msg = f"config reload failed ({reason}): {exc}"
logging.error(msg)
self._notify("Config Reload Failed", str(exc), error=True)
return
status, changed, error = self.daemon.try_apply_config(candidate)
if status == "deferred":
logging.debug("config reload deferred during apply: %s", error)
return
self._clear_pending()
if status == "error":
msg = f"config reload failed ({reason}): {error}"
logging.error(msg)
self._notify("Config Reload Failed", error, error=True)
return
if changed:
msg = ", ".join(changed)
logging.info("config reloaded (%s): %s", reason, msg)
self._notify("Config Reloaded", f"Applied: {msg}", error=False)
return
if force_reload:
logging.info("config reloaded (%s): no effective changes", reason)
self._notify("Config Reloaded", "No effective changes", error=False)
def _clear_pending(self) -> None:
with self._request_lock:
self._pending_reload_reason = ""
self._pending_force = False
def _fingerprint(self) -> str | None:
try:
data = self.config_path.read_bytes()
return hashlib.sha256(data).hexdigest()
except FileNotFoundError:
return None
except OSError as exc:
logging.warning("config fingerprint failed (%s): %s", self.config_path, exc)
return None
def _notify(self, title: str, body: str, *, error: bool) -> None:
if self.notifier is None:
return
sent = self.notifier.send(title, body, error=error)
if not sent:
logging.debug("desktop notifications unavailable: %s", title)
class PipelineReloader:
def __init__(
self,
*,
daemon: Daemon,
pipelines_path: Path,
notifier: DesktopNotifier | None = None,
dry_run: bool = False,
poll_interval_sec: float = CONFIG_RELOAD_POLL_INTERVAL_SEC,
):
self.daemon = daemon
self.pipelines_path = pipelines_path
self.notifier = notifier
self.dry_run = dry_run
self.poll_interval_sec = poll_interval_sec
self._stop_event = threading.Event()
self._request_lock = threading.Lock()
self._pending_reload_reason = ""
self._pending_force = False
self._last_seen_fingerprint = pipelines_fingerprint(self.pipelines_path)
self._thread: threading.Thread | None = None
def start(self) -> None:
if self._thread is not None:
return
self._thread = threading.Thread(target=self._run, daemon=True, name="pipeline-reloader")
self._thread.start()
def stop(self, timeout: float = 2.0) -> None:
self._stop_event.set()
if self._thread is not None:
self._thread.join(timeout=timeout)
self._thread = None
def request_reload(self, reason: str, *, force: bool = False) -> None:
with self._request_lock:
if self._pending_reload_reason:
self._pending_reload_reason = f"{self._pending_reload_reason}; {reason}"
else:
self._pending_reload_reason = reason
self._pending_force = self._pending_force or force
logging.debug("pipeline reload requested: %s (force=%s)", reason, force)
def tick(self) -> None:
self._detect_file_change()
self._apply_if_pending()
def _run(self) -> None:
while not self._stop_event.wait(self.poll_interval_sec):
self.tick()
def _detect_file_change(self) -> None:
fingerprint = pipelines_fingerprint(self.pipelines_path)
if fingerprint == self._last_seen_fingerprint:
return
self._last_seen_fingerprint = fingerprint
self.request_reload("file change detected", force=False)
def _apply_if_pending(self) -> None:
with self._request_lock:
reason = self._pending_reload_reason
force_reload = self._pending_force
if not reason:
return
if self.daemon.get_state() != State.IDLE:
logging.debug("pipeline reload deferred; daemon state=%s", self.daemon.get_state())
return
try:
bindings = load_bindings(
path=self.pipelines_path,
default_hotkey=self.daemon.cfg.daemon.hotkey,
default_handler_factory=self.daemon.build_reference_pipeline,
)
callbacks = self.daemon.build_hotkey_callbacks(
list(bindings.keys()),
dry_run=self.dry_run,
)
except Exception as exc:
self._clear_pending()
logging.error("pipeline reload failed (%s): %s", reason, exc)
self._notify("Pipelines Reload Failed", str(exc), error=True)
return
status, error = self.daemon.apply_pipeline_bindings(bindings, callbacks)
if status == "deferred":
logging.debug("pipeline reload deferred during apply: %s", error)
return
self._clear_pending()
if status == "error":
logging.error("pipeline reload failed (%s): %s", reason, error)
self._notify("Pipelines Reload Failed", error, error=True)
return
hotkeys = ", ".join(sorted(bindings.keys()))
logging.info("pipelines reloaded (%s): %s", reason, hotkeys)
self._notify("Pipelines Reloaded", f"Hotkeys: {hotkeys}", error=False)
if force_reload and not bindings:
logging.info("pipelines reloaded (%s): no hotkeys", reason)
def _clear_pending(self) -> None:
with self._request_lock:
self._pending_reload_reason = ""
self._pending_force = False
def _notify(self, title: str, body: str, *, error: bool) -> None:
if self.notifier is None:
return
sent = self.notifier.send(title, body, error=error)
if not sent:
logging.debug("desktop notifications unavailable: %s", title)
self._stt_hint_kwargs_cache = kwargs
return self._stt_hint_kwargs_cache
def _read_lock_pid(lock_file) -> str:
@ -869,49 +366,19 @@ def main():
level=logging.DEBUG if args.verbose else logging.INFO,
format="aman: %(asctime)s %(levelname)s %(message)s",
)
config_path = Path(args.config) if args.config else DEFAULT_CONFIG_PATH
cfg = load(str(config_path))
cfg = load(args.config)
_LOCK_HANDLE = _lock_single_instance()
logging.info("hotkey: %s", cfg.daemon.hotkey)
logging.info(
"config (%s):\n%s",
str(config_path),
args.config or str(Path.home() / ".config" / "aman" / "config.json"),
json.dumps(redacted_dict(cfg), indent=2),
)
config_reloader = None
pipeline_reloader = None
try:
desktop = get_desktop_adapter()
daemon = Daemon(cfg, desktop, verbose=args.verbose)
notifier = DesktopNotifier(app_name="aman")
config_reloader = ConfigReloader(
daemon=daemon,
config_path=config_path,
notifier=notifier,
poll_interval_sec=CONFIG_RELOAD_POLL_INTERVAL_SEC,
)
pipelines_path = DEFAULT_PIPELINES_PATH
initial_bindings = load_bindings(
path=pipelines_path,
default_hotkey=cfg.daemon.hotkey,
default_handler_factory=daemon.build_reference_pipeline,
)
initial_callbacks = daemon.build_hotkey_callbacks(
list(initial_bindings.keys()),
dry_run=args.dry_run,
)
status, error = daemon.apply_pipeline_bindings(initial_bindings, initial_callbacks)
if status != "applied":
raise RuntimeError(f"pipeline setup failed: {error}")
pipeline_reloader = PipelineReloader(
daemon=daemon,
pipelines_path=pipelines_path,
notifier=notifier,
dry_run=args.dry_run,
poll_interval_sec=CONFIG_RELOAD_POLL_INTERVAL_SEC,
)
except Exception as exc:
logging.error("startup failed: %s", exc)
raise SystemExit(1)
@ -923,10 +390,6 @@ def main():
return
shutdown_once.set()
logging.info("%s, shutting down", reason)
if config_reloader is not None:
config_reloader.stop(timeout=2.0)
if pipeline_reloader is not None:
pipeline_reloader.stop(timeout=2.0)
if not daemon.shutdown(timeout=5.0):
logging.warning("timed out waiting for idle state during shutdown")
desktop.request_quit()
@ -934,30 +397,15 @@ def main():
def handle_signal(_sig, _frame):
threading.Thread(target=shutdown, args=("signal received",), daemon=True).start()
def handle_reload_signal(_sig, _frame):
if shutdown_once.is_set():
return
if config_reloader is not None:
threading.Thread(
target=lambda: config_reloader.request_reload("signal SIGHUP", force=True),
daemon=True,
).start()
if pipeline_reloader is not None:
threading.Thread(
target=lambda: pipeline_reloader.request_reload("signal SIGHUP", force=True),
daemon=True,
).start()
signal.signal(signal.SIGINT, handle_signal)
signal.signal(signal.SIGTERM, handle_signal)
signal.signal(signal.SIGHUP, handle_reload_signal)
try:
desktop.start_hotkey_listener(
cfg.daemon.hotkey,
lambda: logging.info("hotkey pressed (dry-run)") if args.dry_run else daemon.toggle(),
)
desktop.start_cancel_listener(lambda: daemon.cancel_recording())
if config_reloader is not None:
config_reloader.start()
if pipeline_reloader is not None:
pipeline_reloader.start()
except Exception as exc:
logging.error("hotkey setup failed: %s", exc)
raise SystemExit(1)
@ -965,10 +413,6 @@ def main():
try:
desktop.run_tray(daemon.get_state, lambda: shutdown("quit requested"))
finally:
if config_reloader is not None:
config_reloader.stop(timeout=2.0)
if pipeline_reloader is not None:
pipeline_reloader.stop(timeout=2.0)
daemon.shutdown(timeout=1.0)