Files
36Wisdom/cmd/gentss/edge.go
T
2026-08-14 13:45:02 +08:00

310 lines
9.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
// Edge TTS 客户端:微软 Edge 朗读私有 WebSocket 协议。
// 实现对照 edge-tts 7.2.8drm.py / constants.py / communicate.py)逐行校验:
// Sec-MS-GEC 纯算法生成(无 HTTP 预请求),403 时读响应 Date 头校正时钟偏移重试。
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"math"
"net/http"
"strings"
"time"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/google/uuid"
"github.com/gorilla/websocket"
)
const (
trustedClientToken = "6A5AA1D4EAFF4E9FB37E23D68491D6F4"
winEpoch = 11644473600 // Unix 与 Windows 文件时间纪元的秒差(1601-01-01)
originHeader = "chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold"
userAgent = "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"
audioURL = "wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1"
audioFormat = "audio-24khz-48kbitrate-mono-mp3"
maxChunkBytes = 4096 // 单请求文本字节上限(与 edge-tts 一致,超长分块多轮连接)
)
type ttsClient struct {
voice string
rate string
version string
timeout time.Duration
retries int
clockSkew float64 // 403 时按服务端 Date 头累加校正
}
// permanentErr 协议类错误,重试无意义(不重试)。
type permanentErr struct{ err error }
func (e *permanentErr) Error() string { return e.err.Error() }
func (e *permanentErr) Unwrap() error { return e.err }
// synthesize 合成一段文本,返回 mp3 字节;超长文本按字节上限分块、逐块独立连接后拼接。
func (c *ttsClient) synthesize(ctx context.Context, text string) ([]byte, error) {
text = escapeXML(cleanText(text))
var buf []byte
for _, chunk := range splitChunks(text, maxChunkBytes) {
audio, err := c.synthesizeChunk(ctx, chunk)
if err != nil {
return nil, err
}
buf = append(buf, audio...)
}
if len(buf) == 0 {
return nil, gerror.New("合成结果为空")
}
return buf, nil
}
func (c *ttsClient) synthesizeChunk(ctx context.Context, text string) ([]byte, error) {
var lastErr error
for attempt := 0; attempt <= c.retries; attempt++ {
if attempt > 0 {
select {
case <-time.After(time.Duration(attempt) * time.Second):
case <-ctx.Done():
return nil, ctx.Err()
}
}
audio, err := c.turn(ctx, text)
if err == nil {
return audio, nil
}
lastErr = err
var pe *permanentErr
if errors.As(err, &pe) {
return nil, err
}
}
return nil, fmt.Errorf("重试 %d 次仍失败: %w", c.retries, lastErr)
}
// turn 一次连接完成一帧文本的合成,返回 mp3 字节。
func (c *ttsClient) turn(ctx context.Context, text string) ([]byte, error) {
conn, err := c.dial(ctx)
if err != nil {
return nil, err
}
defer conn.Close()
// 帧 1speech.config(输出格式配置)
configFrame := 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":"%s"}}}}`+"\r\n",
dateString(), audioFormat)
// 帧 2ssmlxml:lang 固定 en-US,与 edge-tts 一致;X-Timestamp 尾缀 Z 是微软端约定)
ssml := 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='%s' volume='+0%%'>%s</prosody></voice></speak>",
c.voice, c.rate, text)
ssmlFrame := fmt.Sprintf(
"X-RequestId:%s\r\nContent-Type:application/ssml+xml\r\nX-Timestamp:%sZ\r\nPath:ssml\r\n\r\n%s",
strings.ReplaceAll(uuid.NewString(), "-", ""), dateString(), ssml)
conn.SetWriteDeadline(time.Now().Add(c.timeout))
if err := conn.WriteMessage(websocket.TextMessage, []byte(configFrame)); err != nil {
return nil, fmt.Errorf("发送 speech.config 失败: %w", err)
}
if err := conn.WriteMessage(websocket.TextMessage, []byte(ssmlFrame)); err != nil {
return nil, fmt.Errorf("发送 ssml 失败: %w", err)
}
var audio []byte
for {
conn.SetReadDeadline(time.Now().Add(c.timeout))
mt, payload, err := conn.ReadMessage()
if err != nil {
return nil, fmt.Errorf("读帧失败: %w", err)
}
switch mt {
case websocket.TextMessage:
path, err := textFramePath(payload)
if err != nil {
return nil, &permanentErr{err}
}
switch path {
case "turn.end":
if len(audio) == 0 {
return nil, &permanentErr{gerror.New("无音频返回(文本或参数可能不被支持)")}
}
return audio, nil
case "turn.start", "response", "audio.metadata":
// 忽略
default:
return nil, &permanentErr{fmt.Errorf("未知响应 Path: %s", path)}
}
case websocket.BinaryMessage:
data, err := audioFrameData(payload)
if err != nil {
return nil, &permanentErr{err}
}
audio = append(audio, data...)
}
}
}
func (c *ttsClient) dial(ctx context.Context) (*websocket.Conn, error) {
connID := strings.ReplaceAll(uuid.NewString(), "-", "")
wsURL := fmt.Sprintf("%s?TrustedClientToken=%s&ConnectionId=%s&Sec-MS-GEC=%s&Sec-MS-GEC-Version=%s",
audioURL, trustedClientToken, connID, c.secMsGec(), c.version)
header := http.Header{
"Pragma": {"no-cache"},
"Cache-Control": {"no-cache"},
"Origin": {originHeader},
"User-Agent": {userAgent},
"Accept-Language": {"en-US,en;q=0.9"},
"Cookie": {"muid=" + muid() + ";"},
}
dialer := websocket.Dialer{
EnableCompression: true,
HandshakeTimeout: c.timeout,
}
conn, resp, err := dialer.DialContext(ctx, wsURL, header)
if err != nil {
if resp != nil && resp.StatusCode == http.StatusForbidden {
// 403 多为时钟偏差:读 Date 头校正偏移,下次握手即有效
if serverTS, ok := parseRFC2616Date(resp.Header.Get("Date")); ok {
c.clockSkew += serverTS - float64(time.Now().Unix())
}
}
return nil, fmt.Errorf("握手失败: %w", err)
}
return conn, nil
}
// secMsGec 生成 Sec-MS-GEC 令牌(edge-tts drm.py generate_sec_ms_gec):
// (UTC秒 + 时钟偏差 + 11644473600) 取整到 5 分钟窗 → ×1e9/100 转 Windows 100ns 刻度 →
// 拼接 TrustedClientToken → SHA256 大写 hex。
func (c *ttsClient) secMsGec() string {
ticks := float64(time.Now().Unix()) + c.clockSkew
ticks += winEpoch
ticks -= math.Mod(ticks, 300)
ticks *= 1e9 / 100
sum := sha256.Sum256([]byte(fmt.Sprintf("%.0f%s", ticks, trustedClientToken)))
return strings.ToUpper(hex.EncodeToString(sum[:]))
}
// muid 32 位大写 hex 随机串(Cookie: muid=...;
func muid() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
return strings.ToUpper(hex.EncodeToString(b))
}
// dateString JavaScript 风格 UTC 时间串(speech.config / ssml 帧头)
func dateString() string {
return time.Now().UTC().Format("Mon Jan 02 2006 15:04:05 GMT+0000 (Coordinated Universal Time)")
}
// parseRFC2616Date 解析 HTTP Date 头(如 Thu, 14 Aug 2026 12:34:56 GMT
func parseRFC2616Date(s string) (float64, bool) {
t, err := time.Parse("Mon, 02 Jan 2006 15:04:05 MST", s)
if err != nil {
return 0, false
}
return float64(t.Unix()), true
}
// cleanText 剔除服务端不支持的 ASCII 控制字符(0-8、11-12、14-31 → 空格)
func cleanText(s string) string {
rs := []rune(s)
for i, r := range rs {
if (r >= 0 && r <= 8) || (r >= 11 && r <= 12) || (r >= 14 && r <= 31) {
rs[i] = ' '
}
}
return string(rs)
}
// escapeXML 转义 XML 特殊字符(与 saxutils.escape 默认一致:& < >
func escapeXML(s string) string {
r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;")
return r.Replace(s)
}
// splitChunks 按字节上限在 rune 边界切分(超长文本分块,避免单请求过大)
func splitChunks(s string, maxBytes int) []string {
if len(s) <= maxBytes {
return []string{s}
}
var chunks []string
var buf []byte
for _, r := range s {
rb := string(r)
if len(buf) > 0 && len(buf)+len(rb) > maxBytes {
chunks = append(chunks, string(buf))
buf = nil
}
buf = append(buf, rb...)
}
if len(buf) > 0 {
chunks = append(chunks, string(buf))
}
return chunks
}
// textFramePath 取文本帧的 Path 头(帧结构:头\r\n\r\n载荷)
func textFramePath(payload []byte) (string, error) {
idx := bytes.Index(payload, []byte("\r\n\r\n"))
if idx < 0 {
return "", gerror.New("文本帧缺头尾分隔")
}
return framePath(payload[:idx])
}
// audioFrameData 解析二进制音频帧:前 2 字节大端头长度,头块(自身含尾部 \r\n)后即 mp3 载荷。
// 终止帧:无 Content-Type 且载荷为空(实测头文本含尾部 \r\n,与 Python 的 headerLen+2 偏移等价)。
func audioFrameData(payload []byte) ([]byte, error) {
if len(payload) < 2 {
return nil, gerror.New("音频帧缺头长度")
}
headerLen := int(binary.BigEndian.Uint16(payload[:2]))
if 2+headerLen > len(payload) {
return nil, gerror.New("音频帧头长度越界")
}
path, err := framePath(payload[2 : 2+headerLen])
if err != nil {
return nil, err
}
if path != "audio" {
return nil, fmt.Errorf("二进制帧 Path 非 audio: %s", path)
}
data := payload[2+headerLen:]
contentType, _ := frameHeader(payload[2:2+headerLen], "Content-Type")
switch {
case contentType == "":
if len(data) == 0 {
return nil, nil // 终止帧
}
return nil, gerror.New("无 Content-Type 却有数据")
case contentType != "audio/mpeg":
return nil, fmt.Errorf("意外的 Content-Type: %s", contentType)
case len(data) == 0:
return nil, gerror.New("audio/mpeg 帧缺音频数据")
}
return data, nil
}
func framePath(headers []byte) (string, error) {
return frameHeader(headers, "Path")
}
func frameHeader(headers []byte, key string) (string, error) {
for _, line := range bytes.Split(headers, []byte("\r\n")) {
k, v, ok := bytes.Cut(line, []byte(":"))
if ok && string(k) == key {
return string(v), nil
}
}
return "", fmt.Errorf("帧缺 %s 头", key)
}