- 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.
373 lines
13 KiB
Go
373 lines
13 KiB
Go
package provider
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
)
|
|
|
|
// Edge-TTS-Konstanten. Das Protokoll ist das bekannte, von edge-tts
|
|
// reverse-engineerte „Read Aloud"-Protokoll von Microsoft Edge — Stand der
|
|
// Implementierung (2026): DRM-Token Sec-MS-GEC v2 (CS64 + verschleierte
|
|
// SHA-256-Variante, siehe edgedrm.go) + MUID-Cookie, im Übrigen das
|
|
// bekannte WebSocket-Nachrichtenformat (speech.config + SSML, Binär-Rahmen
|
|
// mit 2-Byte-Header-Länge, turn.end). Ändert Microsoft es, liefern die
|
|
// Synthese-Pfade klare Fehler mit Hinweis auf Protokoll-Änderung statt
|
|
// stiller Fehlfunktion.
|
|
const (
|
|
// DefaultEndpointEdge ist der produktive WebSocket-Endpunkt (Pfad ohne
|
|
// Query — Token/ConnectionId/Sec-MS-GEC werden in requestURL ergänzt).
|
|
DefaultEndpointEdge = "wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1"
|
|
|
|
// EnvEdgeEndpoint überschreibt den Endpunkt (Tests/Firmenserver).
|
|
EnvEdgeEndpoint = "TALK_EDGE_ENDPOINT"
|
|
|
|
edgeTrustedClientToken = "6A5AA1D4EAFF4E9FB37E23D68491D6F4"
|
|
edgeSecMSGECVersion = "2-143.0.3650.75"
|
|
edgeOrigin = "chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold"
|
|
edgeUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0"
|
|
edgeOutputFormat = "audio-24khz-48kbitrate-mono-mp3" // mp3!
|
|
)
|
|
|
|
// edgeIOTimeout begrenzt einzelne Lese-/Schreiboperationen sowie (wenn der
|
|
// Aufrufer keine Deadline mitgibt) die gesamte Synthese.
|
|
const edgeIOTimeout = 20 * time.Second
|
|
|
|
// edgeClockSkew korrigiert lokale Uhrenabweichung: Bei 403 liest die Kette
|
|
// das Date-Header-Feld der Antwort und justiert die Tick-Berechnung (ein
|
|
// Wiederholungsversuch, wie die edge-tts-Referenz).
|
|
var edgeClockSkew atomic.Int64
|
|
|
|
// Edge ist die kostenlose dritte Stufe der Kette: Microsofts „Read Aloud"-
|
|
// Endpoint, kein Key nötig. Synthesize öffnet eine WebSocket-Verbindung,
|
|
// sendet speech.config + SSML-Anfrage und sammelt die Binär-Audio-Frames
|
|
// bis turn.end. Rückgabeformat: MP3 (24 kHz mono — der audio-Sink
|
|
// dekodiert das Format).
|
|
type Edge struct {
|
|
// Endpoint ist die WebSocket-URL (ws:// oder wss://), leer = Default.
|
|
Endpoint string
|
|
|
|
// Voices ist das config-Mapping voice.edge.<lang> = id; leer = neutrale
|
|
// Repo-Defaults (de-DE-KatjaNeural / en-US-AriaNeural).
|
|
Voices map[string]string
|
|
|
|
// Dialer ist der WebSocket-Dialer; nil = websocket.DefaultDialer.
|
|
Dialer *websocket.Dialer
|
|
|
|
pinned string // --voice-Pin (VoicePinner)
|
|
}
|
|
|
|
// NewEdge baut den Provider aus der Ketten-Konfiguration: Endpunkt aus
|
|
// TALK_EDGE_ENDPOINT > [providers.edge].endpoint > Default.
|
|
func NewEdge(cfg ChainConfig) *Edge {
|
|
return &Edge{
|
|
Endpoint: endpointFrom(cfg, "edge", EnvEdgeEndpoint, DefaultEndpointEdge),
|
|
Voices: cfg.Voices["edge"],
|
|
}
|
|
}
|
|
|
|
// Name implementiert Provider.
|
|
func (*Edge) Name() string { return "edge" }
|
|
|
|
// PinVoice implementiert VoicePinner (CLI-Flag --voice).
|
|
func (p *Edge) PinVoice(id string) { p.pinned = id }
|
|
|
|
// Available implementiert Provider: Edge braucht nur einen Endpunkt —
|
|
// bewusst ohne Netz-Call (Erreichbarkeit stellt Synthesize fest, an deren
|
|
// Fehler fällt die Kette weiter).
|
|
func (p *Edge) Available(ctx context.Context) bool { return p.Endpoint != "" }
|
|
|
|
// Synthesize implementiert Provider. Rückgabeformat: MP3.
|
|
func (p *Edge) Synthesize(ctx context.Context, text, lang string) ([]byte, error) {
|
|
if text == "" {
|
|
return nil, fmt.Errorf("edge: leerer Text")
|
|
}
|
|
voice := resolveVoice("edge", lang, p.pinned, p.Voices)
|
|
if voice == "" {
|
|
return nil, fmt.Errorf("edge: keine Stimme für Sprache %q konfiguriert", lang)
|
|
}
|
|
if _, hasDeadline := ctx.Deadline(); !hasDeadline {
|
|
var cancel context.CancelFunc
|
|
ctx, cancel = context.WithTimeout(ctx, edgeIOTimeout)
|
|
defer cancel()
|
|
}
|
|
|
|
conn, wsURL, err := p.dialWithSkewRetry(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer conn.Close()
|
|
|
|
now := time.Now().UTC()
|
|
messages := [][]byte{
|
|
[]byte(edgeSpeechConfigMsg(now)),
|
|
[]byte(edgeSynthesisHeaders(now, edgeRequestID()) + edgeSSML(voice, text)),
|
|
}
|
|
for _, msg := range messages {
|
|
if err := conn.SetWriteDeadline(time.Now().Add(edgeIOTimeout)); err != nil {
|
|
return nil, protocolErr(err)
|
|
}
|
|
if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil {
|
|
return nil, protocolErr(err)
|
|
}
|
|
}
|
|
|
|
var audio bytes.Buffer
|
|
for {
|
|
if err := conn.SetReadDeadline(time.Now().Add(edgeIOTimeout)); err != nil {
|
|
return nil, protocolErr(err)
|
|
}
|
|
msgType, msg, err := conn.ReadMessage()
|
|
if err != nil {
|
|
return nil, protocolErr(err)
|
|
}
|
|
switch msgType {
|
|
case websocket.TextMessage:
|
|
if strings.Contains(string(msg), "Path:turn.end") {
|
|
if audio.Len() == 0 {
|
|
return nil, fmt.Errorf("edge: Server meldete turn.end ohne Audio-Frames — Protokoll geändert? (%s)", redactURL(wsURL))
|
|
}
|
|
return audio.Bytes(), nil
|
|
}
|
|
case websocket.BinaryMessage:
|
|
headerText, payload, ok := splitEdgeBinary(msg)
|
|
if ok && strings.Contains(headerText, "Path:audio") {
|
|
audio.Write(payload)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// dialWithSkewRetry baut die Verbindung; bei 403 (Uhrabweichung) wird der
|
|
// Server-Zeitstempel übernommen und EINMAL wiederholt.
|
|
func (p *Edge) dialWithSkewRetry(ctx context.Context) (*websocket.Conn, string, error) {
|
|
conn, wsURL, resp, err := p.dial(ctx)
|
|
if err == nil {
|
|
return conn, wsURL, nil
|
|
}
|
|
if resp != nil && resp.StatusCode == http.StatusForbidden {
|
|
if serverDate := resp.Header.Get("Date"); serverDate != "" {
|
|
if t, perr := http.ParseTime(serverDate); perr == nil {
|
|
edgeClockSkew.Store(t.Unix() - time.Now().Unix())
|
|
conn, wsURL, resp, err = p.dial(ctx)
|
|
if err == nil {
|
|
return conn, wsURL, nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if resp != nil {
|
|
return nil, wsURL, fmt.Errorf("edge: WebSocket-Verbindung zu %s fehlgeschlagen (HTTP %d; Netz/Timeout oder Protokoll geändert?): %w",
|
|
redactURL(wsURL), resp.StatusCode, err)
|
|
}
|
|
return nil, wsURL, fmt.Errorf("edge: WebSocket-Verbindung zu %s fehlgeschlagen — Netz/Timeout oder das Edge-TTS-Protokoll hat sich geändert: %w",
|
|
redactURL(wsURL), err)
|
|
}
|
|
|
|
// dial öffnet die Verbindung gemäß Protokoll: Query mit Token,
|
|
// ConnectionId, Sec-MS-GEC (v2) und Version; Header mit Origin,
|
|
// Edge-User-Agent und MUID-Cookie.
|
|
func (p *Edge) dial(ctx context.Context) (*websocket.Conn, string, *http.Response, error) {
|
|
wsURL, err := p.requestURL(time.Now())
|
|
if err != nil {
|
|
return nil, "", nil, err
|
|
}
|
|
dialer := p.Dialer
|
|
if dialer == nil {
|
|
dialer = websocket.DefaultDialer
|
|
}
|
|
header := http.Header{}
|
|
header.Set("Origin", edgeOrigin)
|
|
header.Set("User-Agent", edgeUserAgent)
|
|
header.Set("Pragma", "no-cache")
|
|
header.Set("Cache-Control", "no-cache")
|
|
header.Set("Accept-Language", "en-US,en;q=0.9")
|
|
header.Set("Cookie", "muid="+edgeMUID())
|
|
conn, resp, err := dialer.DialContext(ctx, wsURL, header)
|
|
if err != nil {
|
|
return nil, wsURL, resp, err
|
|
}
|
|
return conn, wsURL, nil, nil
|
|
}
|
|
|
|
// requestURL ergänzt die Protokoll-Query-Parameter und validiert das
|
|
// Schema (ws/wss).
|
|
func (p *Edge) requestURL(now time.Time) (string, error) {
|
|
if p.Endpoint == "" {
|
|
return "", fmt.Errorf("edge: kein Endpunkt konfiguriert")
|
|
}
|
|
u, err := url.Parse(p.Endpoint)
|
|
if err != nil {
|
|
return "", fmt.Errorf("edge: Endpunkt %q unlesbar: %w", p.Endpoint, err)
|
|
}
|
|
if u.Scheme != "ws" && u.Scheme != "wss" {
|
|
return "", fmt.Errorf("edge: Endpunkt %q muss ws:// oder wss:// sein (ist %q)", p.Endpoint, u.Scheme)
|
|
}
|
|
q := u.Query()
|
|
q.Set("TrustedClientToken", edgeTrustedClientToken)
|
|
q.Set("ConnectionId", edgeRequestID())
|
|
q.Set("Sec-MS-GEC", edgeSecMSGEC(now))
|
|
q.Set("Sec-MS-GEC-Version", edgeSecMSGECVersion)
|
|
u.RawQuery = q.Encode()
|
|
return u.String(), nil
|
|
}
|
|
|
|
// edgeSecMSGEC erzeugt den DRM-Token Sec-MS-GEC v2: Windows-Filetime-Ticks
|
|
// (100 ns, 5-Minuten-Raster, Uhrenkorrektur) + Trusted-Client-Token →
|
|
// „<CS64>-<SHA256-Variante>" (beides großes Hex, siehe edgedrm.go).
|
|
func edgeSecMSGEC(now time.Time) string {
|
|
secs := now.Unix() + edgeClockSkew.Load() + drmWinEpoch
|
|
secs -= secs % 300 // 5-Minuten-Raster
|
|
ticks := uint64(secs) * 10_000_000
|
|
strToHash := fmt.Appendf(nil, "%d%s", ticks, edgeTrustedClientToken)
|
|
digest := drmSHA256Digest(strToHash)
|
|
cs0, cs1 := drmCS64(strToHash, digest)
|
|
|
|
var cs64Buf [8]byte
|
|
cs64Buf[0] = byte(cs0)
|
|
cs64Buf[1] = byte(cs0 >> 8)
|
|
cs64Buf[2] = byte(cs0 >> 16)
|
|
cs64Buf[3] = byte(cs0 >> 24)
|
|
cs64Buf[4] = byte(cs1)
|
|
cs64Buf[5] = byte(cs1 >> 8)
|
|
cs64Buf[6] = byte(cs1 >> 16)
|
|
cs64Buf[7] = byte(cs1 >> 24)
|
|
|
|
var shaBuf [32]byte
|
|
for i, w := range digest {
|
|
shaBuf[i*4] = byte(w >> 24)
|
|
shaBuf[i*4+1] = byte(w >> 16)
|
|
shaBuf[i*4+2] = byte(w >> 8)
|
|
shaBuf[i*4+3] = byte(w)
|
|
}
|
|
return strings.ToUpper(hex.EncodeToString(cs64Buf[:])) + "-" + strings.ToUpper(hex.EncodeToString(shaBuf[:]))
|
|
}
|
|
|
|
// protocolErr überführt Lese-/Schreib-/Verbindungsabbrüche in klare
|
|
// „Protokoll geändert?"-Fehler — Edge ist ein undokumentierter Endpoint,
|
|
// Änderungen sollen sofort diagnostizierbar sein.
|
|
func protocolErr(err error) error {
|
|
return fmt.Errorf("edge: Kommunikation fehlgeschlagen — Netz/Timeout oder das Edge-TTS-Protokoll hat sich geändert: %w", err)
|
|
}
|
|
|
|
// splitEdgeBinary zerlegt eine binäre Server-Nachricht: Die ersten 2 Bytes
|
|
// (big-endian) geben die Länge des Header-Blocks an (CRLF-Zeilen wie
|
|
// „Path:audio"), danach kommt die Audio-Payload.
|
|
func splitEdgeBinary(msg []byte) (header string, payload []byte, ok bool) {
|
|
if len(msg) < 2 {
|
|
return "", nil, false
|
|
}
|
|
headerLen := int(binary.BigEndian.Uint16(msg[0:2]))
|
|
if 2+headerLen > len(msg) {
|
|
return "", nil, false
|
|
}
|
|
return string(msg[2 : 2+headerLen]), msg[2+headerLen:], true
|
|
}
|
|
|
|
// edgeSpeechConfigMsg ist die erste Client-Nachricht: Header-Block +
|
|
// JSON-Body mit Ausgabeformat und Metadaten-Optionen (Satzgrenzen an,
|
|
// Wortgrenzen aus — Referenz-Default).
|
|
func edgeSpeechConfigMsg(now time.Time) string {
|
|
return fmt.Sprintf(
|
|
"X-Timestamp:%s\r\nContent-Type:application/json; charset=utf-8\r\nPath:speech.config\r\n\r\n"+
|
|
`{"context":{"synthesis":{"audio":{"metadataoptions":{"sentenceBoundaryEnabled":"true","wordBoundaryEnabled":"false"},"outputFormat":%q}}}}`,
|
|
edgeTimestamp(now), edgeOutputFormat)
|
|
}
|
|
|
|
// edgeSynthesisHeaders ist der Header-Block der SSML-Anfrage. Der
|
|
// nachgestellte Z beim Timestamp ist Referenz-Verhalten („Microsoft Edge
|
|
// bug"), bewusst mitgeschleppt.
|
|
func edgeSynthesisHeaders(now time.Time, requestID string) string {
|
|
return fmt.Sprintf(
|
|
"X-RequestId:%s\r\nContent-Type:application/ssml+xml\r\nX-Timestamp:%sZ\r\nPath:ssml\r\n\r\n",
|
|
requestID, edgeTimestamp(now))
|
|
}
|
|
|
|
// edgeSSML baut die Sprach-Anfrage (xml:lang wie die Referenz fest
|
|
// „en-US"; der Text wird von Steuerzeichen befreit und XML-escapt).
|
|
func edgeSSML(voice, text string) string {
|
|
return fmt.Sprintf(
|
|
"<speak version='1.0' xmlns='http://www.w3.org/2001/10/synthesis' xml:lang='en-US'>"+
|
|
"<voice name='%s'>"+
|
|
"<prosody pitch='+0Hz' rate='+0%%' volume='+0%%'>%s</prosody>"+
|
|
"</voice></speak>",
|
|
voice, edgeEscape(text))
|
|
}
|
|
|
|
// edgeEscape entfernt nicht unterstützte Steuerzeichen (Referenz:
|
|
// remove_incompatible_characters) und escapt danach & < >.
|
|
func edgeEscape(s string) string {
|
|
clean := make([]byte, 0, len(s))
|
|
for i := 0; i < len(s); i++ {
|
|
c := s[i]
|
|
if c <= 8 || (c >= 11 && c <= 12) || (c >= 14 && c <= 31) {
|
|
c = ' '
|
|
}
|
|
clean = append(clean, c)
|
|
}
|
|
esc := make([]byte, 0, len(clean))
|
|
for _, c := range clean {
|
|
switch c {
|
|
case '&':
|
|
esc = append(esc, "&"...)
|
|
case '<':
|
|
esc = append(esc, "<"...)
|
|
case '>':
|
|
esc = append(esc, ">"...)
|
|
default:
|
|
esc = append(esc, c)
|
|
}
|
|
}
|
|
return string(esc)
|
|
}
|
|
|
|
// edgeTimestamp formatiert den Zeitstempel im edge-tts-Format.
|
|
func edgeTimestamp(now time.Time) string {
|
|
return now.Format("Mon Jan 02 2006 15:04:05 GMT+0000 (Coordinated Universal Time)")
|
|
}
|
|
|
|
// edgeRequestID erzeugt eine 32-stellige Hex-ID (UUID v4 ohne Striche).
|
|
func edgeRequestID() string {
|
|
var b [16]byte
|
|
if _, err := rand.Read(b[:]); err != nil {
|
|
// Fallback: zeitbasiert — die ID muss nur eindeutig pro Anfrage sein.
|
|
now := time.Now().UnixNano()
|
|
for i := 0; i < 16; i++ {
|
|
b[i] = byte(now >> (uint(i%8) * 8))
|
|
}
|
|
}
|
|
b[6] = (b[6] & 0x0f) | 0x40 // UUID-Version
|
|
b[8] = (b[8] & 0x3f) | 0x80 // Variante
|
|
return hex.EncodeToString(b[:])
|
|
}
|
|
|
|
// edgeMUID erzeugt die zufällige MUID für das Cookie (32 Hex, groß).
|
|
func edgeMUID() string {
|
|
var b [16]byte
|
|
if _, err := rand.Read(b[:]); err != nil {
|
|
return edgeRequestID()
|
|
}
|
|
return strings.ToUpper(hex.EncodeToString(b[:]))
|
|
}
|
|
|
|
// redactURL maskiert die Query-Parameter (enthalten DRM-Token und IDs) in
|
|
// Fehlermeldungen — die URL selbst genügt zur Diagnose.
|
|
func redactURL(raw string) string {
|
|
u, err := url.Parse(raw)
|
|
if err != nil {
|
|
return raw
|
|
}
|
|
u.RawQuery = "…"
|
|
return u.String()
|
|
}
|