语音合成

319 阅读5分钟

前言

某天突发在某音看到了一位博主录了文字转语音的效果,感觉有点好玩,就利用工作之余的时间玩玩看,本人肤浅末学,各位大佬请斧正。

1、需求

文字转语音的效果,可以点击播放全文,也可以点击某一句进行播放

2、分析

  • 问:如何根据文字获取语音
  • 答:使用 讯飞语音 第三方工具,通过WebSocket与讯飞服务器进行通信
  • 解:
    1. 通过axios发送请求,将文本发送到自己的服务器。

    2. 服务器通过讯飞的API,使用WebSocket将文本发送给讯飞服务器

    3. 讯飞服务器将这个文本转成语音(它是一个字节数组)之后,把这个字节数组返回到自己的服务器。

    4. 服务器把这个字节数组转换成base64格式(主要是为了统一相应格式)的数据响应给客户端。

d511d940330049891ea73199741bc7d.png

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、考虑文本量

  • 可以把一段文本分成各种小块,也就是断句
    1. 简单的考虑就是根据标点符号 《图片》 将文本切割
       const text = '臣既受命以来,日夜悲心疾首,恐非陛下所知也。臣言: 伏见天子:先帝创业未半而中道崩殂,今天下三分,益州疲敝,此诚危急存亡之秋也'
       let textCutting =  text.split(/(?<=[,。?!])/)
       console.log(textCutting);
       /**
          打印:
          [
             "臣既受命以来,",
             "日夜悲心疾首,",
             "恐非陛下所知也。",
             "臣言: 伏见天子:先帝创业未半而中道崩殂,",
             "今天下三分,",
             "益州疲敝,",
             "此诚危急存亡之秋也"
          ]
        */
      
    
    html循环数组,渲染页面(此处用的template方式,也可以用render)
       <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;
       } 
    
    运行至浏览器,大致是如下效果
e6c595a52e4db940fb5a15a5177299a.png
  1. 再复杂的断句就要考虑括号、双引号,这涉及到算法。

2、考虑用户体验和性能

  1. 可以把切割下来的文本一句句依次按序的传输发送给服务器

  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:瑟瑟发抖)