前言
某天突发在某音看到了一位博主录了文字转语音的效果,感觉有点好玩,就利用工作之余的时间玩玩看,本人肤浅末学,各位大佬请斧正。
1、需求
文字转语音的效果,可以点击播放全文,也可以点击某一句进行播放
2、分析
- 问:如何根据文字获取语音
- 答:使用 讯飞语音 第三方工具,通过WebSocket与讯飞服务器进行通信
- 解:
-
通过axios发送请求,将文本发送到自己的服务器。
-
服务器通过讯飞的API,使用WebSocket将文本发送给讯飞服务器
-
讯飞服务器将这个文本转成语音(它是一个字节数组)之后,把这个字节数组返回到自己的服务器。
-
服务器把这个字节数组转换成base64格式(主要是为了统一相应格式)的数据响应给客户端。
-
- 图
3、实现
nodeJS书写一个本地的服务器,并定义接口,接受来自客户端的明文文本,向讯飞服务器发起请求
-
简单写一个本地的服务器
// 安装并导入 express 模块 const express = require('express'); // 创建 express 的服务器实例 const app = express(); // 调用 app.listen 方法, 指定端口号并启动 web 服务器 app.listen(3007, function() { console.log('api server running at http://127.0.0.1:3007') }) -
定义接口,获取来着客户端的文本
const express = require('express'); const router = express.Router(); router.post('/getfontconeten', getIflyVoiceInfomation) -
获得客户端的文本,向讯飞服务器请求连接与通信,并返回客户端base64格式的字符串
// 安装 crypto-js 和 ws // 前者用于加密鉴权签名信息,后者是websocket连接讯飞服务器 const CryptoJS = require('crypto-js'); const WebSocket = require('ws'); // 讯飞参数配置,在控制台-我的应用-在线语音合成(流式版)获取 appid apiSecret apiKey const config = { // 请求地址 hostUrl: "wss://tts-api.xfyun.cn/v2/tts", host: "tts-api.xfyun.cn", appid: "", apiSecret: "", apiKey: "", text: "玫瑰花,茉莉花,胡花花", uri: "/v2/tts", } // 获取当前时间 RFC1123格式 let date = (new Date().toUTCString()) // 设置当前临时状态为初始化,获取完整连接socket连接 let wssUrl = config.hostUrl + "?authorization=" + getAuthStr(date) + "&date=" + date + "&host=" + config.host const getIflyVoiceInfomation = (req, resolve) => { config.text = req.body.content let ws = new WebSocket(wssUrl) let voiceBase64 // 建立连接并调用send方法 ws.on('open', () => { send(); }) // 获取讯飞服务返回来的数据 ws.on('message', (data, err) => { if (err) return let res = JSON.parse(data) if (res.code != 0) { wx.close() return } voiceBase64 += res.data.audio if (res.code == 0 && res.data.status == 2) { ws.close() } }) // 资源释放 ws.on('close', () => { resolve.send({ status: 200, message: "获取信息成功!", data: voiceBase64, }) }) // 连接错误 ws.on('error', (err) => { console.log("websocket connect err: " + err) }) } // 可参考讯飞的文档,或者是示例代码,主要是对信息进行加密 function getAuthStr(date) { let signatureOrigin = `host: ${config.host}\ndate: ${date}\nGET ${config.uri} HTTP/1.1` let signatureSha = CryptoJS.HmacSHA256(signatureOrigin, config.apiSecret) let signature = CryptoJS.enc.Base64.stringify(signatureSha) let authorizationOrigin = `api_key="${config.apiKey}", algorithm="hmac-sha256", headers="host date request-line", signature="${signature}"` let authStr = CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(authorizationOrigin)) return authStr } // 传输数据 function send() { let frame = { // 填充common "common": { "app_id": config.appid }, // 填充business "business": { "aue": "raw", "auf": "audio/L16;rate=16000", "vcn": "x4_lingxiaoxuan_en", // 发音人指定 "tte": "UTF8", "sfl": 1, // 边合成边播放 }, // 填充data "data": { // 将字符串转换为缓冲区,在通过toString转换为base64 "text": Buffer.from(config.text).toString('base64'), "status": 2 } } // 发送字符串格式的base64的内容 ws.send(JSON.stringify(frame)) }
通过axios将文本发送到自己的服务器
1、考虑文本量
- 可以把一段文本分成各种小块,也就是断句
- 简单的考虑就是根据标点符号 《图片》 将文本切割
html循环数组,渲染页面(此处用的template方式,也可以用render)const text = '臣既受命以来,日夜悲心疾首,恐非陛下所知也。臣言: 伏见天子:先帝创业未半而中道崩殂,今天下三分,益州疲敝,此诚危急存亡之秋也' let textCutting = text.split(/(?<=[,。?!])/) console.log(textCutting); /** 打印: [ "臣既受命以来,", "日夜悲心疾首,", "恐非陛下所知也。", "臣言: 伏见天子:先帝创业未半而中道崩殂,", "今天下三分,", "益州疲敝,", "此诚危急存亡之秋也" ] */为了页面显示,适当加一点效果<div class="font"> <button @click="asyncPlayText()">并发调用播放全文</button> <span class="font-content" v-for="(item,index) in textCutting" :key="item" @click="sendText(item)">{{ item }}</span> <br /> </div>运行至浏览器,大致是如下效果.font { font-weight: 500; font-size: 24px; line-height: 40px; cursor: pointer; } .font-content:hover { text-decoration: underline; }
- 再复杂的断句就要考虑括号、双引号,这涉及到算法。
2、考虑用户体验和性能
-
可以把切割下来的文本一句句依次按序的传输发送给服务器
-
也可以并发,同时发送多个HTTP请求,效率更高,播放效果也更好,也可能也会造成请求拥塞
- 首先,定义一个方法,从服务器获取语音信息
const send = (text) => { const params = new URLSearchParams(); params.append("content", text) return new Promise((resolve) => { axios.post('http://127.0.0.1:3007/api/iflyvoice/getfontconeten', params).then(async (res)=>{ // resolve 一个 base64格式的数据 resolve(res.data.data) }) }) }- 通过Promise.all的方法,将服务器请求的数据收集
const asyncPlayText = () => { let tasks = [] const requests = state.textCutting.map(res => send(res)); Promise.all(requests).then(responses => { // 异步任务的参数数组 const arr = responses; console.time("Test code"); // 遍历arr arr.forEach((item, index) => { // 为空数组push一个回调函数 tasks.push(function () { return new Promise( (resolve) => { // 回调函数,做一个定时器,当第二个函数参数成功后,返回item task(item, async (ret) => { // 此处进行将base64字符串转译为浏览器可播放的格式,并执行 new AudioContext().createBuffer 进行播放 await transPalyReset(item) console.timeEnd("Test code"); if (index + 1 < arr.length) { console.time("Test code"); } resolve(ret); }); }); }); }); // 调用递归函数来执行任务 runTasks(0).then(function () { // 全部执行完毕 console.log("All tasks are done!"); }).catch(function (error) { console.error(error); }) }).catch(error => { console.log(error) }) } const runTasks = (index) => { if (index >= state.tasks.length) { // 如果所有任务都已经执行完毕,返回一个 resolved 的 Promise return Promise.resolve(); } // 执行当前任务,然后递归执行下一个任务 return state.tasks[index]().then(function () { return runTasks(index + 1); }); }
3、考虑成本,讯飞少请求一次接口,也就少花一点费用
重复的句子,例如一些语气助词(啊,呀),或者是常用的语句,可以利用浏览器缓存存储起来,以文本作为键(避免文本太长,可以转化为一个MD5的编码,或者哈希作为一个摘要),值就是base64的语音内容(从服务器获取的内容),毕竟少调用一次讯飞服务器的接口,也就少花一点成本,此外,还可以使用数据,为所有的用户来缓存相同的句子
结束
兴趣是最好的老师,文中指定多处有缺陷,欢迎交流,欢迎指正。(os:瑟瑟发抖)