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