这篇文章主要来介绍下 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
都会变成下面这样子。
音频的秒数一直在上涨直到结束,成功播放转化后的语音流。
注意事项
这个文本转语音很简单,但是有几块是需要咱们注意的:
一、voiceName
vioceName
这块主要是发音风格,比如还有墨西哥口音的英语发音。
二、输出格式
输出格式这里主要看使用场景,在音频这里:
- 采样率:采样率越高,音频的保真度越好,但文件也会更大。例如,48kHz比16kHz提供更好的音质。
- 比特率:比特率越高,音频质量越好,但文件更大。例如,192kbps的MP3比64kbps的MP3音质更好,但文件更大。
- 压缩方式:有些格式(如MP3、Opus)是有损压缩,会在音质和文件大小之间进行权衡;而原始PCM格式(如Raw和Riff)则通常提供最高音质,但文件非常大。
具体的采样率、比特率、压缩方式都写在变量的名字上了。如果想看具体都有哪些选择,可以参考文档。
这里的话,做一些基础的推荐,仅做参考:
- 如果对音质要求高且不在乎音频大小,可以考虑
Raw48Khz16BitMonoPcm
、Riff48Khz16BitMonoPcm
这种的。 - 如果对音质有一定要求,且尽需要音频可能小一些,可以考虑
Audio48Khz192KBitRateMonoMp3
、Audio24Khz160KBitRateMonoMp3
这种的。 - 如果对音质要求不高,但是需要文件越小越好,可以考虑
Webm24Khz16Bit24KbpsMonoOpus
、Audio24Khz16Bit24KbpsMonoOpus
、Raw8Khz8BitMonoMULaw
、Raw8Khz8BitMonoALaw
这几个。
不过最后两个的音质非常差,建议谨慎选择。
三、延迟问题
关于降低语音合成 SDK
延迟的问题,可以参考文档。
虽然这块的示例代码没有 js
的,但是 js
也是支持的,主要的方法就是:
- 流式处理:可以参考上面示例代码中的
speechSynthesizer.synthesizing
,流式返回语音合成结果。 - 预连接和重用 SpeechSynthesizer:
- 预连接可以参考上面示例代码
sdk.Connection.fromSynthesizer(speechSynthesizer)
,只不过每次请求做预连接完全没有,所以直接注释掉。建议预连接配合SpeechSynthesizer
重用,这样才能达到最佳效果。 SpeechSynthesizer
重用这里和SDK
完全无关,这是需要自己去实现的,相当于创建一个线程池。所有相关的东西已经在里面初始化好了,每次使用的时候直接调就可以了。
- 预连接可以参考上面示例代码
- 通过网络传输压缩音频:这个其实就是指定格式,也就是上面示例代码中的
speechConfig.speechSynthesisOutputFormat = sdk.SpeechSynthesisOutputFormat.Audio24Khz48KBitRateMonoMp3;
而关于输入文本流式处理,只适合比较大的文本,就几十个单词,其实提升的并不明显,最起码没有预连接和重用 SpeechSynthesizer做好带来的提升明显。
四、SSML 相关
文本转语音中 SSML
标记是可选的,虽然是可选的,但是 SSML
标记还是很好用的,所以建议能用则用。
关于 SSML
的详细作用的作用可以参考文档。
不过这里可以简单说一下, SSML
可以用来定义段落、句子、中断/暂停或静音,也可以调整调整重音、语速、音调和音量,还可以控制单词或数学表达式的具体发音,另外 SSML
还可以同时使用多种语音,也就是可以模拟多个人在交谈。
而且 SSML
还可以模拟不同语气、不同年龄、不同性别的音色。比如 style="cheerful"
是积极愉快的语气,role="YoungAdultFemale"
是模仿年轻女性。
不过,相比较微软的文本转语音而言,我觉得 MiniMax
的更胜一筹。
首先,微软的调整过语气、年龄、性别后的声音,听起来很怪。而 MiniMax
的会自然很多。
以及,MiniMax
是可以通过 timber_weights
参数做混音色的,相当于变相增加了更多可选音色。
五、重复播放问题
关于同一段文本需要重复播放的问题,建议在第一次请求时就将音频缓存到客户端本地一份,再同步到 OSS
上一份,这样的话重复播放就不需要再请求一遍微软多花一份钱了。