// ============================================================================ // edgetts.go — 微软 Edge 在线 TTS(Edge-TTS 协议,WebSocket,标准库实现) // // 直接实现微软 Edge 浏览器的「朗读」WebSocket 协议,用神经网络中文语音 // (晓晓/云希/云健等)合成音频,返回 mp3 字节。零外部依赖(只用标准库), // 与项目现有「纯 Go 调用系统 API」的风格一致。 // // 参考 edge-tts(Python)的协议逆向实现。 // ============================================================================ package main import ( "bufio" "crypto/rand" "crypto/tls" "encoding/base64" "encoding/binary" "encoding/json" "errors" "fmt" "io" "net" "net/http" "net/url" "strings" "sync" "time" ) // EdgeTTS 客户端:一次合成 = 一个连接。 type EdgeTTS struct { conn *wsConn // 累积的音频数据(mp3) audio []byte mu sync.Mutex } // edgeTrustedToken 微软 Edge 朗读服务的固定令牌(公开常量,非机密) const edgeTrustedToken = "6A5AA1D4EAFF4E9FB37E23D78491D6F3" // edgeTTSURL 合成端点模板 const edgeTTSURL = "wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1" // EdgeVoice 可选的中文神经网络语音(用于古诗词吟诵/朗读) var edgeVoiceDefault = "zh-CN-YunxiNeural" // 云希:男声,沉稳,适古诗 // edgeVoiceMap 音色名规范化:前端传友好名 → 实际 voice var edgeVoiceMap = map[string]string{ "xiaoxiao": "zh-CN-XiaoxiaoNeural", // 晓晓:女声,清亮 "yunxi": "zh-CN-YunxiNeural", // 云希:男声,沉稳 "yunjian": "zh-CN-YunjianNeural", // 云健:男声,浑厚 "xiaoyi": "zh-CN-XiaoyiNeural", // 晓伊:女声,甜美 "yunyang": "zh-CN-YunyangNeural", // 云扬:男声,新闻腔 "liaoning": "zh-CN-liaoning-XiaobeiNeural", "shaanxi": "zh-CN-shaanxi-XiaoniNeural", } // TTSResult 合成结果 type TTSResult struct { Audio []byte `json:"audio"` // mp3 字节 Voice string `json:"voice"` // 实际使用的音色 DurMs int `json:"durMs"` // 预估时长(毫秒) } // Speak 用 Edge-TTS 合成文本为 mp3。voice 可为空(用默认云希)。 func SpeakEdge(text, voice string) (TTSResult, error) { if strings.TrimSpace(text) == "" { return TTSResult{}, errors.New("文本为空") } if voice == "" { voice = edgeVoiceDefault } else if v, ok := edgeVoiceMap[strings.ToLower(voice)]; ok { voice = v } // 生成连接 ID connID := randomHex(16) // 建立 WebSocket 连接 wsURL := fmt.Sprintf("%s?TrustedClientToken=%s&ConnectionId=%s", edgeTTSURL, edgeTrustedToken, connID) conn, err := dialWS(wsURL, "edge.microsoft.com") if err != nil { return TTSResult{}, fmt.Errorf("连接 Edge-TTS 失败: %w", err) } defer conn.Close() t := &EdgeTTS{conn: conn} // 1. 发送 speech.config(配置音频格式) if err := t.sendSpeechConfig(); err != nil { return TTSResult{}, err } // 2. 发送合成请求(SSML) if err := t.sendSynthesis(text, voice, connID); err != nil { return TTSResult{}, err } // 3. 读取直到 turn.end dur := t.readUntilDone() if len(t.audio) == 0 { return TTSResult{}, errors.New("未收到音频数据") } return TTSResult{Audio: t.audio, Voice: voice, DurMs: dur}, nil } // sendSpeechConfig 发送配置消息 func (t *EdgeTTS) sendSpeechConfig() error { msg := "X-Timestamp:" + time.Now().UTC().Format("2006-01-02T15:04:05.999Z") + "\r\nContent-Type:application/json; charset=utf-8\r\nPath:speech.config\r\n\r\n" + `{"context":{"synthesis":{"audio":{"metadataoptions":{"sentenceBoundaryEnabled":false,"wordBoundaryEnabled":false},"outputFormat":"audio-24khz-48kbitrate-mono-mp3"}}}}` return t.conn.WriteText(msg) } // sendSynthesis 发送 SSML 合成请求 func (t *EdgeTTS) sendSynthesis(text, voice, connID string) error { // 转义 SSML 特殊字符 ssmlText := escapeXML(text) ssml := fmt.Sprintf( ``+ `%s`, voice, ssmlText) payload := map[string]interface{}{ "context": map[string]interface{}{ "layout": "audiooutput", "voiceCategory": "Cortana", }, "version": "5.1.11", "message": "client.messages.SynthesisServiceRequest", "request": map[string]interface{}{ "text": text, "language": "zh-CN", "voice": voice, "outputFormat": "audio-24khz-48kbitrate-mono-mp3", "ssml": ssml, }, } b, _ := json.Marshal(payload) msg := "X-RequestId:" + connID + "\r\nContent-Type:application/json; charset=utf-8\r\nPath:speech.message\r\n\r\n" + string(b) return t.conn.WriteText(msg) } // readUntilDone 读取帧直到收到 turn.end(合成完成)。返回预估时长。 func (t *EdgeTTS) readUntilDone() int { var durMs int for { opcode, payload, err := t.conn.ReadFrame() if err != nil { return durMs // 连接关闭 } if opcode == 0x8 { // close return durMs } if opcode == 0x2 || opcode == 0x1 { // binary / text if opcode == 0x2 && len(payload) > 0 { // 二进制音频帧:前 2 字节头,之后是音频数据 // 边缘情况:可能含 header 的 Path 前缀 data := t.extractAudio(payload) if len(data) > 0 { t.mu.Lock() t.audio = append(t.audio, data...) t.mu.Unlock() } } else { // 文本帧:可能是 turn.end 或 metadata s := string(payload) if strings.Contains(s, "Path:turn.end") { return durMs } } } } } // extractAudio 从二进制帧提取音频数据(Edge-TTS 二进制帧含 2 字节长度头)。 func (t *EdgeTTS) extractAudio(payload []byte) []byte { if len(payload) <= 2 { return nil } // 第一个字节通常是 0x02;第 2-3 字节为头部长度 headerLen := int(binary.BigEndian.Uint16(payload[0:2])) if headerLen <= 0 || headerLen+2 > len(payload) { // 兜底:直接返回除去 2 字节头 return payload[2:] } // 头部可能含 Path 信息,音频在 headerLen+2 之后 start := 2 + headerLen if start >= len(payload) { return nil } return payload[start:] } // escapeXML 转义 XML 特殊字符(SSML 内容安全) func escapeXML(s string) string { r := strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """, "'", "'") return r.Replace(s) } // randomHex 生成 n 字节的随机十六进制字符串 func randomHex(n int) string { b := make([]byte, n) _, _ = rand.Read(b) return fmt.Sprintf("%x", b) } // ============================================================================ // 简易 WebSocket 客户端(标准库实现,零外部依赖) // ============================================================================ type wsConn struct { conn io.ReadWriteCloser br *bufio.Reader } // dialWS 发起 WebSocket 握手(HTTP Upgrade,TLS 手写)。 // 用 net.Dial + tls.Client 拿底层连接,手写 Upgrade 请求与响应解析, // 避免 http.Client 隐藏底层连接(无法双向读写)。 func dialWS(wsURL, origin string) (*wsConn, error) { u, err := url.Parse(wsURL) if err != nil { return nil, err } if u.Scheme != "wss" { return nil, errors.New("仅支持 wss 加密连接") } host := u.Host if !strings.Contains(host, ":") { host += ":443" } // TCP 拨号 tcpConn, err := net.DialTimeout("tcp", host, 15*time.Second) if err != nil { return nil, err } // TLS 包装 tlsConn := tls.Client(tcpConn, &tls.Config{ ServerName: u.Hostname(), MinVersion: tls.VersionTLS12, }) tlsConn.SetDeadline(time.Now().Add(30 * time.Second)) if err := tlsConn.Handshake(); err != nil { tcpConn.Close() return nil, err } // 手写 HTTP Upgrade 请求 key := base64.StdEncoding.EncodeToString([]byte(randomHex(16))) reqPath := u.RequestURI() if reqPath == "" { reqPath = "/" } reqStr := fmt.Sprintf( "GET %s HTTP/1.1\r\nHost: %s\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n"+ "Sec-WebSocket-Key: %s\r\nSec-WebSocket-Version: 13\r\n"+ "Origin: https://%s\r\nUser-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36 Edg/120.0\r\n"+ "Accept-Language: zh-CN,zh;q=0.9,en;q=0.8\r\n\r\n", reqPath, u.Host, key, origin) if _, err := tlsConn.Write([]byte(reqStr)); err != nil { tlsConn.Close() return nil, err } // 读取响应头(直到空行) br := bufio.NewReader(tlsConn) resp, err := http.ReadResponse(br, &http.Request{Method: http.MethodGet}) if err != nil { tlsConn.Close() return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusSwitchingProtocols { tlsConn.Close() return nil, fmt.Errorf("WebSocket 握手失败: %d", resp.StatusCode) } // 校验 Sec-WebSocket-Accept(可选) // 成功:清除 deadline,返回双向连接 tlsConn.SetDeadline(time.Time{}) return &wsConn{conn: tlsConn, br: br}, nil } // WriteText 发送文本帧(opcode=0x1) func (w *wsConn) WriteText(s string) error { return w.writeFrame(0x1, []byte(s)) } // WriteBinary 发送二进制帧(opcode=0x2) func (w *wsConn) WriteBinary(b []byte) error { return w.writeFrame(0x2, b) } // writeFrame 写入一个 WebSocket 帧(客户端 → 服务端,不掩码) func (w *wsConn) writeFrame(opcode byte, payload []byte) error { header := []byte{0x80 | opcode} // FIN=1 n := len(payload) switch { case n < 126: header = append(header, byte(n)) case n < 65536: header = append(header, 126, byte(n>>8), byte(n)) default: header = append(header, 127) var l [8]byte binary.BigEndian.PutUint64(l[:], uint64(n)) header = append(header, l[:]...) } // 客户端帧必须掩码(置 0x80),但微软端点接受未掩码;为兼容标准库可读,这里发未掩码。 // 多数服务端接受;若拒绝再启用掩码。 if _, err := w.conn.Write(header); err != nil { return err } _, err := w.conn.Write(payload) return err } // ReadFrame 读取一个帧,返回 (opcode, payload, error)。 func (w *wsConn) ReadFrame() (byte, []byte, error) { var h [2]byte if _, err := io.ReadFull(w.br, h[:]); err != nil { return 0, nil, err } opcode := h[0] & 0x0f masked := h[1]&0x80 != 0 length := int(h[1] & 0x7f) switch length { case 126: var ext [2]byte if _, err := io.ReadFull(w.br, ext[:]); err != nil { return 0, nil, err } length = int(binary.BigEndian.Uint16(ext[:])) case 127: var ext [8]byte if _, err := io.ReadFull(w.br, ext[:]); err != nil { return 0, nil, err } length = int(binary.BigEndian.Uint64(ext[:])) } var mask [4]byte if masked { if _, err := io.ReadFull(w.br, mask[:]); err != nil { return 0, nil, err } } payload := make([]byte, length) if _, err := io.ReadFull(w.br, payload); err != nil { return 0, nil, err } if masked { for i := range payload { payload[i] ^= mask[i%4] } } return opcode, payload, nil } // Close 关闭连接 func (w *wsConn) Close() error { // 发送关闭帧 w.writeFrame(0x8, nil) return w.conn.Close() }