Add X11 daemon with tray status

This commit is contained in:
Thales Maciel 2026-02-06 11:36:45 -03:00
parent 3506770d09
commit a7f50fed75
19 changed files with 1202 additions and 4 deletions

4
.gitignore vendored
View file

@ -1 +1,5 @@
env
/leld
/lelctl
*.log
*.tmp

37
AGENTS.md Normal file
View file

@ -0,0 +1,37 @@
# Repository Guidelines
## Project Structure & Module Organization
- `lel.sh` is the primary entrypoint; it records audio, runs `whisper`, and prints the transcript.
- `env/` is a local Python virtual environment (optional) used to install runtime dependencies.
- There are no separate source, test, or asset directories at this time.
## Build, Test, and Development Commands
- `./lel.sh` streams transcription from the microphone until you press Enter.
- Example with overrides: `WHISPER_MODEL=small WHISPER_LANG=pt WHISPER_DEVICE=cuda ./lel.sh`.
- Dependencies expected on PATH: `ffmpeg` and `whisper` (the OpenAI Whisper CLI).
## Coding Style & Naming Conventions
- Shell scripts use Bash with `set -euo pipefail`.
- Indentation is two spaces; prefer lowercase variable names for locals and uppercase for environment-configured values.
- Keep functions small and focused; add comments only where the intent is not obvious.
## 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
- Commit history is minimal and does not establish a convention; use short, imperative messages (e.g., "Add device override").
- PRs should include a concise description, repro steps, and any environment variables or dependencies added.
## Configuration Tips
- Audio input is controlled via `WHISPER_FFMPEG_IN` (default `pulse:default`), e.g., `alsa:default`.
- Streaming is on by default; set `WHISPER_STREAM=0` to transcribe after recording.
- Segment duration for streaming is `WHISPER_SEGMENT_SEC` (default `5`).
- Model, language, device, and extra args can be set with `WHISPER_MODEL`, `WHISPER_LANG`, `WHISPER_DEVICE`, and `WHISPER_EXTRA_ARGS`.

78
README.md Normal file
View file

@ -0,0 +1,78 @@
# lel
X11 transcription daemon that records audio and runs Whisper, logging the transcript.
## Requirements
- X11 (not Wayland)
- `ffmpeg`
- `whisper` (OpenAI Whisper CLI)
- `xclip`
- Tray icon deps: `libappindicator3` and `gtk3` (required by `systray`)
## Build
```bash
go build -o leld ./cmd/leld
go build -o lelctl ./cmd/lelctl
```
## Config
Create `~/.config/lel/config.toml`:
```toml
hotkey = "Cmd+m"
ffmpeg_input = "pulse:default"
whisper_model = "base"
whisper_lang = "en"
whisper_device = "cpu"
whisper_extra_args = ""
record_timeout_sec = 120
whisper_timeout_sec = 300
segment_sec = 5
streaming = false
```
Env overrides:
- `WHISPER_MODEL`, `WHISPER_LANG`, `WHISPER_DEVICE`, `WHISPER_EXTRA_ARGS`
- `WHISPER_FFMPEG_IN`
- `WHISPER_STREAM`, `WHISPER_SEGMENT_SEC`, `WHISPER_TIMEOUT_SEC`
- `LEL_RECORD_TIMEOUT_SEC`, `LEL_HOTKEY`
## Run manually
```bash
./leld --config ~/.config/lel/config.toml
```
Disable the tray icon:
```bash
./leld --no-tray
```
## systemd user service
```bash
mkdir -p ~/.local/bin
cp leld lelctl ~/.local/bin/
cp systemd/lel.service ~/.config/systemd/user/lel.service
systemctl --user daemon-reload
systemctl --user enable --now lel
```
## Usage
- Press the hotkey once to start recording.
- Press it again to stop and transcribe.
- The transcript is logged to stderr.
Control:
```bash
lelctl status
lelctl reload
lelctl stop
```

45
cmd/lelctl/main.go Normal file
View file

@ -0,0 +1,45 @@
package main
import (
"flag"
"fmt"
"net"
"os"
"path/filepath"
"strings"
)
func main() {
flag.Parse()
if flag.NArg() == 0 {
fmt.Fprintln(os.Stderr, "usage: lelctl <status|stop|reload>")
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]))
}
}

233
cmd/leld/main.go Normal file
View file

@ -0,0 +1,233 @@
package main
import (
"bufio"
"flag"
"fmt"
"log"
"net"
"os"
"path/filepath"
"strings"
"syscall"
"lel/internal/config"
"lel/internal/daemon"
"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.toml")
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)
d := daemon.New(cfg, x, logger)
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)
if noTray {
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")
default:
systray.SetIcon(ui.IconIdle())
systray.SetTooltip("lel: idle")
status.SetTitle("Idle")
}
}
}()
go func() {
for range quit.ClickedCh {
os.Exit(0)
}
}()
go runX11Loop(logger, x, d, mods, keycode, dryRun)
}
systray.Run(onReady, func() {})
}
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 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
}
*mods = newMods
*keycode = newKeycode
*cfg = newCfg
d.UpdateConfig(newCfg)
_, _ = fmt.Fprintf(conn, "reloaded\n")
default:
_, _ = fmt.Fprintf(conn, "unknown command\n")
}
}

21
go.mod Normal file
View file

@ -0,0 +1,21 @@
module lel
go 1.25.5
require (
github.com/BurntSushi/toml v1.6.0
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
)

36
go.sum Normal file
View file

@ -0,0 +1,36 @@
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
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=

70
internal/audio/record.go Normal file
View file

@ -0,0 +1,70 @@
package audio
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"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...)
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}
}

View file

@ -0,0 +1,26 @@
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
}

116
internal/config/config.go Normal file
View file

@ -0,0 +1,116 @@
package config
import (
"errors"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/BurntSushi/toml"
)
type Config struct {
Hotkey string `toml:"hotkey"`
FfmpegInput string `toml:"ffmpeg_input"`
WhisperModel string `toml:"whisper_model"`
WhisperLang string `toml:"whisper_lang"`
WhisperDevice string `toml:"whisper_device"`
WhisperExtraArgs string `toml:"whisper_extra_args"`
RecordTimeoutSec int `toml:"record_timeout_sec"`
WhisperTimeoutSec int `toml:"whisper_timeout_sec"`
SegmentSec int `toml:"segment_sec"`
Streaming bool `toml:"streaming"`
}
func DefaultPath() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".config", "lel", "config.toml")
}
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,
}
}
func Load(path string) (Config, error) {
cfg := Defaults()
if path == "" {
path = DefaultPath()
}
if _, err := os.Stat(path); err == nil {
if _, err := toml.DecodeFile(path, &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
}
}
func parseBool(v string) bool {
v = strings.ToLower(strings.TrimSpace(v))
return v == "1" || v == "true" || v == "yes" || v == "on"
}

196
internal/daemon/daemon.go Normal file
View file

@ -0,0 +1,196 @@
package daemon
import (
"context"
"errors"
"log"
"os"
"os/exec"
"path/filepath"
"sync"
"time"
"lel/internal/audio"
"lel/internal/clip"
"lel/internal/config"
"lel/internal/whisper"
"lel/internal/x11"
)
type State string
const (
StateIdle State = "idle"
StateRecording State = "recording"
StateTranscribing State = "transcribing"
)
type Daemon struct {
cfg config.Config
x11 *x11.Conn
log *log.Logger
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) *Daemon {
r := &audio.Recorder{Input: cfg.FfmpegInput}
return &Daemon{cfg: cfg, x11: x, log: logger, 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) 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.state = StateTranscribing
d.notify(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) 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.state = StateTranscribing
d.notify(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 {
_ = cmd.Process.Signal(os.Interrupt)
}
_ = 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)
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
}
}
func (d *Daemon) setIdle(msg string) {
d.mu.Lock()
d.state = StateIdle
d.notify(StateIdle)
d.mu.Unlock()
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:
}
}

BIN
internal/ui/assets/idle.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 B

24
internal/ui/icons.go Normal file
View file

@ -0,0 +1,24 @@
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
}

View file

@ -0,0 +1,69 @@
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
}

232
internal/x11/x11.go Normal file
View file

@ -0,0 +1,232 @@
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
}

7
lel.sh
View file

@ -27,8 +27,8 @@ ffmpeg_input_args() {
mkdir -p "$outdir"
echo "Recording from: $FFMPEG_IN"
echo "Press Enter to stop..."
echo "Recording from: $FFMPEG_IN" >&2
echo "Press Enter to stop..." >&2
ffmpeg -hide_banner -loglevel error \
$(ffmpeg_input_args "$FFMPEG_IN") \
-ac 1 -ar 16000 -c:a pcm_s16le "$wav" &
@ -57,6 +57,5 @@ whisper "$wav" \
txt="$outdir/$(basename "$wav" .wav).txt"
text="$(cat "$txt")"
echo
echo >&2
echo "$text"

12
systemd/lel.service Normal file
View file

@ -0,0 +1,12 @@
[Unit]
Description=lel X11 transcription daemon
After=default.target
[Service]
Type=simple
ExecStart=%h/.local/bin/leld --config %h/.config/lel/config.toml
Restart=on-failure
RestartSec=2
[Install]
WantedBy=default.target