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:
m3ta-chiron committed 2026-10-18 16:34:43 +02:00
1 parent 88eddc63d2
commit 91cd0ec8da
15 files changed
+2434 -27

No files matched your search

+38 -18
View File
@@ -8,9 +8,9 @@ import (
"strings"
)
// DefaultName ist der Provider, den die Kette nutzt, wenn kein --provider
// gepinnt ist. Task 02: osbuiltin; Task 03 ersetzt den Default durch die
// Fallback-Kette (ElevenLabs → OpenAI → Edge → osbuiltin).
// DefaultName ist der Provider, den New("") liefert (Einzelkonstruktion,
// z. B. für doctor). Die Default-Ansage-Kette ab Task 03 steht in
// DefaultOrder (chain.go): elevenlabs → openai → edge → osbuiltin.
const DefaultName = "osbuiltin"
// ErrNoByteOutput meldet, dass die Plattform keine über Synthesize
@@ -24,15 +24,16 @@ var ErrNoByteOutput = errors.New(
// Provider ist der Vertrag aller TTS-Backends von talk.
//
// Synthesize liefert dekodierbare Audio-Bytes; das Format ist von der
// Implementierung dokumentiert (osbuiltin: RIFF/WAVE mit PCM — von allen
// Sinks der Aufgabe abspielbar; Cloud-Provider ab Task 03: mp3).
// Implementierung dokumentiert: osbuiltin liefert RIFF/WAVE (PCM), die
// Cloud-Provider elevenlabs, openai und edge liefern MP3 — beides wird vom
// audio-Paket (oto-Sink) verstanden.
type Provider interface {
// Name liefert den eindeutigen Providernamen (CLI --provider, Config).
Name() string
// Available meldet, ob der Provider auf diesem System überhaupt nutzbar
// ist (z. B. Binary oder Key vorhanden). Muss billig und ohne Neben-
// wirkungen sein; false heißt: Kette soll übersprungen werden.
// wirkungen sein; false heißt: Kette soll überspringen werden.
Available(ctx context.Context) bool
// Synthesize erzeugt Audio-Bytes für text in der Sprache lang.
@@ -46,8 +47,9 @@ type Provider interface {
// Audio-Bytes an Prozesse liefern kann — dort spielt osbuiltin.SpeakDirect
// über spd-say selbst ab (blockierend bis zum Ende der Ansage).
//
// Task 03+ muss diese Erweiterung beachten: In der Kette ist SpeakDirect
// die letzte Stufe, wenn kein Provider Bytes liefern kann.
// In der Kette (chain.go) ist SpeakDirect die letzte Stufe, wenn kein
// Provider Bytes liefern kann; Chain implementiert DirectSpeaker selbst und
// reicht an den Stufen-Provider durch.
type DirectSpeaker interface {
// SpeakDirect gibt text in der Sprache lang direkt über das OS-TTS aus
// und blockiert bis zum Ende der Wiedergabe (oder bis ctx abgebrochen
@@ -55,10 +57,25 @@ type DirectSpeaker interface {
SpeakDirect(ctx context.Context, text, lang string) error
}
// registry bildet Providernamen auf Konstruktoren; Task 03 ergänzt hier
// elevenlabs, openai und edge.
var registry = map[string]func() Provider{
"osbuiltin": func() Provider { return OSBuiltin{} },
// VoicePinner ist eine dokumentierte optionale Erweiterung über das
// Provider-Interface hinaus: Provider, die eine explizite Voice-ID pinnen
// können (das CLI-Flag --voice). Die gepinnte Stimme schlägt das
// voice.<provider>.<lang>-Mapping der config.toml und die eingebauten
// neutralen Defaults. Die Kette ruft PinVoice beim Bau auf alle Stufen auf,
// die die Erweiterung implementieren (Stufen ohne Voice-Begriff, z. B.
// osbuiltin, loggen einen Hinweis statt still zu ignorieren).
type VoicePinner interface {
PinVoice(id string)
}
// registry bildet Providernamen auf Konstruktoren; NewChain und New bauen
// darüber. Die Konstruktoren erhalten die ChainConfig (Endpunkte, Voices,
// Keys via Getenv), damit Kette und Daemon (Task 04) dieselbe Quelle haben.
var registry = map[string]func(ChainConfig) Provider{
"elevenlabs": func(cfg ChainConfig) Provider { return NewElevenLabs(cfg) },
"openai": func(cfg ChainConfig) Provider { return NewOpenAI(cfg) },
"edge": func(cfg ChainConfig) Provider { return NewEdge(cfg) },
"osbuiltin": func(ChainConfig) Provider { return OSBuiltin{} },
}
// Names liefert die registrierten Providernamen (sortiert).
@@ -71,17 +88,20 @@ func Names() []string {
return names
}
// New liefert den Provider mit dem angegebenen Namen; "" wählt den Default.
// Unbekannte (oder in diesem Stand noch nicht implementierte) Namen sind
// ein klarer Fehler.
func New(name string) (Provider, error) {
// New liefert den Provider mit dem angegebenen Namen (Einzelkonstruktion);
// "" wählt DefaultName. Für die Ansage-Kette (Fallback) NewChain verwenden.
// Unbekannte Namen sind ein klarer Fehler.
func New(name string, cfg ChainConfig) (Provider, error) {
if name == "" {
name = DefaultName
}
ctor, ok := registry[name]
if !ok {
return nil, fmt.Errorf("unbekannter oder noch nicht implementierter Provider %q (in diesem Stand verfügbar: %s)",
return nil, fmt.Errorf("unbekannter oder noch nicht implementierter Provider %q (verfügbar: %s)",
name, strings.Join(Names(), ", "))
}
return ctor(), nil
if cfg.Getenv == nil {
cfg.Getenv = func(string) string { return "" }
}
return ctor(cfg), nil
}