- 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.
71 lines
2.5 KiB
Go
71 lines
2.5 KiB
Go
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
|
|
}
|