| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369 |
- // ============================================================================
- // 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(
- `<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xml:lang="zh-CN">`+
- `<voice name="%s"><prosody pitch="+0Hz" rate="+0%%" volume="+0%%">%s</prosody></voice></speak>`,
- 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()
- }
|