这篇文章主要来介绍下 Azure 的文本转语音。这个接口非常简单,也没啥坑。

不过,在生产中,为了提高用户体验,文本转语音都是采用流式传输。

一个是再长的文本也可以很快就开始转语音并播放,另一个是在弱网环境下也不会受到音频文件过大的限制。

基于之前的示例,咱们依旧使用 Node.js + TypeScript 来请求微软,再使用 go 获取、解析音频流。

示例

下面是用 Node.js + TypeScript + Koa 写的示例:

// koa
import Koa, {Context} from "koa";
import Router from "koa-router";

// 微软 sdk
import * as sdk from "microsoft-cognitiveservices-speech-sdk";

const app = new Koa();
const router = new Router()

router.get('/text2radio', async function (ctx: Context) {
    const subscriptionKey = "你的订阅密钥"
    const region = "你的地区"

    // 指定语音的名称,参见:https://learn.microsoft.com/zh-cn/azure/ai-services/speech-service/language-support?tabs=tts#voice-styles-and-roles
    const voiceName = "en-US-AvaMultilingualNeural"
    // 指定要转语音的文本
    const text = "ONCE upon a time, a woodcutter and his wife lived in their cottage on the edge of a large and ancient forest. They had two dear little children who met with a most wonderful adventure"
    // voiceName 的组织形式一般为 语言区域-语音音色名
    const language = voiceName.split('-').slice(0, -1).join('-');

    const speechConfig = sdk.SpeechConfig.fromSubscription(subscriptionKey, region);
    speechConfig.speechSynthesisLanguage = language;
    speechConfig.speechSynthesisVoiceName = voiceName;

    // 这里指定输出格式,详细说明参见:https://learn.microsoft.com/zh-cn/javascript/api/microsoft-cognitiveservices-speech-sdk/speechsynthesisoutputformat?view=azure-node-latest
    speechConfig.speechSynthesisOutputFormat = sdk.SpeechSynthesisOutputFormat.Audio24Khz48KBitRateMonoMp3;
    speechConfig.setProperty(sdk.PropertyId.SpeechServiceResponse_RequestSentenceBoundary, "true");

    const speechSynthesizer = new sdk.SpeechSynthesizer(speechConfig);

    // 这里来进行预连接
    //      不过不建议在每次请求做预连接,而是封装到一个 pool 中,在 pool 中初始化时进行预连接。
    // const connection = sdk.Connection.fromSynthesizer(speechSynthesizer)
    // connection.openConnection(()=>{
    //     console.log("openConnection")
    // })

    // 开始合成语音
    speechSynthesizer.synthesisStarted = (_, e) => {
        console.log(`synthesisStarted ${JSON.stringify(e)}`);
    };

    // 流式数据获取,recognizing 为语音合成中
    speechSynthesizer.synthesizing = function (s, e) {
        if (e.result.errorDetails) {
            console.log("Synthesizing errorDetails:", e.result.errorDetails);
        }else if (e.result.audioData && e.result.audioData.byteLength > 0) {
            console.log(`Synthesizing event: AudioData: ${e.result.audioData.byteLength} bytes`);
            // 将音频流数据通过接口返回
            ctx.response.status = 200;
            ctx.set('Content-Type', 'audio/mpeg');
            ctx.set('Transfer-Encoding', 'chunked');
            ctx.set('Cache-Control', 'no-cache, no-store, must-revalidate');
            ctx.set('Connection', 'close');
            ctx.set('X-Content-Type-Options', 'nosniff');
            ctx.set('Access-Control-Allow-Origin', '*');
            ctx.set('Accept-Ranges', 'bytes');
            ctx.res.write(Buffer.from(e.result.audioData));
        }
    }

    await new Promise<void>((resolve, reject) => {
        // 使用 SSML 自定义语音特征,参见:
        //    1. https://learn.microsoft.com/zh-cn/azure/ai-services/speech-service/how-to-speech-synthesis?tabs=browserjs%2Cterminal&pivots=programming-language-javascript#use-ssml-to-customize-speech-characteristics
        //    2. https://learn.microsoft.com/zh-cn/azure/ai-services/speech-service/speech-synthesis-markup
        speechSynthesizer.speakSsmlAsync(
            `<speak version="1.0" xmlns="https://www.w3.org/2001/10/synthesis" xml:lang="${language}">` +
            `  <voice name="${voiceName}">` +
            `    ${text}` +
            `  </voice>` +
            `</speak>`,
            _ => {
                resolve();
            },
            error => {
                console.log(`speakSsmlAsync `, error);
                reject();
            });
    })

    // 链接关闭
    speechSynthesizer.close()
})

app.use(router.routes())

app.listen("1001", () => {
    console.log(`Server is running on http://127.0.0.1:1001`);
});

下面是用 go 写的中转请求代码,这个代码会请求 Node.js 的接口,把返回的音频流返回给接口的同时,还会在本地存一份。

package main

import (
	"bufio"
	"github.com/gin-gonic/gin"
	"io"
	"log"
	"net/http"
	"os"
)

func main() {
	r := gin.Default()

	r.GET("/text2radio", func(c *gin.Context) {

		resp, err := http.Get("http://127.0.0.1:1001/text2radio")
		if err != nil {
			log.Panic(err)
		}

		defer resp.Body.Close()

		var fileWriter *os.File
		fileWriter, err = os.Create("test.mpga")
		if err != nil {
			log.Panic(err)
		}
		defer fileWriter.Close()

		c.Status(http.StatusOK)
		c.Header("Content-Type", "audio/mpeg")
		c.Header("Transfer-Encoding", "chunked")
		c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
		c.Header("Connection", "close")
		c.Header("X-Content-Type-Options", "nosniff")
		c.Header("Access-Control-Allow-Origin", "*")
		c.Header("Accept-Ranges", "bytes")

		// 接口返回流并保存到本地 test.mpga 文件内
		multiWriter := io.MultiWriter(c.Writer, fileWriter)
		buf := make([]byte, 1024)
		reader := bufio.NewReader(resp.Body)

		for {
			line, err := reader.Read(buf)
			if err == io.EOF {
				break
			}
			if err != nil {
				log.Printf("Error reading stream: %v", err)
				c.JSON(http.StatusInternalServerError, gin.H{"error": "Error reading stream"})
				return
			}
			if line > 0 {
				_, _ = multiWriter.Write(buf[:line])
			}
		}

	})

	// 启动 Gin 服务器
	r.Run(":1000")
}

两个代码都启动后,在浏览器端不管是请求 127.0.0.1:1000/text2radio 还是 127.0.0.1:1001/text2radio 都会变成下面这样子。

音频的秒数一直在上涨直到结束,成功播放转化后的语音流。

1736952730820.png

注意事项

这个文本转语音很简单,但是有几块是需要咱们注意的:

一、voiceName

vioceName 这块主要是发音风格,比如还有墨西哥口音的英语发音。

具体见文档。如果觉得这些语音还不够,也可以自行定制声音

二、输出格式

输出格式这里主要看使用场景,在音频这里:

  • 采样率:采样率越高,音频的保真度越好,但文件也会更大。例如,48kHz比16kHz提供更好的音质。
  • 比特率:比特率越高,音频质量越好,但文件更大。例如,192kbps的MP3比64kbps的MP3音质更好,但文件更大。
  • 压缩方式:有些格式(如MP3、Opus)是有损压缩,会在音质和文件大小之间进行权衡;而原始PCM格式(如Raw和Riff)则通常提供最高音质,但文件非常大。

具体的采样率、比特率、压缩方式都写在变量的名字上了。如果想看具体都有哪些选择,可以参考文档

这里的话,做一些基础的推荐,仅做参考:

  • 如果对音质要求高且不在乎音频大小,可以考虑 Raw48Khz16BitMonoPcmRiff48Khz16BitMonoPcm 这种的。
  • 如果对音质有一定要求,且尽需要音频可能小一些,可以考虑 Audio48Khz192KBitRateMonoMp3Audio24Khz160KBitRateMonoMp3 这种的。
  • 如果对音质要求不高,但是需要文件越小越好,可以考虑 Webm24Khz16Bit24KbpsMonoOpusAudio24Khz16Bit24KbpsMonoOpus Raw8Khz8BitMonoMULaw Raw8Khz8BitMonoALaw 这几个。

不过最后两个的音质非常差,建议谨慎选择。

三、延迟问题

关于降低语音合成 SDK 延迟的问题,可以参考文档

虽然这块的示例代码没有 js 的,但是 js 也是支持的,主要的方法就是:

  1. 流式处理:可以参考上面示例代码中的 speechSynthesizer.synthesizing ,流式返回语音合成结果。
  2. 预连接和重用 SpeechSynthesizer
    • 预连接可以参考上面示例代码 sdk.Connection.fromSynthesizer(speechSynthesizer) ,只不过每次请求做预连接完全没有,所以直接注释掉。建议预连接配合 SpeechSynthesizer 重用,这样才能达到最佳效果。
    • SpeechSynthesizer 重用这里和 SDK 完全无关,这是需要自己去实现的,相当于创建一个线程池。所有相关的东西已经在里面初始化好了,每次使用的时候直接调就可以了。
  3. 通过网络传输压缩音频:这个其实就是指定格式,也就是上面示例代码中的 speechConfig.speechSynthesisOutputFormat = sdk.SpeechSynthesisOutputFormat.Audio24Khz48KBitRateMonoMp3;

而关于输入文本流式处理,只适合比较大的文本,就几十个单词,其实提升的并不明显,最起码没有预连接和重用 SpeechSynthesizer做好带来的提升明显。

四、SSML 相关

文本转语音中 SSML 标记是可选的,虽然是可选的,但是 SSML 标记还是很好用的,所以建议能用则用。

关于 SSML 的详细作用的作用可以参考文档

不过这里可以简单说一下, SSML 可以用来定义段落、句子、中断/暂停或静音,也可以调整调整重音、语速、音调和音量,还可以控制单词或数学表达式的具体发音,另外 SSML 还可以同时使用多种语音,也就是可以模拟多个人在交谈。

而且 SSML 还可以模拟不同语气、不同年龄、不同性别的音色。比如 style="cheerful" 是积极愉快的语气,role="YoungAdultFemale" 是模仿年轻女性。

不过,相比较微软的文本转语音而言,我觉得 MiniMax 的更胜一筹。

首先,微软的调整过语气、年龄、性别后的声音,听起来很怪。而 MiniMax 的会自然很多。

以及,MiniMax 是可以通过 timber_weights 参数做混音色的,相当于变相增加了更多可选音色。

1736956255908.png

五、重复播放问题

关于同一段文本需要重复播放的问题,建议在第一次请求时就将音频缓存到客户端本地一份,再同步到 OSS 上一份,这样的话重复播放就不需要再请求一遍微软多花一份钱了。