aman/internal/daemon/daemon.go

269 lines
5.3 KiB
Go

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