diff --git a/cmd/leld/main.go b/cmd/leld/main.go index d5a76a2..6a023c6 100644 --- a/cmd/leld/main.go +++ b/cmd/leld/main.go @@ -2,15 +2,19 @@ package main import ( "bufio" + "context" "flag" "fmt" "log" "net" "os" + "os/signal" "path/filepath" "strings" "syscall" + "time" + "lel/internal/aiprocess" "lel/internal/clip" "lel/internal/config" "lel/internal/daemon" @@ -26,7 +30,7 @@ func main() { var configPath string var dryRun bool var noTray bool - flag.StringVar(&configPath, "config", "", "path to config.toml") + 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() @@ -74,7 +78,21 @@ func main() { logger.Fatalf("backend error: %v", err) } - d := daemon.New(cfg, x, logger, backend) + 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 { @@ -95,6 +113,7 @@ func main() { logger.Printf("ready (hotkey: %s)", cfg.Hotkey) if noTray { + go handleSignals(logger, d) runX11Loop(logger, x, d, mods, keycode, dryRun) return } @@ -119,6 +138,10 @@ func main() { 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") @@ -133,6 +156,7 @@ func main() { } }() + go handleSignals(logger, d) go runX11Loop(logger, x, d, mods, keycode, dryRun) } @@ -164,6 +188,20 @@ func runX11Loop(logger *log.Logger, x *x11.Conn, d *daemon.Daemon, mods uint16, } } +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 == "" { @@ -242,11 +280,27 @@ func handleConn(logger *log.Logger, conn net.Conn, d *daemon.Daemon, cfg *config 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: diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 7e62b23..e934d01 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -10,6 +10,7 @@ import ( "sync" "time" + "lel/internal/aiprocess" "lel/internal/audio" "lel/internal/clip" "lel/internal/config" @@ -24,6 +25,7 @@ const ( StateIdle State = "idle" StateRecording State = "recording" StateTranscribing State = "transcribing" + StateProcessing State = "processing" ) type Daemon struct { @@ -31,6 +33,7 @@ type Daemon struct { x11 *x11.Conn log *log.Logger inj inject.Backend + ai aiprocess.Processor mu sync.Mutex state State @@ -41,9 +44,9 @@ type Daemon struct { stateCh chan State } -func New(cfg config.Config, x *x11.Conn, logger *log.Logger, inj inject.Backend) *Daemon { +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, state: StateIdle, ffmpeg: r, stateCh: make(chan State, 4)} + 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) { @@ -61,6 +64,24 @@ func (d *Daemon) UpdateBackend(inj inject.Backend) { 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() @@ -79,8 +100,7 @@ func (d *Daemon) Toggle() { d.log.Printf("record start failed: %v", err) } case StateRecording: - d.state = StateTranscribing - d.notify(StateTranscribing) + d.setStateLocked(StateTranscribing) d.mu.Unlock() go d.stopAndProcess("user") return @@ -90,6 +110,32 @@ func (d *Daemon) Toggle() { 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") @@ -114,8 +160,7 @@ func (d *Daemon) startRecordingLocked() error { d.mu.Unlock() return } - d.state = StateTranscribing - d.notify(StateTranscribing) + d.setStateLocked(StateTranscribing) d.mu.Unlock() go d.stopAndProcess("timeout") }) @@ -171,8 +216,21 @@ func (d *Daemon) stopAndProcess(reason string) { status = "whisper failed: " + err.Error() return } - d.log.Printf("transcript: %s", text) + + if d.ai != nil && d.cfg.AIEnabled { + 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 { @@ -190,10 +248,7 @@ func (d *Daemon) stopAndProcess(reason string) { } func (d *Daemon) setIdle(msg string) { - d.mu.Lock() - d.state = StateIdle - d.notify(StateIdle) - d.mu.Unlock() + d.setState(StateIdle) d.log.Printf("idle (%s)", msg) }