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: } }