Migrate to Python daemon
This commit is contained in:
parent
49ef349d48
commit
d81f3dbffe
42 changed files with 660 additions and 1816 deletions
209
src/leld.py
Executable file
209
src/leld.py
Executable file
|
|
@ -0,0 +1,209 @@
|
|||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from config import Config, load, redacted_dict
|
||||
from recorder import start_recording, stop_recording
|
||||
from stt import WhisperSTT
|
||||
from aiprocess import AIConfig, build_processor
|
||||
from inject import inject
|
||||
from x11_hotkey import listen
|
||||
from tray import run_tray
|
||||
|
||||
|
||||
class State:
|
||||
IDLE = "idle"
|
||||
RECORDING = "recording"
|
||||
TRANSCRIBING = "transcribing"
|
||||
PROCESSING = "processing"
|
||||
OUTPUTTING = "outputting"
|
||||
|
||||
|
||||
class Daemon:
|
||||
def __init__(self, cfg: Config):
|
||||
self.cfg = cfg
|
||||
self.lock = threading.Lock()
|
||||
self.state = State.IDLE
|
||||
self.proc = None
|
||||
self.record = None
|
||||
self.timer = None
|
||||
self.stt = WhisperSTT(cfg.whisper_model, cfg.whisper_lang, cfg.whisper_device)
|
||||
self.ai = None
|
||||
if cfg.ai_enabled:
|
||||
self.ai = build_processor(
|
||||
AIConfig(
|
||||
provider=cfg.ai_provider,
|
||||
model=cfg.ai_model,
|
||||
temperature=cfg.ai_temperature,
|
||||
system_prompt_file=cfg.ai_system_prompt_file,
|
||||
base_url=cfg.ai_base_url,
|
||||
api_key=cfg.ai_api_key,
|
||||
timeout_sec=cfg.ai_timeout_sec,
|
||||
)
|
||||
)
|
||||
|
||||
def set_state(self, state: str):
|
||||
with self.lock:
|
||||
self.state = state
|
||||
|
||||
def get_state(self):
|
||||
with self.lock:
|
||||
return self.state
|
||||
|
||||
def toggle(self):
|
||||
with self.lock:
|
||||
if self.state == State.IDLE:
|
||||
self._start_recording_locked()
|
||||
return
|
||||
if self.state == State.RECORDING:
|
||||
self.state = State.TRANSCRIBING
|
||||
threading.Thread(target=self._stop_and_process, daemon=True).start()
|
||||
return
|
||||
logging.info("busy (%s), trigger ignored", self.state)
|
||||
|
||||
def _start_recording_locked(self):
|
||||
try:
|
||||
proc, record = start_recording(self.cfg.ffmpeg_input, self.cfg.ffmpeg_path)
|
||||
except Exception as exc:
|
||||
logging.error("record start failed: %s", exc)
|
||||
return
|
||||
self.proc = proc
|
||||
self.record = record
|
||||
self.state = State.RECORDING
|
||||
logging.info("recording started (%s)", record.wav_path)
|
||||
if self.timer:
|
||||
self.timer.cancel()
|
||||
self.timer = threading.Timer(self.cfg.record_timeout_sec, self._timeout_stop)
|
||||
self.timer.daemon = True
|
||||
self.timer.start()
|
||||
|
||||
def _timeout_stop(self):
|
||||
with self.lock:
|
||||
if self.state != State.RECORDING:
|
||||
return
|
||||
self.state = State.TRANSCRIBING
|
||||
threading.Thread(target=self._stop_and_process, daemon=True).start()
|
||||
|
||||
def _stop_and_process(self):
|
||||
proc = self.proc
|
||||
record = self.record
|
||||
self.proc = None
|
||||
self.record = None
|
||||
if self.timer:
|
||||
self.timer.cancel()
|
||||
self.timer = None
|
||||
|
||||
if not proc or not record:
|
||||
self.set_state(State.IDLE)
|
||||
return
|
||||
|
||||
logging.info("stopping recording (user)")
|
||||
try:
|
||||
stop_recording(proc)
|
||||
except Exception as exc:
|
||||
logging.error("record stop failed: %s", exc)
|
||||
self.set_state(State.IDLE)
|
||||
return
|
||||
|
||||
if not Path(record.wav_path).exists():
|
||||
logging.error("no audio captured")
|
||||
self.set_state(State.IDLE)
|
||||
return
|
||||
|
||||
try:
|
||||
self.set_state(State.TRANSCRIBING)
|
||||
text = self.stt.transcribe(record.wav_path)
|
||||
except Exception as exc:
|
||||
logging.error("whisper failed: %s", exc)
|
||||
self.set_state(State.IDLE)
|
||||
return
|
||||
|
||||
logging.info("transcript: %s", text)
|
||||
|
||||
if self.ai:
|
||||
self.set_state(State.PROCESSING)
|
||||
try:
|
||||
text = self.ai.process(text) or text
|
||||
except Exception as exc:
|
||||
logging.error("ai process failed: %s", exc)
|
||||
|
||||
logging.info("output: %s", text)
|
||||
|
||||
try:
|
||||
self.set_state(State.OUTPUTTING)
|
||||
inject(text, self.cfg.injection_backend)
|
||||
except Exception as exc:
|
||||
logging.error("output failed: %s", exc)
|
||||
finally:
|
||||
self.set_state(State.IDLE)
|
||||
|
||||
def stop_recording(self):
|
||||
with self.lock:
|
||||
if self.state != State.RECORDING:
|
||||
return
|
||||
self.state = State.TRANSCRIBING
|
||||
threading.Thread(target=self._stop_and_process, daemon=True).start()
|
||||
|
||||
|
||||
def _lock_single_instance():
|
||||
runtime_dir = Path(os.getenv("XDG_RUNTIME_DIR", "/tmp")) / "lel"
|
||||
runtime_dir.mkdir(parents=True, exist_ok=True)
|
||||
lock_path = runtime_dir / "lel.lock"
|
||||
f = open(lock_path, "w")
|
||||
try:
|
||||
import fcntl
|
||||
|
||||
fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except Exception:
|
||||
raise SystemExit("another instance is running")
|
||||
return f
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--config", default="", help="path to config.json")
|
||||
parser.add_argument("--no-tray", action="store_true", help="disable tray icon")
|
||||
parser.add_argument("--dry-run", action="store_true", help="log hotkey only")
|
||||
args = parser.parse_args()
|
||||
|
||||
logging.basicConfig(stream=sys.stderr, level=logging.INFO, format="leld: %(asctime)s %(message)s")
|
||||
cfg = load(args.config)
|
||||
|
||||
_lock_single_instance()
|
||||
|
||||
logging.info("ready (hotkey: %s)", cfg.hotkey)
|
||||
logging.info("config (%s):\n%s", args.config or str(Path.home() / ".config" / "lel" / "config.json"), json.dumps(redacted_dict(cfg), indent=2))
|
||||
|
||||
daemon = Daemon(cfg)
|
||||
|
||||
def on_quit():
|
||||
os._exit(0)
|
||||
|
||||
def handle_signal(_sig, _frame):
|
||||
logging.info("signal received, shutting down")
|
||||
daemon.stop_recording()
|
||||
end = time.time() + 5
|
||||
while time.time() < end and daemon.get_state() != State.IDLE:
|
||||
time.sleep(0.1)
|
||||
os._exit(0)
|
||||
|
||||
signal.signal(signal.SIGINT, handle_signal)
|
||||
signal.signal(signal.SIGTERM, handle_signal)
|
||||
|
||||
if args.no_tray:
|
||||
listen(cfg.hotkey, lambda: logging.info("hotkey pressed (dry-run)") if args.dry_run else daemon.toggle())
|
||||
return
|
||||
|
||||
threading.Thread(target=lambda: listen(cfg.hotkey, lambda: logging.info("hotkey pressed (dry-run)") if args.dry_run else daemon.toggle()), daemon=True).start()
|
||||
run_tray(daemon.get_state, on_quit)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue