最近接受了一个新的需求,希望制作一个基于微信的英语语音评价页面。即点击录音按钮,用户录音说出预设的英文,根据用户的发音给出对应的评价。
以下是示例二维码,使用微信扫一扫即可查看:
功能列表
☑录音
☑录音动画
☑录音播放
☑英语语音评价(部分实现)
☑只允许微信客户端打开
零 技术选型
录音方面
可供选择的方案有两个:
使用HTML5接口 -getUserMedia();
微信开放平台-微信JS-SDK;.
通过http://caniuse.com查询getUserMedia()的兼容性。
getUserMedia() API兼容性
由于主要应用场景是在移动端,此API只能在iOS 11+与Android 5-6.X及以上可用,兼容性感人,故舍弃此方案。所以此次录音实现基于微信开放平台提供的微信JS-SDK。
英语语音识别
因为主要是基于微信平台,所以要求语音识别平台需要提供Web Api。
语音识别方面功能,主要有两种技术路线。
专门着力于语音识别及相关产业的技术平台,例如讯飞以及调研中发现的驰声。
优势:专业并且提供语音评测相关功能;
劣势:花费昂贵;
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智能接口
其他:目前只支持汉语
大厂竞争果然系列,大鹅厂光语音服务肉眼可见的就折腾了这么多。(大雾)
经过一番折腾,最终可以形成两种方案:
微信JS-SDK音频接口录音->上传到微信临时素材->下载到服务器->转换录音文件格式->百度AI语音识别返回结果->与预置的文件比对->返回比对结果
优势:识别结果准
劣势:慢(因为无法直接获取用户的录音,需要从微信公众平台的临时素材中转,且录音文件格式与百度AI可识别格式不一致,所以整个流程下来太慢);微信公众号需要企业认证
其他:至于为什么不选用腾讯系列,因为腾讯系列语音服务没有调通。。。
微信JS-SDK音频接口录音->调用JS-SDK智能接口返回识别结果->结果转为拼音->与预置的文件比对->返回比对结果
优势:返回结果迅速、方法简单
劣势:识别结果不太准确(因为JS-SDK智能接口不只是单单根据语音直接转换,还会在结果的基础上进行一定程度的联想,话说为啥不能增加个语言选择参数。)
本次整个方案使用方案2。
一 微信JS-SDK环境准备
写在前边:此处的开发环境不是指本地的开发环境,单指使用微信JS-SDK所需完成的一系列的获取AccessToken、jsapi_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需要appid与appsecret。微信公众平台的access_token有效期为7200s (2小时),每天最高可调用上限为2000次。因此获取access_token需要做到:
定时刷新(刷新间隔大于1分钟,小于120分);
全局缓存access_token;
Show me the code
classclassNameextendsCI_Controller{var$appId ="appId";var$appSecret ="appSecret";var$accessTokenFile ="wxtoken.txt";// var $jsapiTicketFile = "wxjsapiTicket.txt";publicfunctionindex(){$this- > build_access_token();//获取access_token// $this - > get_jsapi_ticket(); //获取jsapic_ticket}publicfunctionbuild_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")ordie("Unable to open file!");//打开wxtoken.txt文件,没有会新建fwrite($token_file, $data - > access_token);//重写wxtken.txt全部内容fclose($token_file);//关闭文件流}else{echo$data - > errmsg; } curl_close($ch); }publicfunctionread_token(){ $token_file = fopen($this- > accessTokenFile,"r")ordie("Unable to open file!"); $rs = fgets($token_file); fclose($token_file);return$rs; }}
Talk is cheap
因为使用的是CI框架,将文件写到server\application\controllers\下可直接通过域名+文件名访问到该接口,默认执行文件中的index中的方法;
代码中的基本逻辑通过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
publicfunctionget_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")ordie("Unable to open file!"); fwrite($token_file, $data - > ticket); fclose($token_file);//关闭文件流}else{echo$data - > errmsg; } curl_close($ch);}publicfunctionread_jsapi_ticket(){ $jsapi_ticket_file = fopen($this- > jsapiTicketFile,"r")ordie("Unable to open file!"); $rs = fgets($jsapi_ticket_file); fclose($jsapi_ticket_file);return$rs;}
Talk is cheap
写到跟获取access_token同一文件中,以便同时刷新;
同之前的代码中逻辑类似,通过get_jsapi_ticket()方法获取jsapi_ticket,并存储到wxjsapiTicket.txt,通过read_jsapi_ticket()方法读取jsapi_ticket;
获取access_token的详细情况见官方API。
4)刷新access_token及jsapi_ticket
由于微信公众平台的access_token与jsapi_ticket有两个小时有效期,故需要定期刷新。基本思路有如下三个:
PHP定时执行任务;
服务器定时任务;
定时访问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 sthsleep($interval);//按设置的时间等待100分钟循环执行}while(true);
缺点:缺点严重,启动之后,无法控制。而且一直消耗服务器资源,容易被杀死;
2.服务器定时任务
windows平台的计划任务或者是Unix平台的Crontab都有定时执行php脚本或者访问URL的方法,但是由于使用的腾讯云 · 小程序使用的是Wafer一体化解决方案,无法直接访问远端服务器,故此方法放弃。
3. 定时访问URL
我们这次定时刷新access_token及jsapi_ticket采用的就是此方法,腾讯云平台,有个免费的功能云拨测可定时访问某个URL,并且在无法访问时,将预警信息发送给某个设定好的用户组。
将我们之前写好的获取access_token及jsapi_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接口列表});
其中的appId、jsApiList已知,timestamp、nonceStr动态生成,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
注意事项
签名用的noncestr和timestamp必须与wx.config中的nonceStr和timestamp相同。
签名用的url必须是调用JS接口页面的完整URL。
出于安全考虑,开发者必须在服务器端实现签名的逻辑。
Show me the code
publicfunctionget_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}×tamp={$timestamp}&url={$url}"; $signature = sha1($string1);$this->json(['appId'=>$this->appId,'nonceStr'=> $noncestr,'timestamp'=> $timestamp,'signature'=> $signature,'url'=> $url ]);// return $signPackage;}privatefunctioncreateNonceStr($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
一定要注意,签名用的url必须是调用JS接口页面的完整URL,这里通过前端POST获取调用页的URL;
返回值为json格式,前端通过ajax获取;
因为采用了CI框架,前端ajax请求地址为域名/weapp/此段代码的文件名/get_signpackage。
至此,使用微信公众平台JS-SDK的前置条件均已准备完毕。
二 实现录音按钮动画
基本的交互逻辑如下图演示:
此处来实现长按录音按钮的动画。基本思路是:
通过CSS3的transition属性实现record突变的平滑变小、平滑变大;
通过CSS3的keyframes动画与伪类配合完成环形进度动画;
Show me the code
.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:1pxsolid#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:#f6f6f6url(../../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 linear30s;animation: scoll linear30s;-webkit-animation-fill-mode: forwards;animation-fill-mode: forwards;}.voice-remote:active:after{-webkit-animation: xscoll linear60s;animation: xscoll linear60s;-webkit-animation-fill-mode: forwards;animation-fill-mode: forwards;}.voice-remote:active.cover{-webkit-animation: hide linear60s;animation: hide linear60s;-webkit-animation-fill-mode: forwards;animation-fill-mode: forwards;}@-webkit-keyframesscoll { 0% {-webkit-transform:rotate(0deg); } 100% {-webkit-transform:rotate(180deg); }}@keyframesscoll { 0% {transform:rotate(0deg); } 100% {transform:rotate(180deg); }}@-webkit-keyframesxscoll { 0% {-webkit-transform:rotate(0deg); } 100% {-webkit-transform:rotate(360deg); }}@keyframesxscoll { 0% {transform:rotate(0deg); } 100% {transform:rotate(360deg); }}@-webkit-keyframeshide { 0% {opacity:1} 49.9% {opacity:1; } 50% {opacity:0; } 100% {opacity:0; }}@keyframeshide { 0% {opacity:1} 49.9% {opacity:1; } 50% {opacity:0; } 100% {opacity:0; }}
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
此次使用AMD模式requirejs引入相关文件;
这里引入多个文件,之后的代码需要使用;
注1. 支持使用 AMD/CMD 标准模块加载方法加载,也支持直接使用直接引用;
注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){varbaseWxData = data; wx.config({debug:false,// 开启调试模式,调用的所有api的返回值会在客户端alert出来,若要查看传入的参数,可以在pc端打开,参数信息会通过log打出,仅在pc端时才会打印。appId: baseWxData['appId'],// 必填,公众号的唯一标识timestamp: baseWxData['timestamp'],// 必填,生成签名的时间戳nonceStr: baseWxData['nonceStr'],// 必填,生成签名的随机串signature: baseWxData['signature'],// 必填,签名,见附录1jsApiList: ['startRecord',// 录音开始api'stopRecord',// 录音结束api'onVoiceRecordEnd',// 超过一分钟自动停止api'playVoice',// 播放录音api'pauseVoice',// 暂停录音api'stopVoice',// 停止播放api'onVoicePlayEnd',// 监听语音播放完毕api'translateVoice'] }); }});
Talk is cheap
用post传入当前页面URL,因为签名算法必须是使用调用页的地址;
此次功能只用到如代码中的几个API,更多API详见官方API;
3)通过ready接口处理成功验证
wx.ready(function(){// config信息验证后会执行ready方法,所有接口调用都必须在config接口获得结果之后,config是一个客户端的异步操作,所以如果需要在页面加载时就调用相关接口,则须把相关接口放在ready函数中调用来确保正确执行。对于用户触发时才调用的接口,则可以直接调用,不需要放在ready函数中。});
4)撰写录音代码逻辑
创建一个对象R,封装录音、播放以及翻译等过程。监听录音按钮的touchstart事件启动录音,监听touchend时间结束录音。
Show me the code
/* Javascript Code*/varR = {options: {spoint:0,//记录recordstart时间tpoint:0,//记录touchstart时间epoint:0,//记录touchend时间timer:0,//setIntervaliOrder: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(){vartime = +newDate() - 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) {case1: _str ='
/* 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)00no-repeat;position: absolute;top:50%;margin-top: -12px;left: -12px;}.wordBox{border:1pxsolid#d4d4d4;background-color:#fff;padding:5px10px;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)00no-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)00no-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:1pxsolid#86b850;background-color:#a1e75b;}.p2.wordBox:before{background:url(../../images/sharpMe.png)00no-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
构建了insertContent()方法构建页面,使用scrollTop()方法使填充的新的对话框出现再最下边;
构建了spoint与epoint两个参数,判断录音时间;
构建recode()方法,使用setInterval()方法,限制录音超过60s后停止(因为微信JS-SDK限制录音时长最多为60s);
构建feedback参数,为之后翻译提供反馈;
使用伪类实现对话前后的音频时长;
已知兼容性问题
部分华为手机,长按后弹出弹出菜单,检测document的oncontextmenu事件,阻止默认事件e.preventDefault();
微信开发者工具调试时,超过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
使用$(document).on('touchstart', '.iPlayVoice', function() {})为.iPlayVoice动态绑定事件;
使用playing类名,控制播放时的状态;
四 实现语音评价功能
开篇的技术选型时已经将前因后果说明了。现在就写借助微信JS-SDK中的wx.translateVoice()方法实现语音评价功能的具体实现。具体流程为引入示例json、获取语音翻译结果、语音结果转为拼音、结果比对、反馈评价。
1)引入示例json
将示例的数据写成json,用requirejs引入。
Show me the code
varword = {keyword: [{order:1,content:"请说:
What's your name.",matched:"我次要儿内幕,我想那,我次有那么",localId:"-1"}, {order:2,content:"请说:
How are you.",matched:"好啊有",localId:"-1"}, {order:3,content:"请说:
Nice to meet you.",matched:"挨次图密特油",localId:"-1"}],}
Talk is cheap
content数据项,标识的是引导语;
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.js与pinyinUtil.js两个文件,使用pinyinUtil.getPinyin('')方法将汉字转化成拼音。
4)结果比对
比对语音翻译的拼音与预置的信息的拼音进行比对,返回匹配程度。因为预置的结果有多个,取其中匹配程度最高的的一项。
Show me the code
varstr_User = pinyinUtil.getPinyin(res.translateResult.split("。")[0]), str_Ans = word.keyword[R.options.iOrder].matched.split(","), matchedArray =newArray(), matchedNum =0;for(vari =0; i < str_Ans.length; i++) { matchedArray[i] = strSimilarity2Percent(Trim(str_User), Trim(pinyinUtil.getPinyin(str_Ans[i])));}matchedNum = arrayMax(matchedArray);
Talk is cheap
返回的json数据,返回结果的key为translateResult;
返回的结果有“。”,故需要使用res.translateResult.split("。")[0]将“。”排除;
使用了三个自定义方法,strSimilarity2Percent()返回匹配程度、Trim()排除字符串中的空格、arrayMax()返回数组中的最大值。相关方法存放在unit.js中;
/**
* 两个字符串的相似程度,并返回相差字符个数
*
*
* @param {string} s 字符串1
* @param {string} t 字符串2
* @returns {number} d[n][m] 字符串差异个数
*
* @date 2018-03-02
* @author ReeCode
*/functionstrSimilarity2Number(s, t){varn = s.length, m = t.length, d = [];vari, j, s_i, t_j, cost;if(n ==0)returnm;if(m ==0)returnn;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); } }returnd[n][m];}/**
* 两个字符串的相似程度,并返回相似度百分比
*
*
* @param {string} s 字符串1
* @param {string} t 字符串2
* @returns {number} 字符串差异百分比
*
* @date 2018-03-02
* @author ReeCode
*/functionstrSimilarity2Percent(s, t){varl = s.length > t.length ? s.length : t.length;vard = strSimilarity2Number(s, t);return(1- d / l).toFixed(4);}functionMinimum(a, b, c){returna < 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
*/functionTrim(str, is_global){varresult, _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,""); }returnresult;}/**
* 获取字符串的长度
*
* 获取字符串的长度,
* 汉字为两个字符长度,英语级其他符号为1个长度;
*
* @param {string} val 目标字符串
* @returns {number}
*
* @date 2018-03-05
* @author ReeCode
*/functiongetByteLen(val){varlen =0;for(vari =0; i < val.length; i++) {vara = val.charAt(i);if(a.match(/[^\x00-\xff]/ig) !=null) { len +=2; }else{ len +=1; } }returnlen;}/**
* 移除数组中的某個元素 (改变数组长度)
*
*
* @param {array} arr 目标数组
* @param {any} item 要从数组中移除的元素
* @returns {array}
*
* @date 2018-03-06
* @author ReeCode
*/functionremoveWithoutCopy(arr, item){for(vari =0; i < arr.length; i++) {if(arr[i] == item) { arr.splice(i,1); i--; } }returnarr;}/**
* 找出数组中的最小值
*
*
* @param {array} arr 目标数组
* @returns {number} min 数组最小值
*
* @date 2018-04-19
* @author ReeCode
*/functionarrayMin(arr){varmin = arr[0], len = arr.length;for(vari =1; i < len; i++) {if(arr[i] < min) { min = arr[i]; } }returnmin;}/**
* 找出数组中的最大值
*
*
* @param {array} arr 目标数组
* @returns {number} max 数组最小值
*
* @date 2018-04-19
* @author ReeCode
*/functionarrayMax(arr){varmax = arr[0], len = arr.length;for(vari =1; i < len; i++) {if(arr[i] > max) { max = arr[i]; } }returnmax;}
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){varvoice_time =Math.abs(R.options.epoint - R.options.point), _iClass ="soundMe"+Math.round(voice_time /1000);if(res.hasOwnProperty('translateResult') && voice_time >500) {varstr_User = pinyinUtil.getPinyin(res.translateResult.split("。")[0]), str_Ans = word.keyword[R.options.iOrder].matched.split(","), matchedArray =newArray(), matchedNum =0;for(vari =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("恭喜,本次测试完成!"); } }elseif(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) +"%!"); } }elseif(voice_time >500) { alert('无法识别'); }elseif(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
*/functioniswx(){varua = navigator.userAgent.toLowerCase();returnua.indexOf('micromessenger') !=-1?true:false;}if(!iswx()) {document.head.innerHTML ='
请在微信客户端打开链接
Talk is cheap
判断如果是微信浏览器,对对象R进行初始化,如果不是,返回请在微信客户端打开;
总结
絮絮叨叨终于总结好了。过段时间用小程序对该功能进行重写。
小礼物走一走,来关注我
作者:ReeCode
链接:https://www.jianshu.com/p/e317c74cdd8c
來源:
著作权归作者所有,任何形式的转载都请联系作者获得授权并注明出处。