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 constants. The protocol is the well-known "Read Aloud" protocol // of Microsoft Edge, reverse-engineered by edge-tts. State of the // implementation (2026): DRM token Sec-MS-GEC v2 (CS64 + obfuscated SHA-256 // variant, see edgedrm.go) + MUID cookie; otherwise the known WebSocket // message format (speech.config + SSML, binary frames with a 2-byte header // length, turn.end). If Microsoft changes it, the synthesis paths return // clear errors hinting at a protocol change instead of failing silently. const ( // DefaultEndpointEdge is the production WebSocket endpoint (path without // query; token/ConnectionId/Sec-MS-GEC are added in requestURL). DefaultEndpointEdge = "wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1" // EnvEdgeEndpoint overrides the endpoint (tests/corporate servers). 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 bounds individual read/write operations and (if the caller // provides no deadline) the whole synthesis. const edgeIOTimeout = 20 * time.Second // edgeClockSkew corrects local clock drift: on 403 the provider reads the // Date header of the response and adjusts the tick calculation (one retry, // like the edge-tts reference). var edgeClockSkew atomic.Int64 // Edge is the free third tier of the chain: Microsoft's "Read Aloud" // endpoint, no key needed. Synthesize opens a WebSocket connection, sends // speech.config + the SSML request and collects the binary audio frames // until turn.end. Return format: MP3 (24 kHz mono; the audio sink decodes // the format). type Edge struct { // Endpoint is the WebSocket URL (ws:// or wss://), empty = default. Endpoint string // Voices is the config mapping voice.edge. = id; empty = neutral // repo defaults (de-DE-KatjaNeural / en-US-AriaNeural). Voices map[string]string // Dialer is the WebSocket dialer; nil = websocket.DefaultDialer. Dialer *websocket.Dialer pinned string // --voice pin (VoicePinner) } // NewEdge builds the provider from the chain configuration: endpoint from // 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 implements Provider. func (*Edge) Name() string { return "edge" } // PinVoice implements VoicePinner (CLI flag --voice). func (p *Edge) PinVoice(id string) { p.pinned = id } // Available implements Provider: Edge only needs an endpoint, deliberately // without a network call (reachability is determined by Synthesize, on // whose error the chain falls back). func (p *Edge) Available(ctx context.Context) bool { return p.Endpoint != "" } // Synthesize implements Provider. Return format: MP3. func (p *Edge) Synthesize(ctx context.Context, text, lang string) ([]byte, error) { if text == "" { return nil, fmt.Errorf("edge: empty text") } voice := resolveVoice("edge", lang, p.pinned, p.Voices) if voice == "" { return nil, fmt.Errorf("edge: no voice configured for language %q", 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 sent turn.end without audio frames; protocol changed? (%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 establishes the connection; on 403 (clock drift) the // server timestamp is adopted and the dial is retried ONCE. 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 connection to %s failed (HTTP %d; network/timeout, or the Edge TTS protocol changed?): %w", redactURL(wsURL), resp.StatusCode, err) } return nil, wsURL, fmt.Errorf("edge: WebSocket connection to %s failed; network/timeout, or the Edge TTS protocol changed: %w", redactURL(wsURL), err) } // dial opens the connection according to the protocol: query with token, // ConnectionId, Sec-MS-GEC (v2) and version; headers with Origin, Edge // user agent and 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 adds the protocol query parameters and validates the scheme // (ws/wss). func (p *Edge) requestURL(now time.Time) (string, error) { if p.Endpoint == "" { return "", fmt.Errorf("edge: no endpoint configured") } u, err := url.Parse(p.Endpoint) if err != nil { return "", fmt.Errorf("edge: endpoint %q unreadable: %w", p.Endpoint, err) } if u.Scheme != "ws" && u.Scheme != "wss" { return "", fmt.Errorf("edge: endpoint %q must be ws:// or wss:// (is %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 produces the DRM token Sec-MS-GEC v2: Windows filetime ticks // (100 ns, 5-minute grid, clock correction) + trusted client token → // "-" (both upper-case hex, see edgedrm.go). func edgeSecMSGEC(now time.Time) string { secs := now.Unix() + edgeClockSkew.Load() + drmWinEpoch secs -= secs % 300 // 5-minute grid 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 turns read/write/connection aborts into clear "protocol // changed?" errors; Edge is an undocumented endpoint and changes should be // diagnosable immediately. func protocolErr(err error) error { return fmt.Errorf("edge: communication failed; network/timeout, or the Edge TTS protocol changed: %w", err) } // splitEdgeBinary splits a binary server message: the first 2 bytes // (big-endian) give the length of the header block (CRLF lines such as // "Path:audio"), followed by the 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 is the first client message: header block + JSON body // with output format and metadata options (sentence boundaries on, word // boundaries off; reference 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 is the header block of the SSML request. The // trailing Z on the timestamp is reference behaviour ("Microsoft Edge // bug"), deliberately carried along. 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 builds the speech request (xml:lang fixed to "en-US" like the // reference; the text is stripped of control characters and XML-escaped). func edgeSSML(voice, text string) string { return fmt.Sprintf( ""+ ""+ "%s"+ "", voice, edgeEscape(text)) } // edgeEscape removes unsupported control characters (reference: // remove_incompatible_characters) and then escapes & < >. 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 formats the timestamp in the 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 produces a 32-character hex ID (UUID v4 without dashes). func edgeRequestID() string { var b [16]byte if _, err := rand.Read(b[:]); err != nil { // Fallback: time based; the ID only has to be unique per request. 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 // variant return hex.EncodeToString(b[:]) } // edgeMUID produces the random MUID for the cookie (32 hex, upper case). func edgeMUID() string { var b [16]byte if _, err := rand.Read(b[:]); err != nil { return edgeRequestID() } return strings.ToUpper(hex.EncodeToString(b[:])) } // redactURL masks the query parameters (they contain the DRM token and // IDs) in error messages; the URL itself is enough for diagnosis. func redactURL(raw string) string { u, err := url.Parse(raw) if err != nil { return raw } u.RawQuery = "…" return u.String() }