feat(provider): Cloud-Provider und Vier-Stufen-Fallback-Kette
- elevenlabs: POST /v1/text-to-speech/{voice}, Header xi-api-key, Body mit
model_id eleven_multilingual_v2 und voice_settings 0.5/0.75, MP3-Bytes.
- openai: POST /v1/audio/speech, Bearer-Key, Body {model, voice, input,
response_format:mp3}; Modell konfigurierbar (Default tts-1).
- edge: Microsofts Read-Aloud-WSS-Protokoll nach edge-tts-Referenz,
inklusive aktuellem DRM Sec-MS-GEC v2 (CS64 + verschleierte SHA-256-
Variante, wörtlich portiert und gegen Referenzvektoren getestet) und
MUID-Cookie; bei 403 Uhrenkorrektur aus dem Date-Header + ein Retry.
Klare Protokoll-geaendert-Fehler statt stiller Fehlfunktion.
- chain: geordnete Kette (elevenlabs -> openai -> edge -> osbuiltin), Pin
per --provider/config, Available ohne Netz-Calls, Fallback mit
Protokoll je Stufe (nie still). Chain implementiert Provider UND
DirectSpeaker: Speak orchestriert Bytes -> Player bzw. Direktansage,
verwirft am null-Player tonlos — CLI und Daemon (Task 04) nutzen
dieselbe Kette unverändert.
- voices: neutrale Repo-Defaults je Provider+Sprache (Rachel/alloy/
Katja/Aria); Rangfolge --voice > config-Mapping > Default. KXxZ… bleibt
bewusst Privat-Default der eigenen config.toml (Spec: nicht
Repo-Default).
Tests: Cloud-Provider gegen httptest-Fakes (200/500/401, Request-Vertrag
wörtlich), Edge über Interface + Fehlerpfad-Mocks + Loopback-WSS-Fake
(kein echtes Netz) + als testing.Short überspringbare Integration-
Markierung (TALK_TEST_REAL_EDGE=1, am Dev-Host einmalig ausgeführt:
17136 Bytes echtes Audio). Kette: erster 500 -> zweiter getroffen -> Bytes;
Pin, alle-Stufen-tot, Direktansage mit/ohne null-Player.
This commit is contained in:
1 parent
88eddc63d2
commit
91cd0ec8da
15 files changed
+2434
-27
No files matched your search
@@ -0,0 +1,269 @@
|
||||
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, "; ")
|
||||
}
|
||||
Reference in new issue
Block a user