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>
308 lines
12 KiB
Go
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, "; ")
|
|
}
|