- 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.
322 lines
11 KiB
Go
322 lines
11 KiB
Go
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))
|
|
}
|