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. = 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 → // „-" (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( ""+ ""+ "%s"+ "", 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() }