Files
talk/internal/provider/chain.go
T
m3ta-chironandClaude Fable 5.1 df9a9b30cf feat(cli): doctor, test, voices, setup — Einrichtung und Diagnose ohne TUI
Usability-Schicht für Nutzer, die kein Terminal-Handbuch lesen wollen:

- `talk doctor`: eine Zeile je Prüfpunkt mit klarem Satz, was Sache ist und
  was zu tun wäre — Config (kaputt → ✘, die übrigen Prüfungen laufen mit
  Defaults weiter), Daemon (läuft / startet beim nächsten Sprechbefehl /
  fremder Dienst auf dem Port → ✘ mit Port-Hinweis), Audio (oto-Kontext
  wirklich initialisieren; null → ⚠), jede Provider-Stufe (Key-Env-Name,
  Endpunkt, Default-Stimme; OS-Stimme nennt den gefundenen Mechanismus),
  Mute (an → ⚠ mit unmute-Hinweis), Gesetze inkl. gerade aktiver Ruhezeit.
  --probe macht echte Synthese-Proben, --json liefert die Liste. Exit 0
  ohne ✘. Windows-Konsolen bekommen [OK]/[WARN]/[FAIL].
- `talk test`: Testsatz direkt über den Sprech-Kern (nicht über den
  Daemon), nennt die Stufe, die wirklich gesprochen hat, Synthese- und
  Wiedergabezeit; Mute/Ruhezeit gelten bewusst nicht, Mute wird genannt.
- `talk voices`: kuratierter Katalog mit Namen (ElevenLabs-Premade, OpenAI,
  Edge-Neural de/en-Locales) als Tabelle, „*“ = effektive Default-Stimme
  (Config vor Repo-Default), Config-IDs außerhalb des Katalogs als eigene
  Zeile, Filter --provider/--lang, --json.
- `talk setup`: wenige Fragen statt Bubble Tea — Keys nur als Hinweis
  (ohne Key läuft die Edge-Stimme), Stimme je Sprache aus dem Katalog mit
  Probehören, Ruhezeit; schreibt die config.toml zusammengeführt mit
  bestehenden Werten (.bak), spricht einen Testsatz. q bricht ab, bevor
  etwas geschrieben wird; ohne Terminal keine Fragen, nur die wirksame
  Konfiguration.

Unterbau: provider.Catalog/VoiceName/DefaultVoice, Chain.SpeakOutcome
(Stufe, Bytes, Zeiten) durch speak.Engine bis in Daemon-Log und test,
audio.Check (oto-Initialisierung ohne Ton), config.Path,
OSBuiltin.Describe. README: Abschnitt „Erste Schritte“.

Tests: voices-Zeilen (Default-Markierung, Config-Override, Filter, JSON),
doctor (ohne Daemon, Mute/Key via JSON, fremder Dienst → Exit 1, kaputte
Config → ✘ aber vollständiger Bericht), test (fake espeak), setup
(stdin-Antworten → config.toml-Inhalt, Merge mit .bak, Abbruch schreibt
nichts, Pipe stellt keine Fragen), Prozessgrenze: doctor/--probe/voices/
test gegen echten Daemon.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-24 10:08:44 +02:00

308 lines
12 KiB
Go

package provider
import (
"context"
"errors"
"fmt"
"io"
"strings"
"time"
)
// 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, 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 {
_, err := c.SpeakOutcome(ctx, text, lang)
return err
}
// Outcome beschreibt eine gelungene Ansage: welche Stufe gesprochen hat,
// wie lange Synthese (Ketten-Durchlauf bis zu den Bytes) und Wiedergabe
// gedauert haben, und ob es eine Direktansage war (keine Bytes, Provider
// spielt selbst — dann ist Synthesis die Gesamtzeit und Playback 0).
// Discarded meldet die tonlose Verwerfung einer Direktansage am null-Sink.
type Outcome struct {
Provider string
Direct bool
Discarded bool
Bytes int
Synthesis time.Duration
Playback time.Duration
}
// SpeakOutcome ist Speak mit Bericht (Diagnose: `talk test`, Daemon-Log).
func (c *Chain) SpeakOutcome(ctx context.Context, text, lang string) (Outcome, error) {
start := time.Now()
r := c.walk(ctx, text, lang)
synth := time.Since(start)
switch {
case r.data != nil:
if c.cfg.Player == nil {
return Outcome{}, fmt.Errorf("kette: kein Player konfiguriert — Bytes können nicht abgespielt werden")
}
playStart := time.Now()
if err := c.cfg.Player.Play(ctx, r.data); err != nil {
return Outcome{}, fmt.Errorf("kette: Wiedergabe über %s fehlgeschlagen: %w", c.cfg.Player.Name(), err)
}
return Outcome{Provider: r.used.Name(), Bytes: len(r.data), Synthesis: synth, Playback: time.Since(playStart)}, 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 Outcome{Provider: r.directName, Direct: true, Discarded: true, Synthesis: synth}, nil
}
if err := r.direct.SpeakDirect(ctx, text, lang); err != nil {
return Outcome{}, err
}
return Outcome{Provider: r.directName, Direct: true, Synthesis: time.Since(start)}, nil
default:
return Outcome{}, 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, "; ")
}