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

时间:2020-02-25 13:18:34   收藏:0   阅读:144

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

最近接受了一个新的需求,希望制作一个基于微信的英语语音评价页面。即点击录音按钮,用户录音说出预设的英文,根据用户的发音给出对应的评价。
以下是示例二维码,使用微信扫一扫即可查看:


零 技术选型

录音方面

可供选择的方案有两个:

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

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

英语语音识别

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

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

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

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

大厂提供的免费方案主要有:

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

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

  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

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(); //获取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框架,将文件写到serverapplicationcontrollers下可直接通过域名+文件名访问到该接口,默认执行文件中的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

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对象
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()函数休眠一段时间,如下代码:

1
2
3
4
5
6
7
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的页面必须先注入配置信息,否则将无法调用,配置信息需要的参数如下:

1
2
3
4
5
6
7
8
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

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]"; // 注意 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说明见官方API

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


二 实现录音按钮动画

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

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

  1. 通过CSS3的transition属性实现record突变的平滑变小、平滑变大;
  2. 通过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%;
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

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

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

注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, // 开启调试模式,调用的所有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接口处理成功验证

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

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
/* 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();
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
/* 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

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

  1. 使用$(document).on('touchstart', '.iPlayVoice', function() {}).iPlayVoice动态绑定事件;
  2. 使用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

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

2)获取语音翻译结果

Show me the code

1
2
3
4
5
6
7
8
9
10
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

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

  1. 返回的json数据,返回结果的key为translateResult;
  2. 返回的结果有“。”,故需要使用res.translateResult.split("。")[0]将“。”排除;
  3. 使用了三个自定义方法,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
/**
* 两个字符串的相似程度,并返回相差字符个数
*
*
* @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

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
/**
* 判断是否是微信
*
* @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进行初始化,如果不是,返回请在微信客户端打开;


总结

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

评论(0
© 2014 mamicode.com 版权所有 京ICP备13008772号-2  联系我们:gaon5@hotmail.com
迷上了代码!