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