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..-Mapping aus der config.toml. Voices map[string]map[string]string // Endpoints sind die Datei-Endpunkte [providers.].endpoint; // TALK__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__ENDPOINT (Env) > [providers.].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, r.noTierErr() } // 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 in EINEM Ketten-Durchlauf: 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). Der Durchlauf findet bewusst genau // einmal statt: fehlgeschlagene Cloud-Stufen werden nicht ein zweites Mal // angefragt (keine doppelten POSTs auf bezahlten APIs, keine doppelten // Protokoll-Zeilen), und Bytes aus diesem Durchlauf werden abgespielt, // statt sie wegzuwerfen und neu zu laufen. Nur ein Fehler aller Stufen // oder eine fehlgeschlagene Wiedergabe sind Fehler. func (c *Chain) Speak(ctx context.Context, text, lang string) error { r := c.walk(ctx, text, lang) switch { case r.data != 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, r.data); err != nil { return fmt.Errorf("kette: Wiedergabe über %s fehlgeschlagen: %w", c.cfg.Player.Name(), err) } return nil case r.direct != nil: if c.cfg.Player != nil && c.cfg.Player.Name() == NullPlayerName { chainLogf(c.cfg, "kette: Direktansage verworfen (audio.sink=null)\n") return nil } return r.direct.SpeakDirect(ctx, text, lang) default: return r.noTierErr() } } // noTierErr bündelt den Ende-ohne-Ergebnis-Fehler eines Durchlaufs: keine // Stufe lieferte Bytes oder Direktansage — übersprungene (nicht verfügbar) // und fehlgeschlagene Stufen werden genannt, nie still. func (r walkResult) noTierErr() error { return fmt.Errorf("kette: keine Stufe konnte sprechen (nicht verfügbar: %s; fehlgeschlagen: %s)", joinOr(r.skipped, "keine"), joinOr(r.failed, "keine")) } // 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, "; ") }