基于微信JS-SDK的录音与语音评价功能实现

最近接受了一个新的需求,希望制作一个基于微信的英语语音评价页面。即点击录音按钮,用户录音说出预设的英文,根据用户的发音给出对应的评价。

功能列表

  • 录音
  • 录音动画
  • 录音播放
  • 英语语音评价(部分实现)
  • 只允许微信客户端打开

零 技术选型

录音方面

可供选择的方案有两个:

  1. 使用HTML5接口 - getUserMedia()
  2. 微信开放平台-微信JS-SDK;.

通过http://caniuse.com查询 getUserMedia()的兼容性。

getUserMedia() API兼容性

由于主要应用场景是在移动端,此API只能在iOS 11+Android 5-6.X及以上可用,兼容性感人,故舍弃此方案。所以此次录音实现基于微信开放平台提供的微信JS-SDK

英语语音识别

因为主要是基于微信平台,所以要求语音识别平台需要提供Web Api。

语音识别方面功能,主要有两种技术路线。

  1. 专门着力于语音识别及相关产业的技术平台,例如讯飞以及调研中发现的驰声
    优势:专业并且提供语音评测相关功能;
    劣势:花费昂贵;
  2. AI开放平台,因为各大厂商布局AI,免费提供语音识别相关的接口。
    优势:免费,API清晰;
    劣势:并非为专门为教育定制,无语音评测功能;

结合项目的实际情况,决定使用第二种方案。(主要是因为经费有限……)
大厂提供的免费方案主要有:

  • 百度AI
    限制:50000次/天免费
    格式支持:pcm(不压缩)、wav(不压缩,pcm编码)、amr(压缩格式);固定16k 采样率;
  • 腾讯AI开发平台
    语音参数:必须符合16k或8K采样率、16bit采样位数、单声道
    语音格式:PCM、WAV、AMR、 SILK
    其他:目前只支持汉语
  • 腾讯云智能语音服务
    语音参数:必须符合16k或8K采样率、16bit采样位数、单声道
    语音格式:通用标准格式,例如 mp3,wma,wav 等
  • 微信公众平台AI开放接口
    语音参数:16k,单声道,最大1M
    语音格式:mp3
  • 微信公众平台JS-SDK智能接口
    其他:目前只支持汉语

大厂竞争果然系列,大鹅厂光语音服务肉眼可见的就折腾了这么多。(大雾)

经过一番折腾,最终可以形成两种方案:

  1. 微信JS-SDK音频接口录音->上传到微信临时素材->下载到服务器->转换录音文件格式->百度AI语音识别返回结果->与预置的文件比对->返回比对结果
    优势:识别结果准
    劣势:慢(因为无法直接获取用户的录音,需要从微信公众平台的临时素材中转,且录音文件格式与百度AI可识别格式不一致,所以整个流程下来太慢);微信公众号需要企业认证
    其他:至于为什么不选用腾讯系列,因为腾讯系列语音服务没有调通。。。

  2. 微信JS-SDK音频接口录音->调用JS-SDK智能接口返回识别结果->结果转为拼音->与预置的文件比对->返回比对结果
    优势:返回结果迅速、方法简单
    劣势:识别结果不太准确(因为JS-SDK智能接口不只是单单根据语音直接转换,还会在结果的基础上进行一定程度的联想,话说为啥不能增加个语言选择参数。)

本次整个方案使用方案2


一 微信JS-SDK环境准备

写在前边:此处的开发环境不是指本地的开发环境,单指使用微信JS-SDK所需完成的一系列的获取AccessTokenjsapi_ticket等前置条件。

开发环境
云服务器:腾讯云 · 小程序(特价,买了个折腾)
后台语音:PHP · CodeIgniter(小程序PHP样例使用CI框架)

1)公众号配置

前置的公众号申请等就不再赘述,如果要正常使用微信JS-SDK的功能,需要在公众号配置一些内容。

配置IP白名单

通过微信公众平台 开发 -> 基本配置 -> IP白名单 进行设置,将开发环境的IP配置到IP白名单。

注1. 如果不配置白名单将无法获取access_token,并在返回结果中返回40164错误;
注2. 因为是在腾讯云 · 小程序主机开发环境下折腾的,该环境如果一周不更新新的代码会暂时关闭,IP也会发生变化,所以建议每周更新一下代码;

配置JS接口安全域名

通过微信公众平台 设置 -> 公众号设置 -> 功能设置 -> JS接口安全域名 进行设置,将JS接口安全域名写入。

注1. 一个公众号最多可以配置3个安全域名,需使用字母、数字及“-”的组合,不支持IP地址、端口号及短链域名,且域名必须经过备案;
注2. 需要将MP_verify_qEwAJiPuWerKftkO.txt(可在配置JS接口安全域名处自行下载)放到配置域名的根目录,并确保可以访问到。腾讯云 · 小程序默认样例使用的CI框架,需要放到\server下;
注3. 如不配置JS接口安全域名,则无法成功调用JS-SDK;

2)获取access_token

access_token是公众号的全局唯一接口调用凭据,调用公众号的各个接口时都需要使用。获取access_token需要appidappsecret。微信公众平台的access_token有效期为7200s (2小时),每天最高可调用上限为2000次。因此获取access_token需要做到:

  1. 定时刷新(刷新间隔大于1分钟,小于120分);
  2. 全局缓存access_token

Show me the code

class className extends CI_Controller {
  var $appId = "appId";
  var $appSecret = "appSecret";
  var $accessTokenFile = "wxtoken.txt";
  // var $jsapiTicketFile = "wxjsapiTicket.txt";

  public function index() {
    $this - > build_access_token(); //获取access_token
    // $this - > get_jsapi_ticket(); //获取jsapic_ticket
  }


  public function build_access_token() {
    $ch = curl_init(); //初始化一个CURL对象
    curl_setopt($ch, CURLOPT_URL, "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={$this->appId}&secret={$this->appSecret}");
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); 
    $data = json_decode(curl_exec($ch));
    if ($data - > access_token) {
      $token_file = fopen($this - > accessTokenFile, "w") or die("Unable to open file!"); //打开wxtoken.txt文件,没有会新建
      fwrite($token_file, $data - > access_token); //重写wxtken.txt全部内容
      fclose($token_file); //关闭文件流
    } else {
      echo $data - > errmsg;
    }
    curl_close($ch);
  }

  public function read_token() {
    $token_file = fopen($this - > accessTokenFile, "r") or die("Unable to open file!");
    $rs = fgets($token_file);
    fclose($token_file);
    return $rs;
  }
}

Talk is cheap

  1. 因为使用的是CI框架,将文件写到server\application\controllers\下可直接通过域名+文件名访问到该接口,默认执行文件中的index中的方法;
  2. 代码中的基本逻辑通过build_access_token()方法获取access_token,并存储到wxtken.txt,通过read_token()方法读取access_token

获取access_token的详细情况见官方API

3)获取jsapi_ticket

jsapi_ticket是公众号用于调用微信JS接口的临时票据,通过access_token来获取。微信公众平台的jsapi_ticket有效期为7200s (2小时),每天最高可调用上限为1000000次。因此同样在全局缓存。

Show me the code

public function get_jsapi_ticket() {
  $access_token = $this - > read_token();
  $ch = curl_init(); //初始化一个CURL对象
  curl_setopt($ch, CURLOPT_URL, "https://api.weixin.qq.com/cgi-bin/ticket/getticket?access_token={$access_token}&type=jsapi"); 
  curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); 
  $data = json_decode(curl_exec($ch));
  if ($data - > ticket) {
    $token_file = fopen($this - > jsapiTicketFile, "w") or die("Unable to open file!");
    fwrite($token_file, $data - > ticket);
    fclose($token_file); //关闭文件流
  } else {
    echo $data - > errmsg;
  }
  curl_close($ch);
}

public function read_jsapi_ticket() {
  $jsapi_ticket_file = fopen($this - > jsapiTicketFile, "r") or die("Unable to open file!");
  $rs = fgets($jsapi_ticket_file);
  fclose($jsapi_ticket_file);
  return $rs;
}

Talk is cheap

  1. 写到跟获取access_token同一文件中,以便同时刷新;
  2. 同之前的代码中逻辑类似,通过get_jsapi_ticket()方法获取jsapi_ticket,并存储到wxjsapiTicket.txt,通过read_jsapi_ticket()方法读取jsapi_ticket

获取access_token的详细情况见官方API

4)刷新access_token及jsapi_ticket

由于微信公众平台的access_tokenjsapi_ticket有两个小时有效期,故需要定期刷新。基本思路有如下三个:

  1. PHP定时执行任务;
  2. 服务器定时任务;
  3. 定时访问URL;

1.PHP定时执行任务

主要使用死循环,执行一次时间,使用sleep()函数休眠一段时间,如下代码:

ignore_user_abort();//即使Client断开(如关掉浏览器),PHP脚本也可以继续执行.
set_time_limit(0);//执行时间为无限制,php默认的执行时间是30秒,通过set_time_limit(0)可以让程序无限制的执行下去
$interval=60*100;//每隔100分钟运行
do{
    //do sth
    sleep($interval);//按设置的时间等待100分钟循环执行
}while(true);

缺点:缺点严重,启动之后,无法控制。而且一直消耗服务器资源,容易被杀死;

2.服务器定时任务

windows平台的计划任务或者是Unix平台的Crontab都有定时执行php脚本或者访问URL的方法,但是由于使用的腾讯云 · 小程序使用的是Wafer一体化解决方案,无法直接访问远端服务器,故此方法放弃。

3. 定时访问URL

我们这次定时刷新access_tokenjsapi_ticket采用的就是此方法,腾讯云平台,有个免费的功能云拨测可定时访问某个URL,并且在无法访问时,将预警信息发送给某个设定好的用户组。
将我们之前写好的获取access_tokenjsapi_ticket方法,写到index()方法下,将URL填到拨测地址中,定时刷新,搞定。

注1. 云拨测最长的周期为半个小时,而且每次拨测可能访问地址5-6次,其实更稳妥的方法是在数据库中设置标志位,防治过度刷新,但是每天2000次的限额完全够用,就暂时未做此功能。


5)生成JS-SDK配置信息

所有需要使用JS-SDK的页面必须先注入配置信息,否则将无法调用,配置信息需要的参数如下:

wx.config({
    debug: true, // 开启调试模式,调用的所有api的返回值会在客户端alert出来,若要查看传入的参数,可以在pc端打开,参数信息会通过log打出,仅在pc端时才会打印。
    appId: '', // 必填,公众号的唯一标识
    timestamp: , // 必填,生成签名的时间戳
    nonceStr: '', // 必填,生成签名的随机串
    signature: '',// 必填,签名
    jsApiList: [] // 必填,需要使用的JS接口列表
});

其中的appIdjsApiList已知,timestampnonceStr动态生成,signature由算法生产。其中关于signature的算法官方API描述如下:

签名算法

签名生成规则如下:参与签名的字段包括noncestr(随机字符串), 有效的jsapi_ticket, timestamp(时间戳), url(当前网页的URL,不包含#及其后面部分) 。对所有待签名参数按照字段名的ASCII 码从小到大排序(字典序)后,使用URL键值对的格式(即key1=value1&key2=value2…)拼接成字符串string1。这里需要注意的是所有参数名均为小写字符。对string1作sha1加密,字段名和字段值都采用原始值,不进行URL 转义。

即signature=sha1(string1)。 示例:

noncestr=Wm3WZYTPz0wzccnW
jsapi_ticket=sM4AOVdWfPE4DxkXGEs8VMCPGGVi4C3VM0P37wVUCFvkVAy_90u5h9nbSlYy3-Sl-HhTdfl2fzFy1AOcHKP7qg
timestamp=1414587457
url=http://mp.weixin.qq.com?params=value

步骤1. 对所有待签名参数按照字段名的ASCII 码从小到大排序(字典序)后,使用URL键值对的格式(即key1=value1&key2=value2…)拼接成字符串string1:

jsapi_ticket=sM4AOVdWfPE4DxkXGEs8VMCPGGVi4C3VM0P37wVUCFvkVAy_90u5h9nbSlYy3-Sl-HhTdfl2fzFy1AOcHKP7qg&noncestr=Wm3WZYTPz0wzccnW×tamp=1414587457&url=http://mp.weixin.qq.com?params=value

步骤2. 对string1进行sha1签名,得到signature:

0f9de62fce790f9a083d5c99e95740ceb90c27ed

注意事项

  1. 签名用的noncestr和timestamp必须与wx.config中的nonceStr和timestamp相同。
  2. 签名用的url必须是调用JS接口页面的完整URL。
  3. 出于安全考虑,开发者必须在服务器端实现签名的逻辑。

Show me the code

public function get_signpackage(){
  $jsapi_ticket = $this->read_jsapi_ticket();  
  $protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off' || $_SERVER['SERVER_PORT'] == 443) ? "https://" : "http://";
  $url = "$protocol$_SERVER[HTTP_HOST]$_SERVER[REQUEST_URI]";  // 注意 URL 一定要动态获取,不能 hardcode.
  $noncestr = $this->createNonceStr();
  $timestamp = time();

  $trs_url = $this->input->post('trs_url');

  $url = isset($trs_url)?$trs_url:$url;
  $string1 = "jsapi_ticket={$jsapi_ticket}&noncestr={$noncestr}&timestamp={$timestamp}&url={$url}";
  $signature = sha1($string1);

  $this->json([
    'appId'     => $this->appId,
    'nonceStr'  => $noncestr,
    'timestamp' => $timestamp,
    'signature' => $signature,
    'url' => $url
  ]);
  // return $signPackage;
}

private function createNonceStr($length = 16) {
  $chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
  $str = "";
  for ($i = 0; $i < $length; $i++) {
    $str .= substr($chars, mt_rand(0, strlen($chars) - 1), 1);
  }
  return $str;
}

Talk is cheap

  1. 一定要注意,签名用的url必须是调用JS接口页面的完整URL,这里通过前端POST获取调用页的URL;
  2. 返回值为json格式,前端通过ajax获取;
  3. 因为采用了CI框架,前端ajax请求地址为域名/weapp/此段代码的文件名/get_signpackage

至此,使用微信公众平台JS-SDK的前置条件均已准备完毕。


二 实现录音按钮动画

基本的交互逻辑如下图演示:


此处来实现长按录音按钮的动画。基本思路是:

  1. 通过CSS3的transition属性实现record突变的平滑变小、平滑变大;
  2. 通过CSS3的keyframes动画与伪类配合完成环形进度动画;

Show me the code

<div class="content">
    <div class="dialogBox" id="dialogBox">
    </div>
    <div class="voice-remote">
    <span class="cover"></span>
    <span class="icon"></span>
    </div>
</div>

<style type="text/css">
.voice-remote {
    border-radius: 50%;
    width: 4rem;
    height: 4rem;
    overflow: hidden;
    position: absolute;
    background: #f6f6f6;
    bottom: 1.5rem;
    left: 50%;
    -webkit-transform: translateX(-50%);
    transform: translateX(-50%);
    transition: all .2s;
    -webkit-transition: all .2s;
}

.voice-remote:active {
    width: 4.5rem;
    height: 4.5rem;
    bottom: 1rem;
    border: 1px solid #e7e7e7;
}

.voice-remote:before {
    content: "";
    width: 100%;
    height: 100%;
    position: absolute;
    z-index: 2;
    top: 0;
    left: 0;
    border-radius: 50%;
    background-image: linear-gradient(-90deg, transparent 50%, #1dc61c 50%);
}

.voice-remote:after {
    content: "";
    width: 100%;
    height: 100%;
    position: absolute;
    z-index: 3;
    bottom: 0;
    left: 0;
    border-radius: 50%;
    background-image: linear-gradient(-90deg, transparent 50%, #1dc61c 50%);
}

.voice-remote .cover {
    position: absolute;
    border-radius: 50%;
    width: 100%;
    height: 100%;
    z-index: 4;
    top: 0;
    left: 0;
    background-image: linear-gradient(-90deg, transparent 50%, #f6f6f6 50%);
}

.voice-remote .icon {
    position: absolute;
    width: 100%;
    height: 100%;
    top: 0;
    left: 0;
    background: #f6f6f6 url(../../images/voice.png) no-repeat center center;
    background-size: 100%;
    border-radius: 50%;
    z-index: 5;
}

.voice-remote .icon:active {
    width: 80%;
    height: 80%;
    top: 10%;
    left: 10%;
    background-size: 100%;
}

.voice-remote:active:before {
    -webkit-animation: scoll linear 30s;
    animation: scoll linear 30s;
    -webkit-animation-fill-mode: forwards;
    animation-fill-mode: forwards;
}

.voice-remote:active:after {
    -webkit-animation: xscoll linear 60s;
    animation: xscoll linear 60s;
    -webkit-animation-fill-mode: forwards;
    animation-fill-mode: forwards;
}

.voice-remote:active .cover {
    -webkit-animation: hide linear 60s;
    animation: hide linear 60s;
    -webkit-animation-fill-mode: forwards;
    animation-fill-mode: forwards;
}

@-webkit-keyframes scoll {
    0% {
        -webkit-transform: rotate(0deg);
    }

    100% {
        -webkit-transform: rotate(180deg);
    }
}

@keyframes scoll {
    0% {
        transform: rotate(0deg);
    }

    100% {
        transform: rotate(180deg);
    }
}

@-webkit-keyframes xscoll {
    0% {
        -webkit-transform: rotate(0deg);
    }

    100% {
        -webkit-transform: rotate(360deg);
    }
}

@keyframes xscoll {
    0% {
        transform: rotate(0deg);
    }

    100% {
        transform: rotate(360deg);
    }
}

@-webkit-keyframes hide {
    0% {
        opacity: 1
    }

    49.9% {
        opacity: 1;
    }

    50% {
        opacity: 0;
    }

    100% {
        opacity: 0;
    }
}

@keyframes hide {
    0% {
        opacity: 1
    }

    49.9% {
        opacity: 1;
    }

    50% {
        opacity: 0;
    }

    100% {
        opacity: 0;
    }
}
</style>

Talk is cheap

录音按钮原理图

录音按钮动画原理如上图分层,其中:before层添加动画为旋转180度,时间为30s,与此同时:after层添加动画为旋转360度,时间为60s,即前30s两个图层同时旋转,当30s后:after层继续旋转,:before层保持位置不变,使整个右侧环形显示。.cover层添加动画为前30s覆盖整个左侧,后30s隐藏。 整个动画由最顶部.icon覆盖,使整个动画过程显示为一个环形。

三 实现录音及录音播放功能

开始是实现录音及播放的相关功能。主要流程是引入JS文件通过config接口注入权限验证配置通过ready接口处理成功验证撰写录音代码逻辑撰写录音播放代码逻辑

1)引入JS文件

在需要调用JS接口的页面引入如下JS文件,(支持https):http://res.wx.qq.com/open/js/jweixin-1.2.0.js

Show me the code

requirejs.config({
    baseUrl: './lib/js',
    paths: {
        'jquery': 'jquery',
        'jweixin': 'jweixin',
        'util': 'util',
        'post_data': 'data',
        'pinyin_dict_notone': 'pinyin_dict_notone',
        'pinyinUtil': 'pinyinUtil',
    }
});

define(['jquery', 'jweixin', 'post_data', 'util', 'pinyin_dict_notone', 'pinyinUtil'], function($, wx) {
}

Talk is cheap

  1. 此次使用AMD模式requirejs引入相关文件;
  2. 这里引入多个文件,之后的代码需要使用;

注1. 支持使用 AMD/CMD 标准模块加载方法加载,也支持直接使用<script></script>直接引用;
注2. 调用之前需要完成配置JS接口安全域名

2)通过config接口注入权限验证配置

通过ajax请求之前完成的生成JS-SDK配置信息接口,获取到相关的配置内容,另外jsApiList接口列表需要根据业务需求自行添加。

Show me the code

$.ajax({
    url: "your js-sdk interface",
    dataType: "json",
    contentType : "application/x-www-form-urlencoded; charset=utf-8",
    data:{"trs_url":window.location.href.split("#")[0]},
    type:"POST",
    success: function(data) {
        var baseWxData = data;
        wx.config({
            debug: false, // 开启调试模式,调用的所有api的返回值会在客户端alert出来,若要查看传入的参数,可以在pc端打开,参数信息会通过log打出,仅在pc端时才会打印。
            appId: baseWxData['appId'], // 必填,公众号的唯一标识
            timestamp: baseWxData['timestamp'], // 必填,生成签名的时间戳
            nonceStr: baseWxData['nonceStr'], // 必填,生成签名的随机串
            signature: baseWxData['signature'], // 必填,签名,见附录1
            jsApiList: [
                'startRecord', // 录音开始api
                'stopRecord', // 录音结束api
                'onVoiceRecordEnd', // 超过一分钟自动停止api
                'playVoice', // 播放录音api
                'pauseVoice', // 暂停录音api
                'stopVoice',    // 停止播放api
                'onVoicePlayEnd', // 监听语音播放完毕api
                'translateVoice'
            ]
        });
    }
});

Talk is cheap

  1. post传入当前页面URL,因为签名算法必须是使用调用页的地址;
  2. 此次功能只用到如代码中的几个API,更多API详见官方API

3)通过ready接口处理成功验证

wx.ready(function(){
    // config信息验证后会执行ready方法,所有接口调用都必须在config接口获得结果之后,config是一个客户端的异步操作,所以如果需要在页面加载时就调用相关接口,则须把相关接口放在ready函数中调用来确保正确执行。对于用户触发时才调用的接口,则可以直接调用,不需要放在ready函数中。
});

4)撰写录音代码逻辑

创建一个对象R,封装录音、播放以及翻译等过程。监听录音按钮的touchstart事件启动录音,监听touchend时间结束录音。

Show me the code

/* Javascript Code*/

var R = {
    options: {
        spoint: 0,  //记录recordstart时间
        tpoint: 0,  //记录touchstart时间
        epoint: 0,  //记录touchend时间
        timer: 0,   //setInterval
        iOrder: 0   //记录录音序列order
    },
    feedback: {
        great: ["Excellent!", "Well done!", "口语不错嘛!", "非常棒!", "Great"],
        good: ["Good job!", "Not bad!", "还不错哦!", "Good! Keep going!", "干得不错!加油"],
        normal: ["Please try again!", "Oh,you can do better than that!", "分数有点儿低哦!", "再来一次试试!", "Have another try,please!"]
    },
    recode: function() { //定时最长60s后结束录音
        R.options.timer = setInterval(function() {
            var time = +new Date() - R.options.spoint;
            if (time >= 60000) {
                alert("时间超过60秒,请再次录制!");
                setTimeout(function() {
                    R.translate();
                }, 100);
                clearInterval(R.options.timer);
            }
        }, 1000);
    },
    translate: function() { //结束录音并识别语音
        wx.stopRecord({
            success: function(res) {
                localId = res.localId;
                $(".voice-remote").addClass("vrPause");

                wx.translateVoice({
                    localId: localId,
                    complete: function(res) {}
                });
            },
            fail: function(res) {
                alert(JSON.stringify(res));
            }
        });
    },
    insertContent: function(obj) {
        var _str = "";
        switch (obj.iType) {
            case 1:
                _str = '<div class="p1 dialogItem"><div class="avatarBox"><img src="./images/avatar1.png" class="avatar" /></div><div class="contentBox"><div class="wordBox"><span>' + obj.iContent + '</span></div></div></div>';
                break;
            case 2:
                _str = '<div class="p2 dialogItem isSound ' + obj.iClass + '"><div class="contentBox iPlayVoice" data-localid="' + obj.iContent + '"><div class="wordBox"><span><i class="sound"></i></span></div></div><div class="avatarBox"><img src="./images/avatar2.png"  class="avatar" /></div>'
                break;
            case 3:
                break;
            case 4:
                break;
            default:
                console.log('Undefined element of iType :' + iType);
        }
        $("#dialogBox").append(_str).scrollTop($("#dialogBox")[0].scrollHeight);
    },
    init: function() {

        R.insertContent({
            iType: 1,
            iContent: word.keyword[R.options.iOrder].content,
        });

        // $.ajax();

        wx.ready(function() {
            $('.voice-remote').on('touchstart', function(e) {

                $(".playing").each(function() {
                    _stoplocalId = $(this).data("localid");
                    $(this).removeClass("playing");
                    wx.stopVoice({
                        localId: _stoplocalId
                    });
                });

                R.options.tpoint = +new Date(); //记录touchstart时间点

                wx.startRecord({
                    success: function() {
                        $('.voice-remote').addClass('active');
                        R.options.spoint = +new Date(); //记录开始录音成功时间点
                        R.recode(); //启用定时结束录音定时器

                        if (R.options.spoint > R.options.epoint && R.options.epoint > R.options.tpoint) { //处理因为短按,startRecord还未初始成功,导致无法正常停止录音
                            clearInterval(R.options.timer);

                            $('.voice-remote').removeClass('active');

                        }
                    },
                    fail: function(res) {
                        alert(JSON.stringify(res));
                    },
                    cancel: function() {
                        alert('您拒绝了授权录音');
                    }
                });
            });

            document.oncontextmenu = function(e) {
                // 阻止部分手机长按会产生弹出框的问题
                e.preventDefault();
            };

            $('.voice-remote').on('touchend', function() {
                R.options.epoint = +new Date(); //记录touchend时间点
                $(this).removeClass('active');

                var time = +new Date() - R.options.spoint;
                if (time < 60000) { //当录音间隔时间小于60s,touchend后清除定时结束录音定时器,并调用结束录音方法
                    setTimeout(function() {
                        R.translate();
                    }, 200);
                    clearInterval(R.options.timer);
                }
            });

            $(document).on('touchstart', '.iPlayVoice', function() {
                // do sth
            });

            wx.onVoicePlayEnd({
                complete: function(res) {
                    // do sth
                }
            });

        });
    }
}
R.init();
/* CSS Code*/

.setHide {
    display: none;
}

.content {
    background: #ebebeb;
    width: 100%;
    height: 100%;
    overflow: hidden;
    font-family: Microsoft YaHei;
}

.dialogBox {
    margin: 3%;
    width: 94%;
    height: 81%;
    overflow-y: scroll;
}

.dialogItem {
    margin: 3% 0;
    overflow: hidden;
    text-align: left;
}

.avatarBox {
    display: inline-block;
}

.contentBox {
    display: inline-block;
    max-width: 68%;
    margin-left: 12px;
}

.wordBox:before {
    content: "";
    width: 12px;
    height: 25px;
    background: url(../../images/sharpOther.png) 0 0 no-repeat;
    position: absolute;
    top: 50%;
    margin-top: -12px;
    left: -12px;
}

.wordBox {
    border: 1px solid #d4d4d4;
    background-color: #fff;
    padding: 5px 10px;
    display: inline-block;
    vertical-align: middle;
    -webkit-border-radius: 5px;
    border-radius: 5px;
    position: relative;
    min-height: 40px;
    line-height: 40px;
    vertical-align: middle;
    text-align: left;
}

.wordBox>span {
    line-height: 1.5em;
    display: inline-block;
    vertical-align: middle;
    text-align: justify;
}

.avatar {
    width: 40px;
    vertical-align: middle;
}

.sharpStyle {
    width: 17px;
    height: 35px;
    background: url(../../images/sharpOther.png) 0 0 no-repeat;
    display: inline-block;
    margin-left: 6px;
    vertical-align: middle;
}

.sharpMe {
    background-image: url(../../images/sharpMe.png);
    margin-left: 0;
    margin-right: 6px;
}

.sound {
    display: inline-block;
    width: 18px;
    height: 25px;
    background: url(../../images/sound.png) 0 0 no-repeat;
    background-size: 100% 100%;
}

.playing .sound {
    background-image: url(../../images/sound.gif);
}

.p2 {
    text-align: right;
}

.p2 .contentBox {
    margin-left: 0;
    margin-right: 12px;
}

.p2 .wordBox {
    border: 1px solid #86b850;
    background-color: #a1e75b;
}

.p2 .wordBox:before {
    background: url(../../images/sharpMe.png) 0 0 no-repeat;
    left: auto;
    right: -12px;
}

.p2 .sound {
    background-image: url(../../images/soundMe.png);
}

.p2 .playing .sound {
    background-image: url(../../images/soundMe.gif);
}

.dialogItem .contentBox:after {
    color: #969696;
    margin-left: 3px;
}

.dialogItem .contentBox:before {
    color: #969696;
    margin-right: 3px;
}

.isSound .contentBox {
    width: 68%;
}

.p2.isSound .wordBox {
    text-align: right;
}

.soundOt1 .wordBox {
    width: 15%;
}

.soundOt2 .wordBox {
    width: 16%;
}
/*……*/

.soundOt1  .contentBox:after {
    content: "1 ''";
}

.soundOt2  .contentBox:after {
    content: "2 ''";
}
/*……*/

.soundMe1 .contentBox:before {
    content: "1 ''";
}

.soundMe2 .contentBox:before {
    content: "2 ''";
}
/*……*/

.soundMe1 .wordBox {
    width: 15%;
}

.soundMe2 .wordBox {
    width: 16%;
}
/*……*/

Talk is cheap

  1. 构建了insertContent()方法构建页面,使用scrollTop()方法使填充的新的对话框出现再最下边;
  2. 构建了spointepoint两个参数,判断录音时间;
  3. 构建recode()方法,使用setInterval()方法,限制录音超过60s后停止(因为微信JS-SDK限制录音时长最多为60s);
  4. 构建feedback参数,为之后翻译提供反馈;
  5. 使用伪类实现对话前后的音频时长;

已知兼容性问题

  1. 部分华为手机,长按后弹出弹出菜单,检测documentoncontextmenu事件,阻止默认事件e.preventDefault()
  2. 微信开发者工具调试时,超过60s后会因为alert()会触发一次touchend事件,真正抬手后又会触发一次touchend,真机运行时无此问题;

5)撰写录音播放代码逻辑

在构建页面时将localid写到对应对话语句中,通过该localid对应相应的录音。

Show me the code

$(document).on('touchstart', '.iPlayVoice', function() {
    var $this = $(this),
        _localId = $this.data("localid");

    if ($this.hasClass("playing")) {
        wx.stopVoice({
            localId: _localId
        });
        $this.removeClass("playing");
    } else {
        $(".playing").not($this).each(function() {
            _stoplocalId = $(this).data("localid");
            $(this).removeClass("playing");
            wx.stopVoice({
                localId: _stoplocalId
            });
        });
        wx.playVoice({
            localId: _localId
        });
        $this.addClass("playing");
    }
});

wx.onVoicePlayEnd({
    complete: function(res) {
        $(".playing").removeClass("playing");
    }
});

Talk is cheap

  1. 使用$(document).on('touchstart', '.iPlayVoice', function() {}).iPlayVoice动态绑定事件;
  2. 使用playing类名,控制播放时的状态;

四 实现语音评价功能

开篇的技术选型时已经将前因后果说明了。现在就写借助微信JS-SDK中的wx.translateVoice()方法实现语音评价功能的具体实现。具体流程为引入示例json获取语音翻译结果语音结果转为拼音结果比对反馈评价

1)引入示例json

将示例的数据写成json,用requirejs引入。

Show me the code

var word = {
  keyword: [{
      order: 1,
      content: "请说:<br />What's your name.",
      matched: "我次要儿内幕,我想那,我次有那么",
      localId: "-1"
    }, {
      order: 2,
      content: "请说:<br />How are you.",
      matched: "好啊有",
      localId: "-1"
    }, {
      order: 3,
      content: "请说:<br />Nice to meet you.",
      matched: "挨次图密特油",
      localId: "-1"
    }],
}

Talk is cheap

  1. content数据项,标识的是引导语;
  2. matched项标识的是匹配内容,通过“,”分隔多个匹配内容,以提高匹配度;

2)获取语音翻译结果

Show me the code

wx.translateVoice({
    localId: '', // 需要识别的音频的本地Id,由录音相关接口获得
    isShowProgressTips: 1, // 默认为1,显示进度提示
    success: function(res) {
        alert(res.translateResult); // 语音识别的结果
    }
    fail: function(res) {
        alert(JSON.stringify(res));
    }
});

Talk is cheap

翻译接口主要依靠localId来完成一系列的工作,成功后返回一段json格式的数据。

3)语音结果转为拼音

此步骤主要将返回的内容转换成拼音。借助的是@sxei(小茗同学)的一个库,地址为github
因为只需要转换成无声掉的拼音,那么只需要引入pinyin_dict_notone.jspinyinUtil.js两个文件,使用pinyinUtil.getPinyin('')方法将汉字转化成拼音。

4)结果比对

比对语音翻译的拼音与预置的信息的拼音进行比对,返回匹配程度。因为预置的结果有多个,取其中匹配程度最高的的一项。

Show me the code

var str_User = pinyinUtil.getPinyin(res.translateResult.split("。")[0]),
    str_Ans = word.keyword[R.options.iOrder].matched.split(","),
    matchedArray = new Array(),
    matchedNum = 0;

for (var i = 0; i < str_Ans.length; i++) {
    matchedArray[i] = strSimilarity2Percent(Trim(str_User), Trim(pinyinUtil.getPinyin(str_Ans[i])));
}

matchedNum = arrayMax(matchedArray);

Talk is cheap

  1. 返回的json数据,返回结果的key为translateResult;
  2. 返回的结果有“。”,故需要使用res.translateResult.split("。")[0]将“。”排除;
  3. 使用了三个自定义方法,strSimilarity2Percent()返回匹配程度、Trim()排除字符串中的空格、arrayMax()返回数组中的最大值。相关方法存放在unit.js中;
/**
 * 两个字符串的相似程度,并返回相差字符个数
 *
 *
 * @param    {string}  s  字符串1
 * @param    {string}  t  字符串2
 * @returns  {number}  d[n][m]  字符串差异个数
 *
 * @date     2018-03-02
 * @author   ReeCode
 */
function strSimilarity2Number(s, t) {
  var n = s.length,
    m = t.length,
    d = [];
  var i, j, s_i, t_j, cost;
  if (n == 0) return m;
  if (m == 0) return n;
  for (i = 0; i <= n; i++) {
    d[i] = [];
    d[i][0] = i;
  }
  for (j = 0; j <= m; j++) {
    d[0][j] = j;
  }
  for (i = 1; i <= n; i++) {
    s_i = s.charAt(i - 1);
    for (j = 1; j <= m; j++) {
      t_j = t.charAt(j - 1);
      if (s_i == t_j) {
        cost = 0;
      } else {
        cost = 1;
      }
      d[i][j] = Minimum(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost);
    }
  }
  return d[n][m];
}
/**
 * 两个字符串的相似程度,并返回相似度百分比
 *
 *
 * @param    {string}  s  字符串1
 * @param    {string}  t  字符串2
 * @returns  {number}     字符串差异百分比
 *
 * @date     2018-03-02
 * @author   ReeCode
 */
function strSimilarity2Percent(s, t) {
  var l = s.length > t.length ? s.length : t.length;
  var d = strSimilarity2Number(s, t);
  return (1 - d / l).toFixed(4);
}

function Minimum(a, b, c) {
  return a < b ? (a < c ? a : c) : (b < c ? b : c);
}
/**
 * 去除字符串中的空格
 *
 * 去除字符串中的空格,
 * 如果不加参数"g",只去除字符串前后空格;
 * 如果加参数"g",去除字符串全部空格;
 *
 * @param    {string}  str        目标字符串
 * @param    {string}  is_global  是否检测整个字符串,如果是,输入为 "g",其他情况无视该参数
 * @returns  {string}
 *
 * @date     2018-03-02
 * @author   ReeCode
 */
function Trim(str, is_global) {
  var result,
    _is_global = (typeof(is_global) !== "undefined") ? is_global : "n";
  result = str.replace(/(^\s+)|(\s+$)/g, "");
  if (_is_global.toLowerCase() == "g") {
    result = result.replace(/\s/g, "");
  }
  return result;
}
/**
 * 获取字符串的长度
 *
 * 获取字符串的长度,
 * 汉字为两个字符长度,英语级其他符号为1个长度;
 *
 * @param    {string}  val        目标字符串
 * @returns  {number}
 *
 * @date     2018-03-05
 * @author   ReeCode
 */
function getByteLen(val) {
  var len = 0;
  for (var i = 0; i < val.length; i++) {
    var a = val.charAt(i);
    if (a.match(/[^\x00-\xff]/ig) != null) {
      len += 2;
    } else {
      len += 1;
    }
  }
  return len;
}

/**
 * 移除数组中的某個元素   (改变数组长度)
 *
 *
 * @param    {array}  arr         目标数组
 * @param    {any}    item      要从数组中移除的元素
 * @returns  {array}
 *
 * @date     2018-03-06
 * @author   ReeCode
 */
function removeWithoutCopy(arr, item) {
  for (var i = 0; i < arr.length; i++) {
    if (arr[i] == item) {
      arr.splice(i, 1);
      i--;
    }
  }
  return arr;
}
/**
 * 找出数组中的最小值
 *
 *
 * @param    {array}  arr       目标数组
 * @returns  {number} min       数组最小值
 *
 * @date     2018-04-19
 * @author   ReeCode
 */
function arrayMin(arr) {
  var min = arr[0],
    len = arr.length;
  for (var i = 1; i < len; i++) {
    if (arr[i] < min) {
      min = arr[i];
    }
  }
  return min;
}
/**
 * 找出数组中的最大值
 *
 *
 * @param    {array}  arr       目标数组
 * @returns  {number} max       数组最小值
 *
 * @date     2018-04-19
 * @author   ReeCode
 */
function arrayMax(arr) {
  var max = arr[0],
    len = arr.length;
  for (var i = 1; i < len; i++) {
    if (arr[i] > max) {
      max = arr[i];
    }
  }
  return max;
}

5)反馈评价

根据评价结果的情况,分为三档:

matchedNum >= 0.8 ---------- great
0.8 > matchedNum >= 0.6 -- good
matchedNum < 0.6 ----------- normal

同时在此时对小于0.5s的录音予以忽略。

Show me the code

translate: function() { //结束录音并识别语音
    wx.stopRecord({
        success: function(res) {
            localId = res.localId;
            $(".voice-remote").addClass("vrPause");

            wx.translateVoice({
                localId: localId,
                complete: function(res) {
                    var voice_time = Math.abs(R.options.epoint - R.options.point),
                        _iClass = "soundMe" + Math.round(voice_time / 1000);
                    if (res.hasOwnProperty('translateResult') && voice_time > 500) {
                        var str_User = pinyinUtil.getPinyin(res.translateResult.split("。")[0]),
                            str_Ans = word.keyword[R.options.iOrder].matched.split(","),
                            matchedArray = new Array(),
                            matchedNum = 0;

                        for (var i = 0; i < str_Ans.length; i++) {
                            matchedArray[i] = strSimilarity2Percent(Trim(str_User), Trim(pinyinUtil.getPinyin(str_Ans[i])));
                        }

                        matchedNum = arrayMax(matchedArray);

                        R.insertContent({
                            iType: 2,
                            iClass: _iClass,
                            iContent: localId,
                        });

                        if (matchedNum >= 0.8) {

                            R.options.iOrder++;
                            alert(R.feedback.great[parseInt(Math.random() * 5)] + "\r\n 您本次录音匹配程度为:" + (matchedNum * 100).toFixed(2) + "% 。");
                            if (R.options.iOrder < word.keyword.length) {
                                R.insertContent({
                                    iType: 1,
                                    iContent: word.keyword[R.options.iOrder].content,
                                });
                            } else {
                                alert("恭喜,本次测试完成!");
                            }
                        } else if (matchedNum >= 0.6) {
                            alert(R.feedback.good[parseInt(Math.random() * 5)] + "\r\n 您本次录音匹配程度为:" + (matchedNum * 100).toFixed(2) + "%!");
                        } else {
                            alert(R.feedback.normal[parseInt(Math.random() * 5)] + "\r\n 您本次录音匹配程度为:" + (matchedNum * 100).toFixed(2) + "%!");
                        }

                    } else if (voice_time > 500) {
                        alert('无法识别');
                    } else if (voice_time <= 500) {
                        alert("录音过短,请重新录音!");
                    }
                }
            });
        },
        fail: function(res) {
            alert(JSON.stringify(res));
        }
    });
},

Talk is cheap

使用parseInt(Math.random() * 5)生成随机数,使反馈语随机生成;

五 限制只允许微信客户端打开

检测客户端版本的micromessenger值,微信用的是浏览器内核是这个。

Show me the code

/**
 * 判断是否是微信
 *
 * @returns  {boolen}       true 是微信  false  不是微信
 *
 * @date     2018-05-29
 * @author   ReeCode
 */
function iswx() {
  var ua = navigator.userAgent.toLowerCase();

  return ua.indexOf('micromessenger') != -1 ? true:false;
}

if (!iswx()) {
        document.head.innerHTML = '<title>抱歉,出错了</title><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=0"><link rel="stylesheet" type="text/css" href="https://res.wx.qq.com/open/libs/weui/0.4.1/weui.css">';
        document.body.innerHTML = '<div class="weui_msg"><div class="weui_icon_area"><i class="weui_icon_info weui_icon_msg"></i></div><div class="weui_text_area"><h4 class="weui_msg_title">请在微信客户端打开链接</h4></div></div>';
    }else{
        R.init();
    }

Talk is cheap

判断如果是微信浏览器,对对象R进行初始化,如果不是,返回请在微信客户端打开;

总结

絮絮叨叨终于总结好了。过段时间用小程序对该功能进行重写。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,491评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,856评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,745评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,196评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,073评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,112评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,531评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,215评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,485评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,578评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,356评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,215评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,583评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,898评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,174评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,497评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,697评论 2 335

推荐阅读更多精彩内容