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,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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user