Add X11 daemon with tray status
This commit is contained in:
parent
3506770d09
commit
a7f50fed75
19 changed files with 1202 additions and 4 deletions
196
internal/daemon/daemon.go
Normal file
196
internal/daemon/daemon.go
Normal 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:
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue