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
@@ -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