Handle shutdown signals
This commit is contained in:
parent
9ee301fbeb
commit
123dc0160b
2 changed files with 122 additions and 13 deletions
|
|
@ -2,15 +2,19 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"context"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"lel/internal/aiprocess"
|
||||||
"lel/internal/clip"
|
"lel/internal/clip"
|
||||||
"lel/internal/config"
|
"lel/internal/config"
|
||||||
"lel/internal/daemon"
|
"lel/internal/daemon"
|
||||||
|
|
@ -26,7 +30,7 @@ func main() {
|
||||||
var configPath string
|
var configPath string
|
||||||
var dryRun bool
|
var dryRun bool
|
||||||
var noTray 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(&dryRun, "dry-run", false, "register hotkey and log events without recording")
|
||||||
flag.BoolVar(&noTray, "no-tray", false, "disable system tray icon")
|
flag.BoolVar(&noTray, "no-tray", false, "disable system tray icon")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
@ -74,7 +78,21 @@ func main() {
|
||||||
logger.Fatalf("backend error: %v", err)
|
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")
|
sockPath := filepath.Join(runtimeDir, "ctl.sock")
|
||||||
if err := os.RemoveAll(sockPath); err != nil {
|
if err := os.RemoveAll(sockPath); err != nil {
|
||||||
|
|
@ -95,6 +113,7 @@ func main() {
|
||||||
logger.Printf("ready (hotkey: %s)", cfg.Hotkey)
|
logger.Printf("ready (hotkey: %s)", cfg.Hotkey)
|
||||||
|
|
||||||
if noTray {
|
if noTray {
|
||||||
|
go handleSignals(logger, d)
|
||||||
runX11Loop(logger, x, d, mods, keycode, dryRun)
|
runX11Loop(logger, x, d, mods, keycode, dryRun)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -119,6 +138,10 @@ func main() {
|
||||||
systray.SetIcon(ui.IconTranscribing())
|
systray.SetIcon(ui.IconTranscribing())
|
||||||
systray.SetTooltip("lel: transcribing")
|
systray.SetTooltip("lel: transcribing")
|
||||||
status.SetTitle("Transcribing")
|
status.SetTitle("Transcribing")
|
||||||
|
case daemon.StateProcessing:
|
||||||
|
systray.SetIcon(ui.IconProcessing())
|
||||||
|
systray.SetTooltip("lel: ai processing")
|
||||||
|
status.SetTitle("AI Processing")
|
||||||
default:
|
default:
|
||||||
systray.SetIcon(ui.IconIdle())
|
systray.SetIcon(ui.IconIdle())
|
||||||
systray.SetTooltip("lel: idle")
|
systray.SetTooltip("lel: idle")
|
||||||
|
|
@ -133,6 +156,7 @@ func main() {
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
go handleSignals(logger, d)
|
||||||
go runX11Loop(logger, x, d, mods, keycode, dryRun)
|
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 {
|
func ensureRuntimeDir(logger *log.Logger) string {
|
||||||
dir := os.Getenv("XDG_RUNTIME_DIR")
|
dir := os.Getenv("XDG_RUNTIME_DIR")
|
||||||
if dir == "" {
|
if dir == "" {
|
||||||
|
|
@ -242,11 +280,27 @@ func handleConn(logger *log.Logger, conn net.Conn, d *daemon.Daemon, cfg *config
|
||||||
return
|
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
|
*mods = newMods
|
||||||
*keycode = newKeycode
|
*keycode = newKeycode
|
||||||
*cfg = newCfg
|
*cfg = newCfg
|
||||||
d.UpdateConfig(newCfg)
|
d.UpdateConfig(newCfg)
|
||||||
d.UpdateBackend(backend)
|
d.UpdateBackend(backend)
|
||||||
|
d.UpdateAI(processor)
|
||||||
|
|
||||||
_, _ = fmt.Fprintf(conn, "reloaded\n")
|
_, _ = fmt.Fprintf(conn, "reloaded\n")
|
||||||
default:
|
default:
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"lel/internal/aiprocess"
|
||||||
"lel/internal/audio"
|
"lel/internal/audio"
|
||||||
"lel/internal/clip"
|
"lel/internal/clip"
|
||||||
"lel/internal/config"
|
"lel/internal/config"
|
||||||
|
|
@ -24,6 +25,7 @@ const (
|
||||||
StateIdle State = "idle"
|
StateIdle State = "idle"
|
||||||
StateRecording State = "recording"
|
StateRecording State = "recording"
|
||||||
StateTranscribing State = "transcribing"
|
StateTranscribing State = "transcribing"
|
||||||
|
StateProcessing State = "processing"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Daemon struct {
|
type Daemon struct {
|
||||||
|
|
@ -31,6 +33,7 @@ type Daemon struct {
|
||||||
x11 *x11.Conn
|
x11 *x11.Conn
|
||||||
log *log.Logger
|
log *log.Logger
|
||||||
inj inject.Backend
|
inj inject.Backend
|
||||||
|
ai aiprocess.Processor
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
state State
|
state State
|
||||||
|
|
@ -41,9 +44,9 @@ type Daemon struct {
|
||||||
stateCh chan State
|
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}
|
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) {
|
func (d *Daemon) UpdateConfig(cfg config.Config) {
|
||||||
|
|
@ -61,6 +64,24 @@ func (d *Daemon) UpdateBackend(inj inject.Backend) {
|
||||||
d.mu.Unlock()
|
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 {
|
func (d *Daemon) State() State {
|
||||||
d.mu.Lock()
|
d.mu.Lock()
|
||||||
defer d.mu.Unlock()
|
defer d.mu.Unlock()
|
||||||
|
|
@ -79,8 +100,7 @@ func (d *Daemon) Toggle() {
|
||||||
d.log.Printf("record start failed: %v", err)
|
d.log.Printf("record start failed: %v", err)
|
||||||
}
|
}
|
||||||
case StateRecording:
|
case StateRecording:
|
||||||
d.state = StateTranscribing
|
d.setStateLocked(StateTranscribing)
|
||||||
d.notify(StateTranscribing)
|
|
||||||
d.mu.Unlock()
|
d.mu.Unlock()
|
||||||
go d.stopAndProcess("user")
|
go d.stopAndProcess("user")
|
||||||
return
|
return
|
||||||
|
|
@ -90,6 +110,32 @@ func (d *Daemon) Toggle() {
|
||||||
d.mu.Unlock()
|
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 {
|
func (d *Daemon) startRecordingLocked() error {
|
||||||
if d.state != StateIdle {
|
if d.state != StateIdle {
|
||||||
return errors.New("not idle")
|
return errors.New("not idle")
|
||||||
|
|
@ -114,8 +160,7 @@ func (d *Daemon) startRecordingLocked() error {
|
||||||
d.mu.Unlock()
|
d.mu.Unlock()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
d.state = StateTranscribing
|
d.setStateLocked(StateTranscribing)
|
||||||
d.notify(StateTranscribing)
|
|
||||||
d.mu.Unlock()
|
d.mu.Unlock()
|
||||||
go d.stopAndProcess("timeout")
|
go d.stopAndProcess("timeout")
|
||||||
})
|
})
|
||||||
|
|
@ -171,8 +216,21 @@ func (d *Daemon) stopAndProcess(reason string) {
|
||||||
status = "whisper failed: " + err.Error()
|
status = "whisper failed: " + err.Error()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
d.log.Printf("transcript: %s", text)
|
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)
|
clipCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if err := clip.WriteClipboard(clipCtx, text); err != nil {
|
if err := clip.WriteClipboard(clipCtx, text); err != nil {
|
||||||
|
|
@ -190,10 +248,7 @@ func (d *Daemon) stopAndProcess(reason string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Daemon) setIdle(msg string) {
|
func (d *Daemon) setIdle(msg string) {
|
||||||
d.mu.Lock()
|
d.setState(StateIdle)
|
||||||
d.state = StateIdle
|
|
||||||
d.notify(StateIdle)
|
|
||||||
d.mu.Unlock()
|
|
||||||
d.log.Printf("idle (%s)", msg)
|
d.log.Printf("idle (%s)", msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue