最近接受了一个新的需求,希望制作一个基于微信的英语语音评价页面。即点击录音按钮,用户录音说出预设的英文,根据用户的发音给出对应的评价。
以下是简单的Demo:
最近接受了一个新的需求,希望制作一个基于微信的英语语音评价页面。即点击录音按钮,用户录音说出预设的英文,根据用户的发音给出对应的评价。
以下是示例二维码,使用微信扫一扫即可查看:
- ☑ 录音
- ☑ 录音动画
- ☑ 录音播放
- ☑ 英语语音评价(部分实现)
- ☑ 只允许微信客户端打开
零 技术选型
录音方面
可供选择的方案有两个:
使用HTML5接口 - getUserMedia()
;
- 微信开放平台-
微信JS-SDK
;
由于主要应用场景是在移动端,此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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| class extends CI_Controller { var $appId = "appId"; var $appSecret = "appSecret"; var $accessTokenFile = "wxtoken.txt";
public function index() { $this - > build_access_token(); }
public function build_access_token() { $ch = curl_init(); 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!"); fwrite($token_file, $data - > access_token); 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| public function get_jsapi_ticket() { $access_token = $this - > read_token(); $ch = curl_init(); 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()
函数休眠一段时间,如下代码:
1 2 3 4 5 6 7
| ignore_user_abort(); set_time_limit(0); $interval=60*100; do{ sleep($interval); }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的页面必须先注入配置信息,否则将无法调用,配置信息需要的参数如下:
1 2 3 4 5 6 7 8
| wx.config({ debug: true, appId: '', timestamp: , nonceStr: '', signature: '', jsApiList: [] });
|
其中的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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| 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]"; $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 ]); }
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的前置条件均已准备完毕。
二 实现录音按钮动画
基本的交互逻辑如下图演示:
此处来实现长按录音按钮的动画。基本思路是:
- 通过CSS3的
transition
属性实现record突变的平滑变小、平滑变大;
- 通过CSS3的
keyframes
动画与伪类配合完成环形进度动画;
Show me the code
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184
| <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%; 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 { 4.5rem; height: 4.5rem; bottom: 1rem; border: 1px solid #e7e7e7; }
.voice-remote:before { content: ""; 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: ""; 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%; 100%; height: 100%; z-index: 4; top: 0; left: 0; background-image: linear-gradient(-90deg, transparent 50%, #f6f6f6 50%); }
.voice-remote .icon { position: absolute; 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 { 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| $.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, appId: baseWxData['appId'], timestamp: baseWxData['timestamp'], nonceStr: baseWxData['nonceStr'], signature: baseWxData['signature'], jsApiList: [ 'startRecord', 'stopRecord', 'onVoiceRecordEnd', 'playVoice', 'pauseVoice', 'stopVoice', 'onVoicePlayEnd', 'translateVoice' ] }); } });
|
Talk is cheap
- 用
post
传入当前页面URL,因为签名算法必须是使用调用页的地址;
- 此次功能只用到如代码中的几个API,更多API详见官方API;
3)通过ready接口处理成功验证
1 2 3
| wx.ready(function(){ });
|
4)撰写录音代码逻辑
创建一个对象R,封装录音、播放以及翻译等过程。监听录音按钮的touchstart
事件启动录音,监听touchend
时间结束录音。
Show me the code
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137
|
var R = { options: { spoint: 0, tpoint: 0, epoint: 0, timer: 0, iOrder: 0 }, 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() { 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, });
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();
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) { 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(); $(this).removeClass('active');
var time = +new Date() - R.options.spoint; if (time < 60000) { setTimeout(function() { R.translate(); }, 200); clearInterval(R.options.timer); } });
$(document).on('touchstart', '.iPlayVoice', function() { });
wx.onVoicePlayEnd({ complete: function(res) { } });
}); } } R.init();
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183
|
.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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| $(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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| 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
1 2 3 4 5 6 7 8 9 10
| wx.translateVoice({ localId: '', isShowProgressTips: 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
1 2 3 4 5 6 7 8 9 10
| 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
中;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167
|
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]; }
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); }
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; }
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; }
function removeWithoutCopy(arr, item) { for (var i = 0; i < arr.length; i++) { if (arr[i] == item) { arr.splice(i, 1); i--; } } return arr; }
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; }
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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60
| 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
|
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
进行初始化,如果不是,返回请在微信客户端打开;
总结
絮絮叨叨终于总结好了。过段时间用小程序对该功能进行重写。