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