310 lines
9.8 KiB
Go
310 lines
9.8 KiB
Go
package main
|
||
|
||
// Edge TTS 客户端:微软 Edge 朗读私有 WebSocket 协议。
|
||
// 实现对照 edge-tts 7.2.8(drm.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()
|
||
|
||
// 帧 1:speech.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)
|
||
// 帧 2:ssml(xml: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("&", "&", "<", "<", ">", ">")
|
||
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)
|
||
}
|