测试
func TestEdgeApi(t *testing.T) { ssml := `<speak xmlns="http://www.w3.org/2001/10/synthesis" xmlns:mstts="http://www.w3.org/2001/mstts" xmlns:emo="http://www.w3.org/2009/10/emotionml" version="1.0" xml:lang="en-US"><voice name="zh-CN-XiaoxiaoNeural"><prosody rate="200%" pitch="+0Hz"> 半年后一天,苏浩再次尝试控制身上的血气运动,原本以为会一如既往般毫无动静,没想到意识操控的那部分血气竟然往控制方向移动了一丝。就是这一丝移动,让苏浩欣喜若狂。</prosody></voice></speak>` tts := &TTS{} tts.NewConn() audioData, err := tts.GetAudio(ssml, "webm-24khz-16bit-mono-opus") if err != nil { log.Fatal(err) return } fmt.Println("Received audio data length:", len(audioData)) os.WriteFile("webm-24khz-16bit.mp3", audioData, 0666) }
完整的xml 内容如下:
<speak xmlns="http://www.w3.org/2001/10/synthesis" xmlns:mstts="http://www.w3.org/2001/mstts" xmlns:emo="http://www.w3.org/2009/10/emotionml" version="1.0" xml:lang="en-US"> <voice name="zh-CN-XiaoxiaoNeural"> <prosody rate="100%" pitch="+0Hz"> 半年后一天,苏浩再次尝试控制身上的血气运动,原本以为会一如既往般毫无动静,没想到意识操控的那部分血气竟然往控制方向移动了一丝。就是这一丝移动,让苏浩欣喜若狂。</prosody> </voice> </speak>
字段含义:
<speak>
:这是 SSML 的根元素,所有其他的元素都应该在这个元素中。xmlns
:XML 命名空间的定义,定义了默认的命名空间。xmlns:mstts
和xmlns:emo
:定义了其他命名空间,但在这个例子中并没有使用。version
:定义了 SSML 的版本,这个例子中的版本是 1.0。xml:lang
:定义了文本的语言,这个例子中的语言是 en-US(美国英语),但实际上在<voice>
元素中选择了中文的声音。
<voice>
:这个元素用来选择一个特定的声音。在这个例子中,选择了 "zh-CN-XiaoxiaoNeural",这是一个中文的神经网络声音。<prosody>
:这个元素用来控制语音合成的参数。在这个例子中,设置了语速(rate
)为 100%(即默认速度),音高(pitch
)为 +0Hz(即默认音高)。- 文本部分:在
<prosody>
元素中的文本是要转化为语音的文字。这个例子中的文字是一段中文的故事。
更多Voice and sound with SSML的相关内容: https://learn.microsoft.com/en-us/azure/cognitive-services/speech-service/speech-synthesis-markup-voice
func (t *TTS) GetAudio(ssml, format string) (audioData []byte, err error) { t.uuid = tsg.GetUUID() if t.conn == nil { err := t.NewConn() if err != nil { return nil, err } } running := true defer func() { running = false }() var finished = make(chan bool) var failed = make(chan error) t.onReadMessage = func(messageType int, p []byte, errMessage error) bool { if messageType == -1 && p == nil && errMessage != nil { //已经断开链接 if running { failed <- errMessage } return true } if messageType == websocket.BinaryMessage { index := strings.Index(string(p), "Path:audio") data := []byte(string(p)[index+12:]) audioData = append(audioData, data...) } else if messageType == websocket.TextMessage && string(p)[len(string(p))-14:len(string(p))-6] == "turn.end" { finished <- true return false } return false } err = t.sendConfigMessage(format) if err != nil { return nil, err } err = t.sendSsmlMessage(ssml) if err != nil { return nil, err } select { case <-finished: return audioData, err case errMessage := <-failed: return nil, errMessage } }
sendConfigMessage 配置信息
func (t *TTS) sendConfigMessage(format string) error { cfgMsg := "X-Timestamp:" + tsg.GetISOTime() + "\r\nContent-Type:application/json; charset=utf-8\r\n" + "Path:speech.config\r\n\r\n" + `{"context":{"synthesis":{"audio":{"metadataoptions":{"sentenceBoundaryEnabled":"false","wordBoundaryEnabled":"false"},"outputFormat":"` + format + `"}}}}` _ = t.conn.SetWriteDeadline(time.Now().Add(t.WriteTimeout)) err := t.conn.WriteMessage(websocket.TextMessage, []byte(cfgMsg)) if err != nil { return fmt.Errorf("发送Config失败: %s", err) } return nil }
发送的文本信息的示范:
X-Timestamp: 2023-06-16T14:25:16Z Content-Type: application/json; charset=utf-8 Path: speech.config { "context": { "synthesis": { "audio": { "metadataoptions": { "sentenceBoundaryEnabled": "false", "wordBoundaryEnabled": "false" }, "outputFormat": "riff-16khz-16bit-mono-pcm" } } } }
内容为音频合成 (speech synthesis) 的一种配置。以下是对各个字段的基本解释:
-
context
: 一个包含音频合成相关的各种设置的对象。 -
synthesis
: 在context
对象中,这个字段包含了一些专门用于音频合成的设置。 -
audio
: 在synthesis
对象中,这个字段指定了音频的各种参数。 -
metadataoptions
: 在audio
对象中,这个字段允许你配置一些元数据选项。 -
sentenceBoundaryEnabled
: 此选项决定了是否在合成的音频中包含句子边界的信息。当设置为 "false" 时,音频中不会包含句子边界的信息。 -
wordBoundaryEnabled
: 此选项决定了是否在合成的音频中包含单词边界的信息。当设置为 "false" 时,音频中不会包含单词边界的信息。 -
sentenceBoundaryEnabled
和wordBoundaryEnabled
是两个配置项,通常在语音合成或者语音识别的设置中出现。下面是它们的通俗解释:sentenceBoundaryEnabled
:如果这个设置被启用,系统会在句子的边界处提供额外的信息。例如,在语音识别系统中,如果这个设置被启用,系统会在识别结果中标记出句子的开始和结束。在语音合成系统中,如果这个设置被启用,系统可能会在句子的边界处插入适当的停顿,使得合成的语音听起来更自然。wordBoundaryEnabled
:这个设置与sentenceBoundaryEnabled
类似,但它关注的是单词的边界,而不是句子。如果这个设置被启用,系统会在单词的边界处提供额外的信息。例如,在语音识别系统中,系统会在识别结果中标记出单词的开始和结束。在语音合成系统中,如果这个设置被启用,系统可能会在单词的边界处插入适当的停顿。总的来说,这两个设置可以帮助提高语音系统的易用性和用户体验。
-
outputFormat
: 这个字段指定了音频的输出格式。在这个例子中,输出格式被设定为 "riff-16khz-16bit-mono-pcm",这代表音频会被编码为单声道的 16 位 PCM 格式,采样率为 16 kHz,并且使用 RIFF (资源交换文件格式) 进行封装。
音频输出格式 webm-24khz-16bit.mp3
"webm-24khz-16bit.mp3" 这个字符串中包含了一些混合的信息,我们来分别解释一下:
-
webm
是一种音频和视频文件格式,常用于网页视频(HTML5 视频等)。它是基于 Matroska 容器格式的,通常与 VP8 或 VP9 视频编码以及 Vorbis 或 Opus 音频编码配合使用。 -
24khz
指的是音频的采样率。这是指每秒钟对声音的采样次数,单位是 kHz(千赫兹)。采样率越高,音质通常越好,但也需要更多的存储空间。24 kHz 是一个相对较低的采样率,通常用于语音,而不是音乐。 -
16bit
是指每个音频样本的位深度。位深度越高,可以表示的声音级别就越多,动态范围就越大。16 位是 CD 音质的标准位深度。 -
mp3
是一种非常常见的音频文件格式和编码方式,被广泛用于音乐和其他类型的音频。
sendSsmlMessage 请求内容
func (t *TTS) sendSsmlMessage(ssml string) error { msg := "Path: ssml\r\nX-RequestId: " + t.uuid + "\r\nX-Timestamp: " + tsg.GetISOTime() + "\r\nContent-Type: application/ssml+xml\r\n\r\n" + ssml _ = t.conn.SetWriteDeadline(time.Now().Add(t.WriteTimeout)) err := t.conn.WriteMessage(websocket.TextMessage, []byte(msg)) if err != nil { return err } return nil }
发送的文本信息的示范:
可以通过使用 SSML (Speech Synthesis Markup Language) 来控制语音的各种参数,如速度(rate)、音调(pitch)和音量(volume)。以下是一个例子,展示了如何在 SSML 中设置这些参数:
<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xml:lang="en-US"> <prosody rate="slow" pitch="-2st" volume="soft"> Hello, this is a SSML message with adjusted prosody. </prosody> </speak>
在这个例子中,<prosody>
标签用来调整语音合成的参数:
-
rate
属性用来控制语速,可以设置为 "x-slow", "slow", "medium", "fast", "x-fast" 或者百分比。例如,"50%" 代表一半的速度,"200%" 则代表两倍的速度。 -
pitch
属性用来控制语音的音调,可以设置为 "x-low", "low", "medium", "high", "x-high" 或者相对于默认音调的语义音调。例如,"-2st" 代表比默认音调低两个半音阶。 -
volume
属性用来控制语音的音量,可以设置为 "silent", "x-soft", "soft", "medium", "loud", "x-loud" 或者相对于默认音量的语义增益。例如,"-6dB" 代表比默认音量低 6 分贝。
请注意,不同的语音合成系统可能支持不同的 SSML 特性和参数值,你应该查阅你正在使用的系统的文档以获取准确的信息。
WebSocket 超时连接处理
WriteTimeout
和 DialTimeout
都是与网络通信有关的超时设置。它们的使用可以帮助提升程序的健壮性和可用性。具体来说:
-
WriteTimeout
:在网络通信中,我们需要向服务器发送(写入)数据。如果网络状况不好或者服务器响应缓慢,数据的发送可能会花费较长的时间。为了防止程序在等待发送数据时卡住(这种情况被称为阻塞),我们可以设置一个写入超时(WriteTimeout
)。如果数据在这个超时时间内没有成功发送,程序将停止等待,并抛出一个错误。 -
DialTimeout
:在网络通信中,我们首先需要与服务器建立连接。如果网络状况不好或者服务器无法及时响应,连接的建立可能会花费较长的时间。为了防止程序在等待连接时卡住,我们可以设置一个拨号超时(DialTimeout
)。如果在这个超时时间内没有成功建立连接,程序将停止等待,并抛出一个错误。
这两个超时设置都是为了提高程序的响应速度和稳定性。在网络通信中,超时设置是非常重要的,因为网络状况的不确定性可能会导致程序在等待网络操作时阻塞,从而降低用户体验,甚至导致程序无法正常运行。通过设置合理的超时时间,我们可以确保程序在网络状况不佳时依然能够正常运行。
WebSocket 连接对象
在这个代码中,conn
和 onReadMessage
是 TTS
结构体中的两个字段。我们可以分别来看一下:
-
conn *websocket.Conn
:conn
是一个指向websocket.Conn
的指针。websocket.Conn
是来自gorilla/websocket
包的一个结构体,用于处理 WebSocket 连接。WebSocket 是一种网络通信协议,允许客户端和服务器进行全双工通信。在这里,conn
用于储存和服务器建立的 WebSocket 连接,这样我们就可以通过这个连接发送和接收数据。 -
onReadMessage TReadMessage
:onReadMessage
是一个函数类型,函数的类型是TReadMessage
,它是这样定义的:
type TReadMessage func(messageType int, p []byte, errMessage error) (finished bool)
这表示 onReadMessage
是一个接受三个参数(一个整数、一个字节切片和一个错误)并返回一个布尔值的函数。在这里,onReadMessage
被用作回调函数:当从 WebSocket 连接接收到消息时,就会调用这个函数来处理接收到的消息。例如,你可能会在这个函数中进行数据解码,检查错误,或者触发其他的逻辑操作。
package edge import ( "context" "fmt" "math/rand" "net" "net/http" "strings" "time" "github.com/gorilla/websocket" tsg "github.com/jing332/tts-server-go" log "github.com/sirupsen/logrus" ) const ( wssUrl = `wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1?TrustedClientToken=6A5AA1D4EAFF4E9FB37E23D68491D6F4&ConnectionId=` ) type TTS struct { DnsLookupEnabled bool // 使用DNS解析,而不是北京微软云节点。 DialTimeout time.Duration WriteTimeout time.Duration dialContextCancel context.CancelFunc uuid string conn *websocket.Conn onReadMessage TReadMessage } type TReadMessage func(messageType int, p []byte, errMessage error) (finished bool) func (t *TTS) NewConn() error { log.Infoln("创建WebSocket连接(Edge)...") if t.WriteTimeout == 0 { t.WriteTimeout = time.Second * 2 } if t.DialTimeout == 0 { t.DialTimeout = time.Second * 3 } dl := websocket.Dialer{ EnableCompression: true, } if !t.DnsLookupEnabled { dialer := &net.Dialer{} dl.NetDial = func(network, addr string) (net.Conn, error) { if addr == "speech.platform.bing.com:443" { rand.Seed(time.Now().Unix()) i := rand.Intn(len(ChinaIpList)) addr = fmt.Sprintf("%s:443", ChinaIpList[i]) } log.Infoln("connect to IP: " + addr) return dialer.Dial(network, addr) } } header := http.Header{} header.Set("Accept-Encoding", "gzip, deflate, br") header.Set("Origin", "chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold") header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.66 Safari/537.36 Edg/103.0.1264.44") var ctx context.Context ctx, t.dialContextCancel = context.WithTimeout(context.Background(), t.DialTimeout) defer func() { t.dialContextCancel() t.dialContextCancel = nil }() var err error var resp *http.Response t.conn, resp, err = dl.DialContext(ctx, wssUrl+t.uuid, header) if err != nil { if resp == nil { return err } return fmt.Errorf("%w: %s", err, resp.Status) } go func() { for { if t.conn == nil { return } messageType, p, err := t.conn.ReadMessage() closed := t.onReadMessage(messageType, p, err) if closed { t.conn = nil return } } }() return nil } func (t *TTS) CloseConn() { if t.conn != nil { _ = t.conn.Close() t.conn = nil } } func (t *TTS) GetAudio(ssml, format string) (audioData []byte, err error) { t.uuid = tsg.GetUUID() if t.conn == nil { err := t.NewConn() if err != nil { return nil, err } } running := true defer func() { running = false }() var finished = make(chan bool) var failed = make(chan error) t.onReadMessage = func(messageType int, p []byte, errMessage error) bool { if messageType == -1 && p == nil && errMessage != nil { //已经断开链接 if running { failed <- errMessage } return true } if messageType == websocket.BinaryMessage { index := strings.Index(string(p), "Path:audio") data := []byte(string(p)[index+12:]) audioData = append(audioData, data...) } else if messageType == websocket.TextMessage && string(p)[len(string(p))-14:len(string(p))-6] == "turn.end" { finished <- true return false } return false } err = t.sendConfigMessage(format) if err != nil { return nil, err } err = t.sendSsmlMessage(ssml) if err != nil { return nil, err } select { case <-finished: return audioData, err case errMessage := <-failed: return nil, errMessage } } func (t *TTS) sendConfigMessage(format string) error { cfgMsg := "X-Timestamp:" + tsg.GetISOTime() + "\r\nContent-Type:application/json; charset=utf-8\r\n" + "Path:speech.config\r\n\r\n" + `{"context":{"synthesis":{"audio":{"metadataoptions":{"sentenceBoundaryEnabled":"false","wordBoundaryEnabled":"false"},"outputFormat":"` + format + `"}}}}` _ = t.conn.SetWriteDeadline(time.Now().Add(t.WriteTimeout)) err := t.conn.WriteMessage(websocket.TextMessage, []byte(cfgMsg)) if err != nil { return fmt.Errorf("发送Config失败: %s", err) } return nil } func (t *TTS) sendSsmlMessage(ssml string) error { msg := "Path: ssml\r\nX-RequestId: " + t.uuid + "\r\nX-Timestamp: " + tsg.GetISOTime() + "\r\nContent-Type: application/ssml+xml\r\n\r\n" + ssml _ = t.conn.SetWriteDeadline(time.Now().Add(t.WriteTimeout)) err := t.conn.WriteMessage(websocket.TextMessage, []byte(msg)) if err != nil { return err } return nil }
实现思路 :
- 首先,创建一个
Dialer
对象,设置一些特定的配置选项。比如,开启压缩和其他一些选项。此外,如果DNS解析被禁用,程序将为Dialer设置一个自定义的NetDial
函数,该函数决定如何进行网络连接。
TTS对象 --> 创建 Dialer对象(dl)
--> 如果禁用DNS查找
--> 创建Dialer对象(dialer)
--> 设置 dl 的 NetDial 方法
- 为Dialer设置HTTP头信息。
Dialer对象 --> 设置HTTP头信息
- 创建一个有超时的上下文,并附带一个取消函数,用于在需要的时候取消连接过程。
TTS对象 --> 创建上下文 (ctx) 并设置超时 --> 附带一个取消函数 (dialContextCancel)
- 使用
Dialer.DialContext
方法,传入上述的上下文,WebSocket服务器地址和HTTP头,进行连接。然后检查响应和错误。
Dialer对象 --> 调用 DialContext 方法 --> 连接到 WebSocket服务器 --> 检查错误
- 如果连接成功,创建一个goroutine,该goroutine持续读取来自WebSocket连接的消息,直到连接关闭。
TTS对象 --> 创建goroutine --> 持续读取WebSocket连接上的消息
在整个过程中,有一些错误处理机制,以处理在连接过程中可能遇到的问题,比如连接超时或服务器响应错误等。