Go Azure Speech Websocket Edge 接口实现文本转语音

240 min read

测试

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:msttsxmlns: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" 时,音频中不会包含单词边界的信息。

  • sentenceBoundaryEnabledwordBoundaryEnabled 是两个配置项,通常在语音合成或者语音识别的设置中出现。下面是它们的通俗解释:

    • 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 超时连接处理

WriteTimeoutDialTimeout 都是与网络通信有关的超时设置。它们的使用可以帮助提升程序的健壮性和可用性。具体来说:

  • WriteTimeout:在网络通信中,我们需要向服务器发送(写入)数据。如果网络状况不好或者服务器响应缓慢,数据的发送可能会花费较长的时间。为了防止程序在等待发送数据时卡住(这种情况被称为阻塞),我们可以设置一个写入超时(WriteTimeout)。如果数据在这个超时时间内没有成功发送,程序将停止等待,并抛出一个错误

  • DialTimeout:在网络通信中,我们首先需要与服务器建立连接。如果网络状况不好或者服务器无法及时响应,连接的建立可能会花费较长的时间。为了防止程序在等待连接时卡住,我们可以设置一个拨号超时(DialTimeout)。如果在这个超时时间内没有成功建立连接,程序将停止等待,并抛出一个错误

这两个超时设置都是为了提高程序的响应速度和稳定性。在网络通信中,超时设置是非常重要的,因为网络状况的不确定性可能会导致程序在等待网络操作时阻塞,从而降低用户体验,甚至导致程序无法正常运行。通过设置合理的超时时间,我们可以确保程序在网络状况不佳时依然能够正常运行。

WebSocket 连接对象

在这个代码中,connonReadMessageTTS 结构体中的两个字段。我们可以分别来看一下:

  • 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
}

实现思路 :

  1. 首先,创建一个Dialer对象,设置一些特定的配置选项。比如,开启压缩和其他一些选项。此外,如果DNS解析被禁用,程序将为Dialer设置一个自定义的NetDial函数,该函数决定如何进行网络连接。
TTS对象 --> 创建 Dialer对象(dl)
      --> 如果禁用DNS查找 
            --> 创建Dialer对象(dialer) 
            --> 设置 dl 的 NetDial 方法
  1. 为Dialer设置HTTP头信息。
Dialer对象 --> 设置HTTP头信息
  1. 创建一个有超时的上下文,并附带一个取消函数,用于在需要的时候取消连接过程。
TTS对象 --> 创建上下文 (ctx) 并设置超时 --> 附带一个取消函数 (dialContextCancel)
  1. 使用Dialer.DialContext方法,传入上述的上下文,WebSocket服务器地址和HTTP头,进行连接。然后检查响应和错误。
Dialer对象 --> 调用 DialContext 方法 --> 连接到 WebSocket服务器 --> 检查错误
  1. 如果连接成功,创建一个goroutine,该goroutine持续读取来自WebSocket连接的消息,直到连接关闭。
TTS对象 --> 创建goroutine --> 持续读取WebSocket连接上的消息

在整个过程中,有一些错误处理机制,以处理在连接过程中可能遇到的问题,比如连接超时或服务器响应错误等。