refactor: ein Binary — talk startet den Daemon selbst, talkd und Degraded Mode entfallen
Zwei Binaries erzeugten den halben Komplexitätsballast: Degraded Mode mit Lock-Datei und Stale-Lock-Race, „läuft talkd?", Autostart-Konfiguration je OS, doppelte Chain-Konstruktion in cmd/talk und cmd/talkd — und der Degraded Mode kannte weder Rate-Limit noch Ruhezeiten. Jetzt: - internal/speak: der eine Sprech-Kern (Config + Sink + Kette → Ansage); Daemon und Direkt-Pfad benutzen ihn, es gibt keine zweite Zusammensetzung der Kette mehr. - internal/daemon: Run (der bisherige talkd-Kern), Spawn (detached per Setsid bzw. DETACHED_PROCESS, stdout/stderr → daemon.log), PID-Datei daemon.pid, Shutdown-Trigger. - `talk daemon run|start|stop`: Vordergrund (systemd/launchd), expliziter Hintergrund-Start, Stop per POST /shutdown — stop wartet, bis HTTP weg und die PID-Datei gelöscht ist (Prozess wirklich beendet). - Autostart: say/wrap starten den Daemon beim ersten Sprechbefehl selbst (daemon.autostart, Default an; Env TALK_AUTOSTART). Verliert ein paralleler Aufruf das Port-Rennen, erkennt er das am Kind-Ende und nutzt den Daemon des anderen — kein Timeout-Warten. - Direkt-Pfad statt Degraded Mode: ohne erreichbaren oder startbaren Daemon spricht talk direkt (Mute respektiert, kein Lock, Hinweis + Grund + Log-Pfad auf stderr). Steuerbefehle starten den Daemon bewusst nicht; `talk status` zeigt „läuft nicht" (Exit 1). - cmd/talkd und cmd/talk/degraded.go entfernt; README-Daemon-Abschnitt neu. Tests: Harness baut nur noch talk und startet `talk daemon run`; TALK_AUTOSTART=false als Grundausstattung aller Tests (kein Test hinterlässt Daemons), autostart_test.go schaltet es bewusst an: Autostart beim ersten say, Idempotenz, paralleler Start in EINE Queue, expliziter start/stop, Port fremd belegt → Direkt-Pfad ohne Timeout. Cross-Compile aller sechs Targets mit CGO_ENABLED=0 verifiziert. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
b86632006e
commit
bc26a73aeb
29 files changed
+1145
-584
No files matched your search
@@ -81,6 +81,11 @@ func (c *Client) SetMute(ctx context.Context, muted bool) error {
|
||||
return c.do(ctx, http.MethodPost, path, nil, http.StatusOK, nil)
|
||||
}
|
||||
|
||||
// Shutdown ruft POST /shutdown (`talk daemon stop`).
|
||||
func (c *Client) Shutdown(ctx context.Context) error {
|
||||
return c.do(ctx, http.MethodPost, "/shutdown", nil, http.StatusOK, nil)
|
||||
}
|
||||
|
||||
// do ist der gemeinsame Kern: Request bauen, senden, Status prüfen,
|
||||
// dekodieren. Netz-Fehler kommen ungefiltert zurück (Aufrufer unterscheidet
|
||||
// über errors.As(&HTTPError)).
|
||||
|
||||
@@ -38,6 +38,11 @@ type HandlerConfig struct {
|
||||
// AllowedOrigins ist die CORS-Erlaubnisliste für POST /speak (leer =
|
||||
// keine CORS-Header, der Browser blockt; siehe cors.go).
|
||||
AllowedOrigins []string
|
||||
|
||||
// Shutdown fährt den Daemon herunter (POST /shutdown, `talk daemon
|
||||
// stop`). Muss nicht blockieren und mehrfach aufrufbar sein. Optional:
|
||||
// nil → der Endpunkt antwortet 501.
|
||||
Shutdown func()
|
||||
}
|
||||
|
||||
// NewHandler liefert die HTTP-Routen des Daemons. Methoden-Bindung per
|
||||
@@ -52,6 +57,7 @@ func NewHandler(cfg HandlerConfig) http.Handler {
|
||||
known: make(map[string]bool, len(cfg.KnownProviders)),
|
||||
provider: cfg.Provider,
|
||||
allowedOrigins: cfg.AllowedOrigins,
|
||||
shutdown: cfg.Shutdown,
|
||||
}
|
||||
for _, name := range cfg.KnownProviders {
|
||||
h.known[name] = true
|
||||
@@ -63,6 +69,7 @@ func NewHandler(cfg HandlerConfig) http.Handler {
|
||||
mux.HandleFunc("POST /cancel", h.cancel)
|
||||
mux.HandleFunc("POST /mute", h.muteEndpoint(true))
|
||||
mux.HandleFunc("POST /unmute", h.muteEndpoint(false))
|
||||
mux.HandleFunc("POST /shutdown", h.shutdownEndpoint)
|
||||
mux.HandleFunc("POST /mcp", h.serveMCP) // MCP Streamable HTTP (Task 06)
|
||||
mux.HandleFunc("GET /mcp", h.mcpGet) // 405: keine server-initiierten Streams
|
||||
return h.originGuard(mux)
|
||||
@@ -76,6 +83,7 @@ type handler struct {
|
||||
known map[string]bool
|
||||
provider func() string
|
||||
allowedOrigins []string
|
||||
shutdown func()
|
||||
}
|
||||
|
||||
// speak behandelt POST /speak: validieren, bei Mute verwerfen (202,
|
||||
@@ -241,6 +249,18 @@ func (h *handler) muteEndpoint(muted bool) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// shutdownEndpoint behandelt POST /shutdown (`talk daemon stop`): Antwort
|
||||
// zuerst, dann der Shutdown-Trigger — der Aufrufer bekommt sein 200, bevor
|
||||
// der Server die Verbindung schließt.
|
||||
func (h *handler) shutdownEndpoint(w http.ResponseWriter, r *http.Request) {
|
||||
if h.shutdown == nil {
|
||||
writeError(w, http.StatusNotImplemented, "dieser Daemon kennt keinen Shutdown-Trigger")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, ShutdownResponse{OK: true})
|
||||
h.shutdown()
|
||||
}
|
||||
|
||||
// writeJSON schreibt eine JSON-Antwort mit Status-Code.
|
||||
func writeJSON(w http.ResponseWriter, code int, body any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
@@ -131,3 +131,8 @@ type CancelResponse struct {
|
||||
type MuteResponse struct {
|
||||
Muted bool `json:"muted"`
|
||||
}
|
||||
|
||||
// ShutdownResponse ist die Antwort von POST /shutdown.
|
||||
type ShutdownResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
}
|
||||
@@ -44,6 +44,9 @@ type Config struct {
|
||||
// Queue steuert die Anti-Spam-Gesetze des Daemons (Task 05).
|
||||
Queue Queue `toml:"queue"`
|
||||
|
||||
// Daemon steuert den Hintergrundprozess (Autostart).
|
||||
Daemon Daemon `toml:"daemon"`
|
||||
|
||||
// Provider pinnt die Kette auf genau einen Provider (z. B.
|
||||
// `provider = "elevenlabs"`); leer = komplette Default-Kette
|
||||
// (elevenlabs → openai → edge → osbuiltin). Das CLI-Flag --provider
|
||||
@@ -99,6 +102,15 @@ type Server struct {
|
||||
AllowedOrigins []string `toml:"allowed_origins"`
|
||||
}
|
||||
|
||||
// Daemon sind die Einstellungen des Hintergrundprozesses (TOML `[daemon]`).
|
||||
type Daemon struct {
|
||||
// Autostart lässt talk den Daemon beim ersten Sprechbefehl selbst im
|
||||
// Hintergrund starten (Default true; Env TALK_AUTOSTART). false ist für
|
||||
// Setups, die den Daemon selbst verwalten (systemd, launchd, Task
|
||||
// Scheduler): say spricht dann ohne Daemon direkt, mit Hinweis.
|
||||
Autostart bool `toml:"autostart"`
|
||||
}
|
||||
|
||||
// Wrap sind die Einstellungen von `talk wrap` (TOML `[wrap]`).
|
||||
type Wrap struct {
|
||||
// Label ersetzt den Basisnamen des Befehls in der Ansage — z. B.
|
||||
@@ -137,6 +149,7 @@ func Defaults() Config {
|
||||
Audio: Audio{Sink: SinkOto},
|
||||
Server: Server{Port: DefaultServerPort},
|
||||
Queue: Queue{RateLimit: DefaultRateLimit, AllowHighBurst: true},
|
||||
Daemon: Daemon{Autostart: true},
|
||||
QuietMode: queue.QuietModeDiscard,
|
||||
}
|
||||
}
|
||||
@@ -210,6 +223,13 @@ func applyEnv(cfg Config, getenv func(string) string) (Config, error) {
|
||||
cfg.Server.AllowedOrigins = origins
|
||||
}
|
||||
}
|
||||
if v := getenv("TALK_AUTOSTART"); v != "" {
|
||||
b, err := strconv.ParseBool(v)
|
||||
if err != nil {
|
||||
return cfg, fmt.Errorf("TALK_AUTOSTART: ungültiger Wert %q (true/false)", v)
|
||||
}
|
||||
cfg.Daemon.Autostart = b
|
||||
}
|
||||
if v := getenv("TALK_WRAP_LABEL"); v != "" {
|
||||
cfg.Wrap.Label = v
|
||||
}
|
||||
|
||||
@@ -686,3 +686,48 @@ allow_high_burst = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestLoadDaemonAutostart deckt [daemon] autostart ab: Default an, TOML
|
||||
// kann es ausschalten, Env TALK_AUTOSTART schlägt die Datei, Unsinn ist ein
|
||||
// klarer Fehler.
|
||||
func TestLoadDaemonAutostart(t *testing.T) {
|
||||
fehlendeDatei := filepath.Join(t.TempDir(), "gibt-es-nicht.toml")
|
||||
writeTOML := func(t *testing.T, content string) string {
|
||||
path := filepath.Join(t.TempDir(), "config.toml")
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
cfg, err := Load(Options{File: fehlendeDatei, Getenv: env(nil)})
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if !cfg.Daemon.Autostart {
|
||||
t.Error("Daemon.Autostart = false, want Default true")
|
||||
}
|
||||
|
||||
cfg, err = Load(Options{File: writeTOML(t, "[daemon]\nautostart = false\n"), Getenv: env(nil)})
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.Daemon.Autostart {
|
||||
t.Error("Daemon.Autostart = true, want false aus TOML")
|
||||
}
|
||||
|
||||
cfg, err = Load(Options{
|
||||
File: writeTOML(t, "[daemon]\nautostart = false\n"),
|
||||
Getenv: env(map[string]string{"TALK_AUTOSTART": "true"}),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if !cfg.Daemon.Autostart {
|
||||
t.Error("Daemon.Autostart = false, want true aus Env (Env schlägt Datei)")
|
||||
}
|
||||
|
||||
if _, err := Load(Options{File: fehlendeDatei, Getenv: env(map[string]string{"TALK_AUTOSTART": "vielleicht"})}); err == nil {
|
||||
t.Error("TALK_AUTOSTART=vielleicht: kein Fehler, want klarer Fehler")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
// Package daemon ist der Hintergrundprozess von talk: HTTP-API auf
|
||||
// 127.0.0.1, serialisierte Ansage-Queue mit Prioritäten und now-Sprung,
|
||||
// persistenter Mute-State, die Anti-Spam-Gesetze (Rate-Limit, Ruhezeiten),
|
||||
// CORS-Tür und MCP-Endpoint. Es gibt genau ein Binary: `talk daemon run`
|
||||
// betreibt den Daemon im Vordergrund, die CLI startet ihn mit Spawn bei
|
||||
// Bedarf selbst im Hintergrund (daemon.autostart).
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"code.m3ta.dev/m3tam3re/talk/internal/api"
|
||||
"code.m3ta.dev/m3tam3re/talk/internal/config"
|
||||
"code.m3ta.dev/m3tam3re/talk/internal/provider"
|
||||
"code.m3ta.dev/m3tam3re/talk/internal/queue"
|
||||
"code.m3ta.dev/m3tam3re/talk/internal/speak"
|
||||
)
|
||||
|
||||
// Dateien des Daemons im Config-Verzeichnis.
|
||||
const (
|
||||
// LogFileName ist das Protokoll eines im Hintergrund gestarteten
|
||||
// Daemons (Spawn leitet stdout/stderr dorthin; pro Start neu).
|
||||
LogFileName = "daemon.log"
|
||||
|
||||
// PIDFileName hält die Prozess-ID des laufenden Daemons (für
|
||||
// Diagnose und als Notbremse, wenn die HTTP-API nicht antwortet).
|
||||
PIDFileName = "daemon.pid"
|
||||
)
|
||||
|
||||
// Options sind die Abhängigkeiten von Run.
|
||||
type Options struct {
|
||||
// Config ist die geladene Konfiguration (Port, Gesetze, Sink, …).
|
||||
Config config.Config
|
||||
|
||||
// Getenv liefert Keys und Endpunkt-Overrides (nil = os.Getenv).
|
||||
Getenv func(string) string
|
||||
|
||||
// Version ist die Daemon-Version für /status und MCP serverInfo.
|
||||
Version string
|
||||
|
||||
// Log empfängt das Protokoll (Vordergrund: stderr; Hintergrund: die
|
||||
// Log-Datei). nil = os.Stderr.
|
||||
Log io.Writer
|
||||
}
|
||||
|
||||
// Run betreibt den Daemon, bis ctx endet (Signal), POST /shutdown kommt
|
||||
// oder der HTTP-Server scheitert. Eine laufende Ansage wird beim
|
||||
// Herunterfahren abgebrochen — der Daemon soll prompt reagieren. Ein
|
||||
// belegter Port ist ein klarer Fehler („läuft bereits ein Daemon?").
|
||||
func Run(ctx context.Context, opts Options) error {
|
||||
getenv := opts.Getenv
|
||||
if getenv == nil {
|
||||
getenv = os.Getenv
|
||||
}
|
||||
out := opts.Log
|
||||
if out == nil {
|
||||
out = os.Stderr
|
||||
}
|
||||
logger := log.New(out, "talk daemon: ", log.LstdFlags)
|
||||
lw := lineWriter{logger}
|
||||
cfg := opts.Config
|
||||
|
||||
engine, err := speak.New(cfg, getenv, lw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Anti-Spam-Gesetz: config.Load hat quiet_hours validiert — ein Fehler
|
||||
// hier ist praktisch unmöglich, darf aber nie still werden.
|
||||
quiet, err := queue.ParseQuietHours(cfg.QuietHours)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q := queue.New(queue.Laws{
|
||||
RateLimit: cfg.Queue.RateLimit,
|
||||
AllowHighBurst: cfg.Queue.AllowHighBurst,
|
||||
Quiet: quiet,
|
||||
QuietMode: cfg.QuietMode,
|
||||
}, func(ctx context.Context, item queue.Item) error {
|
||||
return engine.Speak(ctx, speak.Request{
|
||||
Text: item.Text,
|
||||
Lang: item.Lang,
|
||||
Provider: item.Provider,
|
||||
Voice: item.Voice,
|
||||
})
|
||||
}, lw)
|
||||
|
||||
// Persistenter Mute-State und PID-Datei im Config-Dir. Ohne Config-Dir
|
||||
// läuft der Daemon ohne beides weiter (Warnung) statt zu scheitern.
|
||||
var mutePath, pidPath string
|
||||
if dir, err := config.Dir(getenv); err == nil {
|
||||
mutePath = filepath.Join(dir, queue.MuteFileName)
|
||||
pidPath = filepath.Join(dir, PIDFileName)
|
||||
} else {
|
||||
logger.Printf("warnung: Config-Dir nicht bestimmbar — Mute-State und PID-Datei deaktiviert (%v)", err)
|
||||
}
|
||||
mute := queue.MuteStore{Path: mutePath}
|
||||
|
||||
shutdown := make(chan struct{})
|
||||
var once sync.Once
|
||||
handler := api.NewHandler(api.HandlerConfig{
|
||||
Queue: q,
|
||||
Mute: mute,
|
||||
Version: opts.Version,
|
||||
KnownProviders: provider.Names(),
|
||||
AllowedOrigins: cfg.Server.AllowedOrigins,
|
||||
Provider: func() string {
|
||||
if cur, ok := q.Current(); ok {
|
||||
return engine.ActiveProvider(cur.Provider)
|
||||
}
|
||||
if last := engine.LastProvider(); last != "" {
|
||||
return last
|
||||
}
|
||||
return engine.ActiveProvider("")
|
||||
},
|
||||
Shutdown: func() { once.Do(func() { close(shutdown) }) },
|
||||
})
|
||||
|
||||
addr := fmt.Sprintf("127.0.0.1:%d", cfg.Server.Port)
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
q.Close()
|
||||
return fmt.Errorf("Port belegen (%s): %w — läuft bereits ein talk daemon?", addr, err)
|
||||
}
|
||||
if pidPath != "" {
|
||||
writePID(pidPath, logger)
|
||||
// Die PID-Datei verschwindet als Letztes — `talk daemon stop`
|
||||
// wartet darauf und weiß dann: Prozess ist fertig, nicht nur HTTP.
|
||||
defer os.Remove(pidPath)
|
||||
}
|
||||
|
||||
srv := &http.Server{Handler: handler, ReadHeaderTimeout: 5 * time.Second}
|
||||
errCh := make(chan error, 1)
|
||||
go func() { errCh <- srv.Serve(ln) }()
|
||||
|
||||
corsLog := "zu (kein CORS)"
|
||||
if len(cfg.Server.AllowedOrigins) > 0 {
|
||||
corsLog = fmt.Sprintf("%v", cfg.Server.AllowedOrigins)
|
||||
}
|
||||
logger.Printf("listening on http://%s (version %s, pid %d, audio sink %s, mute=%v, rate-limit %s, high-burst=%v, ruhezeit=%q modus %s, cors=%s, mcp=/mcp)",
|
||||
addr, opts.Version, os.Getpid(), engine.SinkName(), mute.Muted(),
|
||||
cfg.Queue.RateLimit, cfg.Queue.AllowHighBurst, cfg.QuietHours, cfg.QuietMode, corsLog)
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
q.Close()
|
||||
return fmt.Errorf("http server: %w", err)
|
||||
case <-ctx.Done():
|
||||
logger.Printf("Signal empfangen — fahre herunter (laufende Ansage wird abgebrochen)")
|
||||
case <-shutdown:
|
||||
logger.Printf("Shutdown angefordert (talk daemon stop) — fahre herunter")
|
||||
}
|
||||
|
||||
// Reihenfolge: erst HTTP dicht (keine neuen Anfragen), dann die Queue
|
||||
// schließen (laufende Ansage abbrechen, Worker beenden und darauf
|
||||
// warten). So läuft nie eine Ansage nach dem Daemon-Ende weiter.
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
logger.Printf("http shutdown: %v", err)
|
||||
}
|
||||
q.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
// writePID schreibt die eigene Prozess-ID (best effort; ein Fehler ist nur
|
||||
// eine Protokollzeile — der Daemon funktioniert auch ohne PID-Datei).
|
||||
func writePID(path string, logger *log.Logger) {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
logger.Printf("warnung: PID-Datei: %v", err)
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(strconv.Itoa(os.Getpid())+"\n"), 0o644); err != nil {
|
||||
logger.Printf("warnung: PID-Datei: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ReadPID liest die PID-Datei; 0, wenn keine (lesbare) Datei existiert.
|
||||
func ReadPID(dir string) int {
|
||||
b, err := os.ReadFile(filepath.Join(dir, PIDFileName))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
pid, err := strconv.Atoi(strings.TrimSpace(string(b)))
|
||||
if err != nil || pid <= 0 {
|
||||
return 0
|
||||
}
|
||||
return pid
|
||||
}
|
||||
|
||||
// lineWriter reicht Zeilen an den Logger weiter (Zeitstempel + Präfix),
|
||||
// statt sie roh zu schreiben: Provider-Kette, Queue und null-Sink schreiben
|
||||
// zeilenweise, so entsteht ein einheitliches Protokoll.
|
||||
type lineWriter struct{ l *log.Logger }
|
||||
|
||||
func (w lineWriter) Write(p []byte) (int, error) {
|
||||
for _, line := range strings.Split(strings.TrimRight(string(p), "\n"), "\n") {
|
||||
if line != "" {
|
||||
w.l.Print(line)
|
||||
}
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// Spawn startet `exe daemon run` als eigenständigen Hintergrundprozess
|
||||
// (eigene Session bzw. eigene Prozessgruppe, losgelöst vom Terminal), mit
|
||||
// stdout/stderr in die Log-Datei logPath (pro Start neu angelegt; leer =
|
||||
// verwerfen). env ist die Umgebung des Kindes (Keys, TALK_*-Overrides).
|
||||
//
|
||||
// Rückgabe ist ein Kanal, der genau einmal das Wait-Ergebnis liefert, wenn
|
||||
// das Kind endet — der Aufrufer erkennt daran einen fehlgeschlagenen Start
|
||||
// (z. B. Port belegt), ohne auf einen Timeout warten zu müssen. Endet der
|
||||
// Aufrufer zuerst, läuft der Daemon weiter (das ist der Zweck).
|
||||
func Spawn(exe, logPath string, env []string) (<-chan error, error) {
|
||||
var logFile *os.File
|
||||
if logPath == "" {
|
||||
logPath = os.DevNull
|
||||
}
|
||||
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Daemon-Log %s: %w", logPath, err)
|
||||
}
|
||||
defer logFile.Close() // das Kind hält sein eigenes Handle
|
||||
|
||||
cmd := exec.Command(exe, "daemon", "run")
|
||||
cmd.Env = env
|
||||
cmd.Stdin = nil
|
||||
cmd.Stdout = logFile
|
||||
cmd.Stderr = logFile
|
||||
detach(cmd)
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, fmt.Errorf("Daemon starten (%s daemon run): %w", exe, err)
|
||||
}
|
||||
exited := make(chan error, 1)
|
||||
go func() { exited <- cmd.Wait() }()
|
||||
return exited, nil
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//go:build !unix && !windows
|
||||
|
||||
package daemon
|
||||
|
||||
import "os/exec"
|
||||
|
||||
// detach ist auf dieser Plattform ein No-op (kein Setsid, keine Konsole).
|
||||
func detach(*exec.Cmd) {}
|
||||
@@ -0,0 +1,14 @@
|
||||
//go:build unix
|
||||
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// detach löst das Kind vom Terminal: eigene Session (kein SIGHUP beim
|
||||
// Schließen des Terminals, kein Ctrl-C der Shell).
|
||||
func detach(cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//go:build windows
|
||||
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// detachedProcess ist das Win32-Flag DETACHED_PROCESS: kein Konsolenfenster,
|
||||
// keine Bindung an die Konsole des Aufrufers.
|
||||
const detachedProcess = 0x00000008
|
||||
|
||||
// detach löst das Kind von der Konsole: eigene Prozessgruppe (kein Ctrl-C
|
||||
// der Shell) und keine Konsole (kein aufblitzendes Fenster).
|
||||
func detach(cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP | detachedProcess,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
// Package speak ist der eine Sprech-Kern von talk: Er setzt Config, Audio-
|
||||
// Sink und Provider-Kette zu einer Ansage zusammen. Der Daemon ruft ihn aus
|
||||
// dem Queue-Worker, die CLI nur im Notfall ohne Daemon (direkt, ohne
|
||||
// Warteschlange) — beide über genau diesen Weg, damit es keine zweite
|
||||
// Zusammensetzung der Kette gibt, die auseinanderlaufen könnte.
|
||||
package speak
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"sync"
|
||||
|
||||
"code.m3ta.dev/m3tam3re/talk/internal/audio"
|
||||
"code.m3ta.dev/m3tam3re/talk/internal/config"
|
||||
"code.m3ta.dev/m3tam3re/talk/internal/langdetect"
|
||||
"code.m3ta.dev/m3tam3re/talk/internal/provider"
|
||||
)
|
||||
|
||||
// NoProvider ist der Provider-Name, den ActiveProvider meldet, wenn keine
|
||||
// Stufe der Kette verfügbar ist (Status-Anzeige).
|
||||
const NoProvider = "keine"
|
||||
|
||||
// Request ist eine Ansage: Text plus optionale Pins. Leere Pins heißen
|
||||
// „Default": Sprache wird erkannt, die Kette läuft in Config-Reihenfolge,
|
||||
// die Stimme kommt aus dem Voice-Mapping.
|
||||
type Request struct {
|
||||
Text string
|
||||
Lang string // de | en; leer = langdetect
|
||||
Provider string // Provider-Pin; leer = Config-Pin bzw. Default-Kette
|
||||
Voice string // Voice-ID-Pin; leer = Mapping/Defaults
|
||||
}
|
||||
|
||||
// Engine hält Sink und Konfiguration und baut pro Ansage die Kette (Pin und
|
||||
// Stimme sind Eigenschaften der einzelnen Anfrage). Sie ist nebenläufig
|
||||
// nutzbar; der Daemon spricht ohnehin seriell.
|
||||
type Engine struct {
|
||||
cfg config.Config
|
||||
sink audio.Sink
|
||||
getenv func(string) string
|
||||
log io.Writer
|
||||
base *provider.Chain // ungepinnte Kette: Verfügbarkeits-Auflösung für Status
|
||||
|
||||
mu sync.Mutex
|
||||
last string // zuletzt verwendeter Provider (Status im Leerlauf)
|
||||
}
|
||||
|
||||
// New baut den Sprech-Kern: Sink nach cfg.Audio.Sink, Basiskette nach
|
||||
// Config. getenv liefert Keys und Endpunkt-Overrides (nil = leer), log
|
||||
// empfängt Ketten- und Sink-Protokoll (nil = still).
|
||||
func New(cfg config.Config, getenv func(string) string, log io.Writer) (*Engine, error) {
|
||||
if getenv == nil {
|
||||
getenv = func(string) string { return "" }
|
||||
}
|
||||
sink, err := audio.NewSink(cfg.Audio.Sink, log)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
base, err := provider.NewChain(chainConfig(cfg, "", "", getenv, nil, log))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Engine{cfg: cfg, sink: sink, getenv: getenv, log: log, base: base}, nil
|
||||
}
|
||||
|
||||
// Speak spricht eine Anfrage: Sprache erkennen (falls nicht gepinnt), Kette
|
||||
// bauen, synthetisieren mit Fallback, über den Sink abspielen. Blockiert
|
||||
// bis zum Ende der Wiedergabe oder bis ctx abgebrochen wird.
|
||||
func (e *Engine) Speak(ctx context.Context, req Request) error {
|
||||
lang := req.Lang
|
||||
if lang == "" {
|
||||
lang = langdetect.Detect(req.Text)
|
||||
}
|
||||
e.setLast(e.ActiveProvider(req.Provider))
|
||||
chain, err := provider.NewChain(chainConfig(e.cfg, req.Provider, req.Voice, e.getenv, e.sink, e.log))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return chain.Speak(ctx, req.Text, lang)
|
||||
}
|
||||
|
||||
// ActiveProvider löst den „aktiven Provider" für eine Anfrage auf: der Pin,
|
||||
// sonst die erste verfügbare Stufe der Basiskette (Available ist billig —
|
||||
// Key-/Binary-Prüfung ohne Netz), sonst NoProvider.
|
||||
func (e *Engine) ActiveProvider(pin string) string {
|
||||
if pin != "" {
|
||||
return pin
|
||||
}
|
||||
for _, p := range e.base.Tiers() {
|
||||
if p.Available(context.Background()) {
|
||||
return p.Name()
|
||||
}
|
||||
}
|
||||
return NoProvider
|
||||
}
|
||||
|
||||
// LastProvider liefert den Provider der zuletzt gestarteten Ansage; leer,
|
||||
// wenn noch nichts gesprochen wurde.
|
||||
func (e *Engine) LastProvider() string {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
return e.last
|
||||
}
|
||||
|
||||
// SinkName liefert den Namen des Audio-Sinks (oto | null).
|
||||
func (e *Engine) SinkName() string { return e.sink.Name() }
|
||||
|
||||
func (e *Engine) setLast(name string) {
|
||||
e.mu.Lock()
|
||||
e.last = name
|
||||
e.mu.Unlock()
|
||||
}
|
||||
|
||||
// chainConfig überführt Config + Anfrage-Pins in die ChainConfig: Anfrage-
|
||||
// Pin schlägt den Config-Pin `provider = "…"`; Endpunkte und das OpenAI-
|
||||
// Modell kommen aus [providers.<name>].
|
||||
func chainConfig(cfg config.Config, pin, voice string, getenv func(string) string, player provider.Player, log io.Writer) provider.ChainConfig {
|
||||
if pin == "" {
|
||||
pin = cfg.Provider
|
||||
}
|
||||
endpoints := make(map[string]string, len(cfg.Providers))
|
||||
for name, settings := range cfg.Providers {
|
||||
if settings.Endpoint != "" {
|
||||
endpoints[name] = settings.Endpoint
|
||||
}
|
||||
}
|
||||
return provider.ChainConfig{
|
||||
Pin: pin,
|
||||
Voice: voice,
|
||||
Voices: cfg.Voice,
|
||||
Endpoints: endpoints,
|
||||
Model: cfg.Providers["openai"].Model,
|
||||
Getenv: getenv,
|
||||
Player: player,
|
||||
Log: log,
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user