From d81f3dbffeb395b0bb5f0e503a41106185705575 Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Sat, 7 Feb 2026 15:12:17 -0300 Subject: [PATCH] Migrate to Python daemon --- AGENTS.md | 1 - Makefile | 21 +- README.md | 47 +-- cmd/lelctl/main.go | 45 --- cmd/leld/main.go | 322 ------------------ go.mod | 20 -- go.sum | 34 -- internal/aiprocess/aiprocess.go | 247 -------------- internal/audio/record.go | 73 ---- internal/clip/clipboard.go | 26 -- internal/config/config.go | 170 --------- internal/daemon/daemon.go | 269 --------------- internal/inject/inject.go | 72 ---- internal/inject/inject_test.go | 102 ------ internal/inject/xdotool.go | 63 ---- internal/ui/icons.go | 28 -- internal/whisper/transcribe.go | 69 ---- internal/x11/x11.go | 232 ------------- requirements.txt | 5 + src/__pycache__/aiprocess.cpython-310.pyc | Bin 0 -> 1889 bytes src/__pycache__/config.cpython-310.pyc | Bin 0 -> 3335 bytes src/__pycache__/inject.cpython-310.pyc | Bin 0 -> 1396 bytes src/__pycache__/leld.cpython-313.pyc | Bin 0 -> 13435 bytes src/__pycache__/recorder.cpython-310.pyc | Bin 0 -> 2146 bytes src/__pycache__/stt.cpython-310.pyc | Bin 0 -> 1230 bytes src/__pycache__/stt.cpython-313.pyc | Bin 0 -> 1889 bytes src/__pycache__/tray.cpython-310.pyc | Bin 0 -> 1759 bytes src/__pycache__/x11_hotkey.cpython-310.pyc | Bin 0 -> 1821 bytes src/aiprocess.py | 46 +++ {internal/ui => src}/assets/idle.png | Bin src/assets/processing.png | Bin 0 -> 82 bytes {internal/ui => src}/assets/recording.png | Bin {internal/ui => src}/assets/transcribing.png | Bin src/config.py | 109 ++++++ src/inject.py | 50 +++ src/leld.py | 209 ++++++++++++ src/recorder.py | 70 ++++ src/stt.py | 25 ++ {internal/aiprocess => src}/system_prompt.txt | 0 src/tray.py | 52 +++ src/x11_hotkey.py | 67 ++++ systemd/lel.service | 2 +- 42 files changed, 660 insertions(+), 1816 deletions(-) delete mode 100644 cmd/lelctl/main.go delete mode 100644 cmd/leld/main.go delete mode 100644 go.mod delete mode 100644 go.sum delete mode 100644 internal/aiprocess/aiprocess.go delete mode 100644 internal/audio/record.go delete mode 100644 internal/clip/clipboard.go delete mode 100644 internal/config/config.go delete mode 100644 internal/daemon/daemon.go delete mode 100644 internal/inject/inject.go delete mode 100644 internal/inject/inject_test.go delete mode 100644 internal/inject/xdotool.go delete mode 100644 internal/ui/icons.go delete mode 100644 internal/whisper/transcribe.go delete mode 100644 internal/x11/x11.go create mode 100644 requirements.txt create mode 100644 src/__pycache__/aiprocess.cpython-310.pyc create mode 100644 src/__pycache__/config.cpython-310.pyc create mode 100644 src/__pycache__/inject.cpython-310.pyc create mode 100644 src/__pycache__/leld.cpython-313.pyc create mode 100644 src/__pycache__/recorder.cpython-310.pyc create mode 100644 src/__pycache__/stt.cpython-310.pyc create mode 100644 src/__pycache__/stt.cpython-313.pyc create mode 100644 src/__pycache__/tray.cpython-310.pyc create mode 100644 src/__pycache__/x11_hotkey.cpython-310.pyc create mode 100644 src/aiprocess.py rename {internal/ui => src}/assets/idle.png (100%) create mode 100644 src/assets/processing.png rename {internal/ui => src}/assets/recording.png (100%) rename {internal/ui => src}/assets/transcribing.png (100%) create mode 100644 src/config.py create mode 100644 src/inject.py create mode 100755 src/leld.py create mode 100644 src/recorder.py create mode 100644 src/stt.py rename {internal/aiprocess => src}/system_prompt.txt (100%) create mode 100644 src/tray.py create mode 100644 src/x11_hotkey.py diff --git a/AGENTS.md b/AGENTS.md index 9bc06bb..f0132da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,6 @@ ## Testing Guidelines - No automated tests are present. -- Go tests (if added): `go test ./...` in repo root. - If you add tests, include a brief note in `AGENTS.md` with the runner command and test location. ## Commit & Pull Request Guidelines diff --git a/Makefile b/Makefile index b832d37..9376510 100644 --- a/Makefile +++ b/Makefile @@ -1,16 +1,15 @@ -BIN_DIR := . -LELD := $(BIN_DIR)/leld -LELCTL := $(BIN_DIR)/lelctl CONFIG := $(HOME)/.config/lel/config.json -.PHONY: build run clean - -build: - go build -o $(LELD) ./cmd/leld - go build -o $(LELCTL) ./cmd/lelctl +.PHONY: run run-py install run: - $(LELD) --config $(CONFIG) + python3 src/leld.py --config $(CONFIG) -clean: - rm -f $(LELD) $(LELCTL) +run-py: run + +install: + mkdir -p $(HOME)/.local/bin + cp src/leld.py $(HOME)/.local/bin/leld.py + cp systemd/lel.service $(HOME)/.config/systemd/user/lel.service + systemctl --user daemon-reload + systemctl --user enable --now lel diff --git a/README.md b/README.md index e1acc4c..070b541 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # lel -X11 transcription daemon that records audio, runs Whisper, logs the transcript, and can optionally run AI post-processing before injecting text. +Python X11 transcription daemon that records audio, runs Whisper, logs the transcript, and can optionally run AI post-processing before injecting text. ## Requirements @@ -8,12 +8,22 @@ X11 transcription daemon that records audio, runs Whisper, logs the transcript, - `ffmpeg` - `whisper` (OpenAI Whisper CLI) - `xclip` +- `xdotool` - Tray icon deps: `libappindicator3` and `gtk3` (required by `systray`) +- Python deps: `pystray`, `pillow`, `python-xlib`, `ollama`, `openai-whisper` -## Build +## Python Daemon + +Install Python deps: ```bash -make build +pip install -r src/requirements.txt +``` + +Run: + +```bash +python3 src/leld.py --config ~/.config/lel/config.json ``` ## Config @@ -56,23 +66,11 @@ Env overrides: - `LEL_AI_ENABLED`, `LEL_AI_PROVIDER`, `LEL_AI_MODEL`, `LEL_AI_TEMPERATURE`, `LEL_AI_SYSTEM_PROMPT_FILE` - `LEL_AI_BASE_URL`, `LEL_AI_API_KEY`, `LEL_AI_TIMEOUT_SEC` -## Run manually - -```bash -./leld --config ~/.config/lel/config.json -``` - -Disable the tray icon: - -```bash -./leld --no-tray -``` - ## systemd user service ```bash mkdir -p ~/.local/bin -cp leld lelctl ~/.local/bin/ +cp src/leld.py ~/.local/bin/leld.py cp systemd/lel.service ~/.config/systemd/user/lel.service systemctl --user daemon-reload systemctl --user enable --now lel @@ -84,10 +82,6 @@ systemctl --user enable --now lel - Press it again to stop and transcribe. - The transcript is logged to stderr. -Execution flow (single in-flight state machine): - -- `recording` -> `transcribing` -> `processing` (optional) -> `outputting` -> `idle` - Injection backends: - `clipboard`: copy to clipboard and inject via Ctrl+V (requires `xclip` + `xdotool`) @@ -95,19 +89,10 @@ Injection backends: AI providers: -- `ollama`: calls the local Ollama HTTP API (`/api/generate`) -- `openai_compat`: calls a chat-completions compatible API (`/v1/chat/completions`) - -Dependency checks: - -- Recording requires `ffmpeg` (or set `ffmpeg_path`) -- Transcribing uses the `whisper` CLI -- Outputting requires `xclip` (and `xdotool` for injection backends) +- `ollama`: calls the local Ollama API Control: ```bash -lelctl status -lelctl reload -lelctl stop +make run ``` diff --git a/cmd/lelctl/main.go b/cmd/lelctl/main.go deleted file mode 100644 index 455f487..0000000 --- a/cmd/lelctl/main.go +++ /dev/null @@ -1,45 +0,0 @@ -package main - -import ( - "flag" - "fmt" - "net" - "os" - "path/filepath" - "strings" -) - -func main() { - flag.Parse() - if flag.NArg() == 0 { - fmt.Fprintln(os.Stderr, "usage: lelctl ") - os.Exit(1) - } - - cmd := strings.TrimSpace(flag.Arg(0)) - if cmd == "" { - fmt.Fprintln(os.Stderr, "invalid command") - os.Exit(1) - } - - runtimeDir := os.Getenv("XDG_RUNTIME_DIR") - if runtimeDir == "" { - runtimeDir = "/tmp" - } - sockPath := filepath.Join(runtimeDir, "lel", "ctl.sock") - - conn, err := net.Dial("unix", sockPath) - if err != nil { - fmt.Fprintf(os.Stderr, "connect failed: %v\n", err) - os.Exit(1) - } - defer conn.Close() - - _, _ = fmt.Fprintf(conn, "%s\n", cmd) - - buf := make([]byte, 4096) - n, _ := conn.Read(buf) - if n > 0 { - fmt.Print(string(buf[:n])) - } -} diff --git a/cmd/leld/main.go b/cmd/leld/main.go deleted file mode 100644 index 0f2dbee..0000000 --- a/cmd/leld/main.go +++ /dev/null @@ -1,322 +0,0 @@ -package main - -import ( - "bufio" - "context" - "encoding/json" - "flag" - "fmt" - "log" - "net" - "os" - "os/signal" - "path/filepath" - "strings" - "syscall" - "time" - - "lel/internal/aiprocess" - "lel/internal/clip" - "lel/internal/config" - "lel/internal/daemon" - "lel/internal/inject" - "lel/internal/ui" - "lel/internal/x11" - - "github.com/BurntSushi/xgb/xproto" - "github.com/getlantern/systray" -) - -func main() { - var configPath string - var dryRun bool - var noTray bool - flag.StringVar(&configPath, "config", "", "path to config.json") - flag.BoolVar(&dryRun, "dry-run", false, "register hotkey and log events without recording") - flag.BoolVar(&noTray, "no-tray", false, "disable system tray icon") - flag.Parse() - - logger := log.New(os.Stderr, "leld: ", log.LstdFlags) - - cfg, err := config.Load(configPath) - if err != nil { - logger.Fatalf("config error: %v", err) - } - - if cfg.Streaming { - logger.Printf("streaming mode is not supported; falling back to non-streaming") - } - - runtimeDir := ensureRuntimeDir(logger) - lockFile := filepath.Join(runtimeDir, "lel.lock") - lock, err := lockSingleInstance(lockFile) - if err != nil { - logger.Fatalf("another instance is running (lock %s): %v", lockFile, err) - } - defer lock.Close() - - x, err := x11.New() - if err != nil { - logger.Fatalf("x11 connection failed: %v", err) - } - defer x.Close() - - mods, keycode, err := x.ParseHotkey(cfg.Hotkey) - if err != nil { - logger.Fatalf("hotkey parse failed: %v", err) - } - if err := x.GrabHotkey(mods, keycode); err != nil { - logger.Fatalf("grab hotkey failed: %v", err) - } - defer x.UngrabHotkey(mods, keycode) - - backend, err := inject.NewBackend(cfg.InjectionBackend, inject.Deps{ - Clipboard: inject.ClipboardWriterFunc(clip.WriteClipboard), - Paster: inject.NewXdotoolPaster(nil), - Typer: inject.NewXdotoolTyper(nil), - }) - if err != nil { - logger.Fatalf("backend error: %v", err) - } - - processor, err := aiprocess.New(aiprocess.Config{ - Enabled: cfg.AIEnabled, - Provider: cfg.AIProvider, - Model: cfg.AIModel, - Temperature: cfg.AITemperature, - SystemPromptFile: cfg.AISystemPromptFile, - BaseURL: cfg.AIBaseURL, - APIKey: cfg.AIAPIKey, - TimeoutSec: cfg.AITimeoutSec, - }) - if err != nil { - logger.Fatalf("ai processor error: %v", err) - } - - d := daemon.New(cfg, x, logger, backend, processor) - - sockPath := filepath.Join(runtimeDir, "ctl.sock") - if err := os.RemoveAll(sockPath); err != nil { - logger.Fatalf("remove socket failed: %v", err) - } - ln, err := net.Listen("unix", sockPath) - if err != nil { - logger.Fatalf("listen socket failed: %v", err) - } - defer ln.Close() - - reloadPath := configPath - if reloadPath == "" { - reloadPath = config.DefaultPath() - } - go serveControl(logger, ln, d, &cfg, x, &mods, &keycode, reloadPath) - - logger.Printf("ready (hotkey: %s)", cfg.Hotkey) - logConfig(logger, cfg, reloadPath) - - if noTray { - go handleSignals(logger, d) - runX11Loop(logger, x, d, mods, keycode, dryRun) - return - } - - onReady := func() { - systray.SetTitle("lel") - systray.SetTooltip("lel: idle") - systray.SetIcon(ui.IconIdle()) - status := systray.AddMenuItem("Idle", "") - status.Disable() - systray.AddSeparator() - quit := systray.AddMenuItem("Quit", "Quit lel") - - go func() { - for st := range d.StateChanges() { - switch st { - case daemon.StateRecording: - systray.SetIcon(ui.IconRecording()) - systray.SetTooltip("lel: recording") - status.SetTitle("Recording") - case daemon.StateTranscribing: - systray.SetIcon(ui.IconTranscribing()) - systray.SetTooltip("lel: transcribing") - status.SetTitle("Transcribing") - case daemon.StateProcessing: - systray.SetIcon(ui.IconProcessing()) - systray.SetTooltip("lel: ai processing") - status.SetTitle("AI Processing") - default: - systray.SetIcon(ui.IconIdle()) - systray.SetTooltip("lel: idle") - status.SetTitle("Idle") - } - } - }() - - go func() { - for range quit.ClickedCh { - os.Exit(0) - } - }() - - go handleSignals(logger, d) - go runX11Loop(logger, x, d, mods, keycode, dryRun) - } - - systray.Run(onReady, func() {}) -} - -func logConfig(logger *log.Logger, cfg config.Config, path string) { - safe := cfg - safe.AIAPIKey = "" - data, err := json.MarshalIndent(safe, "", " ") - if err != nil { - logger.Printf("config: ", err) - return - } - logger.Printf("config (%s):\n%s", path, string(data)) -} - -func matchMods(state uint16, want uint16) bool { - masked := state & ^uint16(xproto.ModMaskLock|xproto.ModMask2) - return masked == want -} - -func runX11Loop(logger *log.Logger, x *x11.Conn, d *daemon.Daemon, mods uint16, keycode xproto.Keycode, dryRun bool) { - for { - ev, err := x.X.WaitForEvent() - if err != nil { - logger.Printf("x11 event error: %v", err) - continue - } - switch e := ev.(type) { - case xproto.KeyPressEvent: - if e.Detail == keycode && matchMods(e.State, mods) { - if dryRun { - logger.Printf("hotkey pressed (dry-run)") - continue - } - d.Toggle() - } - } - } -} - -func handleSignals(logger *log.Logger, d *daemon.Daemon) { - sigCh := make(chan os.Signal, 2) - signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) - sig := <-sigCh - logger.Printf("signal received: %v, shutting down", sig) - d.StopRecording("signal") - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - if !d.WaitForIdle(ctx) { - logger.Printf("shutdown timeout, exiting") - } - os.Exit(0) -} - -func ensureRuntimeDir(logger *log.Logger) string { - dir := os.Getenv("XDG_RUNTIME_DIR") - if dir == "" { - dir = "/tmp" - } - dir = filepath.Join(dir, "lel") - if err := os.MkdirAll(dir, 0o700); err != nil { - logger.Fatalf("runtime dir error: %v", err) - } - return dir -} - -func lockSingleInstance(path string) (*os.File, error) { - f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) - if err != nil { - return nil, err - } - if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { - _ = f.Close() - return nil, err - } - return f, nil -} - -func serveControl(logger *log.Logger, ln net.Listener, d *daemon.Daemon, cfg *config.Config, x *x11.Conn, mods *uint16, keycode *xproto.Keycode, configPath string) { - for { - conn, err := ln.Accept() - if err != nil { - logger.Printf("control accept error: %v", err) - continue - } - go handleConn(logger, conn, d, cfg, x, mods, keycode, configPath) - } -} - -func handleConn(logger *log.Logger, conn net.Conn, d *daemon.Daemon, cfg *config.Config, x *x11.Conn, mods *uint16, keycode *xproto.Keycode, configPath string) { - defer conn.Close() - - reader := bufio.NewReader(conn) - line, _ := reader.ReadString('\n') - line = strings.TrimSpace(line) - - switch line { - case "status": - _, _ = fmt.Fprintf(conn, "state=%s\n", d.State()) - case "stop": - _, _ = fmt.Fprintf(conn, "stopping\n") - logger.Printf("stop requested") - os.Exit(0) - case "reload": - newCfg, err := config.Load(configPath) - if err != nil { - _, _ = fmt.Fprintf(conn, "reload error: %v\n", err) - return - } - - newMods, newKeycode, err := x.ParseHotkey(newCfg.Hotkey) - if err != nil { - _, _ = fmt.Fprintf(conn, "reload error: %v\n", err) - return - } - - x.UngrabHotkey(*mods, *keycode) - if err := x.GrabHotkey(newMods, newKeycode); err != nil { - _, _ = fmt.Fprintf(conn, "reload error: %v\n", err) - return - } - - backend, err := inject.NewBackend(newCfg.InjectionBackend, inject.Deps{ - Clipboard: inject.ClipboardWriterFunc(clip.WriteClipboard), - Paster: inject.NewXdotoolPaster(nil), - Typer: inject.NewXdotoolTyper(nil), - }) - if err != nil { - _, _ = fmt.Fprintf(conn, "reload error: %v\n", err) - return - } - - processor, err := aiprocess.New(aiprocess.Config{ - Enabled: newCfg.AIEnabled, - Provider: newCfg.AIProvider, - Model: newCfg.AIModel, - Temperature: newCfg.AITemperature, - SystemPromptFile: newCfg.AISystemPromptFile, - BaseURL: newCfg.AIBaseURL, - APIKey: newCfg.AIAPIKey, - TimeoutSec: newCfg.AITimeoutSec, - }) - if err != nil { - _, _ = fmt.Fprintf(conn, "reload error: %v\n", err) - return - } - - *mods = newMods - *keycode = newKeycode - *cfg = newCfg - d.UpdateConfig(newCfg) - d.UpdateBackend(backend) - d.UpdateAI(processor) - - _, _ = fmt.Fprintf(conn, "reloaded\n") - default: - _, _ = fmt.Fprintf(conn, "unknown command\n") - } -} diff --git a/go.mod b/go.mod deleted file mode 100644 index b7623ad..0000000 --- a/go.mod +++ /dev/null @@ -1,20 +0,0 @@ -module lel - -go 1.25.5 - -require ( - github.com/BurntSushi/xgb v0.0.0-20210121224620-deaf085860bc - github.com/getlantern/systray v1.2.2 -) - -require ( - github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 // indirect - github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 // indirect - github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 // indirect - github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 // indirect - github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 // indirect - github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f // indirect - github.com/go-stack/stack v1.8.0 // indirect - github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect - golang.org/x/sys v0.1.0 // indirect -) diff --git a/go.sum b/go.sum deleted file mode 100644 index a8d1893..0000000 --- a/go.sum +++ /dev/null @@ -1,34 +0,0 @@ -github.com/BurntSushi/xgb v0.0.0-20210121224620-deaf085860bc h1:7D+Bh06CRPCJO3gr2F7h1sriovOZ8BMhca2Rg85c2nk= -github.com/BurntSushi/xgb v0.0.0-20210121224620-deaf085860bc/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 h1:NRUJuo3v3WGC/g5YiyF790gut6oQr5f3FBI88Wv0dx4= -github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY= -github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 h1:6uJ+sZ/e03gkbqZ0kUG6mfKoqDb4XMAzMIwlajq19So= -github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A= -github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 h1:guBYzEaLz0Vfc/jv0czrr2z7qyzTOGC9hiQ0VC+hKjk= -github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7/go.mod h1:zx/1xUUeYPy3Pcmet8OSXLbF47l+3y6hIPpyLWoR9oc= -github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 h1:micT5vkcr9tOVk1FiH8SWKID8ultN44Z+yzd2y/Vyb0= -github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7/go.mod h1:dD3CgOrwlzca8ed61CsZouQS5h5jIzkK9ZWrTcf0s+o= -github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 h1:XYzSdCbkzOC0FDNrgJqGRo8PCMFOBFL9py72DRs7bmc= -github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55/go.mod h1:6mmzY2kW1TOOrVy+r41Za2MxXM+hhqTtY3oBKd2AgFA= -github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f h1:wrYrQttPS8FHIRSlsrcuKazukx/xqO/PpLZzZXsF+EA= -github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA= -github.com/getlantern/systray v1.2.2 h1:dCEHtfmvkJG7HZ8lS/sLklTH4RKUcIsKrAD9sThoEBE= -github.com/getlantern/systray v1.2.2/go.mod h1:pXFOI1wwqwYXEhLPm9ZGjS2u/vVELeIgNMY5HvhHhcE= -github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk= -github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= -github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ= -github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk= -github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw= -github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U= -golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E= diff --git a/internal/aiprocess/aiprocess.go b/internal/aiprocess/aiprocess.go deleted file mode 100644 index 7725d61..0000000 --- a/internal/aiprocess/aiprocess.go +++ /dev/null @@ -1,247 +0,0 @@ -package aiprocess - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "os" - "strings" - "time" - - _ "embed" -) - -type Config struct { - Enabled bool - Provider string - Model string - Temperature float64 - SystemPromptFile string - BaseURL string - APIKey string - TimeoutSec int -} - -type Processor interface { - Process(ctx context.Context, input string) (string, error) -} - -func New(cfg Config) (Processor, error) { - if !cfg.Enabled { - return nil, nil - } - - provider := strings.ToLower(strings.TrimSpace(cfg.Provider)) - if provider == "" { - return nil, errors.New("ai provider is required when enabled") - } - if strings.TrimSpace(cfg.Model) == "" { - return nil, errors.New("ai model is required when enabled") - } - - systemPrompt, err := loadSystemPrompt(cfg.SystemPromptFile) - if err != nil { - return nil, err - } - - timeout := time.Duration(cfg.TimeoutSec) * time.Second - if timeout <= 0 { - timeout = 20 * time.Second - } - - switch provider { - case "ollama": - base := strings.TrimRight(cfg.BaseURL, "/") - if base == "" { - base = "http://localhost:11434" - } - return &ollamaProcessor{ - client: &http.Client{Timeout: timeout}, - baseURL: base, - model: cfg.Model, - temperature: cfg.Temperature, - system: systemPrompt, - }, nil - case "openai_compat": - base := strings.TrimRight(cfg.BaseURL, "/") - if base == "" { - return nil, errors.New("ai base_url is required for openai_compat") - } - return &openAICompatProcessor{ - client: &http.Client{Timeout: timeout}, - baseURL: base, - apiKey: cfg.APIKey, - model: cfg.Model, - temperature: cfg.Temperature, - system: systemPrompt, - }, nil - default: - return nil, fmt.Errorf("unknown ai provider %q", provider) - } -} - -func loadSystemPrompt(path string) (string, error) { - if strings.TrimSpace(path) == "" { - return strings.TrimSpace(defaultSystemPrompt), nil - } - data, err := os.ReadFile(path) - if err != nil { - return "", fmt.Errorf("read system prompt file: %w", err) - } - return strings.TrimSpace(string(data)), nil -} - -//go:embed system_prompt.txt -var defaultSystemPrompt string - -type ollamaProcessor struct { - client *http.Client - baseURL string - model string - temperature float64 - system string -} - -func (p *ollamaProcessor) Process(ctx context.Context, input string) (string, error) { - reqBody := ollamaRequest{ - Model: p.model, - Prompt: input, - Stream: false, - } - if p.system != "" { - reqBody.System = p.system - } - if p.temperature != 0 { - reqBody.Options = &ollamaOptions{Temperature: p.temperature} - } - - payload, err := json.Marshal(reqBody) - if err != nil { - return "", err - } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.baseURL+"/api/generate", bytes.NewReader(payload)) - if err != nil { - return "", err - } - req.Header.Set("Content-Type", "application/json") - - resp, err := p.client.Do(req) - if err != nil { - return "", err - } - defer resp.Body.Close() - - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return "", fmt.Errorf("ollama request failed: %s", readErrorBody(resp.Body)) - } - - var out ollamaResponse - if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { - return "", err - } - return strings.TrimSpace(out.Response), nil -} - -type ollamaRequest struct { - Model string `json:"model"` - Prompt string `json:"prompt"` - System string `json:"system,omitempty"` - Stream bool `json:"stream"` - Options *ollamaOptions `json:"options,omitempty"` -} - -type ollamaOptions struct { - Temperature float64 `json:"temperature,omitempty"` -} - -type ollamaResponse struct { - Response string `json:"response"` -} - -type openAICompatProcessor struct { - client *http.Client - baseURL string - apiKey string - model string - temperature float64 - system string -} - -func (p *openAICompatProcessor) Process(ctx context.Context, input string) (string, error) { - messages := []openAIMessage{ - {Role: "user", Content: input}, - } - if p.system != "" { - messages = append([]openAIMessage{{Role: "system", Content: p.system}}, messages...) - } - - reqBody := openAIRequest{ - Model: p.model, - Messages: messages, - Temperature: p.temperature, - } - - payload, err := json.Marshal(reqBody) - if err != nil { - return "", err - } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.baseURL+"/v1/chat/completions", bytes.NewReader(payload)) - if err != nil { - return "", err - } - req.Header.Set("Content-Type", "application/json") - if strings.TrimSpace(p.apiKey) != "" { - req.Header.Set("Authorization", "Bearer "+p.apiKey) - } - - resp, err := p.client.Do(req) - if err != nil { - return "", err - } - defer resp.Body.Close() - - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return "", fmt.Errorf("openai_compat request failed: %s", readErrorBody(resp.Body)) - } - - var out openAIResponse - if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { - return "", err - } - if len(out.Choices) == 0 { - return "", errors.New("openai_compat response missing choices") - } - return strings.TrimSpace(out.Choices[0].Message.Content), nil -} - -type openAIRequest struct { - Model string `json:"model"` - Messages []openAIMessage `json:"messages"` - Temperature float64 `json:"temperature,omitempty"` -} - -type openAIMessage struct { - Role string `json:"role"` - Content string `json:"content"` -} - -type openAIResponse struct { - Choices []openAIChoice `json:"choices"` -} - -type openAIChoice struct { - Message openAIMessage `json:"message"` -} - -func readErrorBody(r io.Reader) string { - data, err := io.ReadAll(io.LimitReader(r, 64*1024)) - if err != nil { - return "unknown error" - } - return strings.TrimSpace(string(data)) -} diff --git a/internal/audio/record.go b/internal/audio/record.go deleted file mode 100644 index c65c699..0000000 --- a/internal/audio/record.go +++ /dev/null @@ -1,73 +0,0 @@ -package audio - -import ( - "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - "syscall" - "time" -) - -type Recorder struct { - Input string -} - -type RecordResult struct { - WavPath string - TempDir string -} - -func (r Recorder) Start(ctx context.Context) (*exec.Cmd, *RecordResult, error) { - tmpdir, err := os.MkdirTemp("", "lel-") - if err != nil { - return nil, nil, err - } - wav := filepath.Join(tmpdir, "mic.wav") - - args := []string{"-hide_banner", "-loglevel", "error"} - args = append(args, ffmpegInputArgs(r.Input)...) - args = append(args, "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", wav) - - cmd := exec.CommandContext(ctx, "ffmpeg", args...) - // Put ffmpeg in its own process group so Ctrl+C only targets the daemon. - cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} - if err := cmd.Start(); err != nil { - _ = os.RemoveAll(tmpdir) - return nil, nil, err - } - - return cmd, &RecordResult{WavPath: wav, TempDir: tmpdir}, nil -} - -func WaitWithTimeout(cmd *exec.Cmd, timeout time.Duration) error { - done := make(chan error, 1) - go func() { - done <- cmd.Wait() - }() - - select { - case err := <-done: - return err - case <-time.After(timeout): - if cmd.Process != nil { - _ = cmd.Process.Kill() - } - return fmt.Errorf("process timeout after %s", timeout) - } -} - -func ffmpegInputArgs(spec string) []string { - if spec == "" { - spec = "pulse:default" - } - kind := spec - name := "default" - if idx := strings.Index(spec, ":"); idx != -1 { - kind = spec[:idx] - name = spec[idx+1:] - } - return []string{"-f", kind, "-i", name} -} diff --git a/internal/clip/clipboard.go b/internal/clip/clipboard.go deleted file mode 100644 index 5d841c4..0000000 --- a/internal/clip/clipboard.go +++ /dev/null @@ -1,26 +0,0 @@ -package clip - -import ( - "context" - "errors" - "os/exec" - "strings" -) - -func WriteClipboard(ctx context.Context, text string) error { - if strings.TrimSpace(text) == "" { - return errors.New("empty transcript") - } - - args := []string{"-selection", "clipboard", "-in", "-quiet", "-loops", "1"} - cmd := exec.CommandContext(ctx, "xclip", args...) - cmd.Stdin = strings.NewReader(text) - out, err := cmd.CombinedOutput() - if err != nil { - if len(out) > 0 { - return errors.New(strings.TrimSpace(string(out))) - } - return err - } - return nil -} diff --git a/internal/config/config.go b/internal/config/config.go deleted file mode 100644 index 801a59e..0000000 --- a/internal/config/config.go +++ /dev/null @@ -1,170 +0,0 @@ -package config - -import ( - "encoding/json" - "errors" - "os" - "path/filepath" - "strconv" - "strings" -) - -type Config struct { - Hotkey string `json:"hotkey"` - FfmpegInput string `json:"ffmpeg_input"` - WhisperModel string `json:"whisper_model"` - WhisperLang string `json:"whisper_lang"` - WhisperDevice string `json:"whisper_device"` - WhisperExtraArgs string `json:"whisper_extra_args"` - RecordTimeoutSec int `json:"record_timeout_sec"` - WhisperTimeoutSec int `json:"whisper_timeout_sec"` - SegmentSec int `json:"segment_sec"` - Streaming bool `json:"streaming"` - InjectionBackend string `json:"injection_backend"` - - AIEnabled bool `json:"ai_enabled"` - AIProvider string `json:"ai_provider"` - AIModel string `json:"ai_model"` - AITemperature float64 `json:"ai_temperature"` - AISystemPromptFile string `json:"ai_system_prompt_file"` - AIBaseURL string `json:"ai_base_url"` - AIAPIKey string `json:"ai_api_key"` - AITimeoutSec int `json:"ai_timeout_sec"` -} - -func DefaultPath() string { - home, _ := os.UserHomeDir() - return filepath.Join(home, ".config", "lel", "config.json") -} - -func Defaults() Config { - return Config{ - Hotkey: "Cmd+m", - FfmpegInput: "pulse:default", - WhisperModel: "base", - WhisperLang: "en", - WhisperDevice: "cpu", - WhisperExtraArgs: "", - RecordTimeoutSec: 120, - WhisperTimeoutSec: 300, - SegmentSec: 5, - Streaming: false, - InjectionBackend: "clipboard", - - AIEnabled: false, - AIProvider: "ollama", - AIModel: "llama3.2:3b", - AITemperature: 0.0, - AISystemPromptFile: "", - AIBaseURL: "http://localhost:11434", - AIAPIKey: "", - AITimeoutSec: 20, - } -} - -func Load(path string) (Config, error) { - cfg := Defaults() - - if path == "" { - path = DefaultPath() - } - - if _, err := os.Stat(path); err == nil { - data, err := os.ReadFile(path) - if err != nil { - return cfg, err - } - if err := json.Unmarshal(data, &cfg); err != nil { - return cfg, err - } - } - - applyEnv(&cfg) - - if strings.TrimSpace(cfg.Hotkey) == "" { - return cfg, errors.New("hotkey cannot be empty") - } - if cfg.RecordTimeoutSec <= 0 { - return cfg, errors.New("record_timeout_sec must be > 0") - } - if cfg.WhisperTimeoutSec <= 0 { - return cfg, errors.New("whisper_timeout_sec must be > 0") - } - - return cfg, nil -} - -func applyEnv(cfg *Config) { - if v := os.Getenv("WHISPER_MODEL"); v != "" { - cfg.WhisperModel = v - } - if v := os.Getenv("WHISPER_LANG"); v != "" { - cfg.WhisperLang = v - } - if v := os.Getenv("WHISPER_DEVICE"); v != "" { - cfg.WhisperDevice = v - } - if v := os.Getenv("WHISPER_EXTRA_ARGS"); v != "" { - cfg.WhisperExtraArgs = v - } - if v := os.Getenv("WHISPER_FFMPEG_IN"); v != "" { - cfg.FfmpegInput = v - } - if v := os.Getenv("WHISPER_STREAM"); v != "" { - cfg.Streaming = parseBool(v) - } - if v := os.Getenv("WHISPER_SEGMENT_SEC"); v != "" { - if n, err := strconv.Atoi(v); err == nil { - cfg.SegmentSec = n - } - } - if v := os.Getenv("WHISPER_TIMEOUT_SEC"); v != "" { - if n, err := strconv.Atoi(v); err == nil { - cfg.WhisperTimeoutSec = n - } - } - if v := os.Getenv("LEL_RECORD_TIMEOUT_SEC"); v != "" { - if n, err := strconv.Atoi(v); err == nil { - cfg.RecordTimeoutSec = n - } - } - if v := os.Getenv("LEL_HOTKEY"); v != "" { - cfg.Hotkey = v - } - if v := os.Getenv("LEL_INJECTION_BACKEND"); v != "" { - cfg.InjectionBackend = v - } - if v := os.Getenv("LEL_AI_ENABLED"); v != "" { - cfg.AIEnabled = parseBool(v) - } - if v := os.Getenv("LEL_AI_PROVIDER"); v != "" { - cfg.AIProvider = v - } - if v := os.Getenv("LEL_AI_MODEL"); v != "" { - cfg.AIModel = v - } - if v := os.Getenv("LEL_AI_TEMPERATURE"); v != "" { - if n, err := strconv.ParseFloat(v, 64); err == nil { - cfg.AITemperature = n - } - } - if v := os.Getenv("LEL_AI_SYSTEM_PROMPT_FILE"); v != "" { - cfg.AISystemPromptFile = v - } - if v := os.Getenv("LEL_AI_BASE_URL"); v != "" { - cfg.AIBaseURL = v - } - if v := os.Getenv("LEL_AI_API_KEY"); v != "" { - cfg.AIAPIKey = v - } - if v := os.Getenv("LEL_AI_TIMEOUT_SEC"); v != "" { - if n, err := strconv.Atoi(v); err == nil { - cfg.AITimeoutSec = n - } - } -} - -func parseBool(v string) bool { - v = strings.ToLower(strings.TrimSpace(v)) - return v == "1" || v == "true" || v == "yes" || v == "on" -} diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go deleted file mode 100644 index 679c4f1..0000000 --- a/internal/daemon/daemon.go +++ /dev/null @@ -1,269 +0,0 @@ -package daemon - -import ( - "context" - "errors" - "log" - "os" - "os/exec" - "path/filepath" - "sync" - "syscall" - "time" - - "lel/internal/aiprocess" - "lel/internal/audio" - "lel/internal/clip" - "lel/internal/config" - "lel/internal/inject" - "lel/internal/whisper" - "lel/internal/x11" -) - -type State string - -const ( - StateIdle State = "idle" - StateRecording State = "recording" - StateTranscribing State = "transcribing" - StateProcessing State = "processing" -) - -type Daemon struct { - cfg config.Config - x11 *x11.Conn - log *log.Logger - inj inject.Backend - ai aiprocess.Processor - - mu sync.Mutex - state State - ffmpeg *audio.Recorder - cmd *exec.Cmd - record *audio.RecordResult - timer *time.Timer - stateCh chan State -} - -func New(cfg config.Config, x *x11.Conn, logger *log.Logger, inj inject.Backend, ai aiprocess.Processor) *Daemon { - r := &audio.Recorder{Input: cfg.FfmpegInput} - return &Daemon{cfg: cfg, x11: x, log: logger, inj: inj, ai: ai, state: StateIdle, ffmpeg: r, stateCh: make(chan State, 4)} -} - -func (d *Daemon) UpdateConfig(cfg config.Config) { - d.mu.Lock() - d.cfg = cfg - if d.ffmpeg != nil { - d.ffmpeg.Input = cfg.FfmpegInput - } - d.mu.Unlock() -} - -func (d *Daemon) UpdateBackend(inj inject.Backend) { - d.mu.Lock() - d.inj = inj - d.mu.Unlock() -} - -func (d *Daemon) UpdateAI(proc aiprocess.Processor) { - d.mu.Lock() - d.ai = proc - d.mu.Unlock() -} - -func (d *Daemon) setState(state State) { - d.mu.Lock() - d.state = state - d.notify(state) - d.mu.Unlock() -} - -func (d *Daemon) setStateLocked(state State) { - d.state = state - d.notify(state) -} - -func (d *Daemon) State() State { - d.mu.Lock() - defer d.mu.Unlock() - return d.state -} - -func (d *Daemon) StateChanges() <-chan State { - return d.stateCh -} - -func (d *Daemon) Toggle() { - d.mu.Lock() - switch d.state { - case StateIdle: - if err := d.startRecordingLocked(); err != nil { - d.log.Printf("record start failed: %v", err) - } - case StateRecording: - d.setStateLocked(StateTranscribing) - d.mu.Unlock() - go d.stopAndProcess("user") - return - default: - d.log.Printf("busy (%s), trigger ignored", d.state) - } - d.mu.Unlock() -} - -func (d *Daemon) StopRecording(reason string) { - d.mu.Lock() - if d.state != StateRecording { - d.mu.Unlock() - return - } - d.setStateLocked(StateTranscribing) - d.mu.Unlock() - go d.stopAndProcess(reason) -} - -func (d *Daemon) WaitForIdle(ctx context.Context) bool { - ticker := time.NewTicker(100 * time.Millisecond) - defer ticker.Stop() - for { - if d.State() == StateIdle { - return true - } - select { - case <-ctx.Done(): - return false - case <-ticker.C: - } - } -} - -func (d *Daemon) startRecordingLocked() error { - if d.state != StateIdle { - return errors.New("not idle") - } - - cmd, result, err := d.ffmpeg.Start(context.Background()) - if err != nil { - return err - } - - d.cmd = cmd - d.record = result - d.state = StateRecording - d.notify(StateRecording) - - if d.timer != nil { - d.timer.Stop() - } - d.timer = time.AfterFunc(time.Duration(d.cfg.RecordTimeoutSec)*time.Second, func() { - d.mu.Lock() - if d.state != StateRecording { - d.mu.Unlock() - return - } - d.setStateLocked(StateTranscribing) - d.mu.Unlock() - go d.stopAndProcess("timeout") - }) - - d.log.Printf("recording started (%s)", d.record.WavPath) - return nil -} - -func (d *Daemon) stopAndProcess(reason string) { - d.mu.Lock() - cmd := d.cmd - rec := d.record - d.cmd = nil - d.record = nil - if d.timer != nil { - d.timer.Stop() - d.timer = nil - } - d.mu.Unlock() - - if cmd == nil || rec == nil { - d.setIdle("missing recording state") - return - } - - status := "done" - defer func() { - d.cleanup(rec.TempDir) - d.setIdle(status) - }() - - d.log.Printf("stopping recording (%s)", reason) - if cmd.Process != nil { - _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGINT) - } - _ = audio.WaitWithTimeout(cmd, 5*time.Second) - - info, err := os.Stat(rec.WavPath) - if err != nil || info.Size() == 0 { - status = "no audio captured" - return - } - - outDir := filepath.Join(rec.TempDir, "out") - text, err := whisper.Transcribe(context.Background(), rec.WavPath, outDir, whisper.Config{ - Model: d.cfg.WhisperModel, - Language: d.cfg.WhisperLang, - Device: d.cfg.WhisperDevice, - ExtraArgs: d.cfg.WhisperExtraArgs, - Timeout: time.Duration(d.cfg.WhisperTimeoutSec) * time.Second, - }) - if err != nil { - status = "whisper failed: " + err.Error() - return - } - d.log.Printf("transcript: %s", text) - - if d.cfg.AIEnabled && d.ai != nil { - d.log.Printf("ai enabled") - d.setState(StateProcessing) - aiCtx, cancel := context.WithTimeout(context.Background(), time.Duration(d.cfg.AITimeoutSec)*time.Second) - cleaned, err := d.ai.Process(aiCtx, text) - cancel() - if err != nil { - d.log.Printf("ai process failed: %v", err) - } else if cleaned != "" { - text = cleaned - } - } - - d.log.Printf("output: %s", text) - clipCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - if err := clip.WriteClipboard(clipCtx, text); err != nil { - status = "clipboard failed: " + err.Error() - return - } - - injCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - defer cancel() - if d.inj != nil { - if err := d.inj.Inject(injCtx, text); err != nil { - d.log.Printf("inject failed: %v", err) - } - } -} - -func (d *Daemon) setIdle(msg string) { - d.setState(StateIdle) - d.log.Printf("idle (%s)", msg) -} - -func (d *Daemon) cleanup(dir string) { - if dir == "" { - return - } - _ = os.RemoveAll(dir) -} - -func (d *Daemon) notify(state State) { - select { - case d.stateCh <- state: - default: - } -} diff --git a/internal/inject/inject.go b/internal/inject/inject.go deleted file mode 100644 index 2389da1..0000000 --- a/internal/inject/inject.go +++ /dev/null @@ -1,72 +0,0 @@ -package inject - -import ( - "context" - "errors" - "strings" -) - -type Backend interface { - Inject(ctx context.Context, text string) error -} - -type ClipboardWriter interface { - WriteClipboard(ctx context.Context, text string) error -} - -type ClipboardWriterFunc func(ctx context.Context, text string) error - -func (f ClipboardWriterFunc) WriteClipboard(ctx context.Context, text string) error { - return f(ctx, text) -} - -type Paster interface { - Paste(ctx context.Context) error -} - -type Typer interface { - TypeText(ctx context.Context, text string) error -} - -type Deps struct { - Clipboard ClipboardWriter - Paster Paster - Typer Typer -} - -type ClipboardBackend struct { - Writer ClipboardWriter - Paster Paster -} - -func (b ClipboardBackend) Inject(ctx context.Context, text string) error { - if b.Writer == nil || b.Paster == nil { - return errors.New("clipboard backend missing dependencies") - } - if err := b.Writer.WriteClipboard(ctx, text); err != nil { - return err - } - return b.Paster.Paste(ctx) -} - -type InjectionBackend struct { - Typer Typer -} - -func (b InjectionBackend) Inject(ctx context.Context, text string) error { - if b.Typer == nil { - return errors.New("injection backend missing dependencies") - } - return b.Typer.TypeText(ctx, text) -} - -func NewBackend(name string, deps Deps) (Backend, error) { - switch strings.ToLower(strings.TrimSpace(name)) { - case "", "clipboard": - return ClipboardBackend{Writer: deps.Clipboard, Paster: deps.Paster}, nil - case "injection": - return InjectionBackend{Typer: deps.Typer}, nil - default: - return nil, errors.New("unknown injection backend") - } -} diff --git a/internal/inject/inject_test.go b/internal/inject/inject_test.go deleted file mode 100644 index 10c6dba..0000000 --- a/internal/inject/inject_test.go +++ /dev/null @@ -1,102 +0,0 @@ -package inject - -import ( - "context" - "errors" - "testing" -) - -type fakeClipboard struct { - called bool - err error -} - -func (f *fakeClipboard) WriteClipboard(ctx context.Context, text string) error { - f.called = true - return f.err -} - -type fakePaster struct { - called bool - err error -} - -func (f *fakePaster) Paste(ctx context.Context) error { - f.called = true - return f.err -} - -type fakeTyper struct { - called bool - err error -} - -func (f *fakeTyper) TypeText(ctx context.Context, text string) error { - f.called = true - return f.err -} - -func TestClipboardBackend(t *testing.T) { - cb := &fakeClipboard{} - p := &fakePaster{} - b := ClipboardBackend{Writer: cb, Paster: p} - - err := b.Inject(context.Background(), "hello") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !cb.called || !p.called { - t.Fatalf("expected clipboard and paster to be called") - } -} - -func TestClipboardBackendClipboardError(t *testing.T) { - cb := &fakeClipboard{err: errors.New("boom")} - p := &fakePaster{} - b := ClipboardBackend{Writer: cb, Paster: p} - - err := b.Inject(context.Background(), "hello") - if err == nil { - t.Fatalf("expected error") - } - if !cb.called { - t.Fatalf("expected clipboard to be called") - } - if p.called { - t.Fatalf("did not expect paster to be called") - } -} - -func TestInjectionBackend(t *testing.T) { - typ := &fakeTyper{} - b := InjectionBackend{Typer: typ} - - err := b.Inject(context.Background(), "hello") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !typ.called { - t.Fatalf("expected typer to be called") - } -} - -func TestNewBackend(t *testing.T) { - cb := &fakeClipboard{} - p := &fakePaster{} - typ := &fakeTyper{} - - b, err := NewBackend("clipboard", Deps{Clipboard: cb, Paster: p, Typer: typ}) - if err != nil || b == nil { - t.Fatalf("expected clipboard backend") - } - - b, err = NewBackend("injection", Deps{Clipboard: cb, Paster: p, Typer: typ}) - if err != nil || b == nil { - t.Fatalf("expected injection backend") - } - - b, err = NewBackend("unknown", Deps{Clipboard: cb, Paster: p, Typer: typ}) - if err == nil || b != nil { - t.Fatalf("expected error for unknown backend") - } -} diff --git a/internal/inject/xdotool.go b/internal/inject/xdotool.go deleted file mode 100644 index 856b648..0000000 --- a/internal/inject/xdotool.go +++ /dev/null @@ -1,63 +0,0 @@ -package inject - -import ( - "context" - "errors" - "os/exec" - "strings" -) - -type Runner func(ctx context.Context, name string, args ...string) ([]byte, error) - -func DefaultRunner(ctx context.Context, name string, args ...string) ([]byte, error) { - cmd := exec.CommandContext(ctx, name, args...) - return cmd.CombinedOutput() -} - -type XdotoolPaster struct { - Run Runner -} - -func NewXdotoolPaster(run Runner) XdotoolPaster { - if run == nil { - run = DefaultRunner - } - return XdotoolPaster{Run: run} -} - -func (p XdotoolPaster) Paste(ctx context.Context) error { - out, err := p.Run(ctx, "xdotool", "key", "--clearmodifiers", "ctrl+v") - if err != nil { - return formatRunError(out, err) - } - return nil -} - -type XdotoolTyper struct { - Run Runner -} - -func NewXdotoolTyper(run Runner) XdotoolTyper { - if run == nil { - run = DefaultRunner - } - return XdotoolTyper{Run: run} -} - -func (t XdotoolTyper) TypeText(ctx context.Context, text string) error { - if strings.TrimSpace(text) == "" { - return errors.New("empty transcript") - } - out, err := t.Run(ctx, "xdotool", "type", "--clearmodifiers", "--delay", "1", text) - if err != nil { - return formatRunError(out, err) - } - return nil -} - -func formatRunError(out []byte, err error) error { - if len(out) > 0 { - return errors.New(strings.TrimSpace(string(out))) - } - return err -} diff --git a/internal/ui/icons.go b/internal/ui/icons.go deleted file mode 100644 index aec61c0..0000000 --- a/internal/ui/icons.go +++ /dev/null @@ -1,28 +0,0 @@ -package ui - -import _ "embed" - -//go:embed assets/idle.png -var iconIdle []byte - -//go:embed assets/recording.png -var iconRecording []byte - -//go:embed assets/transcribing.png -var iconTranscribing []byte - -func IconIdle() []byte { - return iconIdle -} - -func IconRecording() []byte { - return iconRecording -} - -func IconTranscribing() []byte { - return iconTranscribing -} - -func IconProcessing() []byte { - return iconProcessing -} diff --git a/internal/whisper/transcribe.go b/internal/whisper/transcribe.go deleted file mode 100644 index e8632e2..0000000 --- a/internal/whisper/transcribe.go +++ /dev/null @@ -1,69 +0,0 @@ -package whisper - -import ( - "context" - "errors" - "os" - "os/exec" - "path/filepath" - "strings" - "time" -) - -type Config struct { - Model string - Language string - Device string - ExtraArgs string - Timeout time.Duration -} - -func Transcribe(ctx context.Context, wavPath, outDir string, cfg Config) (string, error) { - if cfg.Timeout > 0 { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, cfg.Timeout) - defer cancel() - } - - if err := os.MkdirAll(outDir, 0o755); err != nil { - return "", err - } - - args := []string{wavPath, - "--model", cfg.Model, - "--task", "transcribe", - "--device", cfg.Device, - "--output_format", "txt", - "--output_dir", outDir, - "--verbose", "False", - } - - if strings.TrimSpace(cfg.Language) != "" { - args = append(args, "--language", cfg.Language) - } - if strings.TrimSpace(cfg.ExtraArgs) != "" { - extra := strings.Fields(cfg.ExtraArgs) - args = append(args, extra...) - } - - cmd := exec.CommandContext(ctx, "whisper", args...) - out, err := cmd.CombinedOutput() - if err != nil { - if len(out) > 0 { - return "", errors.New(string(out)) - } - return "", err - } - - txt := filepath.Join(outDir, strings.TrimSuffix(filepath.Base(wavPath), filepath.Ext(wavPath))+".txt") - data, err := os.ReadFile(txt) - if err != nil { - return "", err - } - - text := strings.TrimSpace(string(data)) - if text == "" { - return "", errors.New("empty transcript") - } - return text, nil -} diff --git a/internal/x11/x11.go b/internal/x11/x11.go deleted file mode 100644 index feaffaf..0000000 --- a/internal/x11/x11.go +++ /dev/null @@ -1,232 +0,0 @@ -package x11 - -import ( - "errors" - "fmt" - "strings" - - "github.com/BurntSushi/xgb" - "github.com/BurntSushi/xgb/xproto" - "github.com/BurntSushi/xgb/xtest" -) - -type Conn struct { - X *xgb.Conn - Root xproto.Window - minKC xproto.Keycode - maxKC xproto.Keycode -} - -func New() (*Conn, error) { - c, err := xgb.NewConn() - if err != nil { - return nil, err - } - if err := xtest.Init(c); err != nil { - c.Close() - return nil, err - } - setup := xproto.Setup(c) - if setup == nil || len(setup.Roots) == 0 { - c.Close() - return nil, errors.New("no X11 screen setup found") - } - root := setup.Roots[0].Root - return &Conn{X: c, Root: root, minKC: setup.MinKeycode, maxKC: setup.MaxKeycode}, nil -} - -func (c *Conn) Close() error { - if c.X == nil { - return nil - } - c.X.Close() - return nil -} - -func (c *Conn) KeysymToKeycode(target uint32) (xproto.Keycode, error) { - count := int(c.maxKC-c.minKC) + 1 - if count <= 0 { - return 0, errors.New("invalid keycode range") - } - - reply, err := xproto.GetKeyboardMapping(c.X, c.minKC, byte(count)).Reply() - if err != nil { - return 0, err - } - if reply == nil || reply.KeysymsPerKeycode == 0 { - return 0, errors.New("no keyboard mapping") - } - - per := int(reply.KeysymsPerKeycode) - targetKS := xproto.Keysym(target) - for i := 0; i < count; i++ { - start := i * per - end := start + per - for _, ks := range reply.Keysyms[start:end] { - if ks == targetKS { - return xproto.Keycode(int(c.minKC) + i), nil - } - } - } - - return 0, fmt.Errorf("keysym 0x%x not found", target) -} - -func (c *Conn) ParseHotkey(keystr string) (uint16, xproto.Keycode, error) { - parts := strings.Split(keystr, "+") - if len(parts) == 0 { - return 0, 0, errors.New("invalid hotkey") - } - - var mods uint16 - keyPart := "" - for _, raw := range parts { - p := strings.TrimSpace(raw) - if p == "" { - continue - } - switch strings.ToLower(p) { - case "shift": - mods |= xproto.ModMaskShift - case "ctrl", "control": - mods |= xproto.ModMaskControl - case "alt", "mod1": - mods |= xproto.ModMask1 - case "super", "mod4", "cmd", "command": - mods |= xproto.ModMask4 - case "mod2": - mods |= xproto.ModMask2 - case "mod3": - mods |= xproto.ModMask3 - case "mod5": - mods |= xproto.ModMask5 - case "lock": - mods |= xproto.ModMaskLock - default: - keyPart = p - } - } - - if keyPart == "" { - return 0, 0, errors.New("hotkey missing key") - } - - ks, ok := keysymFor(keyPart) - if !ok { - return 0, 0, fmt.Errorf("unsupported key: %s", keyPart) - } - - kc, err := c.KeysymToKeycode(ks) - if err != nil { - return 0, 0, err - } - - return mods, kc, nil -} - -func (c *Conn) GrabHotkey(mods uint16, keycode xproto.Keycode) error { - combos := modifierCombos(mods) - for _, m := range combos { - if err := xproto.GrabKeyChecked(c.X, true, c.Root, m, keycode, xproto.GrabModeAsync, xproto.GrabModeAsync).Check(); err != nil { - return err - } - } - return nil -} - -func (c *Conn) UngrabHotkey(mods uint16, keycode xproto.Keycode) { - combos := modifierCombos(mods) - for _, m := range combos { - _ = xproto.UngrabKeyChecked(c.X, keycode, c.Root, m).Check() - } -} - -func (c *Conn) PasteCtrlV() error { - ctrl, err := c.KeysymToKeycode(0xffe3) // Control_L - if err != nil { - return err - } - vkey, err := c.KeysymToKeycode(0x76) // 'v' - if err != nil { - return err - } - - if err := xtest.FakeInputChecked(c.X, xproto.KeyPress, byte(ctrl), 0, xproto.WindowNone, 0, 0, 0).Check(); err != nil { - return err - } - if err := xtest.FakeInputChecked(c.X, xproto.KeyPress, byte(vkey), 0, xproto.WindowNone, 0, 0, 0).Check(); err != nil { - return err - } - if err := xtest.FakeInputChecked(c.X, xproto.KeyRelease, byte(vkey), 0, xproto.WindowNone, 0, 0, 0).Check(); err != nil { - return err - } - if err := xtest.FakeInputChecked(c.X, xproto.KeyRelease, byte(ctrl), 0, xproto.WindowNone, 0, 0, 0).Check(); err != nil { - return err - } - - _, err = xproto.GetInputFocus(c.X).Reply() - return err -} - -func modifierCombos(base uint16) []uint16 { - combos := []uint16{base, base | xproto.ModMaskLock, base | xproto.ModMask2, base | xproto.ModMaskLock | xproto.ModMask2} - return combos -} - -func keysymFor(key string) (uint32, bool) { - k := strings.ToLower(key) - switch k { - case "space": - return 0x20, true - case "tab": - return 0xff09, true - case "return", "enter": - return 0xff0d, true - case "escape", "esc": - return 0xff1b, true - case "backspace": - return 0xff08, true - } - - if len(k) == 1 { - ch := k[0] - if ch >= 'a' && ch <= 'z' { - return uint32(ch), true - } - if ch >= '0' && ch <= '9' { - return uint32(ch), true - } - } - - if strings.HasPrefix(k, "f") { - num := strings.TrimPrefix(k, "f") - switch num { - case "1": - return 0xffbe, true - case "2": - return 0xffbf, true - case "3": - return 0xffc0, true - case "4": - return 0xffc1, true - case "5": - return 0xffc2, true - case "6": - return 0xffc3, true - case "7": - return 0xffc4, true - case "8": - return 0xffc5, true - case "9": - return 0xffc6, true - case "10": - return 0xffc7, true - case "11": - return 0xffc8, true - case "12": - return 0xffc9, true - } - } - - return 0, false -} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..848f983 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +openai-whisper +ollama +pystray +pillow +python-xlib diff --git a/src/__pycache__/aiprocess.cpython-310.pyc b/src/__pycache__/aiprocess.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b2dd98ab459fe6adb56d4e0bc1d65c4792864d80 GIT binary patch literal 1889 zcmZ`)&5j&35VqUyo}QlG*}uR-0%8OTq$9C|fCEAi3R(f;GDM2VVJ@xSv1hk8{fF&_ z>}YafBW`&DgtU9)S$G3qIq?b+2ddn&?9K{2YP-sI*{-j?s;M_NA_8r`_$B{?6Y?i2 z*Bi`{hlEfqKoCUGf|PVhDau~qm2AqK%nDxmQ{TzF2&RG95q%N8;M1OD-w-hn@e3m2 zGw+N}L(n(G5cJ`hH|>jEu_d-)XC(MzlHB?W?@y>5WK~t0%;a^YLG6poWV0gEsPIRb zIi6VF!0;;4Mva!ENnJ@BC}~<%Sq5`6iYKia$_%~(bmn6amPEw{`ZIb$1r^>)dhT72 z$HWlBV2%rRhpew8e6sLcGym{2ctI$uSuOJFXtAwNwUK4osJd*-zIkR6Um;2}`?i4i~ymoIN%yj@Ar?=>Bs|{ zi%|4I;vxcUHtgniMUj=+qb_!}vTQa#>W=o{gv*992Il~S^F9bmx+EzvbV*MjWk6LG7MkgM%Um#;HBGICDzkS*~R< z?`)BR9Y%->;+$8xNz*%^Ic^0s26ippxZ%|PPAS}rV zWEk|`2}6G&=Q_>LF(3JJZ|R)}f?d#NPw?{|W@qA0)N5dyaJ35qq>;-y^)4#Cx^dOQ zhDz!N%7(NAaJh?X;gPJ6e6n*Djh&G@&9WnD)BxrZrVxI04{bOebAvRtx5~p2?9hl< zKm+PgP9qxpy%Q|@@>_Z(Oz=u@V44U_>~`IY$Ww^RUeefkhOHT&?cIIU4K95OQ)0KJ^JGfE$)eg*u}>&$ab@cLNdVmFmJKm zKmkAq?BL%&5vdgt62b|8Aw_=ZDz2PWS8U+~-TL05df!2cGFPQ@hlVj6l8 M^MWlpjD}nP0CkY2g#Z8m literal 0 HcmV?d00001 diff --git a/src/__pycache__/config.cpython-310.pyc b/src/__pycache__/config.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a46b1f967dca3511998ad87bbefef885c2896cc8 GIT binary patch literal 3335 zcmZve%WoUW5r^l+_fwQaiIyMoV>eLzP`us+7LMUvni|W@N|Zp+PCQ637;&1?$io@B zr>V6FDhQ&*UV;F7%2~pma>+gaK<>FVa@&*dd&(h6RdHy^S&uNK>aVJMx|=ig>#$JB zDsVMN-+GUVit+f zdWM^HQ{Na?n(6C`n|3qbC~oFNJ5j9+#1lQu&AK^=XWcyXP@gD3GtjHx7J=s662rVa zkg_`iq~NMM%1Y%e%tdLfs50X^!s+@>6hXt-cErBa4;^17ITJ%3bX81@FZ4CI8*oQu zFk|IZIa5y6Gg#H30^@3}$GBWeKVp$o?^{|JNJH=;ThU}965Jcegdcv*_=?Ig@vjQU z&HML_)%~!~R>i*KvuIWH2dh5wS0mnC?S?_m+q*Y7k~w?e@QB&FVd$rzQ-o6~t2__? zx^TaD>CpNT?&v>Yn1UN^Y0qJrmd*^8aJ5IU*;bN#1N?-QB0mX!3N{;a(%@&T4Eb5` zb5@r8Jmu%eFMwaP@~i;)CGg8uk^C9(D^`j8S@7qqGMj;%dGM=Nh59ZKpC!Hse$ARA zehK^~Yo7ee;9s$-YytXS1wUae62AuivQ;DhI`K>7-vIxnwM5Uo1^!3yqkJrr4cf&~ zqS1G6_hoWF6tCFPs4y7%5qs#eo-_1?%=dcz0o${^U@#O?k-4}%z?Z_D|Fl7N^K%hhCS-%Cybi2=3Sp-;2CE@G{BSX+U^=77m3S zvF_V9@QcX`XWfvy_QgN~1G$Lp^;tlvPh|$)DdzM&cygxedxPE3;jWzVf&CMh|mi6;E{C6A|SzF zf4BqR1(Wm8j*cQgI7WXU?4IYdQFUL4!Nb*6KkPdGei(^|_wRrD;8S`Q?w~#7K263M zcsBe7Z|7k6$>8T(&|0}H)3zNreP-J-YvWvoKKgmvem!*jNrXeSQKSxAAWOFG1VJbq zTy$jHyaXXWgJu>DK84StsiIi`BXutjyoSvsG?$SX_;K+o*uIM98k%J^*TKj{&kr5J zZy>md<`$Zd(cou%!Q`-60E15Sb1+#YujN(vo7i(tyFh@#7mfXX@F^g>jk~YX?xJUG2~6 znR={`_0!auvZnm5^jq!NI8KQ4SRETD8owhl=#3L6pbM|Bi0o<3)z4J^xyT;mUA?A^ zRl7jc0NNmG5G@i-0BsRX5G@f+0)0s|NwiEf1=J;)B057f4fM6B5Kq@&HSok)iew=2 zdof3mOdOe~NERZ0iX+)LQl&@^B7dPCIeNwd(LB)aJ z(IU}HL`y*b5zn*~kFrFOGDQAK`DMz#Omqh5zlhEdo$PJ}=v#4>cqJa?8s*PIyDazkb%-**4qu=GL0IVdbaI zje2X{Do$H#=8I;-v?|lK`I}C=Zr9uEJJ!sUJ$<^lZLZtR)_J#`PTQ<+TC>xX9dmut zY<27%vvCeP%}sOb`2?0W%ndspXuIBdW>pci&Bj)H&AynJMgDB7^OgD1nn%Cc`jy$} zG`CvzlX~MTv$bXw5!aix*{VO;FxM;^8`|6LtryKTvu)+$gvrV)ajRp(GTQad^R{W# zVz%>g2Qc>B-0s*G@&Zzg^m*CstDx#vD<6ZA=IX&7m88ya83 zwR{2Qz1ojrgmOZ)A0>6H9FQa(t1f)Sc)T3rIa_aWJSUS*!Vwrm-SxTxgxu(cgCo!+ z99gRF|1Y_~nd@`~b1DA-k|LCwEX>PL`7>}=iu^P9$A1Y1j``C=Jc~HaO{P$;d~bL1 z_MraqyJ+aQMSZ0=idF|(4|AeHFHGZH7t@a>$B)POR!6A5X$7|@uU2%;D67S#s!{#o Ee*(NP2><{9 literal 0 HcmV?d00001 diff --git a/src/__pycache__/inject.cpython-310.pyc b/src/__pycache__/inject.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8ee9afd8a3c2fb613640d01cfda7b1e49fbfe5d0 GIT binary patch literal 1396 zcmZ`(L2ukd6rQoi-d%UogtP_P6tTc*g_af9szOMp2M~%X8X>foYU7!-&N{YvGj6i0 z?S&w2{RIdq;g-KNS5Ewef&|~Qx7#HWW6hiKyyrLbee*q+TU&`hs}{dxIuha!e%Wj> zUcN-PKfq|AWiBS|C?`?}CeWdlM@lOl{j4UT-qNw|V5T;FUERY<)T|_{FJgT|_pusl z`9utF{)y~j-z#U%oR7)4nu=8N;;IE0JLq4b+lMet9Lr}o&+l?6bbz;}>+n_dQZD6j zsMRc_`%YmVUBs87x~HRa`IFQF`^Edi&LlcZ^Q>AVBWrS#x~wdGk5>-MDd|Nx%8Eri z`mxT8!qDvwuO5H>_>oUmI7@AO|GT{F(o|v(F zKFy6CyOV01n|y339cRTcuzXg{{q`wk&OBWs!tDxs8%D`k26z;n_^+h1E7d>5jud^} z7zkf}L-+$s-V(kh8<+n|y%bBaqI(Z?ixu5>|4X;>oikm!vdn!rGxOIwqfwfhDV>x$ zJIV~%MVvayKYej<6%t=`!$AYR<@EOI^FcMW&aAkl9wLX5-r*0JnbzFG%x^b=Z}D@C zuZ6n-Ad3lO5W3>`r8Z|(Y@27d)mJX6L0%8KL(-XIehj2AQ*pXd4ECwE# zB^tno6ifhq5ybySAKE#}7R7jN+a`EpkLEHO>2)7M)^%>a;3Bepn6Bu`-7x9z-nh5* EFVCnqdH?_b literal 0 HcmV?d00001 diff --git a/src/__pycache__/leld.cpython-313.pyc b/src/__pycache__/leld.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3578335bf9ce44eb237bf9b0a29d99fe9ebd2081 GIT binary patch literal 13435 zcmeG@Yj9IndPlnVN>?vSe!s90V89Btc^NPcO8^^)ABe0Z2Z)(yg>`KNvgAEihM*-I zPdZHuN!E}iYiQF#+U)@S(Xcby?z6MWHq(71Giza|QZLTz&TOWg{3DnxndDEu?_Aw0 zS%}B>G5e!ia(n?k~_f_>)(JGb~`>K0uXbsC-`f7XYXq}tX zkmba0ts{QhCZjqNTF+p1fH`#75`Re@p$&c~#EplS`dys|2?>dW9By1rHf5KprmlEwI5MK}(Re7VIH(j34JD*-FdP|5c#MiuPK0P8NTs1T z4M$=lN{O6^kLAkHll8#Sh&(1ye;@!2#GO6a9-T+VBhhehjK+r~S&mbvYKp{;NzgCk zq7gYE#ULlr@mMfHL#KwasImk$jD>|}NZure32ql`l>xTN2wTL#_VBPZ0&I(kiheV- zjEEkKBKQ-bg!E}8v{Lv;I4UWY9PV})CMFNj$Pt{0HH#U`$Hx<6mhoHWNf7q-5FQ?L{FxC*+hS)v|*wbqCe@S%eC5z zRyRvdWFY{b3s?6r-){7Cb>tY2Ykb%QC4rS-rGdm?ts%DfMTo6_3&b`gQfl|xAa?le zv}DBLDN#(_A!#%o`?LXuP`IJtks)nV01!L;IQT8xVn_3!D~eDS2K9PA?Eq_^AkC5V z0EuD`EDQp#VHQw+1#=Q%<~h{x#lm=zJu--$?AqKW_qOo}SDgft3# zB$ODZl2Rt00tPXP>|=B+0S66^vUnsUOTlp(Rm`EWNbtCH3fe?QB{;l6SsL=x(ba&6 zVo4mOQiyRYzCS*69C_Q&arNLV!3qK%H%Z}<0VpPwKPUqBL@92+;y9_YG(7o$m|ZJI zL&yQOQdl|>8IlxEP9zj#D56+Hk)RX{9f?X|YJ+YllA>Y*s6JB>3TmGvkjtNMC6tRy z8C&bK#sRd6v-Wv&H!8d=MTgl6$asR>Zi)nhoGcQHB!E3R)f3sK0UF`6{0|UKkW6{i ztKxh`!?gEx?`%cWB>$>qzOrumne)#;)|#oPK}e>m4ia0YvKE2!5|X#&p*}s%v1j{As8mX3DA{F=uQgQ`@Ju&sVLSchzJX+?RqEgPFR9ODz{$ zGVT>=_lA^vLuU13>D4<@t9NMMmn^;Hz3A1yf94cR%N96mb;)G+YkO~T#9pVewvB-P zBF2IT9S_^WVTj&@!|sE$Fc^uO0L#&Y3`%qOr%6Fv_lISnF#EFqjc)fD~B?*jj#6J z7Y}GdtBOZL0Pa+}~K&-)E}-3Zwnu zQyt`CTo!z~6wn}qFl7lagJse=f9~t1Y}QR0*>Wu=lHyC7H(n80#K!9f9-hMrWnn=8 zj;U|Tr>00>3D!EfAZ^yxAzSL4BR8q_6SG3f>hDOY^fxx-UPSdVg; zJiIq>&8V0_*#w$VOkt)wBWr%*?P^K6TIO7B8CTU^GaU8F zL$^fYs>s#@_BiWonQvI0ZdjXYSbL6t!#eM(O}pGTT<)uUx_Nu5dHcJGl&e4O@}*q9 zIadJtak75yxQL@-s{d^NOzo!YiSH-Bo1C-nx?8HV6XZ3ot?R1e-ZSl72FcIXVf@c^ z)~@EoXHI~MjEz!Y9&ZBm59;(-eE*B-YWJyG

VuGY+GTl>uwkHME27DDprS26a03 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 KW%8)UTJ~R|GyXdO literal 0 HcmV?d00001 diff --git a/src/__pycache__/stt.cpython-313.pyc b/src/__pycache__/stt.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..54f9d9d76ed912a13e89d3b2acac001dabc4aa22 GIT binary patch literal 1889 zcmZ`(O-vhC5Pth-o3)AUCOEJt4WX@wr2zRO5>1KJ0tb=^seX{6g7mc5i{ofx)3mJW9gP56Bh!JNWa>8?3J@|{^Ujwk#0;WzkvvH&d+O<5JGDy@+iV^JOz{d7+%OP{>-@pl0|KL{;ED2f`YeO4z6sG}UEGfjp@U zNtf%J9Ng)GI-iFAK>f@lvC*p_N?_NBnJ3%wxTgS!Y0cBc6Is!GZ1E%<_B6Y+XmS1y z${{AKY1LC5%bw#2P>NH+?4ZXkARfiT5ylF|g3Fk9_85r1K#7%bdVoD8HRIw+yw={e z(po!zVf~xUwrZ-klIq<{^)IO_(OSw_QlCft(lxS&i&$)d(+&u0kWmKKlaw7JXgQ@* zsr2C#AtthPb7Iz2D}?b^ZpF*HqfLc0bM%8 z+hHeU(aBzT(Qh74w@YTu$DODA8y&DX2t^%fjo}$D(F`+cusqHkV1RKnzIuE4b|w05 zEq-?O$?}s8YcKxZe!}=Zwr^aj8drWdu53;Gz_!`X6TeRXGF>q~T^f0Q?;uQK7X|cQ zWYJt;^QQaI3vtUW^J2Cc)J8FWgm9u5&7}ez(lNZJ19e*aP9-vpQutnwQ)y5x%9C;i z=jsH(&!!&xKLesr0noR#2LYu!#D2y35U?ljVik`gACH z%k?zJ<%RjQ!m%*?5-`NbfuELx4H`@Y{{Ss*O;tMfhLv@OfkCMvu~mIpUy0R{ZR?#c zI;+W^O0s7w@^a{>q1|N9ZgOlXR7<4R^%wfat-VBdHF0@!wi@rR#QXQ+*LEYgVxn>xeZD%&W>82h4Z+I35uvAF_wT)R-TiX|{BkR~_Kl>o%g zhB3!A-9na`E`Tswb}ff7j%5+g5WfO`2Divi?+gNaM*fynt#c!LKw#OTJ2$sK7vgWq zKd3p3sai%lh$-6j%^L>~e}6yB+o8S4M}B&D_*FQ9jp1Ml c2Z|&~uSm-)a^^53Nw?la64LcU0!#$>7a;3nH2?qr literal 0 HcmV?d00001 diff --git a/src/__pycache__/tray.cpython-310.pyc b/src/__pycache__/tray.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8b1bd9e05645eb7d7efc4c7da59054a291da8909 GIT binary patch literal 1759 zcmZWpJ8#@Z5Z+xrkH?eGaS9`HVy6gTHaZ}+AUFt+;DAI>5~X1gg1TKvwD~yi?iD)N zm4rwx{2!z;Kz7o{mmwQh4YQG8c!0CP+Hc=iqJ*eY6#@VnQG)bOtro8W&jAVSq zWPB=4c|MdIGLgeGKTl;UM`tV_$>0~3J^GvC$ap_2+qSc%t-9909LUm^v$`~7_IeI} zJekfA!E)%nzvX1_SF%?9K&x4&Wz`<_snum`X1Y2cE1BzVri_8UoB4fQ6m8k4qUe*N zXgay5Nl%O7?V_x&cODhRTE0c`2h(Fj?8Lt>CdXZ)Cib|jm6_OPKB-kbF?u$^Z%?-7 zCpyB}As$1tCM*^)k9o?SzYn#t3*FwshT~j}=Uc+&J1CY}e#&19$v4@1WtH$0#vNc}XTGM`^ne6q zq$#&7)Kr!P=V9d_4Ckd*t#w)IkoNp;u39RPgVLxw1odOuOzN(bMMZQzfo(P*Hn;#z zt2_KpKZR>1AZ{+IABqJlH@`u#?38`rmR|y?3;x=Z{L1^l&e=IX_YP50(mRXa3+(%N z0_mTNLoNdup2b(C-8^xzl7g+{>b?w*mQ!+m~6xoQao=t zqS<#Ox- zZB;a?T~Hqz^t{SEy@^Eoz_Hb*&RHBOYn5Kn>KF}I?|zA+<_ieHL)86)TaZ`$vwW%- zZQ)vGyY6OXZFaU+AISZm7L~mRI=%)Bu0f~Tx&ZMYi0|H&6~c|W7HwSFwV>kdx@-<) zx$`yNGDKX+mY+TR_06&UO!T2GHL~kNS*oUMv&2poIb52c(Jx5QI6-3(teUV|ZMj1Z%lIb3&#`Gzh>(x@SUh_? zrb+S=j!fuJ=uMh7*#=Fs!v!V_rkSQR`{C`3Rz_3jAOQ^Ox;j{O?!dZnjj`zB)nCyO qZ*Tg8Yu;5GQwO@A-NAeh+tvKsA!^L05GhLqwQme5yfIIrH2ELQC6vJc literal 0 HcmV?d00001 diff --git a/src/__pycache__/x11_hotkey.cpython-310.pyc b/src/__pycache__/x11_hotkey.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..eff61d407e61fc2e90378c8afcb9e8ab75eada9c GIT binary patch literal 1821 zcmaJ>&2Jk;6rY)${jeRoNr);c0;BtaqK6 z$%mue3yFH?1QHNVCfV#K?F@mkA}3zLPn8iz&oKsiKM$6mV3TD_~P=fFX8-B2?N1pWt$0CcxODU z3SR_gB&?lFE20Y02~pc5&52j=Pts(TZdqT%x}S6gO={WJlV-)bdMDmCA&-noLO;q< zqp~D)Itj>nnOFpagT7QHbI&?aPgt<(^*X7r-cDxrF*ram`b?wnHLm9&rarS41c0?E(hSBAbh%e`#|umtP>NSvJ~ zzKMy?!q$bPpH~Z-(;*oV#m3(s*giP_MgTxp8x{8fSGMq}rG14pF1ObowRW_>tPQ}~U!KO_Vh*$9WUk*)T3*;KC0W!-^zy=$ z@oV~tLeNj9$Fb&mvDR_gor7w;Diu=)Db%f>DI*0+Kbva?kZ?bVjdhdkNGj{EZLGG} zR-W4G*PY}*K2|DI3QHGG;=$5x+hlEU)q|dO5}8^jQ=;js3Yb_IQZ(9Pi|8-_t2PQ$ z9d!=Gx8B(E3KZ2q-Nf~yO@Tzu!8SuVyg~UksK=15vYB%}U<&IIVQv?X3k0@W*=8?+ zzy>Q@ft6u728tCuNFy73l|_4N9ld7*5Z)~*j+&-xIgYI5D$9(z z1-|MX*sKqsFz}lI*UJBl+u~y|)ChZ(-lVf^md*pBoT?8%6k!?{50(_|N;HQ+7*Y-- z%h`~P$gWd3hUYN4-?4%hu5n?A9KhH;<%M@LUm;)z7!-c)@A@X#t-u)G2fCW$Kq6HG z=>)9$Lvn*WAVrV|IS%gqoSp)9`){Arb7$DdJ+Qekf;$l+P9mz`kR!NH%+z3m0!PWW zz#qZ%tv9EY1DS^Y>i9+rJsl}2Q*|4pWy6%`wAEA|o3=cZsj=J);4Ui*5Im8Cr%GyV zJt0jePH>z%Myhww{XL9aJxDK#7S>kM0aPa5j-^uXgVt6aA4jrp;w)`e%9#kOS=xrd z>2|?n4x^NrT$W7DqZ^Zl3hVM_-8{SrWO^4=`a{@Y0O$m5PzG-R<-=j{DEpK74UkVj z8QEXz4j8UC>mlDt;uqz)N=(L6T-n5%9a|}^JsRKZC4(KdcuCzuO3gqZ>%iDw0apgU nmSuUnl`|YH^)hjg$Ys1~G(t5=ARz~5`1 str: + if path: + return Path(path).read_text(encoding="utf-8").strip() + return (Path(__file__).parent / "system_prompt.txt").read_text(encoding="utf-8").strip() + + +@dataclass +class AIConfig: + provider: str + model: str + temperature: float + system_prompt_file: str + base_url: str + api_key: str + timeout_sec: int + + +class OllamaProcessor: + def __init__(self, cfg: AIConfig): + self.cfg = cfg + self.system = load_system_prompt(cfg.system_prompt_file) + self.client = ollama.Client(host=cfg.base_url) + + def process(self, text: str) -> str: + resp = self.client.generate( + model=self.cfg.model, + prompt=text, + system=self.system, + options={"temperature": self.cfg.temperature}, + ) + return (resp.get("response") or "").strip() + + +def build_processor(cfg: AIConfig) -> OllamaProcessor: + provider = cfg.provider.strip().lower() + if provider != "ollama": + raise ValueError(f"unsupported ai provider: {cfg.provider}") + return OllamaProcessor(cfg) diff --git a/internal/ui/assets/idle.png b/src/assets/idle.png similarity index 100% rename from internal/ui/assets/idle.png rename to src/assets/idle.png diff --git a/src/assets/processing.png b/src/assets/processing.png new file mode 100644 index 0000000000000000000000000000000000000000..d001a82d1ac8914cb9eef46be48aa140e71e7504 GIT binary patch literal 82 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`lAbP(Ar*6yIad7mKVRsKLDqp4 fjEmWL#F-iF>W|4=tT@96RK(!v>gTe~DWM4f^2!xw literal 0 HcmV?d00001 diff --git a/internal/ui/assets/recording.png b/src/assets/recording.png similarity index 100% rename from internal/ui/assets/recording.png rename to src/assets/recording.png diff --git a/internal/ui/assets/transcribing.png b/src/assets/transcribing.png similarity index 100% rename from internal/ui/assets/transcribing.png rename to src/assets/transcribing.png diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..ba34808 --- /dev/null +++ b/src/config.py @@ -0,0 +1,109 @@ +import json +import os +from dataclasses import dataclass +from pathlib import Path + + +def _parse_bool(val: str) -> bool: + return val.strip().lower() in {"1", "true", "yes", "on"} + + +@dataclass +class Config: + hotkey: str = "Cmd+m" + ffmpeg_input: str = "pulse:default" + ffmpeg_path: str = "" + + whisper_model: str = "base" + whisper_lang: str = "en" + whisper_device: str = "cpu" + whisper_extra_args: str = "" + whisper_timeout_sec: int = 300 + + record_timeout_sec: int = 120 + segment_sec: int = 5 + streaming: bool = False + + injection_backend: str = "clipboard" + + ai_enabled: bool = False + ai_provider: str = "ollama" + ai_model: str = "llama3.2:3b" + ai_temperature: float = 0.0 + ai_system_prompt_file: str = "" + ai_base_url: str = "http://localhost:11434" + ai_api_key: str = "" + ai_timeout_sec: int = 20 + + +def default_path() -> Path: + return Path.home() / ".config" / "lel" / "config.json" + + +def load(path: str | None) -> Config: + cfg = Config() + p = Path(path) if path else default_path() + if p.exists(): + data = json.loads(p.read_text(encoding="utf-8")) + for k, v in data.items(): + if hasattr(cfg, k): + setattr(cfg, k, v) + + # env overrides + if os.getenv("WHISPER_MODEL"): + cfg.whisper_model = os.environ["WHISPER_MODEL"] + if os.getenv("WHISPER_LANG"): + cfg.whisper_lang = os.environ["WHISPER_LANG"] + if os.getenv("WHISPER_DEVICE"): + cfg.whisper_device = os.environ["WHISPER_DEVICE"] + if os.getenv("WHISPER_EXTRA_ARGS"): + cfg.whisper_extra_args = os.environ["WHISPER_EXTRA_ARGS"] + if os.getenv("WHISPER_FFMPEG_IN"): + cfg.ffmpeg_input = os.environ["WHISPER_FFMPEG_IN"] + if os.getenv("WHISPER_STREAM"): + cfg.streaming = _parse_bool(os.environ["WHISPER_STREAM"]) + if os.getenv("WHISPER_SEGMENT_SEC"): + cfg.segment_sec = int(os.environ["WHISPER_SEGMENT_SEC"]) + if os.getenv("WHISPER_TIMEOUT_SEC"): + cfg.whisper_timeout_sec = int(os.environ["WHISPER_TIMEOUT_SEC"]) + + if os.getenv("LEL_FFMPEG_PATH"): + cfg.ffmpeg_path = os.environ["LEL_FFMPEG_PATH"] + if os.getenv("LEL_RECORD_TIMEOUT_SEC"): + cfg.record_timeout_sec = int(os.environ["LEL_RECORD_TIMEOUT_SEC"]) + if os.getenv("LEL_HOTKEY"): + cfg.hotkey = os.environ["LEL_HOTKEY"] + if os.getenv("LEL_INJECTION_BACKEND"): + cfg.injection_backend = os.environ["LEL_INJECTION_BACKEND"] + + if os.getenv("LEL_AI_ENABLED"): + cfg.ai_enabled = _parse_bool(os.environ["LEL_AI_ENABLED"]) + if os.getenv("LEL_AI_PROVIDER"): + cfg.ai_provider = os.environ["LEL_AI_PROVIDER"] + if os.getenv("LEL_AI_MODEL"): + cfg.ai_model = os.environ["LEL_AI_MODEL"] + if os.getenv("LEL_AI_TEMPERATURE"): + cfg.ai_temperature = float(os.environ["LEL_AI_TEMPERATURE"]) + if os.getenv("LEL_AI_SYSTEM_PROMPT_FILE"): + cfg.ai_system_prompt_file = os.environ["LEL_AI_SYSTEM_PROMPT_FILE"] + if os.getenv("LEL_AI_BASE_URL"): + cfg.ai_base_url = os.environ["LEL_AI_BASE_URL"] + if os.getenv("LEL_AI_API_KEY"): + cfg.ai_api_key = os.environ["LEL_AI_API_KEY"] + if os.getenv("LEL_AI_TIMEOUT_SEC"): + cfg.ai_timeout_sec = int(os.environ["LEL_AI_TIMEOUT_SEC"]) + + if not cfg.hotkey: + raise ValueError("hotkey cannot be empty") + if cfg.record_timeout_sec <= 0: + raise ValueError("record_timeout_sec must be > 0") + if cfg.whisper_timeout_sec <= 0: + raise ValueError("whisper_timeout_sec must be > 0") + + return cfg + + +def redacted_dict(cfg: Config) -> dict: + d = cfg.__dict__.copy() + d["ai_api_key"] = "" + return d diff --git a/src/inject.py b/src/inject.py new file mode 100644 index 0000000..aa20db8 --- /dev/null +++ b/src/inject.py @@ -0,0 +1,50 @@ +import subprocess +import sys + + +def write_clipboard(text: str) -> None: + proc = subprocess.run( + ["xclip", "-selection", "clipboard", "-in", "-quiet", "-loops", "1"], + input=text, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + if proc.returncode != 0: + raise RuntimeError(proc.stderr.strip() or "xclip failed") + + +def paste_clipboard() -> None: + proc = subprocess.run( + ["xdotool", "key", "--clearmodifiers", "ctrl+v"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + if proc.returncode != 0: + raise RuntimeError(proc.stderr.strip() or "xdotool paste failed") + + +def type_text(text: str) -> None: + if not text: + return + proc = subprocess.run( + ["xdotool", "type", "--clearmodifiers", "--delay", "1", text], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + if proc.returncode != 0: + raise RuntimeError(proc.stderr.strip() or "xdotool type failed") + + +def inject(text: str, backend: str) -> None: + backend = (backend or "").strip().lower() + if backend in ("", "clipboard"): + write_clipboard(text) + paste_clipboard() + return + if backend == "injection": + type_text(text) + return + raise ValueError(f"unknown injection backend: {backend}") diff --git a/src/leld.py b/src/leld.py new file mode 100755 index 0000000..e07da8f --- /dev/null +++ b/src/leld.py @@ -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() diff --git a/src/recorder.py b/src/recorder.py new file mode 100644 index 0000000..5666458 --- /dev/null +++ b/src/recorder.py @@ -0,0 +1,70 @@ +import os +import signal +import subprocess +import tempfile +import time +from dataclasses import dataclass +from pathlib import Path + + +@dataclass +class RecordResult: + wav_path: str + temp_dir: str + + +def _resolve_ffmpeg_path(explicit: str) -> str: + if explicit: + return explicit + appdir = os.getenv("APPDIR") + if appdir: + candidate = Path(appdir) / "usr" / "bin" / "ffmpeg" + if candidate.exists(): + return str(candidate) + return "ffmpeg" + + +def _ffmpeg_input_args(spec: str) -> list[str]: + if not spec: + spec = "pulse:default" + kind = spec + name = "default" + if ":" in spec: + kind, name = spec.split(":", 1) + return ["-f", kind, "-i", name] + + +def start_recording(ffmpeg_input: str, ffmpeg_path: str) -> tuple[subprocess.Popen, RecordResult]: + tmpdir = tempfile.mkdtemp(prefix="lel-") + wav = str(Path(tmpdir) / "mic.wav") + + args = ["-hide_banner", "-loglevel", "error"] + args += _ffmpeg_input_args(ffmpeg_input) + args += ["-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", wav] + + proc = subprocess.Popen( + [_resolve_ffmpeg_path(ffmpeg_path), *args], + preexec_fn=os.setsid, + ) + return proc, RecordResult(wav_path=wav, temp_dir=tmpdir) + + +def stop_recording(proc: subprocess.Popen, timeout_sec: float = 5.0) -> None: + if proc.poll() is None: + try: + os.killpg(proc.pid, signal.SIGINT) + except ProcessLookupError: + return + start = time.time() + while proc.poll() is None: + if time.time() - start > timeout_sec: + try: + os.killpg(proc.pid, signal.SIGKILL) + except ProcessLookupError: + pass + break + time.sleep(0.05) + + # ffmpeg returns 255 on SIGINT; treat as success + if proc.returncode not in (0, 255, None): + raise RuntimeError(f"ffmpeg exited with status {proc.returncode}") diff --git a/src/stt.py b/src/stt.py new file mode 100644 index 0000000..638dc97 --- /dev/null +++ b/src/stt.py @@ -0,0 +1,25 @@ +import os +import whisper + + +def _force_cpu(): + os.environ.setdefault("CUDA_VISIBLE_DEVICES", "") + + +class WhisperSTT: + def __init__(self, model: str, language: str | None = None, device: str = "cpu"): + self.model_name = model + self.language = language + self.device = (device or "cpu").lower() + self._model = None + + def _load(self): + if self._model is None: + if self.device == "cpu": + _force_cpu() + self._model = whisper.load_model(self.model_name, device=self.device) + + def transcribe(self, wav_path: str) -> str: + self._load() + result = self._model.transcribe(wav_path, language=self.language) + return (result.get("text") or "").strip() diff --git a/internal/aiprocess/system_prompt.txt b/src/system_prompt.txt similarity index 100% rename from internal/aiprocess/system_prompt.txt rename to src/system_prompt.txt diff --git a/src/tray.py b/src/tray.py new file mode 100644 index 0000000..4c5811c --- /dev/null +++ b/src/tray.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from threading import Thread + +import pystray +from PIL import Image + + +@dataclass +class TrayIcons: + idle: Image.Image + recording: Image.Image + transcribing: Image.Image + processing: Image.Image + + +def load_icons() -> TrayIcons: + base = Path(__file__).parent / "assets" + return TrayIcons( + idle=Image.open(base / "idle.png"), + recording=Image.open(base / "recording.png"), + transcribing=Image.open(base / "transcribing.png"), + processing=Image.open(base / "processing.png"), + ) + + +def run_tray(state_getter, on_quit): + icons = load_icons() + icon = pystray.Icon("lel", icons.idle, "lel") + + def update(): + while True: + state = state_getter() + if state == "recording": + icon.icon = icons.recording + icon.title = "Recording" + elif state == "transcribing": + icon.icon = icons.transcribing + icon.title = "Transcribing" + elif state == "processing": + icon.icon = icons.processing + icon.title = "AI Processing" + else: + icon.icon = icons.idle + icon.title = "Idle" + icon.update_menu() + + icon.menu = pystray.Menu(pystray.MenuItem("Quit", lambda: on_quit())) + Thread(target=update, daemon=True).start() + icon.run() diff --git a/src/x11_hotkey.py b/src/x11_hotkey.py new file mode 100644 index 0000000..a11b759 --- /dev/null +++ b/src/x11_hotkey.py @@ -0,0 +1,67 @@ +from Xlib import X, display +from Xlib import XK + +MOD_MAP = { + "shift": X.ShiftMask, + "ctrl": X.ControlMask, + "control": X.ControlMask, + "alt": X.Mod1Mask, + "mod1": X.Mod1Mask, + "super": X.Mod4Mask, + "mod4": X.Mod4Mask, + "cmd": X.Mod4Mask, + "command": X.Mod4Mask, +} + + +def parse_hotkey(hotkey: str): + parts = [p.strip() for p in hotkey.split("+") if p.strip()] + mods = 0 + key_part = None + for p in parts: + low = p.lower() + if low in MOD_MAP: + mods |= MOD_MAP[low] + else: + key_part = p + if not key_part: + raise ValueError("hotkey missing key") + + keysym = XK.string_to_keysym(key_part) + if keysym == 0 and len(key_part) == 1: + keysym = ord(key_part) + if keysym == 0: + raise ValueError(f"unsupported key: {key_part}") + + return mods, keysym + + +def grab_hotkey(disp, root, mods, keysym): + keycode = disp.keysym_to_keycode(keysym) + root.grab_key(keycode, mods, True, X.GrabModeAsync, X.GrabModeAsync) + # ignore CapsLock/NumLock + root.grab_key(keycode, mods | X.LockMask, True, X.GrabModeAsync, X.GrabModeAsync) + root.grab_key(keycode, mods | X.Mod2Mask, True, X.GrabModeAsync, X.GrabModeAsync) + root.grab_key(keycode, mods | X.LockMask | X.Mod2Mask, True, X.GrabModeAsync, X.GrabModeAsync) + disp.sync() + return keycode + + +def listen(hotkey: str, on_trigger): + disp = display.Display() + root = disp.screen().root + mods, keysym = parse_hotkey(hotkey) + keycode = grab_hotkey(disp, root, mods, keysym) + try: + while True: + ev = disp.next_event() + if ev.type == X.KeyPress and ev.detail == keycode: + state = ev.state & ~(X.LockMask | X.Mod2Mask) + if state == mods: + on_trigger() + finally: + try: + root.ungrab_key(keycode, X.AnyModifier) + disp.sync() + except Exception: + pass diff --git a/systemd/lel.service b/systemd/lel.service index 6530e80..6602409 100644 --- a/systemd/lel.service +++ b/systemd/lel.service @@ -4,7 +4,7 @@ After=default.target [Service] Type=simple -ExecStart=%h/.local/bin/leld --config %h/.config/lel/config.json +ExecStart=/usr/bin/python3 %h/.local/bin/leld.py --config %h/.config/lel/config.json Restart=on-failure RestartSec=2