Files
talk/internal/speak/engine.go
T

144 lines
4.7 KiB
Go
Raw Normal View History

// 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. Das Outcome
// nennt die Stufe, die wirklich gesprochen hat, und die Zeiten.
func (e *Engine) Speak(ctx context.Context, req Request) (provider.Outcome, error) {
lang := req.Lang
if lang == "" {
lang = langdetect.Detect(req.Text)
}
chain, err := provider.NewChain(chainConfig(e.cfg, req.Provider, req.Voice, e.getenv, e.sink, e.log))
if err != nil {
return provider.Outcome{}, err
}
out, err := chain.SpeakOutcome(ctx, req.Text, lang)
if err == nil {
e.setLast(out.Provider)
}
return out, err
}
// Chain liefert die ungepinnte Basiskette (Diagnose: Stufen und ihre
// Verfügbarkeit).
func (e *Engine) Chain() *provider.Chain { return e.base }
// 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,
}
}