269 lines
10 KiB
Go
269 lines
10 KiB
Go
package provider
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"strings"
|
||
|
|
)
|
||
|
|
|
||
|
|
// DefaultOrder ist die bindende Default-Fallback-Reihenfolge der Kette
|
||
|
|
// (Spec: ElevenLabs → OpenAI TTS → Edge-TTS → OS built-in).
|
||
|
|
var DefaultOrder = []string{"elevenlabs", "openai", "edge", "osbuiltin"}
|
||
|
|
|
||
|
|
// NullPlayerName ist der dokumentierte Name des verwerfenden Sinks
|
||
|
|
// (Spec: audio.sink = "oto" | "null"). Die Kette braucht ihn für die
|
||
|
|
// Entscheidung, ob eine Direktansage (DirectSpeaker) verworfen werden
|
||
|
|
// muss — der null-Sink darf niemals Ton erzeugen.
|
||
|
|
const NullPlayerName = "null"
|
||
|
|
|
||
|
|
// Player spielt Audio-Bytes ab, die die Kette geliefert hat. Der Vertrag
|
||
|
|
// ist absichtlich strukturell identisch zu audio.Sink (Play + Name): CLI
|
||
|
|
// und Daemon (Task 04) übergeben das Ergebnis von audio.NewSink(name, log)
|
||
|
|
// unverändert — dadurch bleibt die Kette ohne Import des audio-Pakets
|
||
|
|
// testbar und wiederverwendbar.
|
||
|
|
type Player interface {
|
||
|
|
Play(ctx context.Context, data []byte) error
|
||
|
|
Name() string
|
||
|
|
}
|
||
|
|
|
||
|
|
// ChainConfig ist alles, was die Kette zum Bau braucht. CLI (Task 03) und
|
||
|
|
// Daemon (Task 04) füllen sie aus Config + Flags; die Kette selbst liest
|
||
|
|
// keine Dateien und keine Prozess-Env — Getenv ist die injizierbare Seam
|
||
|
|
// (Tests und Daemon kommen so ohne echte Keys aus).
|
||
|
|
type ChainConfig struct {
|
||
|
|
// Pin reduziert die Kette auf genau einen Provider (CLI-Flag --provider
|
||
|
|
// schlägt config `provider = "…"`). Leer = DefaultOrder.
|
||
|
|
Pin string
|
||
|
|
|
||
|
|
// Voice ist eine explizite Voice-ID (CLI-Flag --voice); sie wird über
|
||
|
|
// VoicePinner an alle Stufen gepinnt und schlägt Mapping + Defaults.
|
||
|
|
Voice string
|
||
|
|
|
||
|
|
// Voices ist das voice.<provider>.<lang>-Mapping aus der config.toml.
|
||
|
|
Voices map[string]map[string]string
|
||
|
|
|
||
|
|
// Endpoints sind die Datei-Endpunkte [providers.<name>].endpoint;
|
||
|
|
// TALK_<NAME>_ENDPOINT (via Getenv) schlägt sie, sie schlägt die
|
||
|
|
// eingebauten Defaults (siehe endpointFrom).
|
||
|
|
Endpoints map[string]string
|
||
|
|
|
||
|
|
// Model ist [providers.openai].model (leer = "tts-1").
|
||
|
|
Model string
|
||
|
|
|
||
|
|
// Getenv liefert Umgebungsvariablen (Keys, Endpunkt-Overrides). Nil
|
||
|
|
// wird beim Bau durch eine Funktion ersetzt, die immer "" liefert.
|
||
|
|
Getenv func(string) string
|
||
|
|
|
||
|
|
// Player gibt erfolgreiche Synthesen aus; nil = die Kette liefert nur
|
||
|
|
// Bytes (Synthesize) und Speak meldet einen klaren Fehler.
|
||
|
|
Player Player
|
||
|
|
|
||
|
|
// Log empfängt die Fallback-Protokoll-Zeilen (CLI: stderr). Fallbacks
|
||
|
|
// sind nie still: Jede übersprungene oder fehlgeschlagene Stufe wird
|
||
|
|
// hier gemeldet — auch wenn am Ende noch eine Stufe Erfolg hat.
|
||
|
|
Log io.Writer
|
||
|
|
}
|
||
|
|
|
||
|
|
// endpointFrom löst einen Provider-Endpunkt auf:
|
||
|
|
// TALK_<NAME>_ENDPOINT (Env) > [providers.<name>].endpoint (Datei) >
|
||
|
|
// eingebauter Default. Env-Overrides in der Provider-Schicht zu lesen hält
|
||
|
|
// das config-Paket frei von Provider-Namen und erlaubt Prozessgrenzen-
|
||
|
|
// Tests ohne Config-Datei.
|
||
|
|
func endpointFrom(cfg ChainConfig, name, envName, def string) string {
|
||
|
|
if cfg.Getenv != nil {
|
||
|
|
if v := cfg.Getenv(envName); v != "" {
|
||
|
|
return v
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if v := cfg.Endpoints[name]; v != "" {
|
||
|
|
return v
|
||
|
|
}
|
||
|
|
return def
|
||
|
|
}
|
||
|
|
|
||
|
|
// Chain ist die geordnete Fallback-Kette über Provider. Sie implementiert
|
||
|
|
// selbst Provider (Synthesize liefert die Bytes der ersten erfolgreichen
|
||
|
|
// Stufe) UND DirectSpeaker (letzter Stufen-Ausweg für Plattformen ohne
|
||
|
|
// Byte-fähiges TTS) — damit ist sie für CLI und Daemon (Task 04)
|
||
|
|
// austauschbar gegen jeden einzelnen Provider.
|
||
|
|
//
|
||
|
|
// Speak ist die komplette Ansage-Orchestrierung: Bytes-Synthese mit
|
||
|
|
// Fallback, Wiedergabe über den Player, und als letzte Stufe die
|
||
|
|
// Direktansage (osbuiltin über spd-say). Bei einem null-Player wird die
|
||
|
|
// Direktansage tonlos verworfen (CI/Server) statt Ton zu erzeugen.
|
||
|
|
type Chain struct {
|
||
|
|
cfg ChainConfig
|
||
|
|
tiers []Provider
|
||
|
|
}
|
||
|
|
|
||
|
|
// NewChain baut die Kette: gepinnt (Pin) oder in DefaultOrder. Unbekannte
|
||
|
|
// Namen sind ein klarer Fehler, der die verfügbaren Namen nennt. Das
|
||
|
|
// --voice-Pin wird auf alle Stufen angewandt, die VoicePinner implementieren
|
||
|
|
// (Stufen ohne Voice-Begriff bekommen einen protokollierten Hinweis).
|
||
|
|
func NewChain(cfg ChainConfig) (*Chain, error) {
|
||
|
|
if cfg.Getenv == nil {
|
||
|
|
cfg.Getenv = func(string) string { return "" }
|
||
|
|
}
|
||
|
|
names := DefaultOrder
|
||
|
|
if cfg.Pin != "" {
|
||
|
|
names = []string{cfg.Pin}
|
||
|
|
}
|
||
|
|
tiers := make([]Provider, 0, len(names))
|
||
|
|
for _, name := range names {
|
||
|
|
p, err := New(name, cfg)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
if cfg.Voice != "" {
|
||
|
|
if vp, ok := p.(VoicePinner); ok {
|
||
|
|
vp.PinVoice(cfg.Voice)
|
||
|
|
} else {
|
||
|
|
chainLogf(cfg, "kette: --voice für Stufe %s ignoriert (kennt keine Voice-IDs)\n", name)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
tiers = append(tiers, p)
|
||
|
|
}
|
||
|
|
return &Chain{cfg: cfg, tiers: tiers}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// Name implementiert Provider.
|
||
|
|
func (*Chain) Name() string { return "chain" }
|
||
|
|
|
||
|
|
// Tiers liefert die Stufen in Ketten-Reihenfolge (Diagnose/doctor).
|
||
|
|
func (c *Chain) Tiers() []Provider { return c.tiers }
|
||
|
|
|
||
|
|
// Available implementiert Provider: true, sobald eine Stunde verfügbar ist.
|
||
|
|
func (c *Chain) Available(ctx context.Context) bool {
|
||
|
|
for _, p := range c.tiers {
|
||
|
|
if p.Available(ctx) {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
// walkResult ist das interne Ergebnis des Ketten-Durchlaufs.
|
||
|
|
type walkResult struct {
|
||
|
|
data []byte // Bytes der ersten erfolgreichen Stufe (nil = keine)
|
||
|
|
used Provider // die erfolgreiche Stufe
|
||
|
|
direct DirectSpeaker // erste Stufe, die nur direkt sprechen kann
|
||
|
|
directName string
|
||
|
|
failed []string // "name: fehler" je fehlgeschlagener Stufe
|
||
|
|
skipped []string // Namen nicht verfügbarer Stufen
|
||
|
|
}
|
||
|
|
|
||
|
|
// walk läuft die Kette in Ordnung ab: nicht verfügbare Stufen werden
|
||
|
|
// übersprungen (protokolliert), fehlgeschlagene protokolliert und
|
||
|
|
// überwunden. Eine Stufe mit ErrNoByteOutput, die DirectSpeaker
|
||
|
|
// implementiert, wird als letzter Ausweg gemerkt (der Durchlauf geht
|
||
|
|
// weiter — eine spätere Stufe könnte noch Bytes liefern).
|
||
|
|
func (c *Chain) walk(ctx context.Context, text, lang string) walkResult {
|
||
|
|
var r walkResult
|
||
|
|
for _, p := range c.tiers {
|
||
|
|
if !p.Available(ctx) {
|
||
|
|
r.skipped = append(r.skipped, p.Name())
|
||
|
|
chainLogf(c.cfg, "kette: Stufe %s übersprungen (nicht verfügbar)\n", p.Name())
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
data, err := p.Synthesize(ctx, text, lang)
|
||
|
|
switch {
|
||
|
|
case err == nil:
|
||
|
|
r.data, r.used = data, p
|
||
|
|
chainLogf(c.cfg, "kette: Ansage über %s\n", p.Name())
|
||
|
|
return r
|
||
|
|
case errors.Is(err, ErrNoByteOutput) && r.direct == nil:
|
||
|
|
if ds, ok := p.(DirectSpeaker); ok {
|
||
|
|
r.direct, r.directName = ds, p.Name()
|
||
|
|
chainLogf(c.cfg, "kette: Stufe %s liefert keine Bytes, kann aber direkt sprechen (als letzte Stufe gemerkt)\n", p.Name())
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
r.failed = append(r.failed, p.Name()+": "+err.Error())
|
||
|
|
chainLogf(c.cfg, "kette: Stufe %s fehlgeschlagen: %v — Fallback auf nächste Stufe\n", p.Name(), err)
|
||
|
|
default:
|
||
|
|
r.failed = append(r.failed, p.Name()+": "+err.Error())
|
||
|
|
chainLogf(c.cfg, "kette: Stufe %s fehlgeschlagen: %v — Fallback auf nächste Stufe\n", p.Name(), err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return r
|
||
|
|
}
|
||
|
|
|
||
|
|
// Synthesize implementiert Provider: Bytes der ersten erfolgreichen Stufe.
|
||
|
|
// Kann nur eine Direkt-Stufe sprechen, ist der Fehler ErrNoByteOutput (die
|
||
|
|
// Kette erfüllt damit den DirectSpeaker-Vertrag am Aufrufer). Scheitert
|
||
|
|
// alles, nennt der Fehler die letzte Stufen-Fehler — nie still.
|
||
|
|
func (c *Chain) Synthesize(ctx context.Context, text, lang string) ([]byte, error) {
|
||
|
|
r := c.walk(ctx, text, lang)
|
||
|
|
if r.data != nil {
|
||
|
|
return r.data, nil
|
||
|
|
}
|
||
|
|
if r.direct != nil {
|
||
|
|
if len(r.failed) > 0 {
|
||
|
|
return nil, fmt.Errorf("%w (Stufe %s spricht nur direkt; daneben fehlgeschlagen: %s)",
|
||
|
|
ErrNoByteOutput, r.directName, strings.Join(r.failed, "; "))
|
||
|
|
}
|
||
|
|
return nil, fmt.Errorf("%w (Stufe %s spricht nur direkt)", ErrNoByteOutput, r.directName)
|
||
|
|
}
|
||
|
|
return nil, fmt.Errorf("kette: keine Stufe konnte sprechen (nicht verfügbar: %s; fehlgeschlagen: %s)",
|
||
|
|
joinOr(r.skipped, "keine"), joinOr(r.failed, "keine"))
|
||
|
|
}
|
||
|
|
|
||
|
|
// SpeakDirect implementiert DirectSpeaker: Direktansage über die Stufe,
|
||
|
|
// die nur direkt sprechen kann (typischerweise osbuiltin/spd-say auf Linux
|
||
|
|
// ohne espeak). Gibt es eine Byte-fähige Stufe, ist SpeakDirect ein klarer
|
||
|
|
// Fehler (der Aufrufer soll Synthesize + Player nutzen).
|
||
|
|
func (c *Chain) SpeakDirect(ctx context.Context, text, lang string) error {
|
||
|
|
r := c.walk(ctx, text, lang)
|
||
|
|
if r.direct != nil {
|
||
|
|
return r.direct.SpeakDirect(ctx, text, lang)
|
||
|
|
}
|
||
|
|
if r.data != nil {
|
||
|
|
return fmt.Errorf("kette: Stufe %s liefert Bytes — über Synthesize und den Player abspielen, nicht direkt", r.used.Name())
|
||
|
|
}
|
||
|
|
return fmt.Errorf("kette: keine Stufe kann direkt sprechen (nicht verfügbar: %s; fehlgeschlagen: %s)",
|
||
|
|
joinOr(r.skipped, "keine"), joinOr(r.failed, "keine"))
|
||
|
|
}
|
||
|
|
|
||
|
|
// Speak ist die komplette Ansage: Synthese mit Fallback → Wiedergabe über
|
||
|
|
// den Player; fällt die Kette auf die Direkt-Stufe zurück, entscheidet der
|
||
|
|
// Player-Name über Ton (alles außer „null") oder tonloses Verwerfen (null:
|
||
|
|
// CI/Server bleiben fehler- und tonfrei, der Verwurf wird protokolliert).
|
||
|
|
// Nur ein Fehler aller Stufen oder eine fehlgeschlagene Wiedergabe sind
|
||
|
|
// Fehler.
|
||
|
|
func (c *Chain) Speak(ctx context.Context, text, lang string) error {
|
||
|
|
data, err := c.Synthesize(ctx, text, lang)
|
||
|
|
switch {
|
||
|
|
case err == nil:
|
||
|
|
if c.cfg.Player == nil {
|
||
|
|
return fmt.Errorf("kette: kein Player konfiguriert — Bytes können nicht abgespielt werden")
|
||
|
|
}
|
||
|
|
if err := c.cfg.Player.Play(ctx, data); err != nil {
|
||
|
|
return fmt.Errorf("kette: Wiedergabe über %s fehlgeschlagen: %w", c.cfg.Player.Name(), err)
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
case errors.Is(err, ErrNoByteOutput):
|
||
|
|
if c.cfg.Player != nil && c.cfg.Player.Name() == NullPlayerName {
|
||
|
|
chainLogf(c.cfg, "kette: Direktansage verworfen (audio.sink=null)\n")
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
return c.SpeakDirect(ctx, text, lang)
|
||
|
|
default:
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// chainLogf schreibt eine Protokoll-Zeile, wenn ein Log gesetzt ist.
|
||
|
|
func chainLogf(cfg ChainConfig, format string, args ...any) {
|
||
|
|
if cfg.Log != nil {
|
||
|
|
fmt.Fprintf(cfg.Log, format, args...)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// joinOr verbindet Einträge mit sep; ist die Liste leer, kommt fallback.
|
||
|
|
func joinOr(items []string, fallback string) string {
|
||
|
|
if len(items) == 0 {
|
||
|
|
return fallback
|
||
|
|
}
|
||
|
|
return strings.Join(items, "; ")
|
||
|
|
}
|