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

+269
View File
@@ -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, "; ")
}
+342
View File
@@ -0,0 +1,342 @@
package provider
import (
"bytes"
"context"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
)
// fakePlayer zeichnet Play-Aufrufe auf (Name steuert die null-Entscheidung
// der Kette).
type fakePlayer struct {
name string
played [][]byte
}
func (f *fakePlayer) Play(_ context.Context, data []byte) error {
f.played = append(f.played, data)
return nil
}
func (f *fakePlayer) Name() string { return f.name }
// countingTTSServer antwortet mit status + body und zählt die Requests
// (atomic, der Server kann mehrere Stufen-Requests sehen).
func countingTTSServer(t *testing.T, status int, body []byte) (url string, hits *atomic.Int32) {
t.Helper()
var n atomic.Int32
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n.Add(1)
w.WriteHeader(status)
w.Write(body)
}))
t.Cleanup(ts.Close)
return ts.URL, &n
}
// chainEnv baut die Getenv-Seam für Tests mit gefakten Cloud-Endpunkten.
func chainEnv(kv map[string]string) func(string) string {
return func(k string) string { return kv[k] }
}
// fakeProcBin schreibt ein ausführbares Shell-Skript (gleicher Mechanismus
// wie in osbuiltin_test.go — echter Prozess, PATH-Manipulation).
func fakeProcBin(t *testing.T, dir, name, body string) {
t.Helper()
script := "#!/bin/sh\n" + body + "\n"
if err := os.WriteFile(filepath.Join(dir, name), []byte(script), 0o755); err != nil {
t.Fatal(err)
}
}
// cloudEnv liefert die Env-Map für eine Kette, deren elevenlabs-Stufe auf
// elevenURL und deren openai-Stufe auf openaiURL zeigt (edge tot, damit
// kein Test den echten Microsoft-Endpunkt anfasst).
func cloudEnv(elevenURL, openaiURL string, extra map[string]string) map[string]string {
kv := map[string]string{
EnvElevenLabsKey: "k",
EnvElevenLabsEndpoint: elevenURL,
EnvOpenAIKey: "k",
EnvOpenAIEndpoint: openaiURL,
EnvEdgeEndpoint: "ws://127.0.0.1:1/",
}
for k, v := range extra {
kv[k] = v
}
return kv
}
// TestChainFallbackErster500ZweiterTreffer (Abnahmekriterium aus dem
// Task-Brief): Stufe 1 (elevenlabs) antwortet 500 → die Kette fällt auf
// Stufe 2 (openai) durch, deren Bytes kommen zurück; der Fallback ist im
// Log sichtbar, nie still.
func TestChainFallbackErster500ZweiterTreffer(t *testing.T) {
eleven, elHits := countingTTSServer(t, 500, []byte("boom"))
openai, oaHits := countingTTSServer(t, 200, fakeMP3)
var log bytes.Buffer
ch, err := NewChain(ChainConfig{
Getenv: chainEnv(cloudEnv(eleven, openai, nil)),
Log: &log,
})
if err != nil {
t.Fatalf("NewChain: %v", err)
}
data, err := ch.Synthesize(context.Background(), "Hallo Welt", "de")
if err != nil {
t.Fatalf("Synthesize: %v", err)
}
if string(data) != string(fakeMP3) {
t.Errorf("Synthesize = %q, want Bytes der openai-Stufe", data)
}
if elHits.Load() != 1 || oaHits.Load() != 1 {
t.Errorf("Hits: elevenlabs = %d, openai = %d — want je 1 (500 → Fallback)", elHits.Load(), oaHits.Load())
}
for _, want := range []string{"elevenlabs", "500", "Fallback", "Ansage über openai"} {
if !strings.Contains(log.String(), want) {
t.Errorf("Log = %q, will %q", log.String(), want)
}
}
}
// TestChainAllesFehlt: beide Cloud-Stufen 500, edge tot, osbuiltin nicht
// verfügbar → Fehler nennt die fehlgeschlagenen Stufen; Log zeigt jeden.
func TestChainAllesFehlt(t *testing.T) {
eleven, _ := countingTTSServer(t, 500, []byte("el-boom"))
openai, _ := countingTTSServer(t, 500, []byte("oa-boom"))
// PATH leer: osbuiltin nicht verfügbar.
t.Setenv("PATH", t.TempDir())
var log bytes.Buffer
ch, err := NewChain(ChainConfig{
Getenv: chainEnv(cloudEnv(eleven, openai, nil)),
Log: &log,
})
if err != nil {
t.Fatalf("NewChain: %v", err)
}
_, err = ch.Synthesize(context.Background(), "Hallo", "de")
if err == nil {
t.Fatal("Synthesize mit allen Stufen kaputt: kein Fehler")
}
for _, want := range []string{"elevenlabs", "openai", "osbuiltin", "500"} {
if !strings.Contains(err.Error(), want) && !strings.Contains(log.String(), want) {
t.Errorf("Weder Fehler (%v) noch Log nennen %q", err, want)
}
}
}
// TestChainOhneKeysEdgeOsbuiltin (Abnahmekriterium): ohne jeden Key und
// mit totem edge wählt die Kette osbuiltin — mit gefaktem espeak kommen
// dessen Bytes zurück.
func TestChainOhneKeysEdgeOsbuiltin(t *testing.T) {
dir := t.TempDir()
fakeProcBin(t, dir, "espeak", `printf 'RIFFxxxxWAVEfmt '`)
t.Setenv("PATH", dir)
var log bytes.Buffer
ch, err := NewChain(ChainConfig{
Getenv: chainEnv(map[string]string{EnvEdgeEndpoint: "ws://127.0.0.1:1/"}),
Log: &log,
})
if err != nil {
t.Fatalf("NewChain: %v", err)
}
data, err := ch.Synthesize(context.Background(), "Hallo", "de")
if err != nil {
t.Fatalf("Synthesize: %v", err)
}
if !bytes.HasPrefix(data, []byte("RIFF")) {
t.Errorf("Synthesize = %q, want WAV-Bytes des fake espeak", data)
}
if !strings.Contains(log.String(), "übersprungen (nicht verfügbar)") {
t.Errorf("Log = %q, will Skip-Hinweise für die Key-Stufen", log.String())
}
}
// TestChainPin: --provider/config pinnen die Kette auf genau eine Stufe —
// andere Stufen werden nicht einmal angefragt.
func TestChainPin(t *testing.T) {
eleven, elHits := countingTTSServer(t, 200, fakeMP3)
openai, oaHits := countingTTSServer(t, 500, []byte("boom"))
ch, err := NewChain(ChainConfig{
Pin: "openai",
Getenv: chainEnv(cloudEnv(eleven, openai, nil)),
})
if err != nil {
t.Fatalf("NewChain: %v", err)
}
if _, err := ch.Synthesize(context.Background(), "Hallo", "de"); err == nil {
t.Fatal("Gepinnte kaputte Stufe soll fehlschlagen (kein Fallback bei Pin)")
}
if elHits.Load() != 0 || oaHits.Load() != 1 {
t.Errorf("Hits: elevenlabs = %d (want 0), openai = %d (want 1)", elHits.Load(), oaHits.Load())
}
}
// TestChainUnbekannterPin ist ein klarer Fehler mit Namensliste.
func TestChainUnbekannterPin(t *testing.T) {
if _, err := NewChain(ChainConfig{Pin: "gibtsnicht"}); err == nil || !strings.Contains(err.Error(), "elevenlabs") {
t.Errorf("NewChain-Fehler = %v, will verfügbare Namen nennen", err)
}
}
// TestChainSpeakBytesUeberPlayer: erfolgreiche Synthese wird über den
// Player abgespielt.
func TestChainSpeakBytesUeberPlayer(t *testing.T) {
eleven, _ := countingTTSServer(t, 200, fakeMP3)
player := &fakePlayer{name: "oto"}
ch, err := NewChain(ChainConfig{
Getenv: chainEnv(cloudEnv(eleven, eleven, nil)),
Player: player,
})
if err != nil {
t.Fatalf("NewChain: %v", err)
}
if err := ch.Speak(context.Background(), "Hallo", "de"); err != nil {
t.Fatalf("Speak: %v", err)
}
if len(player.played) != 1 || string(player.played[0]) != string(fakeMP3) {
t.Errorf("Player.Play = %x, want die Synthese-Bytes", player.played)
}
}
// TestChainDirektMitNullPlayer: fällt die Kette auf die Direkt-Stufe
// (osbuiltin ohne espeak), verwirft ein null-Player die Ansage tonlos und
// SUCCESS — spd-say wird nicht gerufen.
func TestChainDirektMitNullPlayer(t *testing.T) {
dir := t.TempDir()
seen := filepath.Join(dir, "seen.txt")
fakeProcBin(t, dir, "spd-say", `printf '%s\n' "$*" > "`+seen+`"`)
t.Setenv("PATH", dir) // kein espeak → keine Bytes, aber spd-say da
var log bytes.Buffer
ch, err := NewChain(ChainConfig{
Pin: "osbuiltin",
Getenv: chainEnv(nil),
Player: &fakePlayer{name: NullPlayerName},
Log: &log,
})
if err != nil {
t.Fatalf("NewChain: %v", err)
}
if err := ch.Speak(context.Background(), "Hallo Welt", "de"); err != nil {
t.Fatalf("Speak (null-Player): %v", err)
}
if _, err := os.Stat(seen); err == nil {
t.Error("spd-say wurde gerufen — null-Player hätte verwerfen müssen")
}
if !strings.Contains(log.String(), "verworfen") {
t.Errorf("Log = %q, will Verwurf-Zeile", log.String())
}
}
// TestChainDirektMitTonPlayer: mit echtem Player (Name ≠ null) läuft die
// Direktansage über SpeakDirect — blockierend (-w) mit Sprache und Text.
func TestChainDirektMitTonPlayer(t *testing.T) {
dir := t.TempDir()
seen := filepath.Join(dir, "seen.txt")
fakeProcBin(t, dir, "spd-say", `printf '%s\n' "$*" > "`+seen+`"`)
t.Setenv("PATH", dir)
player := &fakePlayer{name: "oto"}
ch, err := NewChain(ChainConfig{
Pin: "osbuiltin",
Getenv: chainEnv(nil),
Player: player,
})
if err != nil {
t.Fatalf("NewChain: %v", err)
}
if err := ch.Speak(context.Background(), "Hallo Welt", "de"); err != nil {
t.Fatalf("Speak (Direkt): %v", err)
}
args, err := os.ReadFile(seen)
if err != nil {
t.Fatalf("fake spd-say nicht gerufen: %v", err)
}
if got := strings.TrimSpace(string(args)); got != "-w -l de Hallo Welt" {
t.Errorf("spd-say ARGS = %q, want %q", got, "-w -l de Hallo Welt")
}
if len(player.played) != 0 {
t.Errorf("Player.Play wurde gerufen (%x) — Direktansage geht am Player vorbei", player.played)
}
}
// TestChainSynthesizeErrNoByteOutput: Die Kette als Provider meldet
// ErrNoByteOutput, wenn nur eine Direkt-Stufe übrig bleibt — der
// DirectSpeaker-Vertrag bleibt am Aufrufer erhalten.
func TestChainSynthesizeErrNoByteOutput(t *testing.T) {
dir := t.TempDir()
fakeProcBin(t, dir, "spd-say", "true")
t.Setenv("PATH", dir)
ch, err := NewChain(ChainConfig{Pin: "osbuiltin", Getenv: chainEnv(nil)})
if err != nil {
t.Fatalf("NewChain: %v", err)
}
_, err = ch.Synthesize(context.Background(), "Hallo", "de")
if !errors.Is(err, ErrNoByteOutput) {
t.Fatalf("Synthesize-Fehler = %v, want ErrNoByteOutput", err)
}
// Chain selbst ist DirectSpeaker.
if _, ok := any(ch).(DirectSpeaker); !ok {
t.Error("Chain implementiert DirectSpeaker nicht")
}
}
// TestChainAvailableUndNamen: Available true sobald eine Stufe verfügbar;
// Default-Reihenfolge ist die bindende aus der Spec.
func TestChainAvailableUndNamen(t *testing.T) {
ch, err := NewChain(ChainConfig{Getenv: chainEnv(map[string]string{
EnvEdgeEndpoint: "wss://edge.example.com", // edge: immer verfügbar mit Endpunkt
})})
if err != nil {
t.Fatalf("NewChain: %v", err)
}
if !ch.Available(context.Background()) {
t.Error("Available = false trotz verfügbarer edge-Stufe")
}
want := []string{"elevenlabs", "openai", "edge", "osbuiltin"}
if len(ch.Tiers()) != len(want) {
t.Fatalf("Tiers = %d, want %d", len(ch.Tiers()), len(want))
}
for i, name := range want {
if ch.Tiers()[i].Name() != name {
t.Errorf("Tiers[%d] = %s, want %s", i, ch.Tiers()[i].Name(), name)
}
}
}
// TestChainVoicePinDurchgereicht: ChainConfig.Voice wird via VoicePinner an
// die Stufen gepinnt — beobachtbar am Request-Pfad des ElevenLabs-Fakes.
func TestChainVoicePinDurchgereicht(t *testing.T) {
ts, rec := fakeTTSServer(t, 200, fakeMP3)
ch, err := NewChain(ChainConfig{
Pin: "elevenlabs",
Voice: "ChainVoice",
Getenv: chainEnv(map[string]string{
EnvElevenLabsKey: "k",
EnvElevenLabsEndpoint: ts.URL,
}),
})
if err != nil {
t.Fatalf("NewChain: %v", err)
}
if _, err := ch.Synthesize(context.Background(), "Hallo", "de"); err != nil {
t.Fatalf("Synthesize: %v", err)
}
if want := "/v1/text-to-speech/ChainVoice"; rec.path != want {
t.Errorf("Pfad = %q, want %q", rec.path, want)
}
}
+11 -5
View File
@@ -1,6 +1,12 @@
// Package provider implementiert die TTS-Provider von talk. Der Vertrag
// (Name/Available/Synthesize) trägt die Fallback-Kette aus Task 03
// (ElevenLabs → OpenAI TTS → Edge-TTS → OS built-in); die dokumentierte
// optionale Erweiterung DirectSpeaker deckt Plattformen ab, deren OS-TTS
// keine Audio-Bytes liefern kann (Linux speech-dispatcher).
// Package provider implementiert die TTS-Provider von talk und ihre
// Fallback-Kette. Der Vertrag (Name/Available/Synthesize) trägt die
// Vier-Stufen-Kette aus Task 03: ElevenLabs → OpenAI TTS → Edge-TTS →
// OS built-in (überschreibbar per --provider/config provider). Die
// Cloud-Provider liefern MP3 (Keys ausschließlich über TALK_ELEVENLABS_KEY
// bzw. TALK_OPENAI_KEY, Endpunkte konfigurierbar und damit fake-bar);
// osbuiltin liefert RIFF/WAVE-PCM. Die dokumentierte optionale Erweiterung
// DirectSpeaker deckt Plattformen ab, deren OS-TTS keine Audio-Bytes
// liefern kann (Linux speech-dispatcher) — Chain.Speak behandelt diesen
// Fall inklusive tonlosem Verwerfen am null-Sink, damit CLI (Task 03) und
// Daemon (Task 04) dieselbe Kette unverändert nutzen.
package provider
+372
View File
@@ -0,0 +1,372 @@
package provider
import (
"bytes"
"context"
"crypto/rand"
"encoding/binary"
"encoding/hex"
"fmt"
"net/http"
"net/url"
"strings"
"sync/atomic"
"time"
"github.com/gorilla/websocket"
)
// Edge-TTS-Konstanten. Das Protokoll ist das bekannte, von edge-tts
// reverse-engineerte „Read Aloud"-Protokoll von Microsoft Edge — Stand der
// Implementierung (2026): DRM-Token Sec-MS-GEC v2 (CS64 + verschleierte
// SHA-256-Variante, siehe edgedrm.go) + MUID-Cookie, im Übrigen das
// bekannte WebSocket-Nachrichtenformat (speech.config + SSML, Binär-Rahmen
// mit 2-Byte-Header-Länge, turn.end). Ändert Microsoft es, liefern die
// Synthese-Pfade klare Fehler mit Hinweis auf Protokoll-Änderung statt
// stiller Fehlfunktion.
const (
// DefaultEndpointEdge ist der produktive WebSocket-Endpunkt (Pfad ohne
// Query — Token/ConnectionId/Sec-MS-GEC werden in requestURL ergänzt).
DefaultEndpointEdge = "wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1"
// EnvEdgeEndpoint überschreibt den Endpunkt (Tests/Firmenserver).
EnvEdgeEndpoint = "TALK_EDGE_ENDPOINT"
edgeTrustedClientToken = "6A5AA1D4EAFF4E9FB37E23D68491D6F4"
edgeSecMSGECVersion = "2-143.0.3650.75"
edgeOrigin = "chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold"
edgeUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0"
edgeOutputFormat = "audio-24khz-48kbitrate-mono-mp3" // mp3!
)
// edgeIOTimeout begrenzt einzelne Lese-/Schreiboperationen sowie (wenn der
// Aufrufer keine Deadline mitgibt) die gesamte Synthese.
const edgeIOTimeout = 20 * time.Second
// edgeClockSkew korrigiert lokale Uhrenabweichung: Bei 403 liest die Kette
// das Date-Header-Feld der Antwort und justiert die Tick-Berechnung (ein
// Wiederholungsversuch, wie die edge-tts-Referenz).
var edgeClockSkew atomic.Int64
// Edge ist die kostenlose dritte Stufe der Kette: Microsofts „Read Aloud"-
// Endpoint, kein Key nötig. Synthesize öffnet eine WebSocket-Verbindung,
// sendet speech.config + SSML-Anfrage und sammelt die Binär-Audio-Frames
// bis turn.end. Rückgabeformat: MP3 (24 kHz mono — der audio-Sink
// dekodiert das Format).
type Edge struct {
// Endpoint ist die WebSocket-URL (ws:// oder wss://), leer = Default.
Endpoint string
// Voices ist das config-Mapping voice.edge.<lang> = id; leer = neutrale
// Repo-Defaults (de-DE-KatjaNeural / en-US-AriaNeural).
Voices map[string]string
// Dialer ist der WebSocket-Dialer; nil = websocket.DefaultDialer.
Dialer *websocket.Dialer
pinned string // --voice-Pin (VoicePinner)
}
// NewEdge baut den Provider aus der Ketten-Konfiguration: Endpunkt aus
// TALK_EDGE_ENDPOINT > [providers.edge].endpoint > Default.
func NewEdge(cfg ChainConfig) *Edge {
return &Edge{
Endpoint: endpointFrom(cfg, "edge", EnvEdgeEndpoint, DefaultEndpointEdge),
Voices: cfg.Voices["edge"],
}
}
// Name implementiert Provider.
func (*Edge) Name() string { return "edge" }
// PinVoice implementiert VoicePinner (CLI-Flag --voice).
func (p *Edge) PinVoice(id string) { p.pinned = id }
// Available implementiert Provider: Edge braucht nur einen Endpunkt —
// bewusst ohne Netz-Call (Erreichbarkeit stellt Synthesize fest, an deren
// Fehler fällt die Kette weiter).
func (p *Edge) Available(ctx context.Context) bool { return p.Endpoint != "" }
// Synthesize implementiert Provider. Rückgabeformat: MP3.
func (p *Edge) Synthesize(ctx context.Context, text, lang string) ([]byte, error) {
if text == "" {
return nil, fmt.Errorf("edge: leerer Text")
}
voice := resolveVoice("edge", lang, p.pinned, p.Voices)
if voice == "" {
return nil, fmt.Errorf("edge: keine Stimme für Sprache %q konfiguriert", lang)
}
if _, hasDeadline := ctx.Deadline(); !hasDeadline {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, edgeIOTimeout)
defer cancel()
}
conn, wsURL, err := p.dialWithSkewRetry(ctx)
if err != nil {
return nil, err
}
defer conn.Close()
now := time.Now().UTC()
messages := [][]byte{
[]byte(edgeSpeechConfigMsg(now)),
[]byte(edgeSynthesisHeaders(now, edgeRequestID()) + edgeSSML(voice, text)),
}
for _, msg := range messages {
if err := conn.SetWriteDeadline(time.Now().Add(edgeIOTimeout)); err != nil {
return nil, protocolErr(err)
}
if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil {
return nil, protocolErr(err)
}
}
var audio bytes.Buffer
for {
if err := conn.SetReadDeadline(time.Now().Add(edgeIOTimeout)); err != nil {
return nil, protocolErr(err)
}
msgType, msg, err := conn.ReadMessage()
if err != nil {
return nil, protocolErr(err)
}
switch msgType {
case websocket.TextMessage:
if strings.Contains(string(msg), "Path:turn.end") {
if audio.Len() == 0 {
return nil, fmt.Errorf("edge: Server meldete turn.end ohne Audio-Frames — Protokoll geändert? (%s)", redactURL(wsURL))
}
return audio.Bytes(), nil
}
case websocket.BinaryMessage:
headerText, payload, ok := splitEdgeBinary(msg)
if ok && strings.Contains(headerText, "Path:audio") {
audio.Write(payload)
}
}
}
}
// dialWithSkewRetry baut die Verbindung; bei 403 (Uhrabweichung) wird der
// Server-Zeitstempel übernommen und EINMAL wiederholt.
func (p *Edge) dialWithSkewRetry(ctx context.Context) (*websocket.Conn, string, error) {
conn, wsURL, resp, err := p.dial(ctx)
if err == nil {
return conn, wsURL, nil
}
if resp != nil && resp.StatusCode == http.StatusForbidden {
if serverDate := resp.Header.Get("Date"); serverDate != "" {
if t, perr := http.ParseTime(serverDate); perr == nil {
edgeClockSkew.Store(t.Unix() - time.Now().Unix())
conn, wsURL, resp, err = p.dial(ctx)
if err == nil {
return conn, wsURL, nil
}
}
}
}
if resp != nil {
return nil, wsURL, fmt.Errorf("edge: WebSocket-Verbindung zu %s fehlgeschlagen (HTTP %d; Netz/Timeout oder Protokoll geändert?): %w",
redactURL(wsURL), resp.StatusCode, err)
}
return nil, wsURL, fmt.Errorf("edge: WebSocket-Verbindung zu %s fehlgeschlagen — Netz/Timeout oder das Edge-TTS-Protokoll hat sich geändert: %w",
redactURL(wsURL), err)
}
// dial öffnet die Verbindung gemäß Protokoll: Query mit Token,
// ConnectionId, Sec-MS-GEC (v2) und Version; Header mit Origin,
// Edge-User-Agent und MUID-Cookie.
func (p *Edge) dial(ctx context.Context) (*websocket.Conn, string, *http.Response, error) {
wsURL, err := p.requestURL(time.Now())
if err != nil {
return nil, "", nil, err
}
dialer := p.Dialer
if dialer == nil {
dialer = websocket.DefaultDialer
}
header := http.Header{}
header.Set("Origin", edgeOrigin)
header.Set("User-Agent", edgeUserAgent)
header.Set("Pragma", "no-cache")
header.Set("Cache-Control", "no-cache")
header.Set("Accept-Language", "en-US,en;q=0.9")
header.Set("Cookie", "muid="+edgeMUID())
conn, resp, err := dialer.DialContext(ctx, wsURL, header)
if err != nil {
return nil, wsURL, resp, err
}
return conn, wsURL, nil, nil
}
// requestURL ergänzt die Protokoll-Query-Parameter und validiert das
// Schema (ws/wss).
func (p *Edge) requestURL(now time.Time) (string, error) {
if p.Endpoint == "" {
return "", fmt.Errorf("edge: kein Endpunkt konfiguriert")
}
u, err := url.Parse(p.Endpoint)
if err != nil {
return "", fmt.Errorf("edge: Endpunkt %q unlesbar: %w", p.Endpoint, err)
}
if u.Scheme != "ws" && u.Scheme != "wss" {
return "", fmt.Errorf("edge: Endpunkt %q muss ws:// oder wss:// sein (ist %q)", p.Endpoint, u.Scheme)
}
q := u.Query()
q.Set("TrustedClientToken", edgeTrustedClientToken)
q.Set("ConnectionId", edgeRequestID())
q.Set("Sec-MS-GEC", edgeSecMSGEC(now))
q.Set("Sec-MS-GEC-Version", edgeSecMSGECVersion)
u.RawQuery = q.Encode()
return u.String(), nil
}
// edgeSecMSGEC erzeugt den DRM-Token Sec-MS-GEC v2: Windows-Filetime-Ticks
// (100 ns, 5-Minuten-Raster, Uhrenkorrektur) + Trusted-Client-Token →
// „<CS64>-<SHA256-Variante>" (beides großes Hex, siehe edgedrm.go).
func edgeSecMSGEC(now time.Time) string {
secs := now.Unix() + edgeClockSkew.Load() + drmWinEpoch
secs -= secs % 300 // 5-Minuten-Raster
ticks := uint64(secs) * 10_000_000
strToHash := fmt.Appendf(nil, "%d%s", ticks, edgeTrustedClientToken)
digest := drmSHA256Digest(strToHash)
cs0, cs1 := drmCS64(strToHash, digest)
var cs64Buf [8]byte
cs64Buf[0] = byte(cs0)
cs64Buf[1] = byte(cs0 >> 8)
cs64Buf[2] = byte(cs0 >> 16)
cs64Buf[3] = byte(cs0 >> 24)
cs64Buf[4] = byte(cs1)
cs64Buf[5] = byte(cs1 >> 8)
cs64Buf[6] = byte(cs1 >> 16)
cs64Buf[7] = byte(cs1 >> 24)
var shaBuf [32]byte
for i, w := range digest {
shaBuf[i*4] = byte(w >> 24)
shaBuf[i*4+1] = byte(w >> 16)
shaBuf[i*4+2] = byte(w >> 8)
shaBuf[i*4+3] = byte(w)
}
return strings.ToUpper(hex.EncodeToString(cs64Buf[:])) + "-" + strings.ToUpper(hex.EncodeToString(shaBuf[:]))
}
// protocolErr überführt Lese-/Schreib-/Verbindungsabbrüche in klare
// „Protokoll geändert?"-Fehler — Edge ist ein undokumentierter Endpoint,
// Änderungen sollen sofort diagnostizierbar sein.
func protocolErr(err error) error {
return fmt.Errorf("edge: Kommunikation fehlgeschlagen — Netz/Timeout oder das Edge-TTS-Protokoll hat sich geändert: %w", err)
}
// splitEdgeBinary zerlegt eine binäre Server-Nachricht: Die ersten 2 Bytes
// (big-endian) geben die Länge des Header-Blocks an (CRLF-Zeilen wie
// „Path:audio"), danach kommt die Audio-Payload.
func splitEdgeBinary(msg []byte) (header string, payload []byte, ok bool) {
if len(msg) < 2 {
return "", nil, false
}
headerLen := int(binary.BigEndian.Uint16(msg[0:2]))
if 2+headerLen > len(msg) {
return "", nil, false
}
return string(msg[2 : 2+headerLen]), msg[2+headerLen:], true
}
// edgeSpeechConfigMsg ist die erste Client-Nachricht: Header-Block +
// JSON-Body mit Ausgabeformat und Metadaten-Optionen (Satzgrenzen an,
// Wortgrenzen aus — Referenz-Default).
func edgeSpeechConfigMsg(now time.Time) string {
return fmt.Sprintf(
"X-Timestamp:%s\r\nContent-Type:application/json; charset=utf-8\r\nPath:speech.config\r\n\r\n"+
`{"context":{"synthesis":{"audio":{"metadataoptions":{"sentenceBoundaryEnabled":"true","wordBoundaryEnabled":"false"},"outputFormat":%q}}}}`,
edgeTimestamp(now), edgeOutputFormat)
}
// edgeSynthesisHeaders ist der Header-Block der SSML-Anfrage. Der
// nachgestellte Z beim Timestamp ist Referenz-Verhalten („Microsoft Edge
// bug"), bewusst mitgeschleppt.
func edgeSynthesisHeaders(now time.Time, requestID string) string {
return fmt.Sprintf(
"X-RequestId:%s\r\nContent-Type:application/ssml+xml\r\nX-Timestamp:%sZ\r\nPath:ssml\r\n\r\n",
requestID, edgeTimestamp(now))
}
// edgeSSML baut die Sprach-Anfrage (xml:lang wie die Referenz fest
// „en-US"; der Text wird von Steuerzeichen befreit und XML-escapt).
func edgeSSML(voice, text string) string {
return fmt.Sprintf(
"<speak version='1.0' xmlns='http://www.w3.org/2001/10/synthesis' xml:lang='en-US'>"+
"<voice name='%s'>"+
"<prosody pitch='+0Hz' rate='+0%%' volume='+0%%'>%s</prosody>"+
"</voice></speak>",
voice, edgeEscape(text))
}
// edgeEscape entfernt nicht unterstützte Steuerzeichen (Referenz:
// remove_incompatible_characters) und escapt danach & < >.
func edgeEscape(s string) string {
clean := make([]byte, 0, len(s))
for i := 0; i < len(s); i++ {
c := s[i]
if c <= 8 || (c >= 11 && c <= 12) || (c >= 14 && c <= 31) {
c = ' '
}
clean = append(clean, c)
}
esc := make([]byte, 0, len(clean))
for _, c := range clean {
switch c {
case '&':
esc = append(esc, "&amp;"...)
case '<':
esc = append(esc, "&lt;"...)
case '>':
esc = append(esc, "&gt;"...)
default:
esc = append(esc, c)
}
}
return string(esc)
}
// edgeTimestamp formatiert den Zeitstempel im edge-tts-Format.
func edgeTimestamp(now time.Time) string {
return now.Format("Mon Jan 02 2006 15:04:05 GMT+0000 (Coordinated Universal Time)")
}
// edgeRequestID erzeugt eine 32-stellige Hex-ID (UUID v4 ohne Striche).
func edgeRequestID() string {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
// Fallback: zeitbasiert — die ID muss nur eindeutig pro Anfrage sein.
now := time.Now().UnixNano()
for i := 0; i < 16; i++ {
b[i] = byte(now >> (uint(i%8) * 8))
}
}
b[6] = (b[6] & 0x0f) | 0x40 // UUID-Version
b[8] = (b[8] & 0x3f) | 0x80 // Variante
return hex.EncodeToString(b[:])
}
// edgeMUID erzeugt die zufällige MUID für das Cookie (32 Hex, groß).
func edgeMUID() string {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return edgeRequestID()
}
return strings.ToUpper(hex.EncodeToString(b[:]))
}
// redactURL maskiert die Query-Parameter (enthalten DRM-Token und IDs) in
// Fehlermeldungen — die URL selbst genügt zur Diagnose.
func redactURL(raw string) string {
u, err := url.Parse(raw)
if err != nil {
return raw
}
u.RawQuery = "…"
return u.String()
}
+321
View File
@@ -0,0 +1,321 @@
package provider
import (
"context"
"encoding/hex"
"net/http"
"net/http/httptest"
"os"
"strings"
"sync"
"testing"
"time"
"github.com/gorilla/websocket"
)
// TestEdgeAvailable: Edge braucht nur einen Endpunkt, keinen Key — der
// Konstruktor bäkt den Default-Endpunkt ein, daher ist ein über NewEdge
// gebauter Edge immer verfügbar; nur der Nullwert (direkt gebaut, ohne
// Endpunkt) ist es nicht.
func TestEdgeAvailable(t *testing.T) {
if (&Edge{}).Available(context.Background()) {
t.Error("Available ohne Endpunkt (Nullwert) = true, want false")
}
p := NewEdge(ChainConfig{Getenv: envMap(map[string]string{
EnvEdgeEndpoint: "wss://edge.example.com",
})})
if !p.Available(context.Background()) {
t.Error("Available mit Endpunkt = false, want true")
}
if !NewEdge(ChainConfig{Getenv: envMap(nil)}).Available(context.Background()) {
t.Error("Available mit Default-Endpunkt = false, want true (edge ist der Key-freie Fallback)")
}
}
// TestEdgeVerbindungFehlschlaegt (Fehlerpfad-Mock): ein garantiert toter
// Loopback-Port liefert einen klaren Fehler — sofort, ohne Netz.
func TestEdgeVerbindungFehlschlaegt(t *testing.T) {
p := NewEdge(ChainConfig{Getenv: envMap(map[string]string{
EnvEdgeEndpoint: "ws://127.0.0.1:1/",
})})
_, err := p.Synthesize(context.Background(), "Hallo", "de")
if err == nil {
t.Fatal("Synthesize gegen toten Port: kein Fehler")
}
if !strings.Contains(err.Error(), "edge") || !strings.Contains(err.Error(), "WebSocket") {
t.Errorf("Fehler = %v, will klaren edge/WebSocket-Fehler", err)
}
}
// TestEdgeUngueltigesSchema: ein Nicht-w(s)-Endpunkt ist ein klarer Fehler.
func TestEdgeUngueltigesSchema(t *testing.T) {
p := NewEdge(ChainConfig{Getenv: envMap(map[string]string{
EnvEdgeEndpoint: "http://127.0.0.1:1234/",
})})
_, err := p.Synthesize(context.Background(), "Hallo", "de")
if err == nil || !strings.Contains(err.Error(), "ws://") {
t.Errorf("Fehler = %v, will Schema-Hinweis ws://", err)
}
}
// TestEdgeSSML: die SSML-Anfrage enthält Stimme und den von Steuerzeichen
// befreiten, XML-escapten Text; xml:lang ist wie in der Referenz fest
// „en-US".
func TestEdgeSSML(t *testing.T) {
got := edgeSSML("de-DE-KatjaNeural", "AT&T <Kunde> „fertig“")
for _, want := range []string{
"voice name='de-DE-KatjaNeural'",
"xml:lang='en-US'",
"AT&amp;T &lt;Kunde&gt;",
} {
if !strings.Contains(got, want) {
t.Errorf("SSML = %q, will %q", got, want)
}
}
if strings.Contains(got, "AT&T") {
t.Errorf("SSML enthält unescaptes &: %q", got)
}
}
// TestEdgeEscape: Steuerzeichen (u. a. Vertical Tab aus OCR-Texten) werden
// durch Leerzeichen ersetzt, & < > escapt.
func TestEdgeEscape(t *testing.T) {
in := "a\x0bb&c<d>\x08e"
if want := "a b&amp;c&lt;d&gt; e"; edgeEscape(in) != want {
t.Errorf("edgeEscape(%q) = %q, want %q", in, edgeEscape(in), want)
}
}
// TestEdgeSpeechConfigMsg: Output-Format (mono-mp3) und Pfad stimmen.
func TestEdgeSpeechConfigMsg(t *testing.T) {
msg := edgeSpeechConfigMsg(time.Unix(0, 0).UTC())
if !strings.Contains(msg, "Path:speech.config") {
t.Errorf("speech.config-Nachricht ohne Path: %q", msg)
}
if !strings.Contains(msg, edgeOutputFormat) {
t.Errorf("speech.config-Nachricht ohne Ausgabeformat %q: %q", edgeOutputFormat, msg)
}
}
// TestEdgeRequestURL: Query-Parameter (Token, Sec-MS-GEC, Version) werden
// ergänzt; der Token landet nicht in der URL-Debug-Ausgabe.
func TestEdgeRequestURL(t *testing.T) {
p := &Edge{Endpoint: "wss://edge.example.com/path"}
raw, err := p.requestURL(time.Unix(1700000000, 0).UTC())
if err != nil {
t.Fatalf("requestURL: %v", err)
}
for _, want := range []string{
"TrustedClientToken=" + edgeTrustedClientToken,
"Sec-MS-GEC=",
"Sec-MS-GEC-Version=" + edgeSecMSGECVersion,
} {
if !strings.Contains(raw, want) {
t.Errorf("requestURL = %q, will %q", raw, want)
}
}
if got := redactURL(raw); strings.Contains(got, edgeTrustedClientToken) {
t.Errorf("redactURL leakt den Token: %q", got)
}
}
// TestEdgeSecMSGEC: Format „<16 Hex>-<64 Hex>" (groß), 5-Minuten-Raster
// (innerhalb des Fensters stabil, über die Grenze anders) und exakte
// Referenzvektoren der edge-tts-DRM-Implementierung (v2).
func TestEdgeSecMSGEC(t *testing.T) {
// Referenzvektoren: (Unix-Sekunden, erwarteter Token) — erzeugt mit der
// Python-Referenz (edge-tts src/edge_tts/drm.py, Stand 2026-10).
vectors := []struct {
ts int64
want string
}{
{1700000400, "73C7C85FE76CF347-452672184D494A9F80E179C2FBFEE2E98480C4D4AAFC1F4A577E43B023BEEEC9"},
{1700000599, "73C7C85FE76CF347-452672184D494A9F80E179C2FBFEE2E98480C4D4AAFC1F4A577E43B023BEEEC9"},
{1765800000, "B9B5447115B4FD1A-6BB36279F5B3D1B53F84F786AF05C81A86909ABE30E695496BBE88FFAE2E3E83"},
{0, "52BC7A17B50DFEC9-28C4394775C8751CD2F6859C030C06DB1489ACAB3B3A7651831FC26895177ED6"},
}
for _, v := range vectors {
if got := edgeSecMSGEC(time.Unix(v.ts, 0).UTC()); got != v.want {
t.Errorf("edgeSecMSGEC(%d) =\n %s\nwant\n %s", v.ts, got, v.want)
}
}
// Fenster-Stabilität (Basis am Fensterbeginn, mod 300 == 0).
base := time.Unix(1700000400, 0).UTC()
token := edgeSecMSGEC(base)
if edgeSecMSGEC(base.Add(4*time.Minute)) != token {
t.Error("Sec-MS-GEC weicht innerhalb des 5-Minuten-Fensters ab")
}
if edgeSecMSGEC(base.Add(6*time.Minute)) == token {
t.Error("Sec-MS-GEC ändert sich über die Fenster-Grenze nicht")
}
if len(token) != 16+1+64 || !strings.Contains(token, "-") {
t.Errorf("Token-Format = %q, want <16 Hex>-<64 Hex>", token)
}
}
// TestEdgeRequestID: 32 Hex-Zeichen, eindeutig.
func TestEdgeRequestID(t *testing.T) {
a, b := edgeRequestID(), edgeRequestID()
if len(a) != 32 {
t.Errorf("RequestID-Länge = %d, want 32", len(a))
}
if _, err := hex.DecodeString(a); err != nil {
t.Errorf("RequestID ist kein Hex: %v", err)
}
if a == b {
t.Error("RequestID nicht eindeutig")
}
}
// TestEdgeSplitBinary: Binär-Rahmen: 2 Bytes big-endian Header-Länge, dann
// Header (Path:audio), dann Payload.
func TestEdgeSplitBinary(t *testing.T) {
msg := []byte{0, 12, 'P', 'a', 't', 'h', ':', 'a', 'u', 'd', 'i', 'o', '\r', '\n', 1, 2, 3}
header, payload, ok := splitEdgeBinary(msg)
if !ok || header != "Path:audio\r\n" || string(payload) != "\x01\x02\x03" {
t.Errorf("splitEdgeBinary = %q, %x, %v", header, payload, ok)
}
if _, _, ok := splitEdgeBinary([]byte{0, 99, 'x'}); ok {
t.Error("übergroßer Header-Längenangabe soll abgelehnt werden")
}
}
// fakeEdgeServer ist ein Loopback-Fake des Edge-WSS-Protokolls (kein
// echter Netz-Call — alles bleibt auf 127.0.0.1). Er prüft die beiden
// Client-Nachrichten und antwortet nach Protokoll: Audio-Binär-Rahmen mit
// Path:audio, danach turn.end. audio ist die zu liefernde Payload;
// closing schließt die Verbindung vor turn.end (Fehlerpfad).
func fakeEdgeServer(t *testing.T, audio []byte, closing bool) (*httptest.Server, *fakeEdgeSeen) {
t.Helper()
seen := &fakeEdgeSeen{}
upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.Query().Get("TrustedClientToken"); got != edgeTrustedClientToken {
t.Errorf("TrustedClientToken = %q", got)
}
if r.Header.Get("Origin") != edgeOrigin {
t.Errorf("Origin = %q", r.Header.Get("Origin"))
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
t.Errorf("Upgrade: %v", err)
return
}
defer conn.Close()
if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
return
}
if _, cfg, err := conn.ReadMessage(); err != nil {
t.Errorf("speech.config lesen: %v", err)
} else {
seen.mu.Lock()
seen.config = string(cfg)
seen.mu.Unlock()
}
if _, req, err := conn.ReadMessage(); err != nil {
t.Errorf("ssml lesen: %v", err)
} else {
seen.mu.Lock()
seen.ssml = string(req)
seen.mu.Unlock()
}
if closing {
return // Verbindung dichtmachen, ohne zu antworten
}
header := "X-RequestId:dummy\r\nPath:audio\r\n"
frame := make([]byte, 0, 2+len(header)+len(audio))
frame = append(frame, byte(len(header)>>8), byte(len(header)))
frame = append(frame, header...)
frame = append(frame, audio...)
if err := conn.WriteMessage(websocket.BinaryMessage, frame); err != nil {
return
}
if err := conn.WriteMessage(websocket.TextMessage, []byte("X-RequestId:dummy\r\nPath:turn.end\r\n\r\n")); err != nil {
return
}
}))
t.Cleanup(ts.Close)
return ts, seen
}
type fakeEdgeSeen struct {
mu sync.Mutex
config string
ssml string
}
func (s *fakeEdgeSeen) snapshot() (configMsg, ssml string) {
s.mu.Lock()
defer s.mu.Unlock()
return s.config, s.ssml
}
// TestEdgeLoopbackFakeServer: kompletter Protokoll-Durchlauf gegen den
// Loopback-Fake (fake-bar getestet: Handshake-Parameter, beide
// Client-Nachrichten, Binär-Rahmen-Parsing, turn.end-Ende) — siehe
// Task-03-Report für die Abgrenzung zum echten Endpoint.
func TestEdgeLoopbackFakeServer(t *testing.T) {
wantAudio := []byte("\xff\xfb\x90\x00" + "edge-audio")
ts, seen := fakeEdgeServer(t, wantAudio, false)
p := NewEdge(ChainConfig{Getenv: envMap(map[string]string{
EnvEdgeEndpoint: "ws://" + ts.Listener.Addr().String(),
})})
data, err := p.Synthesize(context.Background(), "Hallo & fertig", "de")
if err != nil {
t.Fatalf("Synthesize gegen Loopback-Fake: %v", err)
}
if string(data) != string(wantAudio) {
t.Errorf("Synthesize = %x, want %x", data, wantAudio)
}
configMsg, ssml := seen.snapshot()
if !strings.Contains(configMsg, "Path:speech.config") {
t.Errorf("speech.config = %q", configMsg)
}
if !strings.Contains(ssml, "Path:ssml") {
t.Errorf("ssml-Anfrage ohne Path:ssml: %q", ssml)
}
if !strings.Contains(ssml, "voice name='de-DE-KatjaNeural'") {
t.Errorf("ssml ohne Default-Stimme de: %q", ssml)
}
if !strings.Contains(ssml, "Hallo &amp; fertig") {
t.Errorf("ssml ohne escapten Text: %q", ssml)
}
}
// TestEdgeLoopbackVerbindungsAbbruch: Server schließt nach den Anfragen
// ohne Audio → klarer „Protokoll geändert?"-Fehler.
func TestEdgeLoopbackVerbindungsAbbruch(t *testing.T) {
ts, _ := fakeEdgeServer(t, nil, true)
p := NewEdge(ChainConfig{Getenv: envMap(map[string]string{
EnvEdgeEndpoint: "ws://" + ts.Listener.Addr().String(),
})})
_, err := p.Synthesize(context.Background(), "Hallo", "de")
if err == nil || !strings.Contains(err.Error(), "Protokoll") {
t.Errorf("Fehler = %v, will Protokoll-Änderungs-Hinweis", err)
}
}
// TestEdgeIntegrationEcht ist die als testing.Short überspringbare
// Integrations-Markierung gegen den ECHTEN Microsoft-Endpunkt (kostenlos,
// aber echtes Netz — Standard-Testläufe und -short überspringen; einmalige
// manuelle Abnahme mit TALK_TEST_REAL_EDGE=1).
func TestEdgeIntegrationEcht(t *testing.T) {
if testing.Short() {
t.Skip("Übersprungen (-short)")
}
if os.Getenv("TALK_TEST_REAL_EDGE") != "1" {
t.Skip("Übersprungen (echtes Netz) — mit TALK_TEST_REAL_EDGE=1 ausführen")
}
p := NewEdge(ChainConfig{})
data, err := p.Synthesize(context.Background(), "Kurzer Sprachtest über Edge.", "de")
if err != nil {
t.Fatalf("Synthesize gegen echten Endpunkt: %v", err)
}
if len(data) == 0 {
t.Fatal("Synthesize gegen echten Endpunkt: 0 Bytes")
}
t.Logf("Edge-Integration: %d Bytes Audio", len(data))
}
+303
View File
@@ -0,0 +1,303 @@
package provider
import (
"encoding/binary"
)
// Edge-DRM: Sec-MS-GEC v2. Microsoft verlangt seit der Protokoll-Änderung
// (edge-tts-Referenz, Stand 2026) einen Token der Form
// „<16 Hex CS64>-<64 Hex SHA256-Variante>" über den 5-Minuten-gerundeten
// Windows-Filetime-Ticks + Trusted-Client-Token. Beide Digests sind
// absichtlich verschleierte Varianten bekannter Algorithmen — die
// Konstanten und der Kontrollfluss unten sind eine wörtliche Portierung
// der Referenz (edge-tts src/edge_tts/drm.py, MIT) und gegen deren
// Ausgaben tabellengeprüft (TestEdgeDRMReferenzvektoren). Ändert Microsoft
// das Schema erneut, liefert der Edge-Provider klare Fehler („Protokoll
// geändert?"), statt still zu versagen.
const (
drmWinEpoch = 11644473600 // Sekunden zwischen 1601-01-01 und 1970-01-01
)
// drmIV ist der Standard-SHA-256-Initialvektor.
var drmIV = [8]uint32{
0x6A09E667, 0xBB67AE85, 0x3C6EF372, 0xA54FF53A,
0x510E527F, 0x9B05688C, 0x1F83D9AB, 0x5BE0CD19,
}
// drmMidState ist der Arbeitsregister-Stand der Referenz für den
// Mid-Block-Einstieg (Runde 14 des ersten Blocks).
var drmMidState = [8]uint32{
0xFD9FBEA2, 0x91243CE7, 0xBE499094, 0xCD2E1E0A,
0x6C006F49, 0x04BD35AB, 0xFEB0A7E4, 0x03EFFBD2,
}
// drmSchedule sind vorberechnete Schedule-Anteile des ersten Blocks.
var drmSchedule = [14]uint32{
0xEB24BF9E, 0x1558691F, 0xA0C09EE2, 0x0CFA32BA,
0xA622EE8C, 0xD1DD9491, 0xC0DD25A0, 0xF9E70AB6,
0xF0C31573, 0x7058CE4E, 0x7188E606, 0x00E5AB33,
0xD258B060, 0x72766564,
}
// drmAdjustment ersetzt die Rundungskonstante K[14] im Mid-Block-Pfad.
const (
drmAdjustment uint32 = 0xC9DC06A7
drmSigma0Adj uint32 = 0x059C0B80
drmSigma1Adj uint32 = 0x000B92C0
drmCS64Factor uint32 = 0x82893933
drmCS64Adj0 uint32 = 0xF2F90B1A
drmCS64Adj1 uint32 = 0x0B1A0000
)
// drmK sind die Standard-SHA-256-Rundungskonstanten.
var drmK = [64]uint32{
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
}
// drmCS64Coefficients: (Term-, Offset-Koeffizient) je Zeile.
var drmCS64Coefficients = [14][2]uint32{
{0xE82E91B1, 0xB32700D0}, {0x44DE24C6, 0x88DFF51D},
{0x3BEB65BC, 0x29514038}, {0xC89C5933, 0xB328CBDF},
{0x8D1EC211, 0xD405B4D5}, {0x314EF5D5, 0x26E55F3C},
{0xE92491B1, 0x607801C6}, {0xF0A5B9D5, 0x82B2C1FB},
{0x06064CF3, 0xFA51A3DA}, {0xDA79FA60, 0x207CC464},
{0xBED20560, 0x3C3C74F3}, {0x89F9C9F8, 0xD2D14B61},
{0x35FA38C6, 0xBB04A2C1}, {0xCB49E3C6, 0xC5626167},
}
// drmSigma0 ist die (verschleiert notierte) SHA-256-σ0-Mischfunktion.
func drmSigma0(v uint32) uint32 {
return v>>7 ^ v<<25 ^ v>>18 ^ v<<14 ^ v>>3
}
// drmSigma1 ist die SHA-256-σ1-Mischfunktion.
func drmSigma1(v uint32) uint32 {
return v>>17 ^ v<<15 ^ v>>19 ^ v<<13 ^ v>>10
}
// drmRol16 rotiert 32 Bit um 16.
func drmRol16(v uint32) uint32 { return v<<16 | v>>16 }
// drmRotR rotiert 32 Bit rechts um n.
func drmRotR(v uint32, n uint) uint32 { return v>>n | v<<(32-n) }
// drmExpand erweitert die Message-Schedule ab from auf 64 Wörter.
func drmExpand(words []uint32, from int) []uint32 {
for i := from; i < 64; i++ {
x, y := words[i-2], words[i-15]
words = append(words, drmSigma1(x)+words[i-7]+drmSigma0(y)+words[i-16])
}
return words
}
// drmRounds führt die SHA-256-Runden aus — wörtlich nach Referenz,
// inklusive des Mid-Block-Einstiegs (firstRound 14 mit ersetzter
// Rundungskonstante) und der 8er-Rollen-Rotation der Register.
func drmRounds(state [8]uint32, words []uint32, firstRound int) [8]uint32 {
a, b, c, d, e, f, g, h := state[0], state[1], state[2], state[3], state[4], state[5], state[6], state[7]
midBlock := firstRound == 14
k := func(i int) uint32 {
if midBlock && i == 14 {
return drmAdjustment
}
return drmK[i]
}
aligned := ((firstRound + 7) / 8) * 8
if aligned > 64 {
aligned = 64
}
for i := firstRound; i < aligned; i++ {
t1 := h + (drmRotR(e, 6) ^ drmRotR(e, 11) ^ drmRotR(e, 25)) + (g ^ (e & (f ^ g))) + k(i) + words[i]
t2 := (drmRotR(a, 2) ^ drmRotR(a, 13) ^ drmRotR(a, 22)) + ((a & b) | (c & (a | b)))
a, b, c, d, e, f, g, h = t1+t2, a, b, c, d+t1, e, f, g
}
for i := aligned; i < 64; i += 8 {
// Runde i
t1 := h + (drmRotR(e, 6) ^ drmRotR(e, 11) ^ drmRotR(e, 25)) + (g ^ (e & (f ^ g))) + k(i) + words[i]
d += t1
h = t1 + (drmRotR(a, 2) ^ drmRotR(a, 13) ^ drmRotR(a, 22)) + ((a & b) | (c & (a | b)))
// Runde i+1 (Rollen rotiert)
t1 = g + (drmRotR(d, 6) ^ drmRotR(d, 11) ^ drmRotR(d, 25)) + (f ^ (d & (e ^ f))) + k(i+1) + words[i+1]
c += t1
g = t1 + (drmRotR(h, 2) ^ drmRotR(h, 13) ^ drmRotR(h, 22)) + ((h & a) | (b & (h | a)))
// Runde i+2
t1 = f + (drmRotR(c, 6) ^ drmRotR(c, 11) ^ drmRotR(c, 25)) + (e ^ (c & (d ^ e))) + k(i+2) + words[i+2]
b += t1
f = t1 + (drmRotR(g, 2) ^ drmRotR(g, 13) ^ drmRotR(g, 22)) + ((g & h) | (a & (g | h)))
// Runde i+3
t1 = e + (drmRotR(b, 6) ^ drmRotR(b, 11) ^ drmRotR(b, 25)) + (d ^ (b & (c ^ d))) + k(i+3) + words[i+3]
a += t1
e = t1 + (drmRotR(f, 2) ^ drmRotR(f, 13) ^ drmRotR(f, 22)) + ((f & g) | (h & (f | g)))
// Runde i+4
t1 = d + (drmRotR(a, 6) ^ drmRotR(a, 11) ^ drmRotR(a, 25)) + (c ^ (a & (b ^ c))) + k(i+4) + words[i+4]
h += t1
d = t1 + (drmRotR(e, 2) ^ drmRotR(e, 13) ^ drmRotR(e, 22)) + ((e & f) | (g & (e | f)))
// Runde i+5
t1 = c + (drmRotR(h, 6) ^ drmRotR(h, 11) ^ drmRotR(h, 25)) + (b ^ (h & (a ^ b))) + k(i+5) + words[i+5]
g += t1
c = t1 + (drmRotR(d, 2) ^ drmRotR(d, 13) ^ drmRotR(d, 22)) + ((d & e) | (f & (d | e)))
// Runde i+6
t1 = b + (drmRotR(g, 6) ^ drmRotR(g, 11) ^ drmRotR(g, 25)) + (a ^ (g & (h ^ a))) + k(i+6) + words[i+6]
f += t1
b = t1 + (drmRotR(c, 2) ^ drmRotR(c, 13) ^ drmRotR(c, 22)) + ((c & d) | (e & (c | d)))
// Runde i+7
t1 = a + (drmRotR(f, 6) ^ drmRotR(f, 11) ^ drmRotR(f, 25)) + (h ^ (f & (g ^ h))) + k(i+7) + words[i+7]
e += t1
a = t1 + (drmRotR(b, 2) ^ drmRotR(b, 13) ^ drmRotR(b, 22)) + ((b & c) | (d & (b | c)))
}
return [8]uint32{a, b, c, d, e, f, g, h}
}
// drmSHA256Init berechnet den Zustand nach dem (verschleiert notierten)
// ersten Block: 14 Nullwörter + die ersten 7 Bytes von data als
// 24-Bit-/32-Bit-Big-Endian-Wörter, Schedule mit Korrekturen, Runden ab 14.
func drmSHA256Init(data []byte) [8]uint32 {
words := make([]uint32, 30, 64)
words[14] = uint32(data[0])<<16 | uint32(data[1])<<8 | uint32(data[2])
words[15] = binary.BigEndian.Uint32(data[3:7])
for i := 16; i < 30; i++ {
v := drmSchedule[i-16]
// σ1-Anteil von W[i-2] (Index 16 liest W[14], dazu die Korrektur).
s1 := drmSigma1(words[i-2])
if i == 16 {
s1 ^= drmSigma1Adj
}
v += s1
if i-7 >= 14 {
v += words[i-7]
}
if i-15 >= 14 { // nur i == 29, liest W[14], dazu die Korrektur
s0 := drmSigma0(words[i-15])
if i == 29 {
s0 ^= drmSigma0Adj
}
v += s0
}
words[i] = v
}
words = append(words, drmSigma1(words[28])+words[23]+drmSigma0(words[15])+words[14]+drmAdjustment-drmK[14])
words = drmExpand(words, 31)
working := drmRounds(drmMidState, words, 14)
var out [8]uint32
for i := range out {
out[i] = drmIV[i] + working[i]
}
return out
}
// drmSHA256Digest ist die verschleierte SHA-256-Variante der Referenz:
// Block 1 steckt in drmSHA256Init (erste 7 Bytes), danach data[7:] mit
// Referenz-Längenfeld (57+len(data)) und Standard-Runden.
func drmSHA256Digest(data []byte) [8]uint32 {
length := 57 + len(data)
padded := append([]byte(nil), data[7:]...)
padded = append(padded, 0x80)
zeros := (55 - length) % 64
if zeros < 0 {
zeros += 64
}
padded = append(padded, make([]byte, zeros)...)
var lenBuf [8]byte
binary.BigEndian.PutUint64(lenBuf[:], uint64(length)*8)
padded = append(padded, lenBuf[:]...)
state := drmSHA256Init(data[:7])
for off := 0; off < len(padded); off += 64 {
words := make([]uint32, 16, 64)
for i := range words {
words[i] = binary.BigEndian.Uint32(padded[off+i*4:])
}
words = drmExpand(words, 16)
working := drmRounds(state, words, 0)
for i := range state {
state[i] += working[i]
}
}
return state
}
// drmWordsLE4Even zerlegt data in little-endian u32-Wörter (auf 4 Bytes
// null-aufgefüllt, Wortzahl gerade).
func drmWordsLE4Even(data []byte) []uint32 {
padded := make([]byte, len(data))
copy(padded, data)
if r := len(padded) % 4; r != 0 {
padded = append(padded, make([]byte, 4-r)...)
}
words := make([]uint32, len(padded)/4)
for i := range words {
words[i] = binary.LittleEndian.Uint32(padded[i*4:])
}
if len(words)%2 == 1 {
words = append(words, 0)
}
return words
}
// drmCS64 berechnet die beiden CS64-Prüfsummen-Wörter (wörtliche
// Portierung; Schlüssel aus den ersten 8 Digest-Bytes little-endian).
func drmCS64(data []byte, digest [8]uint32) (a, b uint32) {
var digestBytes [32]byte
for i, w := range digest {
binary.BigEndian.PutUint32(digestBytes[i*4:], w)
}
keyA := binary.LittleEndian.Uint32(digestBytes[0:4]) | 1
keyB := binary.LittleEndian.Uint32(digestBytes[4:8]) | 1
factors := [2]uint32{keyA * drmCS64Factor, keyB * drmCS64Factor}
terms := make([]uint32, 0, 14+8)
offsets := make([]uint32, 0, 14+8)
for i := range drmCS64Coefficients {
terms = append(terms, drmCS64Coefficients[i][0]*factors[i%2])
offsets = append(offsets, drmCS64Coefficients[i][1])
}
for i, word := range drmWordsLE4Even(append([]byte{0}, data...)) {
var term, offset uint32
if i%2 == 0 {
term = word * keyA
offset = drmRol16(word) * 0xEF0569FB
} else {
term = word * keyB
offset = drmRol16(word) * 0xC31713DB
}
if i == 0 {
term += drmCS64Adj0 * factors[0]
offset += drmCS64Adj1
}
terms = append(terms, term)
offsets = append(offsets, offset)
}
var swapAcc, swapLast, st, acc uint32
for i := 0; i < len(terms); i += 2 {
x, y := terms[i], terms[i+1]
t := drmRol16(offsets[i] + x)
q := t * 0x76F50F34
swapAcc += drmRol16(q)*0xC3EFEA97 + q
t = drmRol16(offsets[i+1] + y)
q = t * 0x1881CE3C
swapLast = drmRol16(q)*0x35BD1EC9 + q
swapAcc += swapLast
st = st*keyA + x
st = drmRol16(st) * 0xCF98B111
st = drmRol16(st) * 0x87085B9F
st = drmRol16(st) * 0x12CEB96D
st = drmRol16(st) * 0x257E1D83
acc += st
t = st*keyB + y
t = drmRol16(t) * 0xA27416F5
t = drmRol16(t) * 0xD38396FF
st = drmRol16(drmRol16(t)*0x7C932B89) * 0xBFA49F69
acc += st
}
return swapLast ^ st, swapAcc ^ acc
}
+112
View File
@@ -0,0 +1,112 @@
package provider
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
)
// ElevenLabs-Konstanten (öffentlicher Vertrag: Env-Namen sind Teil der
// Fleet-Doku, Endpunkt-Default Teil der Konfigurierbarkeit).
const (
// DefaultEndpointElevenLabs ist der produktive API-Endpunkt.
DefaultEndpointElevenLabs = "https://api.elevenlabs.io"
// EnvElevenLabsKey liefert den API-Key (Spec: Keys ausschließlich Env,
// nie config.toml).
EnvElevenLabsKey = "TALK_ELEVENLABS_KEY"
// EnvElevenLabsEndpoint überschreibt den Endpunkt (Tests/Firmenserver;
// schlägt die config.toml, siehe voices.go/chain.go-Doku).
EnvElevenLabsEndpoint = "TALK_ELEVENLABS_ENDPOINT"
elevenLabsModel = "eleven_multilingual_v2" // multilingual v2, Task-Brief
)
// ElevenLabs ist die Premium-Stufe der Kette. Synthesize führt POST
// <endpoint>/v1/text-to-speech/<voiceID> aus — Header `xi-api-key`, Body
// {text, model_id: "eleven_multilingual_v2", voice_settings:
// {stability: 0.5, similarity_boost: 0.75}} — und liefert die Antwort-Bytes
// unverändert zurück. Rückgabeformat: MP3 (dokumentierter Vertrag des
// audio-Pakets).
type ElevenLabs struct {
// Endpoint ist die Basis-URL (ohne Pfad); leer wird beim Bau durch den
// Default ersetzt.
Endpoint string
// APIKey ist der ElevenLabs-Key (aus TALK_ELEVENLABS_KEY).
APIKey string
// Voices ist das config-Mapping voice.elevenlabs.<lang> = id; leer =
// neutrale Repo-Defaults (siehe voices.go).
Voices map[string]string
// Client ist der HTTP-Client; nil = geteilter Client mit Timeout
// (cloudTimeout).
Client *http.Client
pinned string // --voice-Pin (VoicePinner)
}
// NewElevenLabs baut den Provider aus der Ketten-Konfiguration: Endpunkt
// aus TALK_ELEVENLABS_ENDPOINT > [providers.elevenlabs].endpoint > Default;
// Key ausschließlich aus TALK_ELEVENLABS_KEY.
func NewElevenLabs(cfg ChainConfig) *ElevenLabs {
return &ElevenLabs{
Endpoint: endpointFrom(cfg, "elevenlabs", EnvElevenLabsEndpoint, DefaultEndpointElevenLabs),
APIKey: cfg.Getenv(EnvElevenLabsKey),
Voices: cfg.Voices["elevenlabs"],
}
}
// Name implementiert Provider.
func (*ElevenLabs) Name() string { return "elevenlabs" }
// PinVoice implementiert VoicePinner (CLI-Flag --voice).
func (p *ElevenLabs) PinVoice(id string) { p.pinned = id }
// Available implementiert Provider: Key und Endpunkt müssen gesetzt sein
// (bewusst ohne Netz-Call — Available muss billig bleiben; ob der Key
// gültig ist, stellt Synthesize fest und die Kette fällt weiter).
func (p *ElevenLabs) Available(ctx context.Context) bool {
return p.APIKey != "" && p.Endpoint != ""
}
// elevenLabsRequest ist der Antwort-Body (Task-Brief, wörtliche Werte).
type elevenLabsRequest struct {
Text string `json:"text"`
ModelID string `json:"model_id"`
VoiceSettings elevenLabsVoiceSettings `json:"voice_settings"`
}
type elevenLabsVoiceSettings struct {
Stability float64 `json:"stability"`
SimilarityBoost float64 `json:"similarity_boost"`
}
// Synthesize implementiert Provider. Rückgabeformat: MP3.
func (p *ElevenLabs) Synthesize(ctx context.Context, text, lang string) ([]byte, error) {
if text == "" {
return nil, fmt.Errorf("elevenlabs: leerer Text")
}
voice := resolveVoice("elevenlabs", lang, p.pinned, p.Voices)
if voice == "" {
return nil, fmt.Errorf("elevenlabs: keine Stimme für Sprache %q konfiguriert", lang)
}
body, err := json.Marshal(elevenLabsRequest{
Text: text,
ModelID: elevenLabsModel,
VoiceSettings: elevenLabsVoiceSettings{Stability: 0.5, SimilarityBoost: 0.75},
})
if err != nil {
return nil, fmt.Errorf("elevenlabs: Anfrage-Body bauen: %w", err)
}
url := strings.TrimRight(p.Endpoint, "/") + "/v1/text-to-speech/" + voice
return postForBytes(ctx, p.Client, "elevenlabs", url,
map[string]string{
"xi-api-key": p.APIKey,
"Accept": "audio/mpeg",
}, body)
}
+204
View File
@@ -0,0 +1,204 @@
package provider
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
)
// fakeTTServation zeichnet einen Request am Fake-Server auf.
type recordedRequest struct {
method string
path string
header http.Header
body []byte
}
// fakeTTSServer ist der httptest-Fake eines Cloud-Providers: antwortet mit
// status + body und zeichnet den (einzigen) gesehenen Request auf.
func fakeTTSServer(t *testing.T, status int, body []byte) (*httptest.Server, *recordedRequest) {
t.Helper()
rec := &recordedRequest{}
var mu sync.Mutex
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body2, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("Fake-Server: Body lesen: %v", err)
}
mu.Lock()
rec.method, rec.path, rec.header, rec.body = r.Method, r.URL.Path, r.Header.Clone(), body2
mu.Unlock()
w.WriteHeader(status)
w.Write(body)
}))
t.Cleanup(ts.Close)
return ts, rec
}
func envMap(kv map[string]string) func(string) string {
return func(k string) string { return kv[k] }
}
// fakeMP3 ist eine beliebige Byte-Folge — die Provider geben Antwort-Bytes
// unverändert zurück, der audio-Sink dekodiert (eigene Tests).
var fakeMP3 = []byte("\xff\xfb\x90\x00" + "audio-bytes-von-elf")
// TestElevenLabsSynthese200: 200 mit Bytes → Bytes kommen unverändert
// zurück; Methode, Pfad (inkl. Voice-ID), Header (xi-api-key) und der
// JSON-Body (model_id, voice_settings) entsprechen dem Task-Brief.
func TestElevenLabsSynthese200(t *testing.T) {
ts, rec := fakeTTSServer(t, 200, fakeMP3)
p := NewElevenLabs(ChainConfig{
Getenv: envMap(map[string]string{
EnvElevenLabsKey: "testkey",
EnvElevenLabsEndpoint: ts.URL,
}),
})
data, err := p.Synthesize(context.Background(), "Hallo Welt", "de")
if err != nil {
t.Fatalf("Synthesize: %v", err)
}
if string(data) != string(fakeMP3) {
t.Errorf("Synthesize = %q, want Fake-Bytes %q", data, fakeMP3)
}
if rec.method != http.MethodPost {
t.Errorf("Methode = %s, want POST", rec.method)
}
if want := "/v1/text-to-speech/21m00Tcm4TlvDq8ikWAM"; rec.path != want {
t.Errorf("Pfad = %q, want %q (neutrales Repo-Default „Rachel“)", rec.path, want)
}
if got := rec.header.Get("xi-api-key"); got != "testkey" {
t.Errorf("xi-api-key = %q, want %q", got, "testkey")
}
var body struct {
Text string `json:"text"`
ModelID string `json:"model_id"`
VoiceSettings struct {
Stability float64 `json:"stability"`
SimilarityBoost float64 `json:"similarity_boost"`
} `json:"voice_settings"`
}
if err := json.Unmarshal(rec.body, &body); err != nil {
t.Fatalf("Antwort-Body ist kein JSON: %v (%s)", err, rec.body)
}
if body.Text != "Hallo Welt" {
t.Errorf("text = %q", body.Text)
}
if body.ModelID != "eleven_multilingual_v2" {
t.Errorf("model_id = %q, want eleven_multilingual_v2", body.ModelID)
}
if body.VoiceSettings.Stability != 0.5 || body.VoiceSettings.SimilarityBoost != 0.75 {
t.Errorf("voice_settings = %+v, want stability 0.5 / similarity_boost 0.75", body.VoiceSettings)
}
}
// TestElevenLabsHTTPFehler: 500 und 401 sind klare Fehler, die Status und
// einen Body-Ausschnitt nennen (die Kette fällt daran weiter).
func TestElevenLabsHTTPFehler(t *testing.T) {
cases := []struct {
status int
body string
want string
}{
{500, "internal boom", "500"},
{401, `{"detail":{"status":"invalid_api_key"}}`, "401"},
}
for _, tc := range cases {
t.Run(http.StatusText(tc.status), func(t *testing.T) {
ts, _ := fakeTTSServer(t, tc.status, []byte(tc.body))
p := NewElevenLabs(ChainConfig{Getenv: envMap(map[string]string{
EnvElevenLabsKey: "testkey",
EnvElevenLabsEndpoint: ts.URL,
})})
_, err := p.Synthesize(context.Background(), "Hallo", "de")
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("Synthesize-Fehler = %v, will HTTP %s", err, tc.want)
}
if !strings.Contains(err.Error(), "elevenlabs") {
t.Errorf("Fehler soll den Provider nennen: %v", err)
}
})
}
}
// TestElevenLabsAvailable: ohne Key nicht verfügbar, mit Key verfügbar —
// ohne jeden Netz-Call.
func TestElevenLabsAvailable(t *testing.T) {
if NewElevenLabs(ChainConfig{Getenv: envMap(nil)}).Available(context.Background()) {
t.Error("Available ohne Key = true, want false")
}
p := NewElevenLabs(ChainConfig{Getenv: envMap(map[string]string{EnvElevenLabsKey: "k"})})
if !p.Available(context.Background()) {
t.Error("Available mit Key = false, want true")
}
}
// TestElevenLabsVoiceRangfolge: --voice-Pin schlägt config-Mapping, Mapping
// schlägt Repo-Default (beobachtbar am Request-Pfad).
func TestElevenLabsVoiceRangfolge(t *testing.T) {
mk := func(t *testing.T, voices map[string]string, pin string) (*ElevenLabs, *recordedRequest) {
t.Helper()
ts, rec := fakeTTSServer(t, 200, fakeMP3)
p := NewElevenLabs(ChainConfig{
Getenv: envMap(map[string]string{
EnvElevenLabsKey: "k",
EnvElevenLabsEndpoint: ts.URL,
}),
Voices: map[string]map[string]string{"elevenlabs": voices},
})
if pin != "" {
p.PinVoice(pin)
}
return p, rec
}
t.Run("config-Mapping schlägt Default", func(t *testing.T) {
p, rec := mk(t, map[string]string{"de": "AusConfig"}, "")
if _, err := p.Synthesize(context.Background(), "Hallo", "de"); err != nil {
t.Fatalf("Synthesize: %v", err)
}
if want := "/v1/text-to-speech/AusConfig"; rec.path != want {
t.Errorf("Pfad = %q, want %q", rec.path, want)
}
})
t.Run("Pin (--voice) schlägt config-Mapping", func(t *testing.T) {
p, rec := mk(t, map[string]string{"de": "AusConfig"}, "Gepinnt")
if _, err := p.Synthesize(context.Background(), "Hallo", "de"); err != nil {
t.Fatalf("Synthesize: %v", err)
}
if want := "/v1/text-to-speech/Gepinnt"; rec.path != want {
t.Errorf("Pfad = %q, want %q", rec.path, want)
}
})
t.Run("unbekannte Sprache fällt auf en-Default", func(t *testing.T) {
p, rec := mk(t, nil, "")
if _, err := p.Synthesize(context.Background(), "Hallo", "fr"); err != nil {
t.Fatalf("Synthesize: %v", err)
}
if want := "/v1/text-to-speech/21m00Tcm4TlvDq8ikWAM"; rec.path != want {
t.Errorf("Pfad = %q, want en-Default %q", rec.path, want)
}
})
}
// TestElevenLabsLeererText ist ein klarer Fehler ohne Request.
func TestElevenLabsLeererText(t *testing.T) {
ts, rec := fakeTTSServer(t, 200, fakeMP3)
p := NewElevenLabs(ChainConfig{Getenv: envMap(map[string]string{
EnvElevenLabsKey: "k",
EnvElevenLabsEndpoint: ts.URL,
})})
if _, err := p.Synthesize(context.Background(), "", "de"); err == nil {
t.Error("Synthesize(\"\") soll fehlschlagen")
}
if rec.method != "" {
t.Error("Bei leerem Text darf kein Request gehen")
}
}
+70
View File
@@ -0,0 +1,70 @@
package provider
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"time"
)
// cloudTimeout ist das Budget für einen einzigen Synthese-Call eines
// Cloud-Providers (Aufbau + Anfrage + vollständige Antwort). Ansagen sind
// kurz; nach Ablauf greift der Fallback auf die nächste Ketten-Stufe.
const cloudTimeout = 45 * time.Second
// cloudClient ist der geteilte HTTP-Client der REST-Provider (mit Timeout,
// im Gegensatz zu http.DefaultClient). Bei Ausbleiben einer Antwort greift
// so der Ketten-Fallback statt einer Endlos-Blockade.
var cloudClient = &http.Client{Timeout: cloudTimeout}
// maxErrBody begrenzt, wie viel einer Fehler-Antwort in die Fehlermeldung
// übernommen wird (Diagnose ja, Spam nein).
const maxErrBody = 200
// postForBytes macht den gemeinsamen Kern der REST-Provider: POST von JSON
// an url mit Headern, erwartet 2xx und liefert die rohen Antwort-Bytes
// (Audio). Alles andere — non-2xx, Netz-Fehler, Timeouts, leere Antworten —
// ist ein klarer Fehler, an dem die Kette in die nächste Stufe fällt.
func postForBytes(ctx context.Context, client *http.Client, providerName, url string, headers map[string]string, body []byte) ([]byte, error) {
if client == nil {
client = cloudClient
}
// Eigenes Zeit-Budget setzen, falls der Aufrufer keins mitgibt — die
// Kette soll auch ohne fremden Deadline-Kontext weiterfallen können.
if _, hasDeadline := ctx.Deadline(); !hasDeadline {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, cloudTimeout)
defer cancel()
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("%s: Anfrage an %s bauen: %w", providerName, url, err)
}
req.Header.Set("Content-Type", "application/json")
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("%s: Anfrage an %s fehlgeschlagen (Netz/Timeout?): %w", providerName, url, err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode > 299 {
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrBody))
return nil, fmt.Errorf("%s: HTTP %d von %s: %s", providerName, resp.StatusCode, url, bytes.TrimSpace(snippet))
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("%s: Antwort von %s lesen: %w", providerName, url, err)
}
if len(data) == 0 {
return nil, fmt.Errorf("%s: leere Antwort von %s (0 Bytes Audio)", providerName, url)
}
return data, nil
}
+106
View File
@@ -0,0 +1,106 @@
package provider
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
)
// OpenAI-TTS-Konstanten (öffentlicher Vertrag wie bei ElevenLabs).
const (
// DefaultEndpointOpenAI ist der produktive API-Endpunkt.
DefaultEndpointOpenAI = "https://api.openai.com"
// DefaultModelOpenAI: Entscheidungsnotiz aus dem Task-Brief — der
// Modell-String ist konfigurierbar ([providers.openai] model), Default
// "tts-1"; "gpt-4o-mini-tts" ist ein gültiger Alternativwert.
DefaultModelOpenAI = "tts-1"
EnvOpenAIKey = "TALK_OPENAI_KEY"
EnvOpenAIEndpoint = "TALK_OPENAI_ENDPOINT"
)
// OpenAI ist die zweite Stufe der Kette (für Nutzer mit vorhandenem
// OpenAI-Key). Synthesize führt POST <endpoint>/v1/audio/speech aus —
// Bearer-Key, Body {model, voice, input, response_format: "mp3"} — und
// liefert die Antwort-Bytes unverändert zurück. Rückgabeformat: MP3.
type OpenAI struct {
// Endpoint ist die Basis-URL (ohne Pfad); leer = Default beim Bau.
Endpoint string
// APIKey ist der OpenAI-Key (aus TALK_OPENAI_KEY).
APIKey string
// Model ist der TTS-Modell-String; leer = DefaultModelOpenAI.
Model string
// Voices ist das config-Mapping voice.openai.<lang> = id; leer =
// neutrale Repo-Defaults (alloy, siehe voices.go).
Voices map[string]string
// Client ist der HTTP-Client; nil = geteilter Client mit Timeout.
Client *http.Client
pinned string // --voice-Pin (VoicePinner)
}
// NewOpenAI baut den Provider aus der Ketten-Konfiguration: Endpunkt aus
// TALK_OPENAI_ENDPOINT > [providers.openai].endpoint > Default; Modell aus
// [providers.openai].model; Key ausschließlich aus TALK_OPENAI_KEY.
func NewOpenAI(cfg ChainConfig) *OpenAI {
return &OpenAI{
Endpoint: endpointFrom(cfg, "openai", EnvOpenAIEndpoint, DefaultEndpointOpenAI),
APIKey: cfg.Getenv(EnvOpenAIKey),
Model: cfg.Model,
Voices: cfg.Voices["openai"],
}
}
// Name implementiert Provider.
func (*OpenAI) Name() string { return "openai" }
// PinVoice implementiert VoicePinner (CLI-Flag --voice).
func (p *OpenAI) PinVoice(id string) { p.pinned = id }
// Available implementiert Provider: Key und Endpunkt müssen gesetzt sein
// (ohne Netz-Call, siehe ElevenLabs.Available).
func (p *OpenAI) Available(ctx context.Context) bool {
return p.APIKey != "" && p.Endpoint != ""
}
// openAIRequest ist der Anfrage-Body.
type openAIRequest struct {
Model string `json:"model"`
Voice string `json:"voice"`
Input string `json:"input"`
ResponseFormat string `json:"response_format"`
}
// Synthesize implementiert Provider. Rückgabeformat: MP3.
func (p *OpenAI) Synthesize(ctx context.Context, text, lang string) ([]byte, error) {
if text == "" {
return nil, fmt.Errorf("openai: leerer Text")
}
voice := resolveVoice("openai", lang, p.pinned, p.Voices)
if voice == "" {
return nil, fmt.Errorf("openai: keine Stimme für Sprache %q konfiguriert", lang)
}
model := p.Model
if model == "" {
model = DefaultModelOpenAI
}
body, err := json.Marshal(openAIRequest{
Model: model,
Voice: voice,
Input: text,
ResponseFormat: "mp3",
})
if err != nil {
return nil, fmt.Errorf("openai: Anfrage-Body bauen: %w", err)
}
url := strings.TrimRight(p.Endpoint, "/") + "/v1/audio/speech"
return postForBytes(ctx, p.Client, "openai", url,
map[string]string{"Authorization": "Bearer " + p.APIKey}, body)
}
+164
View File
@@ -0,0 +1,164 @@
package provider
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"testing"
)
// TestOpenAISynthese200: 200 mit Bytes → Bytes unverändert; POST
// /v1/audio/speech, Bearer-Key, Body mit model/voice/input/response_format
// laut Task-Brief (Default-Modell tts-1, Default-Stimme alloy).
func TestOpenAISynthese200(t *testing.T) {
ts, rec := fakeTTSServer(t, 200, fakeMP3)
p := NewOpenAI(ChainConfig{
Getenv: envMap(map[string]string{
EnvOpenAIKey: "sk-test",
EnvOpenAIEndpoint: ts.URL,
}),
})
data, err := p.Synthesize(context.Background(), "Build fertig", "en")
if err != nil {
t.Fatalf("Synthesize: %v", err)
}
if string(data) != string(fakeMP3) {
t.Errorf("Synthesize = %q, want Fake-Bytes", data)
}
if rec.method != http.MethodPost {
t.Errorf("Methode = %s, want POST", rec.method)
}
if want := "/v1/audio/speech"; rec.path != want {
t.Errorf("Pfad = %q, want %q", rec.path, want)
}
if got := rec.header.Get("Authorization"); got != "Bearer sk-test" {
t.Errorf("Authorization = %q, want Bearer sk-test", got)
}
var body struct {
Model string `json:"model"`
Voice string `json:"voice"`
Input string `json:"input"`
ResponseFormat string `json:"response_format"`
}
if err := json.Unmarshal(rec.body, &body); err != nil {
t.Fatalf("Body ist kein JSON: %v (%s)", err, rec.body)
}
if body.Model != "tts-1" {
t.Errorf("model = %q, want Default tts-1", body.Model)
}
if body.Voice != "alloy" {
t.Errorf("voice = %q, want Default alloy", body.Voice)
}
if body.Input != "Build fertig" {
t.Errorf("input = %q", body.Input)
}
if body.ResponseFormat != "mp3" {
t.Errorf("response_format = %q, want mp3", body.ResponseFormat)
}
}
// TestOpenAIHTTPFehler: 500 und 401 sind klare Fehler mit Status.
func TestOpenAIHTTPFehler(t *testing.T) {
for _, status := range []int{500, 401} {
t.Run(http.StatusText(status), func(t *testing.T) {
ts, _ := fakeTTSServer(t, status, []byte("boom"))
p := NewOpenAI(ChainConfig{Getenv: envMap(map[string]string{
EnvOpenAIKey: "sk-test",
EnvOpenAIEndpoint: ts.URL,
})})
_, err := p.Synthesize(context.Background(), "Hallo", "de")
if err == nil || !strings.Contains(err.Error(), fmt.Sprintf("%d", status)) {
t.Fatalf("Synthesize-Fehler = %v, will HTTP %d", err, status)
}
if !strings.Contains(err.Error(), "openai") {
t.Errorf("Fehler soll den Provider nennen: %v", err)
}
})
}
}
// TestOpenAIModelKonfigurierbar: [providers.openai].model ändert den
// Modell-String im Body (Entscheidungsnotiz: Default tts-1, Alternativen
// wie gpt-4o-mini-tts erlaubt).
func TestOpenAIModelKonfigurierbar(t *testing.T) {
ts, rec := fakeTTSServer(t, 200, fakeMP3)
p := NewOpenAI(ChainConfig{
Getenv: envMap(map[string]string{
EnvOpenAIKey: "sk-test",
EnvOpenAIEndpoint: ts.URL,
}),
Model: "gpt-4o-mini-tts",
})
if _, err := p.Synthesize(context.Background(), "Hallo", "de"); err != nil {
t.Fatalf("Synthesize: %v", err)
}
var body struct {
Model string `json:"model"`
}
if err := json.Unmarshal(rec.body, &body); err != nil {
t.Fatal(err)
}
if body.Model != "gpt-4o-mini-tts" {
t.Errorf("model = %q, want gpt-4o-mini-tts", body.Model)
}
}
// TestOpenAIAvailable: Key entscheidet — ohne Netz-Call.
func TestOpenAIAvailable(t *testing.T) {
if NewOpenAI(ChainConfig{Getenv: envMap(nil)}).Available(context.Background()) {
t.Error("Available ohne Key = true, want false")
}
p := NewOpenAI(ChainConfig{Getenv: envMap(map[string]string{EnvOpenAIKey: "sk"})})
if !p.Available(context.Background()) {
t.Error("Available mit Key = false, want true")
}
}
// TestOpenAIVoiceRangfolge: Pin schlägt Mapping schlägt Default.
func TestOpenAIVoiceRangfolge(t *testing.T) {
t.Run("Mapping schlägt Default", func(t *testing.T) {
ts, rec := fakeTTSServer(t, 200, fakeMP3)
p := NewOpenAI(ChainConfig{
Getenv: envMap(map[string]string{
EnvOpenAIKey: "sk",
EnvOpenAIEndpoint: ts.URL,
}),
Voices: map[string]map[string]string{"openai": {"en": "nova"}},
})
if _, err := p.Synthesize(context.Background(), "Hi", "en"); err != nil {
t.Fatalf("Synthesize: %v", err)
}
var body struct {
Voice string `json:"voice"`
}
json.Unmarshal(rec.body, &body)
if body.Voice != "nova" {
t.Errorf("voice = %q, want nova (config)", body.Voice)
}
})
t.Run("Pin schlägt Mapping", func(t *testing.T) {
ts, rec := fakeTTSServer(t, 200, fakeMP3)
p := NewOpenAI(ChainConfig{
Getenv: envMap(map[string]string{
EnvOpenAIKey: "sk",
EnvOpenAIEndpoint: ts.URL,
}),
Voices: map[string]map[string]string{"openai": {"en": "nova"}},
})
p.PinVoice("echo")
if _, err := p.Synthesize(context.Background(), "Hi", "en"); err != nil {
t.Fatalf("Synthesize: %v", err)
}
var body struct {
Voice string `json:"voice"`
}
json.Unmarshal(rec.body, &body)
if body.Voice != "echo" {
t.Errorf("voice = %q, want echo (--voice)", body.Voice)
}
})
}
+14 -4
View File
@@ -48,7 +48,7 @@ func TestOSBuiltinName(t *testing.T) {
func TestNewRegistry(t *testing.T) {
for _, name := range []string{"", "osbuiltin"} {
p, err := New(name)
p, err := New(name, ChainConfig{})
if err != nil {
t.Fatalf("New(%q): %v", name, err)
}
@@ -56,9 +56,19 @@ func TestNewRegistry(t *testing.T) {
t.Errorf("New(%q).Name() = %q", name, p.Name())
}
}
if _, err := New("elevenlabs"); err == nil {
t.Error("New(\"elevenlabs\") soll in diesem Stand fehlschlagen (Task 03 ergänzt)")
} else if !strings.Contains(err.Error(), "osbuiltin") {
// Task 03: die Cloud-Provider stehen in der Registry.
for _, name := range []string{"elevenlabs", "openai", "edge"} {
p, err := New(name, ChainConfig{})
if err != nil {
t.Fatalf("New(%q): %v", name, err)
}
if p.Name() != name {
t.Errorf("New(%q).Name() = %q", name, p.Name())
}
}
if _, err := New("gibtsnicht", ChainConfig{}); err == nil {
t.Error("New(\"gibtsnicht\") soll fehlschlagen")
} else if !strings.Contains(err.Error(), "osbuiltin") || !strings.Contains(err.Error(), "elevenlabs") {
t.Errorf("New-Fehler soll verfügbare Provider nennen: %v", err)
}
}
+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
}
+76
View File
@@ -0,0 +1,76 @@
package provider
import "sort"
// voiceDefaults sind die neutralen Repo-Defaults je Provider und Sprache
// (Teil des öffentlichen Vertrags: `talk voices` aus Task 08 listet sie,
// überschrieben werden sie durch voice.<provider>.<lang> in der config.toml
// oder das CLI-Flag --voice).
//
// Entscheidungsnotiz ElevenLabs-Stimme: Spec und Plan führen die persönliche
// Stimme KXxZd16DiBqt82nbarJx ausdrücklich als PRIVATEN Default des Nutzers
// („nicht Repo-Default", Spec Further Notes); der Repo-Default ist deshalb
// die neutrale, mehrsprachige Standardstimme „Rachel" (21m00Tcm4TlvDq8ikWAM
// aus dem öffentlichen ElevenLabs-Katalog). Die persönliche Stimme gehört in
// die eigene config.toml:
//
// [voice.elevenlabs]
// de = "KXxZd16DiBqt82nbarJx"
// en = "KXxZd16DiBqt82nbarJx"
//
// OpenAI „alloy" und die Edge-Neural-Stimmen (de-DE-KatjaNeural,
// en-US-AriaNeural) sind die jeweils neutralen Standardstimmen der APIs
// (Edge-Stimmen sind im Task-Brief vorgegeben).
var voiceDefaults = map[string]map[string]string{
"elevenlabs": {
"de": "21m00Tcm4TlvDq8ikWAM", // „Rachel", multilingual
"en": "21m00Tcm4TlvDq8ikWAM",
},
"openai": {
"de": "alloy",
"en": "alloy",
},
"edge": {
"de": "de-DE-KatjaNeural",
"en": "en-US-AriaNeural",
},
"osbuiltin": {
// osbuiltin mappt intern selbst (macOS Anna/Samantha, SAPI-Kultur,
// espeak -v <lang>) und kennt keine Voice-IDs — hier nur der
// Vollständigkeit wegen dokumentiert.
"de": "",
"en": "",
},
}
// resolveVoice liefert die Voice-ID für einen Provider und eine Sprache.
// Rangfolge: override (CLI-Flag --voice bzw. PinVoice) > Mapping aus der
// config.toml > neutrales Repo-Default > en-Default > alphabetisch erstes
// Default. Ein leerer Rückgabewert heißt: der Provider kennt für diese
// Sprache keine Stimme (osbuiltin) und entscheidet selbst.
func resolveVoice(name, lang, override string, cfgVoices map[string]string) string {
if override != "" {
return override
}
if v := cfgVoices[lang]; v != "" {
return v
}
def := voiceDefaults[name]
if v := def[lang]; v != "" {
return v
}
if v := def["en"]; v != "" {
return v
}
keys := make([]string, 0, len(def))
for k := range def {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
if v := def[k]; v != "" {
return v
}
}
return ""
}
+32
View File
@@ -0,0 +1,32 @@
package provider
import "testing"
// TestResolveVoiceRangfolge pinnt die dokumentierte Rangfolge
// --voice-Pin > config-Mapping > Repo-Default > en-Default.
func TestResolveVoiceRangfolge(t *testing.T) {
cases := []struct {
name string
provider string
lang string
override string
cfg map[string]string
want string
}{
{"Default edge de", "edge", "de", "", nil, "de-DE-KatjaNeural"},
{"Default edge en", "edge", "en", "", nil, "en-US-AriaNeural"},
{"Default elevenlabs de (neutral, nicht privat)", "elevenlabs", "de", "", nil, "21m00Tcm4TlvDq8ikWAM"},
{"Default openai en", "openai", "en", "", nil, "alloy"},
{"Mapping schlägt Default", "edge", "de", "", map[string]string{"de": "de-DE-ConradNeural"}, "de-DE-ConradNeural"},
{"Pin schlägt Mapping", "edge", "de", "Gepinnt", map[string]string{"de": "de-DE-ConradNeural"}, "Gepinnt"},
{"unbekannte Sprache → en-Default", "edge", "fr", "", nil, "en-US-AriaNeural"},
{"osbuiltin kennt keine Voice-IDs", "osbuiltin", "de", "", nil, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := resolveVoice(tc.provider, tc.lang, tc.override, tc.cfg); got != tc.want {
t.Errorf("resolveVoice = %q, want %q", got, tc.want)
}
})
}
}