Files
talk/internal/provider/edge.go
T

372 lines
13 KiB
Go
Raw Normal View History

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, "&amp;"...)
case '<':
esc = append(esc, "&lt;"...)
case '>':
esc = append(esc, "&gt;"...)
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()
}