Files
talk/internal/provider/edge.go
T
m3ta-chironandClaude Fable 5.1 85fa64cfe6 refactor: switch the whole project to English
talk is going to be published. Code comments, CLI output, error messages,
daemon log lines, MCP descriptions and results, test names and assertions,
README, spec, skill, build scripts and flake are now English. Only the
spoken announcement texts stay bilingual: the wrap result and the test
sentence are German or English depending on the detected language.

Language fallback is now configurable: `lang = "en" | "de"` (env
TALK_LANG, default en) is used when detection cannot tell (numbers, single
words, ties) and for talk's own announcements. langdetect.Detect takes the
fallback as an argument instead of hard-coding German. `talk setup` asks for
it; `talk doctor` and `talk status` show it.

Vocabulary pinned across packages so the process-boundary tests observe
stable strings: chain log lines ("chain: tier X skipped (not available)",
"falling back to the next tier", "spoken via X"), queue lines ("discarded:
quiet hours ... active", "replaced by toast", "max_age"), CLI notes
("speaking directly", "daemon started at", "no daemon", "nothing to cancel"),
status block with aligned English labels, MCP messages, origin guard 403.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-24 11:15:35 +02:00

372 lines
12 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 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()
}