Files
talk/internal/provider/edge.go
T

371 lines
12 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 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.<lang> = 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 →
// "<CS64>-<SHA256 variant>" (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(
"<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 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, "&amp;"...)
case '<':
esc = append(esc, "&lt;"...)
case '>':
esc = append(esc, "&gt;"...)
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()
}