196 lines
3.8 KiB
Go
196 lines
3.8 KiB
Go
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:
|
|
}
|
|
}
|