uni-app 连接腾讯云实时语音识别 (安卓),小程序和h5改吧改吧也能用

271 阅读5分钟

uni-app 连接腾讯云实时语音识别 (安卓),小程序和h5改吧改吧也能用

步骤

  1. 将传入的长文本,截取分片发送给后端,让后端生成签名,请求地址 (短文本的话可以一次性发送)
  2. 从后端获取签名,因为签名包含 “SecretId” “appId” 等信息,所以由后端生成比较安全
  3. 用后端返回的签名去和腾讯云建立 WebScoket 连接,然后将腾讯云一次返回的数据(ArrayBuffer形式存储的数据)整合到一起
  4. 将一次返回的数据存储到本地里面 (因为uni-app 播放语音的api 只支持传入本地连接或者线上连接,所以并不能直接使用音频数据播放,并且uni-app 的 uni.uploadFile api存储后返回的临时地址,并不能直接播放),这里使用的是安卓原生的方法,保存的,小程序的或 h5 可以使用其他方法,也会简单很多
  5. 然后等待上一次保存成功后,进行下一次保存,这里会进行阻塞等待(短文本的话不需要去循环,保存一次就可以了)
  6. 存储成功后获取到地址,将文件地址存储到一个数组中,以便将来播放
  7. 使用 uni.createInnerAudioContext api 播放音频

这里播放音频和 获取音频数据是独立的,一是为了减少用户等待的时间,二是防止每次一小段播放完毕后再去请求存储本次播放的音频文件,可能会导致每段话中间停顿太久,所以提前存储到数组,到时候直接去遍历播放,边播放边转换

好了,话不多说,直接上代码,详细的解释都写在注释里面了,可以直接看注释(注释写的很详细哦~)

import request from '@/utils/request.js'


class TTSPlayer {
  constructor() {
      this.ttsUrl = '', // url签名
      this.textArr = [], // 文本数组
      this.audioBlob = null, // 单次循环存储 ArrayBuffer
      this.filePath = '',  // 文件路径
      this.isFrist = true, // 是否是第一次播放
      this.isOver = false, // 本地文件是否已经存储到本地
      this.fileList = [],  // 存储到本地的地址,数组
      this.fileIndex = 0, // 当前播放到的地址索引 fileList手动索引
      this.overPlayer = false  // 是否已经播放完了
  }

  // 开始播放
  // info 里面除了 text,其他都是获取签名的参数
  async start(info) {
    console.log(info.reply)
    console.log(info)
    // 分割文本
    this.textArr = splitTextIntoSubtexts(info.text)
    this.overPlayer = false
    for (let i = 0; i < this.textArr.length; i++) {
      this.isOver = false
      this.connectScoket(this.textArr[i], i, info)
      // 每次阻塞,等待音频存储完毕后进行下一次
      await this.waitFor(true)
    }
  }
  // 等待播放完毕,调用方法,进行一些操作
  async onOverPlayer(callback) {
    await this.waitFor()
    callback()
  }
  // 等待阻塞 上次语音转换完毕
  waitFor(flag = false) {
    return new Promise(resolve => {
      const checkHasAdd = setInterval(() => {
          if (this.isOver === true && flag) {
            clearInterval(checkHasAdd);
            resolve();
          }
          if (this.overPlayer === true && !flag) {
            clearInterval(checkHasAdd);
            resolve();
          }
      }, 100);
    });
  }
  inFile(blob, index) {
    // this.removeFile()
    // 使用plus.io将Blob写入本地文件
    
    return new Promise((resolve, reject) => {
    // 这里使用 安卓原生的方式 PRIVATE_DOC (操作私有文档目录)
      plus.io.requestFileSystem(plus.io.PRIVATE_DOC, (fs) => {
      // 传入操作文件路径,这里只传一级目录的话就直接存储在根目录下
      // create: true 没有该目录的话,直接穿件新的,有的话就覆盖掉之前的
      // 能看到我这里并没有删除文件,因为我这里是语音对话模型,所以采取直接覆盖的形式
        fs.root.getFile(`_doc/audio/test${index}.mp3`, {
          create: true
        }, (fileEntry) => {        
          fileEntry.createWriter((writer) => {
          // 存储文件成功后的回调
            writer.onwriteend = () => {
              // 获取文件路径
              this.filePath = fileEntry.toLocalURL();
              this.fileList.push(this.filePath)
              console.log(this.fileList)
              resolve(this.filePath)
              this.audioBlob = null
              // 存储完毕
              this.isOver = true
              console.log('文件路径:', this.filePath)
              console.log('Blob文件已成功写入本地文件');
            };
            writer.onerror = (e) => {
              console.error('写入本地文件时发生错误:', e);
            };
            
            console.log(blob)
            // 将ArrayBuffer 转成 base64,并且存储进本地中
            const base64 = uni.arrayBufferToBase64(blob)
            // writer.write(blob);
            console.log('base64', base64)
            writer.writeAsBinary(base64)
          }, (e) => {
            console.error('创建写入器时发生错误:', e);
          });
        }, (e) => {
          console.error('获取文件条目时发生错误:', e);
        });
      }, (e) => {
        console.error('请求文件系统时发生错误:', e);
      });
    })

  }
  // 播放音频
  async palyAudio(blob) {
    const innerAudioContext = uni.createInnerAudioContext();
    innerAudioContext.autoplay = true;
    innerAudioContext.src = this.fileList[this.fileIndex];
    this.isFrist = false
    innerAudioContext.onPlay(() => {
      console.log('开始播放');
    });
    innerAudioContext.onEnded(() => {
       // 判断是否是最后一次播放
      if(this.fileIndex === this.textArr.length - 1) {
        this.overPlayer = true
        this.fileIndex = 0
      }
      console.log('fileIndex', this.fileIndex)
      this.fileIndex++
      // 每次播放完销毁播放实例,因为安卓如果开启太多播放器可能会导致播放不出声音
      innerAudioContext.destroy()
      // 重新递归,去播放第二段音频
      setTimeout(() => {
        if(this.fileIndex <= this.textArr.length - 1 && !this.overPlayer) {
          this.palyAudio()
        }
      }, 1)
    })
    
  }
  concatArrayBuffer(buffer1, buffer2) {
      // 创建一个新的 ArrayBuffer,长度为两个输入缓冲区的总长度
      const mergedBuffer = new ArrayBuffer(buffer1.byteLength + buffer2.byteLength);
    
      // 创建视图以便操作缓冲区
      const mergedView = new Uint8Array(mergedBuffer);
      const view1 = new Uint8Array(buffer1);
      const view2 = new Uint8Array(buffer2);
    
      // 将第一个缓冲区的数据复制到合并后的缓冲区
      mergedView.set(view1, 0);
    
      // 将第二个缓冲区的数据复制到合并后的缓冲区
      mergedView.set(view2, view1.length);
    
      return mergedBuffer;
  } 
  // 连接 webscoket 连接
  async connectScoket(text, index, info) {
    console.log('text', text)
    await this.getUrl(text, info)
    if (!this.ttsUrl) {
      return
    }
    uni.connectSocket({
      url: this.ttsUrl
    })
    uni.onSocketOpen(() => {
      console.log('连接已经打开')
    })
    uni.onSocketError((res) => {
      console.log('WebSocket连接打开失败,请检查!');
    })
    uni.onSocketMessage(async (res) => {
      console.log('接受到消息:', res)
      if (res.data instanceof ArrayBuffer) {
         // 如果不是第一次返回就将 两个 ArrayBuffer 合并到一起
        if (!this.audioBlob) {
          this.audioBlob = res.data
        } else {
          this.audioBlob =  this.concatArrayBuffer(this.audioBlob, res.data)
        }
      } else {
        const data = JSON.parse(res.data)
        if (data.code !== 0) {
          uni.onSocketClose(() => {
            console.log('手动关闭连接, code = 0')
          })
        }
        if (data.final === 1 && data.code === 0) {
           // 存储文件
          await this.inFile(this.audioBlob, index)
          // 第一保存文件成功后就开始播放
          if(this.isFrist) {
            this.palyAudio()
          }
          console.log('接受消息结束', this.isOver)
        }
      }
    })
  }

  // 获取 wss 地址
  async getUrl(text, info) {
    const data = {
      testId: info.test_id,
      target: 'text2audio',
      text: text
    }
    console.log(data)
    // 此处的 uni.request 封装过, 演示地址('/api/user/666')
    const res = await request('/api/user/666', 'POST', data)
    this.ttsUrl = res.data.request_url
  }
}

// 分割 文本, 每次请求最长 60 哥字符
function splitTextIntoSubtexts(text, maxLen = 60) {
  const punctuation = ['。', '!', '?', ';'];

  function findBreakpoint(str, length) {
    for (let i = length; i > 0; i--) {
      if (punctuation.includes(str[i])) {
        return i + 1;
      }
    }
    return length;
  }

  let result = [];
  while (text.length > 0) {
    let breakpoint = text.length <= maxLen ? text.length : findBreakpoint(text, maxLen);
    result.push(text.substring(0, breakpoint));
    text = text.substring(breakpoint);
  }
  return result;
}

export default TTSPlayer