feat(provider): Cloud-Provider und Vier-Stufen-Fallback-Kette
- elevenlabs: POST /v1/text-to-speech/{voice}, Header xi-api-key, Body mit
model_id eleven_multilingual_v2 und voice_settings 0.5/0.75, MP3-Bytes.
- openai: POST /v1/audio/speech, Bearer-Key, Body {model, voice, input,
response_format:mp3}; Modell konfigurierbar (Default tts-1).
- edge: Microsofts Read-Aloud-WSS-Protokoll nach edge-tts-Referenz,
inklusive aktuellem DRM Sec-MS-GEC v2 (CS64 + verschleierte SHA-256-
Variante, wörtlich portiert und gegen Referenzvektoren getestet) und
MUID-Cookie; bei 403 Uhrenkorrektur aus dem Date-Header + ein Retry.
Klare Protokoll-geaendert-Fehler statt stiller Fehlfunktion.
- chain: geordnete Kette (elevenlabs -> openai -> edge -> osbuiltin), Pin
per --provider/config, Available ohne Netz-Calls, Fallback mit
Protokoll je Stufe (nie still). Chain implementiert Provider UND
DirectSpeaker: Speak orchestriert Bytes -> Player bzw. Direktansage,
verwirft am null-Player tonlos — CLI und Daemon (Task 04) nutzen
dieselbe Kette unverändert.
- voices: neutrale Repo-Defaults je Provider+Sprache (Rachel/alloy/
Katja/Aria); Rangfolge --voice > config-Mapping > Default. KXxZ… bleibt
bewusst Privat-Default der eigenen config.toml (Spec: nicht
Repo-Default).
Tests: Cloud-Provider gegen httptest-Fakes (200/500/401, Request-Vertrag
wörtlich), Edge über Interface + Fehlerpfad-Mocks + Loopback-WSS-Fake
(kein echtes Netz) + als testing.Short überspringbare Integration-
Markierung (TALK_TEST_REAL_EDGE=1, am Dev-Host einmalig ausgeführt:
17136 Bytes echtes Audio). Kette: erster 500 -> zweiter getroffen -> Bytes;
Pin, alle-Stufen-tot, Direktansage mit/ohne null-Player.
This commit is contained in:
1 parent
88eddc63d2
commit
91cd0ec8da
15 files changed
+2434
-27
No files matched your search
@@ -0,0 +1,269 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DefaultOrder ist die bindende Default-Fallback-Reihenfolge der Kette
|
||||
// (Spec: ElevenLabs → OpenAI TTS → Edge-TTS → OS built-in).
|
||||
var DefaultOrder = []string{"elevenlabs", "openai", "edge", "osbuiltin"}
|
||||
|
||||
// NullPlayerName ist der dokumentierte Name des verwerfenden Sinks
|
||||
// (Spec: audio.sink = "oto" | "null"). Die Kette braucht ihn für die
|
||||
// Entscheidung, ob eine Direktansage (DirectSpeaker) verworfen werden
|
||||
// muss — der null-Sink darf niemals Ton erzeugen.
|
||||
const NullPlayerName = "null"
|
||||
|
||||
// Player spielt Audio-Bytes ab, die die Kette geliefert hat. Der Vertrag
|
||||
// ist absichtlich strukturell identisch zu audio.Sink (Play + Name): CLI
|
||||
// und Daemon (Task 04) übergeben das Ergebnis von audio.NewSink(name, log)
|
||||
// unverändert — dadurch bleibt die Kette ohne Import des audio-Pakets
|
||||
// testbar und wiederverwendbar.
|
||||
type Player interface {
|
||||
Play(ctx context.Context, data []byte) error
|
||||
Name() string
|
||||
}
|
||||
|
||||
// ChainConfig ist alles, was die Kette zum Bau braucht. CLI (Task 03) und
|
||||
// Daemon (Task 04) füllen sie aus Config + Flags; die Kette selbst liest
|
||||
// keine Dateien und keine Prozess-Env — Getenv ist die injizierbare Seam
|
||||
// (Tests und Daemon kommen so ohne echte Keys aus).
|
||||
type ChainConfig struct {
|
||||
// Pin reduziert die Kette auf genau einen Provider (CLI-Flag --provider
|
||||
// schlägt config `provider = "…"`). Leer = DefaultOrder.
|
||||
Pin string
|
||||
|
||||
// Voice ist eine explizite Voice-ID (CLI-Flag --voice); sie wird über
|
||||
// VoicePinner an alle Stufen gepinnt und schlägt Mapping + Defaults.
|
||||
Voice string
|
||||
|
||||
// Voices ist das voice.<provider>.<lang>-Mapping aus der config.toml.
|
||||
Voices map[string]map[string]string
|
||||
|
||||
// Endpoints sind die Datei-Endpunkte [providers.<name>].endpoint;
|
||||
// TALK_<NAME>_ENDPOINT (via Getenv) schlägt sie, sie schlägt die
|
||||
// eingebauten Defaults (siehe endpointFrom).
|
||||
Endpoints map[string]string
|
||||
|
||||
// Model ist [providers.openai].model (leer = "tts-1").
|
||||
Model string
|
||||
|
||||
// Getenv liefert Umgebungsvariablen (Keys, Endpunkt-Overrides). Nil
|
||||
// wird beim Bau durch eine Funktion ersetzt, die immer "" liefert.
|
||||
Getenv func(string) string
|
||||
|
||||
// Player gibt erfolgreiche Synthesen aus; nil = die Kette liefert nur
|
||||
// Bytes (Synthesize) und Speak meldet einen klaren Fehler.
|
||||
Player Player
|
||||
|
||||
// Log empfängt die Fallback-Protokoll-Zeilen (CLI: stderr). Fallbacks
|
||||
// sind nie still: Jede übersprungene oder fehlgeschlagene Stufe wird
|
||||
// hier gemeldet — auch wenn am Ende noch eine Stufe Erfolg hat.
|
||||
Log io.Writer
|
||||
}
|
||||
|
||||
// endpointFrom löst einen Provider-Endpunkt auf:
|
||||
// TALK_<NAME>_ENDPOINT (Env) > [providers.<name>].endpoint (Datei) >
|
||||
// eingebauter Default. Env-Overrides in der Provider-Schicht zu lesen hält
|
||||
// das config-Paket frei von Provider-Namen und erlaubt Prozessgrenzen-
|
||||
// Tests ohne Config-Datei.
|
||||
func endpointFrom(cfg ChainConfig, name, envName, def string) string {
|
||||
if cfg.Getenv != nil {
|
||||
if v := cfg.Getenv(envName); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
if v := cfg.Endpoints[name]; v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// Chain ist die geordnete Fallback-Kette über Provider. Sie implementiert
|
||||
// selbst Provider (Synthesize liefert die Bytes der ersten erfolgreichen
|
||||
// Stufe) UND DirectSpeaker (letzter Stufen-Ausweg für Plattformen ohne
|
||||
// Byte-fähiges TTS) — damit ist sie für CLI und Daemon (Task 04)
|
||||
// austauschbar gegen jeden einzelnen Provider.
|
||||
//
|
||||
// Speak ist die komplette Ansage-Orchestrierung: Bytes-Synthese mit
|
||||
// Fallback, Wiedergabe über den Player, und als letzte Stufe die
|
||||
// Direktansage (osbuiltin über spd-say). Bei einem null-Player wird die
|
||||
// Direktansage tonlos verworfen (CI/Server) statt Ton zu erzeugen.
|
||||
type Chain struct {
|
||||
cfg ChainConfig
|
||||
tiers []Provider
|
||||
}
|
||||
|
||||
// NewChain baut die Kette: gepinnt (Pin) oder in DefaultOrder. Unbekannte
|
||||
// Namen sind ein klarer Fehler, der die verfügbaren Namen nennt. Das
|
||||
// --voice-Pin wird auf alle Stufen angewandt, die VoicePinner implementieren
|
||||
// (Stufen ohne Voice-Begriff bekommen einen protokollierten Hinweis).
|
||||
func NewChain(cfg ChainConfig) (*Chain, error) {
|
||||
if cfg.Getenv == nil {
|
||||
cfg.Getenv = func(string) string { return "" }
|
||||
}
|
||||
names := DefaultOrder
|
||||
if cfg.Pin != "" {
|
||||
names = []string{cfg.Pin}
|
||||
}
|
||||
tiers := make([]Provider, 0, len(names))
|
||||
for _, name := range names {
|
||||
p, err := New(name, cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cfg.Voice != "" {
|
||||
if vp, ok := p.(VoicePinner); ok {
|
||||
vp.PinVoice(cfg.Voice)
|
||||
} else {
|
||||
chainLogf(cfg, "kette: --voice für Stufe %s ignoriert (kennt keine Voice-IDs)\n", name)
|
||||
}
|
||||
}
|
||||
tiers = append(tiers, p)
|
||||
}
|
||||
return &Chain{cfg: cfg, tiers: tiers}, nil
|
||||
}
|
||||
|
||||
// Name implementiert Provider.
|
||||
func (*Chain) Name() string { return "chain" }
|
||||
|
||||
// Tiers liefert die Stufen in Ketten-Reihenfolge (Diagnose/doctor).
|
||||
func (c *Chain) Tiers() []Provider { return c.tiers }
|
||||
|
||||
// Available implementiert Provider: true, sobald eine Stunde verfügbar ist.
|
||||
func (c *Chain) Available(ctx context.Context) bool {
|
||||
for _, p := range c.tiers {
|
||||
if p.Available(ctx) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// walkResult ist das interne Ergebnis des Ketten-Durchlaufs.
|
||||
type walkResult struct {
|
||||
data []byte // Bytes der ersten erfolgreichen Stufe (nil = keine)
|
||||
used Provider // die erfolgreiche Stufe
|
||||
direct DirectSpeaker // erste Stufe, die nur direkt sprechen kann
|
||||
directName string
|
||||
failed []string // "name: fehler" je fehlgeschlagener Stufe
|
||||
skipped []string // Namen nicht verfügbarer Stufen
|
||||
}
|
||||
|
||||
// walk läuft die Kette in Ordnung ab: nicht verfügbare Stufen werden
|
||||
// übersprungen (protokolliert), fehlgeschlagene protokolliert und
|
||||
// überwunden. Eine Stufe mit ErrNoByteOutput, die DirectSpeaker
|
||||
// implementiert, wird als letzter Ausweg gemerkt (der Durchlauf geht
|
||||
// weiter — eine spätere Stufe könnte noch Bytes liefern).
|
||||
func (c *Chain) walk(ctx context.Context, text, lang string) walkResult {
|
||||
var r walkResult
|
||||
for _, p := range c.tiers {
|
||||
if !p.Available(ctx) {
|
||||
r.skipped = append(r.skipped, p.Name())
|
||||
chainLogf(c.cfg, "kette: Stufe %s übersprungen (nicht verfügbar)\n", p.Name())
|
||||
continue
|
||||
}
|
||||
data, err := p.Synthesize(ctx, text, lang)
|
||||
switch {
|
||||
case err == nil:
|
||||
r.data, r.used = data, p
|
||||
chainLogf(c.cfg, "kette: Ansage über %s\n", p.Name())
|
||||
return r
|
||||
case errors.Is(err, ErrNoByteOutput) && r.direct == nil:
|
||||
if ds, ok := p.(DirectSpeaker); ok {
|
||||
r.direct, r.directName = ds, p.Name()
|
||||
chainLogf(c.cfg, "kette: Stufe %s liefert keine Bytes, kann aber direkt sprechen (als letzte Stufe gemerkt)\n", p.Name())
|
||||
continue
|
||||
}
|
||||
r.failed = append(r.failed, p.Name()+": "+err.Error())
|
||||
chainLogf(c.cfg, "kette: Stufe %s fehlgeschlagen: %v — Fallback auf nächste Stufe\n", p.Name(), err)
|
||||
default:
|
||||
r.failed = append(r.failed, p.Name()+": "+err.Error())
|
||||
chainLogf(c.cfg, "kette: Stufe %s fehlgeschlagen: %v — Fallback auf nächste Stufe\n", p.Name(), err)
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// Synthesize implementiert Provider: Bytes der ersten erfolgreichen Stufe.
|
||||
// Kann nur eine Direkt-Stufe sprechen, ist der Fehler ErrNoByteOutput (die
|
||||
// Kette erfüllt damit den DirectSpeaker-Vertrag am Aufrufer). Scheitert
|
||||
// alles, nennt der Fehler die letzte Stufen-Fehler — nie still.
|
||||
func (c *Chain) Synthesize(ctx context.Context, text, lang string) ([]byte, error) {
|
||||
r := c.walk(ctx, text, lang)
|
||||
if r.data != nil {
|
||||
return r.data, nil
|
||||
}
|
||||
if r.direct != nil {
|
||||
if len(r.failed) > 0 {
|
||||
return nil, fmt.Errorf("%w (Stufe %s spricht nur direkt; daneben fehlgeschlagen: %s)",
|
||||
ErrNoByteOutput, r.directName, strings.Join(r.failed, "; "))
|
||||
}
|
||||
return nil, fmt.Errorf("%w (Stufe %s spricht nur direkt)", ErrNoByteOutput, r.directName)
|
||||
}
|
||||
return nil, fmt.Errorf("kette: keine Stufe konnte sprechen (nicht verfügbar: %s; fehlgeschlagen: %s)",
|
||||
joinOr(r.skipped, "keine"), joinOr(r.failed, "keine"))
|
||||
}
|
||||
|
||||
// SpeakDirect implementiert DirectSpeaker: Direktansage über die Stufe,
|
||||
// die nur direkt sprechen kann (typischerweise osbuiltin/spd-say auf Linux
|
||||
// ohne espeak). Gibt es eine Byte-fähige Stufe, ist SpeakDirect ein klarer
|
||||
// Fehler (der Aufrufer soll Synthesize + Player nutzen).
|
||||
func (c *Chain) SpeakDirect(ctx context.Context, text, lang string) error {
|
||||
r := c.walk(ctx, text, lang)
|
||||
if r.direct != nil {
|
||||
return r.direct.SpeakDirect(ctx, text, lang)
|
||||
}
|
||||
if r.data != nil {
|
||||
return fmt.Errorf("kette: Stufe %s liefert Bytes — über Synthesize und den Player abspielen, nicht direkt", r.used.Name())
|
||||
}
|
||||
return fmt.Errorf("kette: keine Stufe kann direkt sprechen (nicht verfügbar: %s; fehlgeschlagen: %s)",
|
||||
joinOr(r.skipped, "keine"), joinOr(r.failed, "keine"))
|
||||
}
|
||||
|
||||
// Speak ist die komplette Ansage: Synthese mit Fallback → Wiedergabe über
|
||||
// den Player; fällt die Kette auf die Direkt-Stufe zurück, entscheidet der
|
||||
// Player-Name über Ton (alles außer „null") oder tonloses Verwerfen (null:
|
||||
// CI/Server bleiben fehler- und tonfrei, der Verwurf wird protokolliert).
|
||||
// Nur ein Fehler aller Stufen oder eine fehlgeschlagene Wiedergabe sind
|
||||
// Fehler.
|
||||
func (c *Chain) Speak(ctx context.Context, text, lang string) error {
|
||||
data, err := c.Synthesize(ctx, text, lang)
|
||||
switch {
|
||||
case err == nil:
|
||||
if c.cfg.Player == nil {
|
||||
return fmt.Errorf("kette: kein Player konfiguriert — Bytes können nicht abgespielt werden")
|
||||
}
|
||||
if err := c.cfg.Player.Play(ctx, data); err != nil {
|
||||
return fmt.Errorf("kette: Wiedergabe über %s fehlgeschlagen: %w", c.cfg.Player.Name(), err)
|
||||
}
|
||||
return nil
|
||||
case errors.Is(err, ErrNoByteOutput):
|
||||
if c.cfg.Player != nil && c.cfg.Player.Name() == NullPlayerName {
|
||||
chainLogf(c.cfg, "kette: Direktansage verworfen (audio.sink=null)\n")
|
||||
return nil
|
||||
}
|
||||
return c.SpeakDirect(ctx, text, lang)
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// chainLogf schreibt eine Protokoll-Zeile, wenn ein Log gesetzt ist.
|
||||
func chainLogf(cfg ChainConfig, format string, args ...any) {
|
||||
if cfg.Log != nil {
|
||||
fmt.Fprintf(cfg.Log, format, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// joinOr verbindet Einträge mit sep; ist die Liste leer, kommt fallback.
|
||||
func joinOr(items []string, fallback string) string {
|
||||
if len(items) == 0 {
|
||||
return fallback
|
||||
}
|
||||
return strings.Join(items, "; ")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,372 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// Edge-TTS-Konstanten. Das Protokoll ist das bekannte, von edge-tts
|
||||
// reverse-engineerte „Read Aloud"-Protokoll von Microsoft Edge — Stand der
|
||||
// Implementierung (2026): DRM-Token Sec-MS-GEC v2 (CS64 + verschleierte
|
||||
// SHA-256-Variante, siehe edgedrm.go) + MUID-Cookie, im Übrigen das
|
||||
// bekannte WebSocket-Nachrichtenformat (speech.config + SSML, Binär-Rahmen
|
||||
// mit 2-Byte-Header-Länge, turn.end). Ändert Microsoft es, liefern die
|
||||
// Synthese-Pfade klare Fehler mit Hinweis auf Protokoll-Änderung statt
|
||||
// stiller Fehlfunktion.
|
||||
const (
|
||||
// DefaultEndpointEdge ist der produktive WebSocket-Endpunkt (Pfad ohne
|
||||
// Query — Token/ConnectionId/Sec-MS-GEC werden in requestURL ergänzt).
|
||||
DefaultEndpointEdge = "wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1"
|
||||
|
||||
// EnvEdgeEndpoint überschreibt den Endpunkt (Tests/Firmenserver).
|
||||
EnvEdgeEndpoint = "TALK_EDGE_ENDPOINT"
|
||||
|
||||
edgeTrustedClientToken = "6A5AA1D4EAFF4E9FB37E23D68491D6F4"
|
||||
edgeSecMSGECVersion = "2-143.0.3650.75"
|
||||
edgeOrigin = "chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold"
|
||||
edgeUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0"
|
||||
edgeOutputFormat = "audio-24khz-48kbitrate-mono-mp3" // mp3!
|
||||
)
|
||||
|
||||
// edgeIOTimeout begrenzt einzelne Lese-/Schreiboperationen sowie (wenn der
|
||||
// Aufrufer keine Deadline mitgibt) die gesamte Synthese.
|
||||
const edgeIOTimeout = 20 * time.Second
|
||||
|
||||
// edgeClockSkew korrigiert lokale Uhrenabweichung: Bei 403 liest die Kette
|
||||
// das Date-Header-Feld der Antwort und justiert die Tick-Berechnung (ein
|
||||
// Wiederholungsversuch, wie die edge-tts-Referenz).
|
||||
var edgeClockSkew atomic.Int64
|
||||
|
||||
// Edge ist die kostenlose dritte Stufe der Kette: Microsofts „Read Aloud"-
|
||||
// Endpoint, kein Key nötig. Synthesize öffnet eine WebSocket-Verbindung,
|
||||
// sendet speech.config + SSML-Anfrage und sammelt die Binär-Audio-Frames
|
||||
// bis turn.end. Rückgabeformat: MP3 (24 kHz mono — der audio-Sink
|
||||
// dekodiert das Format).
|
||||
type Edge struct {
|
||||
// Endpoint ist die WebSocket-URL (ws:// oder wss://), leer = Default.
|
||||
Endpoint string
|
||||
|
||||
// Voices ist das config-Mapping voice.edge.<lang> = id; leer = neutrale
|
||||
// Repo-Defaults (de-DE-KatjaNeural / en-US-AriaNeural).
|
||||
Voices map[string]string
|
||||
|
||||
// Dialer ist der WebSocket-Dialer; nil = websocket.DefaultDialer.
|
||||
Dialer *websocket.Dialer
|
||||
|
||||
pinned string // --voice-Pin (VoicePinner)
|
||||
}
|
||||
|
||||
// NewEdge baut den Provider aus der Ketten-Konfiguration: Endpunkt aus
|
||||
// TALK_EDGE_ENDPOINT > [providers.edge].endpoint > Default.
|
||||
func NewEdge(cfg ChainConfig) *Edge {
|
||||
return &Edge{
|
||||
Endpoint: endpointFrom(cfg, "edge", EnvEdgeEndpoint, DefaultEndpointEdge),
|
||||
Voices: cfg.Voices["edge"],
|
||||
}
|
||||
}
|
||||
|
||||
// Name implementiert Provider.
|
||||
func (*Edge) Name() string { return "edge" }
|
||||
|
||||
// PinVoice implementiert VoicePinner (CLI-Flag --voice).
|
||||
func (p *Edge) PinVoice(id string) { p.pinned = id }
|
||||
|
||||
// Available implementiert Provider: Edge braucht nur einen Endpunkt —
|
||||
// bewusst ohne Netz-Call (Erreichbarkeit stellt Synthesize fest, an deren
|
||||
// Fehler fällt die Kette weiter).
|
||||
func (p *Edge) Available(ctx context.Context) bool { return p.Endpoint != "" }
|
||||
|
||||
// Synthesize implementiert Provider. Rückgabeformat: MP3.
|
||||
func (p *Edge) Synthesize(ctx context.Context, text, lang string) ([]byte, error) {
|
||||
if text == "" {
|
||||
return nil, fmt.Errorf("edge: leerer Text")
|
||||
}
|
||||
voice := resolveVoice("edge", lang, p.pinned, p.Voices)
|
||||
if voice == "" {
|
||||
return nil, fmt.Errorf("edge: keine Stimme für Sprache %q konfiguriert", lang)
|
||||
}
|
||||
if _, hasDeadline := ctx.Deadline(); !hasDeadline {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, edgeIOTimeout)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
conn, wsURL, err := p.dialWithSkewRetry(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
now := time.Now().UTC()
|
||||
messages := [][]byte{
|
||||
[]byte(edgeSpeechConfigMsg(now)),
|
||||
[]byte(edgeSynthesisHeaders(now, edgeRequestID()) + edgeSSML(voice, text)),
|
||||
}
|
||||
for _, msg := range messages {
|
||||
if err := conn.SetWriteDeadline(time.Now().Add(edgeIOTimeout)); err != nil {
|
||||
return nil, protocolErr(err)
|
||||
}
|
||||
if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil {
|
||||
return nil, protocolErr(err)
|
||||
}
|
||||
}
|
||||
|
||||
var audio bytes.Buffer
|
||||
for {
|
||||
if err := conn.SetReadDeadline(time.Now().Add(edgeIOTimeout)); err != nil {
|
||||
return nil, protocolErr(err)
|
||||
}
|
||||
msgType, msg, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
return nil, protocolErr(err)
|
||||
}
|
||||
switch msgType {
|
||||
case websocket.TextMessage:
|
||||
if strings.Contains(string(msg), "Path:turn.end") {
|
||||
if audio.Len() == 0 {
|
||||
return nil, fmt.Errorf("edge: Server meldete turn.end ohne Audio-Frames — Protokoll geändert? (%s)", redactURL(wsURL))
|
||||
}
|
||||
return audio.Bytes(), nil
|
||||
}
|
||||
case websocket.BinaryMessage:
|
||||
headerText, payload, ok := splitEdgeBinary(msg)
|
||||
if ok && strings.Contains(headerText, "Path:audio") {
|
||||
audio.Write(payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// dialWithSkewRetry baut die Verbindung; bei 403 (Uhrabweichung) wird der
|
||||
// Server-Zeitstempel übernommen und EINMAL wiederholt.
|
||||
func (p *Edge) dialWithSkewRetry(ctx context.Context) (*websocket.Conn, string, error) {
|
||||
conn, wsURL, resp, err := p.dial(ctx)
|
||||
if err == nil {
|
||||
return conn, wsURL, nil
|
||||
}
|
||||
if resp != nil && resp.StatusCode == http.StatusForbidden {
|
||||
if serverDate := resp.Header.Get("Date"); serverDate != "" {
|
||||
if t, perr := http.ParseTime(serverDate); perr == nil {
|
||||
edgeClockSkew.Store(t.Unix() - time.Now().Unix())
|
||||
conn, wsURL, resp, err = p.dial(ctx)
|
||||
if err == nil {
|
||||
return conn, wsURL, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if resp != nil {
|
||||
return nil, wsURL, fmt.Errorf("edge: WebSocket-Verbindung zu %s fehlgeschlagen (HTTP %d; Netz/Timeout oder Protokoll geändert?): %w",
|
||||
redactURL(wsURL), resp.StatusCode, err)
|
||||
}
|
||||
return nil, wsURL, fmt.Errorf("edge: WebSocket-Verbindung zu %s fehlgeschlagen — Netz/Timeout oder das Edge-TTS-Protokoll hat sich geändert: %w",
|
||||
redactURL(wsURL), err)
|
||||
}
|
||||
|
||||
// dial öffnet die Verbindung gemäß Protokoll: Query mit Token,
|
||||
// ConnectionId, Sec-MS-GEC (v2) und Version; Header mit Origin,
|
||||
// Edge-User-Agent und MUID-Cookie.
|
||||
func (p *Edge) dial(ctx context.Context) (*websocket.Conn, string, *http.Response, error) {
|
||||
wsURL, err := p.requestURL(time.Now())
|
||||
if err != nil {
|
||||
return nil, "", nil, err
|
||||
}
|
||||
dialer := p.Dialer
|
||||
if dialer == nil {
|
||||
dialer = websocket.DefaultDialer
|
||||
}
|
||||
header := http.Header{}
|
||||
header.Set("Origin", edgeOrigin)
|
||||
header.Set("User-Agent", edgeUserAgent)
|
||||
header.Set("Pragma", "no-cache")
|
||||
header.Set("Cache-Control", "no-cache")
|
||||
header.Set("Accept-Language", "en-US,en;q=0.9")
|
||||
header.Set("Cookie", "muid="+edgeMUID())
|
||||
conn, resp, err := dialer.DialContext(ctx, wsURL, header)
|
||||
if err != nil {
|
||||
return nil, wsURL, resp, err
|
||||
}
|
||||
return conn, wsURL, nil, nil
|
||||
}
|
||||
|
||||
// requestURL ergänzt die Protokoll-Query-Parameter und validiert das
|
||||
// Schema (ws/wss).
|
||||
func (p *Edge) requestURL(now time.Time) (string, error) {
|
||||
if p.Endpoint == "" {
|
||||
return "", fmt.Errorf("edge: kein Endpunkt konfiguriert")
|
||||
}
|
||||
u, err := url.Parse(p.Endpoint)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("edge: Endpunkt %q unlesbar: %w", p.Endpoint, err)
|
||||
}
|
||||
if u.Scheme != "ws" && u.Scheme != "wss" {
|
||||
return "", fmt.Errorf("edge: Endpunkt %q muss ws:// oder wss:// sein (ist %q)", p.Endpoint, u.Scheme)
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("TrustedClientToken", edgeTrustedClientToken)
|
||||
q.Set("ConnectionId", edgeRequestID())
|
||||
q.Set("Sec-MS-GEC", edgeSecMSGEC(now))
|
||||
q.Set("Sec-MS-GEC-Version", edgeSecMSGECVersion)
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
// edgeSecMSGEC erzeugt den DRM-Token Sec-MS-GEC v2: Windows-Filetime-Ticks
|
||||
// (100 ns, 5-Minuten-Raster, Uhrenkorrektur) + Trusted-Client-Token →
|
||||
// „<CS64>-<SHA256-Variante>" (beides großes Hex, siehe edgedrm.go).
|
||||
func edgeSecMSGEC(now time.Time) string {
|
||||
secs := now.Unix() + edgeClockSkew.Load() + drmWinEpoch
|
||||
secs -= secs % 300 // 5-Minuten-Raster
|
||||
ticks := uint64(secs) * 10_000_000
|
||||
strToHash := fmt.Appendf(nil, "%d%s", ticks, edgeTrustedClientToken)
|
||||
digest := drmSHA256Digest(strToHash)
|
||||
cs0, cs1 := drmCS64(strToHash, digest)
|
||||
|
||||
var cs64Buf [8]byte
|
||||
cs64Buf[0] = byte(cs0)
|
||||
cs64Buf[1] = byte(cs0 >> 8)
|
||||
cs64Buf[2] = byte(cs0 >> 16)
|
||||
cs64Buf[3] = byte(cs0 >> 24)
|
||||
cs64Buf[4] = byte(cs1)
|
||||
cs64Buf[5] = byte(cs1 >> 8)
|
||||
cs64Buf[6] = byte(cs1 >> 16)
|
||||
cs64Buf[7] = byte(cs1 >> 24)
|
||||
|
||||
var shaBuf [32]byte
|
||||
for i, w := range digest {
|
||||
shaBuf[i*4] = byte(w >> 24)
|
||||
shaBuf[i*4+1] = byte(w >> 16)
|
||||
shaBuf[i*4+2] = byte(w >> 8)
|
||||
shaBuf[i*4+3] = byte(w)
|
||||
}
|
||||
return strings.ToUpper(hex.EncodeToString(cs64Buf[:])) + "-" + strings.ToUpper(hex.EncodeToString(shaBuf[:]))
|
||||
}
|
||||
|
||||
// protocolErr überführt Lese-/Schreib-/Verbindungsabbrüche in klare
|
||||
// „Protokoll geändert?"-Fehler — Edge ist ein undokumentierter Endpoint,
|
||||
// Änderungen sollen sofort diagnostizierbar sein.
|
||||
func protocolErr(err error) error {
|
||||
return fmt.Errorf("edge: Kommunikation fehlgeschlagen — Netz/Timeout oder das Edge-TTS-Protokoll hat sich geändert: %w", err)
|
||||
}
|
||||
|
||||
// splitEdgeBinary zerlegt eine binäre Server-Nachricht: Die ersten 2 Bytes
|
||||
// (big-endian) geben die Länge des Header-Blocks an (CRLF-Zeilen wie
|
||||
// „Path:audio"), danach kommt die Audio-Payload.
|
||||
func splitEdgeBinary(msg []byte) (header string, payload []byte, ok bool) {
|
||||
if len(msg) < 2 {
|
||||
return "", nil, false
|
||||
}
|
||||
headerLen := int(binary.BigEndian.Uint16(msg[0:2]))
|
||||
if 2+headerLen > len(msg) {
|
||||
return "", nil, false
|
||||
}
|
||||
return string(msg[2 : 2+headerLen]), msg[2+headerLen:], true
|
||||
}
|
||||
|
||||
// edgeSpeechConfigMsg ist die erste Client-Nachricht: Header-Block +
|
||||
// JSON-Body mit Ausgabeformat und Metadaten-Optionen (Satzgrenzen an,
|
||||
// Wortgrenzen aus — Referenz-Default).
|
||||
func edgeSpeechConfigMsg(now time.Time) string {
|
||||
return fmt.Sprintf(
|
||||
"X-Timestamp:%s\r\nContent-Type:application/json; charset=utf-8\r\nPath:speech.config\r\n\r\n"+
|
||||
`{"context":{"synthesis":{"audio":{"metadataoptions":{"sentenceBoundaryEnabled":"true","wordBoundaryEnabled":"false"},"outputFormat":%q}}}}`,
|
||||
edgeTimestamp(now), edgeOutputFormat)
|
||||
}
|
||||
|
||||
// edgeSynthesisHeaders ist der Header-Block der SSML-Anfrage. Der
|
||||
// nachgestellte Z beim Timestamp ist Referenz-Verhalten („Microsoft Edge
|
||||
// bug"), bewusst mitgeschleppt.
|
||||
func edgeSynthesisHeaders(now time.Time, requestID string) string {
|
||||
return fmt.Sprintf(
|
||||
"X-RequestId:%s\r\nContent-Type:application/ssml+xml\r\nX-Timestamp:%sZ\r\nPath:ssml\r\n\r\n",
|
||||
requestID, edgeTimestamp(now))
|
||||
}
|
||||
|
||||
// edgeSSML baut die Sprach-Anfrage (xml:lang wie die Referenz fest
|
||||
// „en-US"; der Text wird von Steuerzeichen befreit und XML-escapt).
|
||||
func edgeSSML(voice, text string) string {
|
||||
return fmt.Sprintf(
|
||||
"<speak version='1.0' xmlns='http://www.w3.org/2001/10/synthesis' xml:lang='en-US'>"+
|
||||
"<voice name='%s'>"+
|
||||
"<prosody pitch='+0Hz' rate='+0%%' volume='+0%%'>%s</prosody>"+
|
||||
"</voice></speak>",
|
||||
voice, edgeEscape(text))
|
||||
}
|
||||
|
||||
// edgeEscape entfernt nicht unterstützte Steuerzeichen (Referenz:
|
||||
// remove_incompatible_characters) und escapt danach & < >.
|
||||
func edgeEscape(s string) string {
|
||||
clean := make([]byte, 0, len(s))
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if c <= 8 || (c >= 11 && c <= 12) || (c >= 14 && c <= 31) {
|
||||
c = ' '
|
||||
}
|
||||
clean = append(clean, c)
|
||||
}
|
||||
esc := make([]byte, 0, len(clean))
|
||||
for _, c := range clean {
|
||||
switch c {
|
||||
case '&':
|
||||
esc = append(esc, "&"...)
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// TestEdgeAvailable: Edge braucht nur einen Endpunkt, keinen Key — der
|
||||
// Konstruktor bäkt den Default-Endpunkt ein, daher ist ein über NewEdge
|
||||
// gebauter Edge immer verfügbar; nur der Nullwert (direkt gebaut, ohne
|
||||
// Endpunkt) ist es nicht.
|
||||
func TestEdgeAvailable(t *testing.T) {
|
||||
if (&Edge{}).Available(context.Background()) {
|
||||
t.Error("Available ohne Endpunkt (Nullwert) = true, want false")
|
||||
}
|
||||
p := NewEdge(ChainConfig{Getenv: envMap(map[string]string{
|
||||
EnvEdgeEndpoint: "wss://edge.example.com",
|
||||
})})
|
||||
if !p.Available(context.Background()) {
|
||||
t.Error("Available mit Endpunkt = false, want true")
|
||||
}
|
||||
if !NewEdge(ChainConfig{Getenv: envMap(nil)}).Available(context.Background()) {
|
||||
t.Error("Available mit Default-Endpunkt = false, want true (edge ist der Key-freie Fallback)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEdgeVerbindungFehlschlaegt (Fehlerpfad-Mock): ein garantiert toter
|
||||
// Loopback-Port liefert einen klaren Fehler — sofort, ohne Netz.
|
||||
func TestEdgeVerbindungFehlschlaegt(t *testing.T) {
|
||||
p := NewEdge(ChainConfig{Getenv: envMap(map[string]string{
|
||||
EnvEdgeEndpoint: "ws://127.0.0.1:1/",
|
||||
})})
|
||||
_, err := p.Synthesize(context.Background(), "Hallo", "de")
|
||||
if err == nil {
|
||||
t.Fatal("Synthesize gegen toten Port: kein Fehler")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "edge") || !strings.Contains(err.Error(), "WebSocket") {
|
||||
t.Errorf("Fehler = %v, will klaren edge/WebSocket-Fehler", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEdgeUngueltigesSchema: ein Nicht-w(s)-Endpunkt ist ein klarer Fehler.
|
||||
func TestEdgeUngueltigesSchema(t *testing.T) {
|
||||
p := NewEdge(ChainConfig{Getenv: envMap(map[string]string{
|
||||
EnvEdgeEndpoint: "http://127.0.0.1:1234/",
|
||||
})})
|
||||
_, err := p.Synthesize(context.Background(), "Hallo", "de")
|
||||
if err == nil || !strings.Contains(err.Error(), "ws://") {
|
||||
t.Errorf("Fehler = %v, will Schema-Hinweis ws://", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEdgeSSML: die SSML-Anfrage enthält Stimme und den von Steuerzeichen
|
||||
// befreiten, XML-escapten Text; xml:lang ist wie in der Referenz fest
|
||||
// „en-US".
|
||||
func TestEdgeSSML(t *testing.T) {
|
||||
got := edgeSSML("de-DE-KatjaNeural", "AT&T <Kunde> „fertig“")
|
||||
for _, want := range []string{
|
||||
"voice name='de-DE-KatjaNeural'",
|
||||
"xml:lang='en-US'",
|
||||
"AT&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<d>\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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ElevenLabs-Konstanten (öffentlicher Vertrag: Env-Namen sind Teil der
|
||||
// Fleet-Doku, Endpunkt-Default Teil der Konfigurierbarkeit).
|
||||
const (
|
||||
// DefaultEndpointElevenLabs ist der produktive API-Endpunkt.
|
||||
DefaultEndpointElevenLabs = "https://api.elevenlabs.io"
|
||||
|
||||
// EnvElevenLabsKey liefert den API-Key (Spec: Keys ausschließlich Env,
|
||||
// nie config.toml).
|
||||
EnvElevenLabsKey = "TALK_ELEVENLABS_KEY"
|
||||
|
||||
// EnvElevenLabsEndpoint überschreibt den Endpunkt (Tests/Firmenserver;
|
||||
// schlägt die config.toml, siehe voices.go/chain.go-Doku).
|
||||
EnvElevenLabsEndpoint = "TALK_ELEVENLABS_ENDPOINT"
|
||||
|
||||
elevenLabsModel = "eleven_multilingual_v2" // multilingual v2, Task-Brief
|
||||
)
|
||||
|
||||
// ElevenLabs ist die Premium-Stufe der Kette. Synthesize führt POST
|
||||
// <endpoint>/v1/text-to-speech/<voiceID> aus — Header `xi-api-key`, Body
|
||||
// {text, model_id: "eleven_multilingual_v2", voice_settings:
|
||||
// {stability: 0.5, similarity_boost: 0.75}} — und liefert die Antwort-Bytes
|
||||
// unverändert zurück. Rückgabeformat: MP3 (dokumentierter Vertrag des
|
||||
// audio-Pakets).
|
||||
type ElevenLabs struct {
|
||||
// Endpoint ist die Basis-URL (ohne Pfad); leer wird beim Bau durch den
|
||||
// Default ersetzt.
|
||||
Endpoint string
|
||||
|
||||
// APIKey ist der ElevenLabs-Key (aus TALK_ELEVENLABS_KEY).
|
||||
APIKey string
|
||||
|
||||
// Voices ist das config-Mapping voice.elevenlabs.<lang> = id; leer =
|
||||
// neutrale Repo-Defaults (siehe voices.go).
|
||||
Voices map[string]string
|
||||
|
||||
// Client ist der HTTP-Client; nil = geteilter Client mit Timeout
|
||||
// (cloudTimeout).
|
||||
Client *http.Client
|
||||
|
||||
pinned string // --voice-Pin (VoicePinner)
|
||||
}
|
||||
|
||||
// NewElevenLabs baut den Provider aus der Ketten-Konfiguration: Endpunkt
|
||||
// aus TALK_ELEVENLABS_ENDPOINT > [providers.elevenlabs].endpoint > Default;
|
||||
// Key ausschließlich aus TALK_ELEVENLABS_KEY.
|
||||
func NewElevenLabs(cfg ChainConfig) *ElevenLabs {
|
||||
return &ElevenLabs{
|
||||
Endpoint: endpointFrom(cfg, "elevenlabs", EnvElevenLabsEndpoint, DefaultEndpointElevenLabs),
|
||||
APIKey: cfg.Getenv(EnvElevenLabsKey),
|
||||
Voices: cfg.Voices["elevenlabs"],
|
||||
}
|
||||
}
|
||||
|
||||
// Name implementiert Provider.
|
||||
func (*ElevenLabs) Name() string { return "elevenlabs" }
|
||||
|
||||
// PinVoice implementiert VoicePinner (CLI-Flag --voice).
|
||||
func (p *ElevenLabs) PinVoice(id string) { p.pinned = id }
|
||||
|
||||
// Available implementiert Provider: Key und Endpunkt müssen gesetzt sein
|
||||
// (bewusst ohne Netz-Call — Available muss billig bleiben; ob der Key
|
||||
// gültig ist, stellt Synthesize fest und die Kette fällt weiter).
|
||||
func (p *ElevenLabs) Available(ctx context.Context) bool {
|
||||
return p.APIKey != "" && p.Endpoint != ""
|
||||
}
|
||||
|
||||
// elevenLabsRequest ist der Antwort-Body (Task-Brief, wörtliche Werte).
|
||||
type elevenLabsRequest struct {
|
||||
Text string `json:"text"`
|
||||
ModelID string `json:"model_id"`
|
||||
VoiceSettings elevenLabsVoiceSettings `json:"voice_settings"`
|
||||
}
|
||||
|
||||
type elevenLabsVoiceSettings struct {
|
||||
Stability float64 `json:"stability"`
|
||||
SimilarityBoost float64 `json:"similarity_boost"`
|
||||
}
|
||||
|
||||
// Synthesize implementiert Provider. Rückgabeformat: MP3.
|
||||
func (p *ElevenLabs) Synthesize(ctx context.Context, text, lang string) ([]byte, error) {
|
||||
if text == "" {
|
||||
return nil, fmt.Errorf("elevenlabs: leerer Text")
|
||||
}
|
||||
voice := resolveVoice("elevenlabs", lang, p.pinned, p.Voices)
|
||||
if voice == "" {
|
||||
return nil, fmt.Errorf("elevenlabs: keine Stimme für Sprache %q konfiguriert", lang)
|
||||
}
|
||||
body, err := json.Marshal(elevenLabsRequest{
|
||||
Text: text,
|
||||
ModelID: elevenLabsModel,
|
||||
VoiceSettings: elevenLabsVoiceSettings{Stability: 0.5, SimilarityBoost: 0.75},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("elevenlabs: Anfrage-Body bauen: %w", err)
|
||||
}
|
||||
url := strings.TrimRight(p.Endpoint, "/") + "/v1/text-to-speech/" + voice
|
||||
return postForBytes(ctx, p.Client, "elevenlabs", url,
|
||||
map[string]string{
|
||||
"xi-api-key": p.APIKey,
|
||||
"Accept": "audio/mpeg",
|
||||
}, body)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// OpenAI-TTS-Konstanten (öffentlicher Vertrag wie bei ElevenLabs).
|
||||
const (
|
||||
// DefaultEndpointOpenAI ist der produktive API-Endpunkt.
|
||||
DefaultEndpointOpenAI = "https://api.openai.com"
|
||||
|
||||
// DefaultModelOpenAI: Entscheidungsnotiz aus dem Task-Brief — der
|
||||
// Modell-String ist konfigurierbar ([providers.openai] model), Default
|
||||
// "tts-1"; "gpt-4o-mini-tts" ist ein gültiger Alternativwert.
|
||||
DefaultModelOpenAI = "tts-1"
|
||||
|
||||
EnvOpenAIKey = "TALK_OPENAI_KEY"
|
||||
EnvOpenAIEndpoint = "TALK_OPENAI_ENDPOINT"
|
||||
)
|
||||
|
||||
// OpenAI ist die zweite Stufe der Kette (für Nutzer mit vorhandenem
|
||||
// OpenAI-Key). Synthesize führt POST <endpoint>/v1/audio/speech aus —
|
||||
// Bearer-Key, Body {model, voice, input, response_format: "mp3"} — und
|
||||
// liefert die Antwort-Bytes unverändert zurück. Rückgabeformat: MP3.
|
||||
type OpenAI struct {
|
||||
// Endpoint ist die Basis-URL (ohne Pfad); leer = Default beim Bau.
|
||||
Endpoint string
|
||||
|
||||
// APIKey ist der OpenAI-Key (aus TALK_OPENAI_KEY).
|
||||
APIKey string
|
||||
|
||||
// Model ist der TTS-Modell-String; leer = DefaultModelOpenAI.
|
||||
Model string
|
||||
|
||||
// Voices ist das config-Mapping voice.openai.<lang> = id; leer =
|
||||
// neutrale Repo-Defaults (alloy, siehe voices.go).
|
||||
Voices map[string]string
|
||||
|
||||
// Client ist der HTTP-Client; nil = geteilter Client mit Timeout.
|
||||
Client *http.Client
|
||||
|
||||
pinned string // --voice-Pin (VoicePinner)
|
||||
}
|
||||
|
||||
// NewOpenAI baut den Provider aus der Ketten-Konfiguration: Endpunkt aus
|
||||
// TALK_OPENAI_ENDPOINT > [providers.openai].endpoint > Default; Modell aus
|
||||
// [providers.openai].model; Key ausschließlich aus TALK_OPENAI_KEY.
|
||||
func NewOpenAI(cfg ChainConfig) *OpenAI {
|
||||
return &OpenAI{
|
||||
Endpoint: endpointFrom(cfg, "openai", EnvOpenAIEndpoint, DefaultEndpointOpenAI),
|
||||
APIKey: cfg.Getenv(EnvOpenAIKey),
|
||||
Model: cfg.Model,
|
||||
Voices: cfg.Voices["openai"],
|
||||
}
|
||||
}
|
||||
|
||||
// Name implementiert Provider.
|
||||
func (*OpenAI) Name() string { return "openai" }
|
||||
|
||||
// PinVoice implementiert VoicePinner (CLI-Flag --voice).
|
||||
func (p *OpenAI) PinVoice(id string) { p.pinned = id }
|
||||
|
||||
// Available implementiert Provider: Key und Endpunkt müssen gesetzt sein
|
||||
// (ohne Netz-Call, siehe ElevenLabs.Available).
|
||||
func (p *OpenAI) Available(ctx context.Context) bool {
|
||||
return p.APIKey != "" && p.Endpoint != ""
|
||||
}
|
||||
|
||||
// openAIRequest ist der Anfrage-Body.
|
||||
type openAIRequest struct {
|
||||
Model string `json:"model"`
|
||||
Voice string `json:"voice"`
|
||||
Input string `json:"input"`
|
||||
ResponseFormat string `json:"response_format"`
|
||||
}
|
||||
|
||||
// Synthesize implementiert Provider. Rückgabeformat: MP3.
|
||||
func (p *OpenAI) Synthesize(ctx context.Context, text, lang string) ([]byte, error) {
|
||||
if text == "" {
|
||||
return nil, fmt.Errorf("openai: leerer Text")
|
||||
}
|
||||
voice := resolveVoice("openai", lang, p.pinned, p.Voices)
|
||||
if voice == "" {
|
||||
return nil, fmt.Errorf("openai: keine Stimme für Sprache %q konfiguriert", lang)
|
||||
}
|
||||
model := p.Model
|
||||
if model == "" {
|
||||
model = DefaultModelOpenAI
|
||||
}
|
||||
body, err := json.Marshal(openAIRequest{
|
||||
Model: model,
|
||||
Voice: voice,
|
||||
Input: text,
|
||||
ResponseFormat: "mp3",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("openai: Anfrage-Body bauen: %w", err)
|
||||
}
|
||||
url := strings.TrimRight(p.Endpoint, "/") + "/v1/audio/speech"
|
||||
return postForBytes(ctx, p.Client, "openai", url,
|
||||
map[string]string{"Authorization": "Bearer " + p.APIKey}, body)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DefaultName ist der Provider, den die Kette nutzt, wenn kein --provider
|
||||
// gepinnt ist. Task 02: osbuiltin; Task 03 ersetzt den Default durch die
|
||||
// Fallback-Kette (ElevenLabs → OpenAI → Edge → osbuiltin).
|
||||
// DefaultName ist der Provider, den New("") liefert (Einzelkonstruktion,
|
||||
// z. B. für doctor). Die Default-Ansage-Kette ab Task 03 steht in
|
||||
// DefaultOrder (chain.go): elevenlabs → openai → edge → osbuiltin.
|
||||
const DefaultName = "osbuiltin"
|
||||
|
||||
// ErrNoByteOutput meldet, dass die Plattform keine über Synthesize
|
||||
@@ -24,15 +24,16 @@ var ErrNoByteOutput = errors.New(
|
||||
// Provider ist der Vertrag aller TTS-Backends von talk.
|
||||
//
|
||||
// Synthesize liefert dekodierbare Audio-Bytes; das Format ist von der
|
||||
// Implementierung dokumentiert (osbuiltin: RIFF/WAVE mit PCM — von allen
|
||||
// Sinks der Aufgabe abspielbar; Cloud-Provider ab Task 03: mp3).
|
||||
// Implementierung dokumentiert: osbuiltin liefert RIFF/WAVE (PCM), die
|
||||
// Cloud-Provider elevenlabs, openai und edge liefern MP3 — beides wird vom
|
||||
// audio-Paket (oto-Sink) verstanden.
|
||||
type Provider interface {
|
||||
// Name liefert den eindeutigen Providernamen (CLI --provider, Config).
|
||||
Name() string
|
||||
|
||||
// Available meldet, ob der Provider auf diesem System überhaupt nutzbar
|
||||
// ist (z. B. Binary oder Key vorhanden). Muss billig und ohne Neben-
|
||||
// wirkungen sein; false heißt: Kette soll übersprungen werden.
|
||||
// wirkungen sein; false heißt: Kette soll überspringen werden.
|
||||
Available(ctx context.Context) bool
|
||||
|
||||
// Synthesize erzeugt Audio-Bytes für text in der Sprache lang.
|
||||
@@ -46,8 +47,9 @@ type Provider interface {
|
||||
// Audio-Bytes an Prozesse liefern kann — dort spielt osbuiltin.SpeakDirect
|
||||
// über spd-say selbst ab (blockierend bis zum Ende der Ansage).
|
||||
//
|
||||
// Task 03+ muss diese Erweiterung beachten: In der Kette ist SpeakDirect
|
||||
// die letzte Stufe, wenn kein Provider Bytes liefern kann.
|
||||
// In der Kette (chain.go) ist SpeakDirect die letzte Stufe, wenn kein
|
||||
// Provider Bytes liefern kann; Chain implementiert DirectSpeaker selbst und
|
||||
// reicht an den Stufen-Provider durch.
|
||||
type DirectSpeaker interface {
|
||||
// SpeakDirect gibt text in der Sprache lang direkt über das OS-TTS aus
|
||||
// und blockiert bis zum Ende der Wiedergabe (oder bis ctx abgebrochen
|
||||
@@ -55,10 +57,25 @@ type DirectSpeaker interface {
|
||||
SpeakDirect(ctx context.Context, text, lang string) error
|
||||
}
|
||||
|
||||
// registry bildet Providernamen auf Konstruktoren; Task 03 ergänzt hier
|
||||
// elevenlabs, openai und edge.
|
||||
var registry = map[string]func() Provider{
|
||||
"osbuiltin": func() Provider { return OSBuiltin{} },
|
||||
// VoicePinner ist eine dokumentierte optionale Erweiterung über das
|
||||
// Provider-Interface hinaus: Provider, die eine explizite Voice-ID pinnen
|
||||
// können (das CLI-Flag --voice). Die gepinnte Stimme schlägt das
|
||||
// voice.<provider>.<lang>-Mapping der config.toml und die eingebauten
|
||||
// neutralen Defaults. Die Kette ruft PinVoice beim Bau auf alle Stufen auf,
|
||||
// die die Erweiterung implementieren (Stufen ohne Voice-Begriff, z. B.
|
||||
// osbuiltin, loggen einen Hinweis statt still zu ignorieren).
|
||||
type VoicePinner interface {
|
||||
PinVoice(id string)
|
||||
}
|
||||
|
||||
// registry bildet Providernamen auf Konstruktoren; NewChain und New bauen
|
||||
// darüber. Die Konstruktoren erhalten die ChainConfig (Endpunkte, Voices,
|
||||
// Keys via Getenv), damit Kette und Daemon (Task 04) dieselbe Quelle haben.
|
||||
var registry = map[string]func(ChainConfig) Provider{
|
||||
"elevenlabs": func(cfg ChainConfig) Provider { return NewElevenLabs(cfg) },
|
||||
"openai": func(cfg ChainConfig) Provider { return NewOpenAI(cfg) },
|
||||
"edge": func(cfg ChainConfig) Provider { return NewEdge(cfg) },
|
||||
"osbuiltin": func(ChainConfig) Provider { return OSBuiltin{} },
|
||||
}
|
||||
|
||||
// Names liefert die registrierten Providernamen (sortiert).
|
||||
@@ -71,17 +88,20 @@ func Names() []string {
|
||||
return names
|
||||
}
|
||||
|
||||
// New liefert den Provider mit dem angegebenen Namen; "" wählt den Default.
|
||||
// Unbekannte (oder in diesem Stand noch nicht implementierte) Namen sind
|
||||
// ein klarer Fehler.
|
||||
func New(name string) (Provider, error) {
|
||||
// New liefert den Provider mit dem angegebenen Namen (Einzelkonstruktion);
|
||||
// "" wählt DefaultName. Für die Ansage-Kette (Fallback) NewChain verwenden.
|
||||
// Unbekannte Namen sind ein klarer Fehler.
|
||||
func New(name string, cfg ChainConfig) (Provider, error) {
|
||||
if name == "" {
|
||||
name = DefaultName
|
||||
}
|
||||
ctor, ok := registry[name]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unbekannter oder noch nicht implementierter Provider %q (in diesem Stand verfügbar: %s)",
|
||||
return nil, fmt.Errorf("unbekannter oder noch nicht implementierter Provider %q (verfügbar: %s)",
|
||||
name, strings.Join(Names(), ", "))
|
||||
}
|
||||
return ctor(), nil
|
||||
if cfg.Getenv == nil {
|
||||
cfg.Getenv = func(string) string { return "" }
|
||||
}
|
||||
return ctor(cfg), nil
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package provider
|
||||
|
||||
import "sort"
|
||||
|
||||
// voiceDefaults sind die neutralen Repo-Defaults je Provider und Sprache
|
||||
// (Teil des öffentlichen Vertrags: `talk voices` aus Task 08 listet sie,
|
||||
// überschrieben werden sie durch voice.<provider>.<lang> in der config.toml
|
||||
// oder das CLI-Flag --voice).
|
||||
//
|
||||
// Entscheidungsnotiz ElevenLabs-Stimme: Spec und Plan führen die persönliche
|
||||
// Stimme KXxZd16DiBqt82nbarJx ausdrücklich als PRIVATEN Default des Nutzers
|
||||
// („nicht Repo-Default", Spec Further Notes); der Repo-Default ist deshalb
|
||||
// die neutrale, mehrsprachige Standardstimme „Rachel" (21m00Tcm4TlvDq8ikWAM
|
||||
// aus dem öffentlichen ElevenLabs-Katalog). Die persönliche Stimme gehört in
|
||||
// die eigene config.toml:
|
||||
//
|
||||
// [voice.elevenlabs]
|
||||
// de = "KXxZd16DiBqt82nbarJx"
|
||||
// en = "KXxZd16DiBqt82nbarJx"
|
||||
//
|
||||
// OpenAI „alloy" und die Edge-Neural-Stimmen (de-DE-KatjaNeural,
|
||||
// en-US-AriaNeural) sind die jeweils neutralen Standardstimmen der APIs
|
||||
// (Edge-Stimmen sind im Task-Brief vorgegeben).
|
||||
var voiceDefaults = map[string]map[string]string{
|
||||
"elevenlabs": {
|
||||
"de": "21m00Tcm4TlvDq8ikWAM", // „Rachel", multilingual
|
||||
"en": "21m00Tcm4TlvDq8ikWAM",
|
||||
},
|
||||
"openai": {
|
||||
"de": "alloy",
|
||||
"en": "alloy",
|
||||
},
|
||||
"edge": {
|
||||
"de": "de-DE-KatjaNeural",
|
||||
"en": "en-US-AriaNeural",
|
||||
},
|
||||
"osbuiltin": {
|
||||
// osbuiltin mappt intern selbst (macOS Anna/Samantha, SAPI-Kultur,
|
||||
// espeak -v <lang>) und kennt keine Voice-IDs — hier nur der
|
||||
// Vollständigkeit wegen dokumentiert.
|
||||
"de": "",
|
||||
"en": "",
|
||||
},
|
||||
}
|
||||
|
||||
// resolveVoice liefert die Voice-ID für einen Provider und eine Sprache.
|
||||
// Rangfolge: override (CLI-Flag --voice bzw. PinVoice) > Mapping aus der
|
||||
// config.toml > neutrales Repo-Default > en-Default > alphabetisch erstes
|
||||
// Default. Ein leerer Rückgabewert heißt: der Provider kennt für diese
|
||||
// Sprache keine Stimme (osbuiltin) und entscheidet selbst.
|
||||
func resolveVoice(name, lang, override string, cfgVoices map[string]string) string {
|
||||
if override != "" {
|
||||
return override
|
||||
}
|
||||
if v := cfgVoices[lang]; v != "" {
|
||||
return v
|
||||
}
|
||||
def := voiceDefaults[name]
|
||||
if v := def[lang]; v != "" {
|
||||
return v
|
||||
}
|
||||
if v := def["en"]; v != "" {
|
||||
return v
|
||||
}
|
||||
keys := make([]string, 0, len(def))
|
||||
for k := range def {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
if v := def[k]; v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user