uniapp - 接入科大讯飞语音评测

Posted GitLqr

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了uniapp - 接入科大讯飞语音评测相关的知识,希望对你有一定的参考价值。

欢迎关注微信公众号:FSA全栈行动 👋

一、简介

科大讯飞语音评测可以对字、词、句、篇章等题型进行多维度评分(准确度、流畅度、完整度、声韵调型等),支持中文和英文。最新的流式版使用 webSocket 调用接口,开发者可以边录音边上边音频数据(录音与评测同时进行),可以缩短用户等待评测结果的时间,大大提高用户体验。

科大讯飞语音评测提供了多种平台的 SDK 与 Demo,但是没有提供微信小程序版本的 SDK 与 Demo,不过,科大讯飞语音评测【WebApi】有提供浏览器版本的 Demo,理论上可以对它进行适当的改造,就能运行到微信小程序环境下,所以本文的主要内容就是对科大讯飞语音评测【WebApi】的浏览器版本 Demo 进行改造和封装。

温馨提示:着急伸手拿最终成品的,可以直接滚到 【文章末尾】 查看。

二、分析

打开科大讯飞语音评测 API 文档页面,在 调用示例 章节处,找到 语音评测流式API demo js语言 并下载,解压后使用 vscode 打开,查看 index.js,可以看到开头有如下注释:

// 1. websocket连接:判断浏览器是否兼容,获取websocket url并连接,这里为了方便本地生成websocket url
// 2. 获取浏览器录音权限:判断浏览器是否兼容,获取浏览器录音权限,
// 3. js获取浏览器录音数据
// 4. 将录音数据处理为文档要求的数据格式:采样率16k或8K、位长16bit、单声道;该操作属于纯数据处理,使用webWork处理
// 5. 根据要求(采用base64编码,每次发送音频间隔40ms,每次发送音频字节数1280B)将处理后的数据通过websocket传给服务器,
// 6. 实时接收websocket返回的数据并进行处理

// ps: 该示例用到了es6中的一些语法,建议在chrome下运行

通过这个注释说明,可以直观的了解到该 js demo 的大致流程,下面会按照注释里的顺序,逐个分析,并对应到微信小程序里的实现。

注意:因为本人使用的是 uniapp 开发,所以以下关于微信小程序代码实现的部分,并非 传统的 js + wxss + wxml,而是 vue3 + typescript

1、创建 webSocket

官方 demo 是运行在浏览器环境下的,而不同的浏览器对 webSocket 的创建方式不太一样,所以这里做兼容:

// 连接websocket
connectWebSocket() 
  return getWebSocketUrl().then(url => 
    let iseWS
    if ('WebSocket' in window) 
      iseWS = new WebSocket(url)
     else if ('MozWebSocket' in window) 
      iseWS = new MozWebSocket(url)
     else 
      alert('浏览器不支持WebSocket')
      return
    
    this.webSocket = iseWS
    iseWS.onopen = e => 
      ...
    
    iseWS.onmessage = e => 
      ...
    
    iseWS.onerror = e => 
      ...
    
    iseWS.onclose = e => 
      ...
    
  )

微信小程序内创建 webSocket 就很简单了,使用 uni.connectSocket 即可:

/* socket相关 */
private socketTask: UniApp.SocketTask | null = null;

async connect() 
  const url = await this.getWebSocketUrl();
  const newUrl = encodeURI(url);
  this.socketTask = uni.connectSocket(
    url: newUrl,
    // 如果希望返回一个 socketTask 对象,需要至少传入 success / fail / complete 参数中的一个
    complete: () => ,
  );
  this.socketTask.onOpen((res) => 
    ...
  );
  this.socketTask.onMessage((res) => 
    ...
  );
  this.socketTask.onError((err) => 
    ...
  );
  this.socketTask.onClose(() => 
    ...
  );

注意:这里有一个坑,uni.connectSocket() 如果不指定 success/fail/complete 参数中的一个,则返回的不是 SocketTask,而是一个 Promise!我们需要的是 SocketTask,所以这里指定了一个空的 complete 回调函数。

上述代码中有一处十分重要的函数调用,即 getWebSocketUrl(),它负责生成科大讯飞语音评测【WebApi】的 webSocket 链接,涉及到参数加密:

import CryptoJS from "crypto-js";
import  Base64  from "js/base64js.js";

/**
 * 获取websocket url
 * 该接口需要后端提供,这里为了方便前端处理
 */
function getWebSocketUrl() 
  return new Promise((resolve, reject) => 
    // 请求地址根据语种不同变化
    var url = "wss://ise-api.xfyun.cn/v2/open-ise";
    var host = "ise-api.xfyun.cn";
    var apiKeyName = "api_key";
    var apiKey = API_KEY;
    var apiSecret = API_SECRET;
    var date = new Date().toGMTString();
    var algorithm = "hmac-sha256";
    var headers = "host date request-line";
    var signatureOrigin = `host: $host\\ndate: $date\\nGET /v2/open-ise HTTP/1.1`;
    var signatureSha = CryptoJS.HmacSHA256(signatureOrigin, apiSecret);
    var signature = CryptoJS.enc.Base64.stringify(signatureSha);
    var authorizationOrigin = `$apiKeyName="$apiKey", algorithm="$algorithm", headers="$headers", signature="$signature"`;
    var authorization = btoa(authorizationOrigin);
    url = `$url?authorization=$authorization&date=$date&host=$host`;
    resolve(url);
  );

js/base64js.js 是 demo 工程里 js 目录下的一个文件(采用 CommonJs 导入规范),而 crypto-js 是使用 npm 安装的一个第三方模块,理论上只需要将这 2 个模块直接拷贝或 npm 安装集成到项目中就好了,但是我的这个工程用的构建工具是 Vite,只支持 ES 模块导入规范,并且项目中使用了 TypeScript,所以,为了更好的编码体验,这里替换为支持 ES 模块导入规范且支持 TypeScript 的另外 2 个模块(js-base64crypto-es),与 demo 中的那 2 个模块功能完全相同:

// npm i crypto-es -S
// npm i js-base64 -S
import CryptoES from "crypto-es";
import  Base64  from "js-base64";

/**
 * @returns 生成wss链接
 */
protected getWebSocketUrl(): Promise<string> 
  if (this.apiKey === "" || this.apiSecret === "") 
    throw new Error("apiKey、apiSecret must not be empty !!!");
  
  return new Promise<string>((resolve, reject) => 
    // 请求地址根据语种不同变化
    let url = "wss://ise-api.xfyun.cn/v2/open-ise";
    const host = "ise-api.xfyun.cn";
    const date = (new Date() as any).toGMTString();
    const apiKeyName = "api_key";
    const algorithm = "hmac-sha256";
    const headers = "host date request-line";
    const signatureOrigin = `host: $host\\ndate: $date\\nGET /v2/open-ise HTTP/1.1`;
    const signatureSha = CryptoES.HmacSHA256(signatureOrigin, this.apiSecret);
    const signature = CryptoES.enc.Base64.stringify(signatureSha);
    const authorizationOrigin = `$apiKeyName="$this.apiKey", algorithm="$algorithm", headers="$headers", signature="$signature"`;
    const authorization = Base64.encode(authorizationOrigin);
    url = `$url?authorization=$authorization&date=$date&host=$host`;
    resolve(url);
  );

Date#toGMTString() 是一个过时的方法,在 TypeScript 中不被识别,从而导致编译不通过,除了像上述代码中通过强转 any 来规避外,还可以在 src/env.d.ts 文件中进行如下声明解决,interface Date toGMTString(): string; ,如果工程中频繁使用该方法的话,建议用第二种方法,这样就不必每处都写一次强转代码了。

注意:getWebSocketUrl() 函数内在生成 webSocket url 时,会使用到 API_KEYAPI_SECRET,这 2 个参数需要你自己注册一个科大讯飞的开发者账号之后,在开发者账号后台获取,为了防止被别人盗用,这个 url 的生成逻辑应该放置到自己的业务后端去实现。

2、录音上下文与权限

录音上下文的创建,以及获取录音权限的方式在不同浏览器环境中各不相同,所以官方 Demo 中做了大量兼容判断:

// 初始化浏览器录音
recorderInit() 
  navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia || navigator.msGetUserMedia
  // 创建音频环境
  try 
    this.audioContext = new (window.AudioContext || window.webkitAudioContext)()
    this.audioContext.resume()
   catch (e) 
    if (!this.audioContext) 
      alert('浏览器不支持webAudioApi相关接口')
      return
    
  
  // 获取浏览器录音权限
  if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) 
    navigator.mediaDevices
      ...
      .then(stream =>  getMediaSuccess(stream) )
      .catch(e =>  getMediaFail(e) )
   else if (navigator.getUserMedia) 
    navigator.getUserMedia(
      ...,
      stream =>  getMediaSuccess(stream) ,
      function(e)  getMediaFail(e) 
    )
   else 
    ...
    alert('无法获取浏览器录音功能,请升级浏览器或使用chrome')
    this.audioContext && this.audioContext.close()
    return
  
  // 获取浏览器录音权限成功的回调
  let getMediaSuccess = stream => 
    console.log('getMediaSuccess')
    // 创建一个用于通过javascript直接处理音频
    this.scriptProcessor = this.audioContext.createScriptProcessor(0, 1, 1)
    this.scriptProcessor.onaudioprocess = e => 
      // 去处理音频数据
      ...
    
    // 创建一个新的MediaStreamAudiosourceNode 对象,使来自MediaStream的音频可以被播放和操作
    this.mediaSource = this.audioContext.createMediaStreamSource(stream)
    // 连接
    this.mediaSource.connect(this.scriptProcessor)
    this.scriptProcessor.connect(this.audioContext.destination)
    ...
  
  let getMediaFail = (e) => 
    alert('请求麦克风失败')
    this.audioContext && this.audioContext.close()
    this.audioContext = undefined
    ...
  

而在微信小程序环境下,获取录音上下文与权限就简单多了,通过 uni.getRecorderManager() 即可获取录音上下文(管理器),然后调用 start(option) 时会自动询问用户是否授权录音权限:

const recordManager = uni.getRecorderManager();

/**
 * 开始录音
 */
const startRecord = () => 
  recordManager.onStart(() => 
    console.log("recorder start");
    ...
  );
  recordManager.onPause(() => 
    console.log("recorder pause");
  );
  recordManager.onStop((res) => 
    // tempFilePath	String	录音文件的临时路径
    console.log("recorder stop", res);
    ...
  );
  recordManager.onError((err) => 
    // errMsg	String	错误信息
    console.log("recorder err", err);
  );
  recordManager.onFrameRecorded((res) => 
    // frameBuffer	ArrayBuffer	录音分片结果数据
    // isLastFrame	Boolean	当前帧是否正常录音结束前的最后一帧
    const  frameBuffer  = res;
    ...
  );
  recordManager.start(option);
;

3、获取录音数据

浏览器的录音数据是通过 scriptProcessor.onaudioprocess 回调函数获得的:

// 获取浏览器录音权限成功的回调
let getMediaSuccess = (stream) => 
  ...
  this.scriptProcessor.onaudioprocess = (e) => 
    // 去处理音频数据
    transWorker.postMessage(e.inputBuffer.getChannelData(0));
  ;
  ...
;

微信小程序的录音数据是通过 recordManager.onFrameRecorded() 回调函数获得:

recordManager.onFrameRecorded((res) => 
  // frameBuffer	ArrayBuffer	录音分片结果数据
  // isLastFrame	Boolean	当前帧是否正常录音结束前的最后一帧
  const  frameBuffer  = res;
  pushAudioData(frameBuffer); // 将每一帧音频保存起来
);

4、录音数据格式

从上面的 语音评测接口要求 中可以知道,科大讯飞语音评测接口对录音数据的格式是有要求的:

内容说明
音频属性采样率 16k、位长 16bit、单声道
音频格式pcm、wav、mp3(需更改 aue 的值为 lame)、speex-wb;
音频大小音频数据发送会话时长不能超过 5 分钟

官方 Demo 中,通过 scriptProcessor.onaudioprocess 回调拿到的录音数据是双声道的 PCM 数据,而接口要求的是单声道,所以通过代码 e.inputBuffer.getChannelData(0) 提取出第 1 个声道的数据,并交给 transWorker 去处理成 采样率 16k位长 16bit 的 PCM 数据:

// index.js
this.scriptProcessor.onaudioprocess = (e) => 
  // 去处理音频数据
  transWorker.postMessage(e.inputBuffer.getChannelData(0));
;

// transcode.worker.js
(function()
  self.onmessage = function(e)
    transAudioData.transcode(e.data)
  

  let transAudioData = 
    transcode(audioData) 
      let output = transAudioData.to16kHz(audioData)
      output = transAudioData.to16BitPCM(output)
      ...
    ,
    to16kHz(audioData) 
      ...
      return newData
    ,
    to16BitPCM(input) 
      ...
      return dataView
    ,
  
)()

微信小程序中在启动录音时需要传入一个配置参数 option,在这个 option 中我们可以指定采样率、通道数等配置,之后通过 recordManager.onFrameRecorded() 回调拿到的就已经是 采样率 16k位长 16bit 的单声道 PCM 数据了:

const recordManager = uni.getRecorderManager();
const option = 
  duration: duration, // 录音的时长,单位 ms,最大值 600000(10 分钟)
  sampleRate: 16000, // 采样率(pc不支持)
  numberOfChannels: 1, // 录音通道数
  // encodeBitRate: 48000, // 编码码率(默认就是48000)
  frameSize: 1, // 指定帧大小,单位 KB。传入 frameSize 后,每录制指定帧大小的内容后,会回调录制的文件内容,不指定则不会回调。暂仅支持 mp3、pcm 格式。
  format: "pcm", // 音频格式,默认是 aac

const startRecord = () => 
  ...
  recordManager.onFrameRecorded((res) => 
    // frameBuffer	ArrayBuffer	录音分片结果数据
    // isLastFrame	Boolean	当前帧是否正常录音结束前的最后一帧
    const  frameBuffer  = res;
    pushAudioData(frameBuffer); // 将每一帧音频保存起来
  );
  recordManager.start(option);
;

注意:微信小程序录音时长最大值为 10 分钟,而科大讯飞语音评测录音时长最大值为 5 分钟!!

5、发送 webSocket 数据

官方 Demo 中,使用 1 个 audioData 数组来存放每一帧经过 transWorker 处理过的 PCM "散装" 数据(音频流数据);在 webSocket 连接开启的 500ms 之后,开始发送音频流数据:

class IseRecorder 
  constructor( language, accent, appId  = ) 
    // 记录音频数据
    this.audioData = []
    transWorker.onmessage = function (event) 
      self.audioData.push(...event.data) // GitLqr: 注意这里是 "散装" 的
    
    ...
  
  recorderStart() 
    this.audioContext.resume()
    this.connectWebSocket()
  
  // 连接we

以上是关于uniapp - 接入科大讯飞语音评测的主要内容,如果未能解决你的问题,请参考以下文章

uniapp - 接入科大讯飞语音评测

uniapp - 接入科大讯飞语音评测

uniapp - 接入科大讯飞语音评测

科大讯飞语音评测卡死,卡这里两天

吾剑未尝不利,国内Azure平替,科大讯飞人工智能免费AI语音合成(TTS)服务Python3.10接入

Unity 实战项目 ☀️| 接入科大讯飞语音SDK如何在科大讯飞平台搞到SDK!系列共两万多字超级新手教程!