From 91cd0ec8da2a7a22fef955057bcde557228e8e45 Mon Sep 17 00:00:00 2001 From: m3ta-chiron Date: Sun, 18 Oct 2026 16:34:43 +0200 Subject: [PATCH] feat(provider): Cloud-Provider und Vier-Stufen-Fallback-Kette MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- internal/provider/chain.go | 269 +++++++++++++++++++ internal/provider/chain_test.go | 342 ++++++++++++++++++++++++ internal/provider/doc.go | 16 +- internal/provider/edge.go | 372 +++++++++++++++++++++++++++ internal/provider/edge_test.go | 321 +++++++++++++++++++++++ internal/provider/edgedrm.go | 303 ++++++++++++++++++++++ internal/provider/elevenlabs.go | 112 ++++++++ internal/provider/elevenlabs_test.go | 204 +++++++++++++++ internal/provider/httpclient.go | 70 +++++ internal/provider/openai.go | 106 ++++++++ internal/provider/openai_test.go | 164 ++++++++++++ internal/provider/osbuiltin_test.go | 18 +- internal/provider/provider.go | 56 ++-- internal/provider/voices.go | 76 ++++++ internal/provider/voices_test.go | 32 +++ 15 files changed, 2434 insertions(+), 27 deletions(-) create mode 100644 internal/provider/chain.go create mode 100644 internal/provider/chain_test.go create mode 100644 internal/provider/edge.go create mode 100644 internal/provider/edge_test.go create mode 100644 internal/provider/edgedrm.go create mode 100644 internal/provider/elevenlabs.go create mode 100644 internal/provider/elevenlabs_test.go create mode 100644 internal/provider/httpclient.go create mode 100644 internal/provider/openai.go create mode 100644 internal/provider/openai_test.go create mode 100644 internal/provider/voices.go create mode 100644 internal/provider/voices_test.go diff --git a/internal/provider/chain.go b/internal/provider/chain.go new file mode 100644 index 0000000..a4df552 --- /dev/null +++ b/internal/provider/chain.go @@ -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..-Mapping aus der config.toml. + Voices map[string]map[string]string + + // Endpoints sind die Datei-Endpunkte [providers.].endpoint; + // TALK__ENDPOINT (via Getenv) schlägt sie, sie schlägt die + // eingebauten Defaults (siehe endpointFrom). + Endpoints map[string]string + + // Model ist [providers.openai].model (leer = "tts-1"). + Model string + + // Getenv liefert Umgebungsvariablen (Keys, Endpunkt-Overrides). Nil + // wird beim Bau durch eine Funktion ersetzt, die immer "" liefert. + Getenv func(string) string + + // Player gibt erfolgreiche Synthesen aus; nil = die Kette liefert nur + // Bytes (Synthesize) und Speak meldet einen klaren Fehler. + Player Player + + // Log empfängt die Fallback-Protokoll-Zeilen (CLI: stderr). Fallbacks + // sind nie still: Jede übersprungene oder fehlgeschlagene Stufe wird + // hier gemeldet — auch wenn am Ende noch eine Stufe Erfolg hat. + Log io.Writer +} + +// endpointFrom löst einen Provider-Endpunkt auf: +// TALK__ENDPOINT (Env) > [providers.].endpoint (Datei) > +// eingebauter Default. Env-Overrides in der Provider-Schicht zu lesen hält +// das config-Paket frei von Provider-Namen und erlaubt Prozessgrenzen- +// Tests ohne Config-Datei. +func endpointFrom(cfg ChainConfig, name, envName, def string) string { + if cfg.Getenv != nil { + if v := cfg.Getenv(envName); v != "" { + return v + } + } + if v := cfg.Endpoints[name]; v != "" { + return v + } + return def +} + +// Chain ist die geordnete Fallback-Kette über Provider. Sie implementiert +// selbst Provider (Synthesize liefert die Bytes der ersten erfolgreichen +// Stufe) UND DirectSpeaker (letzter Stufen-Ausweg für Plattformen ohne +// Byte-fähiges TTS) — damit ist sie für CLI und Daemon (Task 04) +// austauschbar gegen jeden einzelnen Provider. +// +// Speak ist die komplette Ansage-Orchestrierung: Bytes-Synthese mit +// Fallback, Wiedergabe über den Player, und als letzte Stufe die +// Direktansage (osbuiltin über spd-say). Bei einem null-Player wird die +// Direktansage tonlos verworfen (CI/Server) statt Ton zu erzeugen. +type Chain struct { + cfg ChainConfig + tiers []Provider +} + +// NewChain baut die Kette: gepinnt (Pin) oder in DefaultOrder. Unbekannte +// Namen sind ein klarer Fehler, der die verfügbaren Namen nennt. Das +// --voice-Pin wird auf alle Stufen angewandt, die VoicePinner implementieren +// (Stufen ohne Voice-Begriff bekommen einen protokollierten Hinweis). +func NewChain(cfg ChainConfig) (*Chain, error) { + if cfg.Getenv == nil { + cfg.Getenv = func(string) string { return "" } + } + names := DefaultOrder + if cfg.Pin != "" { + names = []string{cfg.Pin} + } + tiers := make([]Provider, 0, len(names)) + for _, name := range names { + p, err := New(name, cfg) + if err != nil { + return nil, err + } + if cfg.Voice != "" { + if vp, ok := p.(VoicePinner); ok { + vp.PinVoice(cfg.Voice) + } else { + chainLogf(cfg, "kette: --voice für Stufe %s ignoriert (kennt keine Voice-IDs)\n", name) + } + } + tiers = append(tiers, p) + } + return &Chain{cfg: cfg, tiers: tiers}, nil +} + +// Name implementiert Provider. +func (*Chain) Name() string { return "chain" } + +// Tiers liefert die Stufen in Ketten-Reihenfolge (Diagnose/doctor). +func (c *Chain) Tiers() []Provider { return c.tiers } + +// Available implementiert Provider: true, sobald eine Stunde verfügbar ist. +func (c *Chain) Available(ctx context.Context) bool { + for _, p := range c.tiers { + if p.Available(ctx) { + return true + } + } + return false +} + +// walkResult ist das interne Ergebnis des Ketten-Durchlaufs. +type walkResult struct { + data []byte // Bytes der ersten erfolgreichen Stufe (nil = keine) + used Provider // die erfolgreiche Stufe + direct DirectSpeaker // erste Stufe, die nur direkt sprechen kann + directName string + failed []string // "name: fehler" je fehlgeschlagener Stufe + skipped []string // Namen nicht verfügbarer Stufen +} + +// walk läuft die Kette in Ordnung ab: nicht verfügbare Stufen werden +// übersprungen (protokolliert), fehlgeschlagene protokolliert und +// überwunden. Eine Stufe mit ErrNoByteOutput, die DirectSpeaker +// implementiert, wird als letzter Ausweg gemerkt (der Durchlauf geht +// weiter — eine spätere Stufe könnte noch Bytes liefern). +func (c *Chain) walk(ctx context.Context, text, lang string) walkResult { + var r walkResult + for _, p := range c.tiers { + if !p.Available(ctx) { + r.skipped = append(r.skipped, p.Name()) + chainLogf(c.cfg, "kette: Stufe %s übersprungen (nicht verfügbar)\n", p.Name()) + continue + } + data, err := p.Synthesize(ctx, text, lang) + switch { + case err == nil: + r.data, r.used = data, p + chainLogf(c.cfg, "kette: Ansage über %s\n", p.Name()) + return r + case errors.Is(err, ErrNoByteOutput) && r.direct == nil: + if ds, ok := p.(DirectSpeaker); ok { + r.direct, r.directName = ds, p.Name() + chainLogf(c.cfg, "kette: Stufe %s liefert keine Bytes, kann aber direkt sprechen (als letzte Stufe gemerkt)\n", p.Name()) + continue + } + r.failed = append(r.failed, p.Name()+": "+err.Error()) + chainLogf(c.cfg, "kette: Stufe %s fehlgeschlagen: %v — Fallback auf nächste Stufe\n", p.Name(), err) + default: + r.failed = append(r.failed, p.Name()+": "+err.Error()) + chainLogf(c.cfg, "kette: Stufe %s fehlgeschlagen: %v — Fallback auf nächste Stufe\n", p.Name(), err) + } + } + return r +} + +// Synthesize implementiert Provider: Bytes der ersten erfolgreichen Stufe. +// Kann nur eine Direkt-Stufe sprechen, ist der Fehler ErrNoByteOutput (die +// Kette erfüllt damit den DirectSpeaker-Vertrag am Aufrufer). Scheitert +// alles, nennt der Fehler die letzte Stufen-Fehler — nie still. +func (c *Chain) Synthesize(ctx context.Context, text, lang string) ([]byte, error) { + r := c.walk(ctx, text, lang) + if r.data != nil { + return r.data, nil + } + if r.direct != nil { + if len(r.failed) > 0 { + return nil, fmt.Errorf("%w (Stufe %s spricht nur direkt; daneben fehlgeschlagen: %s)", + ErrNoByteOutput, r.directName, strings.Join(r.failed, "; ")) + } + return nil, fmt.Errorf("%w (Stufe %s spricht nur direkt)", ErrNoByteOutput, r.directName) + } + return nil, 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, "; ") +} diff --git a/internal/provider/chain_test.go b/internal/provider/chain_test.go new file mode 100644 index 0000000..5c034dd --- /dev/null +++ b/internal/provider/chain_test.go @@ -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) + } +} diff --git a/internal/provider/doc.go b/internal/provider/doc.go index 8310813..b883619 100644 --- a/internal/provider/doc.go +++ b/internal/provider/doc.go @@ -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 diff --git a/internal/provider/edge.go b/internal/provider/edge.go new file mode 100644 index 0000000..e3fe6f3 --- /dev/null +++ b/internal/provider/edge.go @@ -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. = 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 → +// „-" (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( + ""+ + ""+ + "%s"+ + "", + 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, "&"...) + case '<': + esc = append(esc, "<"...) + case '>': + esc = append(esc, ">"...) + 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() +} diff --git a/internal/provider/edge_test.go b/internal/provider/edge_test.go new file mode 100644 index 0000000..d76118b --- /dev/null +++ b/internal/provider/edge_test.go @@ -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 „fertig“") + for _, want := range []string{ + "voice name='de-DE-KatjaNeural'", + "xml:lang='en-US'", + "AT&T <Kunde>", + } { + 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\x08e" + if want := "a b&c<d> 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 & 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)) +} diff --git a/internal/provider/edgedrm.go b/internal/provider/edgedrm.go new file mode 100644 index 0000000..eb80d19 --- /dev/null +++ b/internal/provider/edgedrm.go @@ -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 +} diff --git a/internal/provider/elevenlabs.go b/internal/provider/elevenlabs.go new file mode 100644 index 0000000..e05aba4 --- /dev/null +++ b/internal/provider/elevenlabs.go @@ -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 +// /v1/text-to-speech/ 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. = 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) +} diff --git a/internal/provider/elevenlabs_test.go b/internal/provider/elevenlabs_test.go new file mode 100644 index 0000000..b995333 --- /dev/null +++ b/internal/provider/elevenlabs_test.go @@ -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") + } +} diff --git a/internal/provider/httpclient.go b/internal/provider/httpclient.go new file mode 100644 index 0000000..eb47a6c --- /dev/null +++ b/internal/provider/httpclient.go @@ -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 +} diff --git a/internal/provider/openai.go b/internal/provider/openai.go new file mode 100644 index 0000000..07ffae4 --- /dev/null +++ b/internal/provider/openai.go @@ -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 /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. = 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) +} diff --git a/internal/provider/openai_test.go b/internal/provider/openai_test.go new file mode 100644 index 0000000..0ae3b2b --- /dev/null +++ b/internal/provider/openai_test.go @@ -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) + } + }) +} diff --git a/internal/provider/osbuiltin_test.go b/internal/provider/osbuiltin_test.go index 690dcd1..16bad41 100644 --- a/internal/provider/osbuiltin_test.go +++ b/internal/provider/osbuiltin_test.go @@ -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) } } diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 637407b..e8f10b8 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -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..-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 } diff --git a/internal/provider/voices.go b/internal/provider/voices.go new file mode 100644 index 0000000..1e24186 --- /dev/null +++ b/internal/provider/voices.go @@ -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.. 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 ) 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 "" +} diff --git a/internal/provider/voices_test.go b/internal/provider/voices_test.go new file mode 100644 index 0000000..901a794 --- /dev/null +++ b/internal/provider/voices_test.go @@ -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) + } + }) + } +}