最近接受了一个新的需求,希望制作一个基于微信的英语语音评价页面。即点击录音按钮,用户录音说出预设的英文,根据用户的发音给出对应的评价。
以下是简单的Demo:
![](reecode/qrcode.png)
-->
最近接受了一个新的需求,希望制作一个基于微信的英语语音评价页面。即点击录音按钮,用户录音说出预设的英文,根据用户的发音给出对应的评价。
以下是示例二维码,使用微信扫一扫即可查看:
- ? 录音
- ? 录音动画
- ? 录音播放
- ? 英语语音评价(部分实现)
- ? 只允许微信客户端打开
零 技术选型
录音方面
可供选择的方案有两个:
使用HTML5接口 -getUserMedia()
;- 微信开放平台-
微信JS-SDK
;
![getUserMedia() API兼容性](reecode/getUserMedia.png)
-->
由于主要应用场景是在移动端,此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
12345678910111213141516171819202122232425262728293031323334 |
class extends CI_Controller { var $appId = "appId"; var $appSecret = "appSecret"; var $accessTokenFile = "wxtoken.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
- 因为使用的是CI框架,将文件写到
serverapplicationcontrollers
下可直接通过域名+文件名
访问到该接口,默认执行文件中的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
12345678910111213141516171819202122 |
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
- 写到跟获取
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()
函数休眠一段时间,如下代码:
1234567 |
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_token
及jsapi_ticket
采用的就是此方法,腾讯云平台
,有个免费的功能云拨测
可定时访问某个URL,并且在无法访问时,将预警信息发送给某个设定好的用户组。
将我们之前写好的获取access_token
及jsapi_ticket
方法,写到index()方法下,将URL填到拨测地址中,定时刷新,搞定。
注1. 云拨测最长的周期为半个小时,而且每次拨测可能访问地址5-6次,其实更稳妥的方法是在数据库中设置标志位,防治过度刷新,但是每天2000次的限额完全够用,就暂时未做此功能。
5)生成JS-SDK配置信息
所有需要使用JS-SDK的页面必须先注入配置信息,否则将无法调用,配置信息需要的参数如下:
12345678 |
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
12345678910111213141516171819202122232425262728293031 |
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}×tamp={$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
- 一定要注意,签名用的url必须是调用JS接口页面的完整URL,这里通过前端POST获取调用页的URL;
- 返回值为json格式,前端通过ajax获取;
- 因为采用了CI框架,前端ajax请求地址为
域名
/weapp
/此段代码的文件名
/get_signpackage
。
微信JS-SDK说明见官方API。
至此,使用微信公众平台JS-SDK的前置条件均已准备完毕。
二 实现录音按钮动画
基本的交互逻辑如下图演示:
![](reecode/show.gif)
-->
此处来实现长按录音按钮的动画。基本思路是:
- 通过CSS3的
transition
属性实现record突变的平滑变小、平滑变大; - 通过CSS3的
keyframes
动画与伪类配合完成环形进度动画;
Show me the code
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184 |
<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
![录音按钮原理图](reecode/animate-pages.png)
-->
录音按钮动画原理如上图分层,其中: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
1234567891011121314 |
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
123456789101112131415161718192021222324252627 |
$.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
- 用
post
传入当前页面URL,因为签名算法必须是使用调用页的地址; - 此次功能只用到如代码中的几个API,更多API详见官方API;
3)通过ready接口处理成功验证
123 |
wx.ready(function(){ // config信息验证后会执行ready方法,所有接口调用都必须在config接口获得结果之后,config是一个客户端的异步操作,所以如果需要在页面加载时就调用相关接口,则须把相关接口放在ready函数中调用来确保正确执行。对于用户触发时才调用的接口,则可以直接调用,不需要放在ready函数中。}); |
4)撰写录音代码逻辑
创建一个对象R,封装录音、播放以及翻译等过程。监听录音按钮的touchstart
事件启动录音,监听touchend
时间结束录音。
Show me the code
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137 |
/* 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);大专栏 从零开始实现基于微信JS-SDK的录音与语音评价功能ass="line"> } $("#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(); |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183 |
/* 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
- 构建了
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
1234567891011121314151617181920212223242526272829 |
$(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
123456789101112131415161718 |
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
content
数据项,标识的是引导语;matched
项标识的是匹配内容,通过“,”分隔多个匹配内容,以提高匹配度;
2)获取语音翻译结果
Show me the code
12345678910 |
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
12345678910 |
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
- 返回的json数据,返回结果的key为translateResult;
- 返回的结果有“。”,故需要使用
res.translateResult.split("。")[0]
将“。”排除; - 使用了三个自定义方法,
strSimilarity2Percent()
返回匹配程度、Trim()
排除字符串中的空格、arrayMax()
返回数组中的最大值。相关方法存放在unit.js
中;
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167 |
/** * 两个字符串的相似程度,并返回相差字符个数 * * * @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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960 |
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)] + "rn 您本次录音匹配程度为:" + (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)] + "rn 您本次录音匹配程度为:" + (matchedNum * 100).toFixed(2) + "%!"); } else { alert(R.feedback.normal[parseInt(Math.random() * 5)] + "rn 您本次录音匹配程度为:" + (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
1234567891011121314151617181920 |
/** * 判断是否是微信 * * @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
进行初始化,如果不是,返回请在微信客户端打开;
总结
絮絮叨叨终于总结好了。过段时间用小程序对该功能进行重写。
原文地址:https://www.cnblogs.com/lijianming180/p/12360990.html