通过WebSocket接入SDK
一、SDK接口说明
基于语音识别和评价技术对发音做客观打分,反馈发音正误和定位问题,有助于语音教学,发音练习,也可测试考生的口语水平。
二、集成要求:
集成API时,需按照以下要求:
内容 | 说明 |
---|---|
请求协议 | ws[s](为提高安全性,强烈推荐wss) |
请求地址 | 英文:ws://ws-edu.hivoice.cn:8081/ws/eval/或wss://wss-edu.hivoice.cn:443/ws/eval/ 中文:ws://wscn-edu.hivoice.cn:18081/ws/eval/或wss://wsscn-edu.hivoice.cn/ws/eval/ |
字符编码 | UTF-8 |
响应格式 | 统一采用JSON格式 |
开发语言 | 任意,只要可以向服务发起Websocket请求的均可 |
操作系统 | 任意 |
音频属性 | 采样率16K,比特率16bit、单声道 |
音频格式 | mp3,wxspeex 或pcm,推荐使用mp3格式 |
评测语言 | 中文/英文 |
三、 接口调用流程:
时序图

1.请求地址
英文
主域名:ws-edu.hivoice.cn
端口:8081
请求地址:ws://ws-edu.hivoice.cn:8081/ws/eval/或wss://wss-edu.hivoice.cn:443/ws/eval/
中文
主域名:wscn-edu.hivoice.cn
端口:18081
请求地址:ws://wscn-edu.hivoice.cn:18081/ws/eval/或wss://wsscn-edu.hivoice.cn/ws/eval/
2.英文API
普通评测
{ "mode": "word", "displayText": "hello world", "appkey": "联系商务同学获取", "scoreCoefficient": "1", "userID": "", "audioFormat": "opus", "eof": "gnh-test-end" }
jsgf接口
{ "mode": "qa", "Version": "1", "DisplayText": "Jsgf Grammar Tool Generated", "GrammarWeight": "{\"weight_struct\":[[{\"weight\":0.5,\"key\":\"good morning\"}]]}", "Grammar": "#JSGF V1.0 utf-8 cn;\ngrammar main;\npublic <main> = \"<s>\"(<a>|<a> to you)\"</s>\";\n<a> = (good morning);\n", "Appkey": "", "scoreCoefficient": "1", "UserID": "", "eof": "test-end", "audioFormat": "pcm" }
枚举接口
{ "mode": "qa", "version": "1", "displayText": "Enumerate Grammar Tool Generated", "grammarWeight": "{\"weight_struct\":[[{\"weight\":0.5,\"key\":\"teacher\"}]]}", "grammar": "#enumerate \nmy mother is a teacher\na teacher\n", "appkey": "", "scoreCoefficient": "1", "userID": "", "eof": "test-end", "audioFormat": "pcm" }
retell接口
{ "mode": "retell", "Version": 1, "EvalType": "en.exam.retell", "DisplayText": "OralComposition Grammar Tool Generated", "Language": "en", "Grammar": "", "GrammarWeight": "{\"weight_struct\":[[{\"weight\":0.5,\"key\":\"bookstore\"}]]}", "Reference": { "ID": "", "answers": [ { "type": 1, "text": "bookstore" }, { "type": 1, "text": "she is going to bookstore" } ] }, "Appkey": "", "scoreCoefficient": "1", "UserID": "", "eof": "test-end" }
英文评测请求接口字段说明(关键字不区别大小写)
字段 | 是否必选 | 含义 | 备注 |
---|---|---|---|
mode | 必填 | 可选值为word,sent,para,qa,retell | |
displayText | 必填 | 评测文本 | |
appkey | 必填 | 秘钥信息 | AppKey@AppSecret形式,AppKey和AppSecret 请在应用详情获取 |
userID | 可选 | 用户id信息 | 建议传入,排查问题方便 |
audioFormat | 必填 | 可选值:mp3,speex 音频格式,音频16K单声道 | |
eof | 必填 | 设置eof消息包内容 | 客户端需要保证该内容的唯一性,可选用uuid |
scoreCoefficient | 可选 | 值范围:0.6-1.9 | 打分系数,调整打分的松紧度,值越大打分越宽松 |
3.中文API
中文评测
{ "EvalType": "sentence", "Language":"cn", "displayText": "你好", "appkey": "联系商务同学获取", "scoreCoefficient": "1", "userID": "", "audioFormat": "mp3", "eof": "gnh-test-end" }
中文评测请求接口字段说明(关键字不区别大小写)
字段 | 是否必选 | 含义 | 备注 |
---|---|---|---|
EvalType | 必填 | 可选值为word,sentence,paragraph | |
Language | 必填 | 语种,可选值为cn | |
displayText | 必填 | 评测文本 | |
appkey | 必填 | 秘钥信息 | AppKey@AppSecret形式,AppKey和AppSecret 请在应用详情获取 |
userID | 可选 | 用户id信息 | 建议传入,排查问题方便 |
audioFormat | 必填 | 可选值:mp3,speex 音频格式,音频16K单声道 | |
eof | 必填 | 设置eof消息包内容 | 客户端需要保证该内容的唯一性,可选用uuid |
scoreCoefficient | 可选 | 值范围:0.6-1.9 | 打分系数,调整打分的松紧度,值越大打分越宽松 |
响应接口
{ "result": {}, "area": "sh", "time": "1551409712576231666", "sid": "f4376e83-7ad0-4635-9812-bec949a2fa27", "errcode": 0, "errmsg": "ok" }
响应接口字段说明
字段 | 含义 | 备注 |
---|---|---|
result | 评测结果 | Json字段说明 |
area | 返回实际评测的的机房区域 | |
time | 时间戳 | |
sid sessionID | 服务端返回的唯一标识 | |
errorcode | 错误码 | |
errmsg | 错误消息 |
评测返回结果中Json字段说明
名称 | 类型 | 说明 |
---|---|---|
version | string | 结果格式版本及版本号 |
lines | array | 每行输入文本的评测结果 |
EvalType | string | 评测类型:general(朗读评测)、askandanswer(情景问答)、composition(作文) |
sample | string | 输入的标准文本 |
usertext | string | 用户实际朗读的文本(语音识别结果) |
subwords | array | 包含单词的音标、开始时间、结束时间、分数、音量信息 |
begin | double | 开始时间,单位为秒 |
end | double | 开始时间,单位为秒 |
volume | double | 音量 |
score | string | 分值 |
subtext | string | 音标或重音符号信息 |
integrity | double | 录入语音的完整度 |
pronunciation | double | 录入语音的标准度 |
fluency | double | 录入语音的流利度 |
words | array | 每个词的评测结果 |
text | string | 单词或音素文本 |
type | int | 类型,共有6种类型,分别是: 0 多词:仅B,C,G模式出现,当朗读内容大于文本内容时,多余的单词type值为0;eg:文本:nice to meet you,音频:nice nice to meet you,第二个nice的type值为0; 1 漏词:所有模式都有,当朗读内容小于文本内容时,未读的单词type值为1;eg:文本:nice to meet you,音频:nice meet you,结果中to的type值为1; 2 正常词:所有模式都有,识别正常的词; 3 错误词:仅B,C,G模式出现,当朗读的文本某个单词识别成文本中其他单词时,该单词type值为3。 eg:文本:nice to meet you,音频:nice you meet you,结果中第一个you的type值为3; 4 静音:所有模式; 5 重复词:预留接口,未实现; 7 空格or标点:仅E模式,空格和标点的结构type值为7; 8 生词:所有模式 |
sentSample | array | 句式标准文本 |
sentScore | array | 句式总分 |
sentPronunciation | array | 句式标准度得分 |
sentFluency | array | 句式流利度得分 |
sentIntegrity | array | 句式完整度得分 |
keySample | array | 关键词sample(包括关键词和每个关键词的得分 |
keysScore | array | 关键词总分 |
keysPronunciation | array | 关键词标准度得分 |
keysIntegrity | array | 关键词完整度得分 |
keysFluency | array | 关键词流利度 |
standardScore | string | 客户定制,输出的分制,当前含有4分制和8分制 |
StressOfSent | string | 句子重读,每个单词都输出,0:该单词没有被重读;1:该单词被重读 |
StressOfWord | string | 单词重音,将用户发音和词典的重音位置做比较,0:该单词重音朗读错误;1:该单词重音朗读正确 |
tone | array | 输出全部信息,数据可以用于画用户的发音曲线,目前只有内部在使用 |
audiocheck | array | 音质检测结果。 volume:音量过小的置信度; clipping:截幅的置信度; noise:噪音过大的置信度; cut:截断的置信度; too short:是否音频过短; emptyAudio:是否是空音频。 备注:置信度的值为0和10,10代表可能存在该项音质问题,0代表该项检测正常 |
拼接评测audio url
想要获取用户录音结果,则可以根据响应接口数据拼接音频url,获取音频
“area”: “sh”,
“time”: “1551409712576231666”,
“sid”: “f4376e83-7ad0-4635-9812-bec949a2fa27”,
其中,sh是代表地域名称area,1551409712576231666是代表createtime; f4376e83-7ad0-4635-9812-bec949a2fa27是代表请求中传入的session-id
最后可以根据以上三个信息拼接url,固定字段http://edu.hivoice.cn:9088/WebAudio-1.0-SNAPSHOT/audio/play/{session-id}/{createtime}/{area}
以上示例,最后拼接的url是http://edu.hivoice.cn:9088/WebAudio-1.0-SNAPSHOT/audio/play/ 02055555-f4cd-4fef-8ed8-1a2089056acf/1542272221203805792/sh
如果是https协议,拼接按照 https://edu.hivoice.cn/WebAudio-1.0-SNAPSHOT/audio/play/{session-id}/{createtime}/{area}
四、错误码
参见:https://github.com/oraleval/ErrorCodeList/wiki/HomePage
五、示例DEMO
index.html文件
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>英语评分</title> <script src="Recorder.js"></script> </head> <body> <div id="sse"> <button onclick="startSounRecording()">开始录音</button> <button onclick="stopSounRecording()">结束录音</button> </div> <script type="text/javascript"> let ws; const eof = 'gnh-test-end' let recorder = null function WebSocketTest() { if ("WebSocket" in window) { console.log('支持WebSocket') /* 打开一个 web socket ws://localhost:9998/ 表示 socket 连接地址 echo-protocol 表示子协议 */ ws = new WebSocket("ws://ws-edu.hivoice.cn:8081/ws/eval/"); ws.onopen = function () { console.log('连接成功') // Web Socket 已连接上,使用 send() 方法发送数据 const params = { "mode": "word", "displayText": "hello world", "appkey": "vfvu3rj2allcsgtnurewn3n7paueipqoesyon6qa@4cdb9e4076d0dff3588db072b7d17836", "scoreCoefficient": "1", "userID": "", "audioFormat": "pcm", "eof": eof }; ws.send(JSON.stringify(params)); }; ws.onmessage = function (evt) { var result = JSON.parse(evt.data); console.log(result) ws.close(); recorder && recorder.stop(); }; ws.onclose = function () { // 关闭 websocket ws = null; }; } else { // 浏览器不支持 WebSocket alert("您的浏览器不支持 WebSocket!"); } } function startSounRecording() { console.log('startSounRecording') WebSocketTest() recorder = new Recorder(onaudioprocess); function onaudioprocess(buffer) { console.log('buffer-',buffer) if (ws && ws.readyState === 1) { console.log('buffer--',buffer) ws.send(buffer); } } recorder.ready().then( () => { recorder.start(); // if (current == 'en') }, () => { console.log('录音启动失败!'); }, ); } function stopSounRecording() { console.log('stop--------') if (ws&&ws.readyState === 1) { ws.send(eof); } } </script> </body> </html>
Recorder.js 文件
let Recorder = function (onaudioprocess) { console.log('Recorder-------') this.config = { sampleBits: 16, // 采样数位 8, 16 sampleRate: 16000, // 采样率(1/6 44100) }; this.size = 0; // 录音文件总长度 this.buffer = []; // 录音缓存 this.realtimeBuffer = []; // 录音实时获取数据 this.input = function (data) { // 记录数据,这儿的buffer是二维的 this.buffer.push(new Float32Array(data)); this.size += data.length; }; this.onaudioprocess = onaudioprocess; }; // 设置如采样位数的参数 Recorder.prototype.setOption = function (option) { // 修改采样率,采样位数配置 Object.assign(this.config, option); }; Recorder.prototype.ready = function () { this.context = new (window.AudioContext || window.webkitAudioContext)(); // 第一个参数表示收集采样的大小,采集完这么多后会触发 onaudioprocess 接口一次,该值一般为1024,2048,4096等,一般就设置为4096 // 第二,三个参数分别是输入的声道数和输出的声道数,保持一致即可。 this.createScript = this.context.createScriptProcessor || this.context.createJavaScriptNode; this.recorder = this.createScript.apply(this.context, [4096, 1, 1]); // 音频采集 this.recorder.onaudioprocess = e => { const data = e.inputBuffer.getChannelData(0); this.input(data); if (this.onaudioprocess) { this.onaudioprocess(this.encodePCMFragment(data)); // this.onaudioprocess(data); } }; console.log('navigator',navigator) console.log('navigator.mediaDevices',navigator.mediaDevices) // navigator.getUserMedia = navigator.mediaDevices.getUserMedia || navigator.webkitGetUserMedia || navigator.getUserMedia || navigator.mediaDevices; // if (!navigator.mediaDevices) { // Recorder.throwError('无法发现指定的硬件设备。'); // } return navigator.mediaDevices .getUserMedia({ audio: true, }) .then( stream => { // audioInput表示音频源节点 // stream是通过navigator.getUserMedia获取的外部(如麦克风)stream音频输出,对于这就是输入 this.audioInput = this.context.createMediaStreamSource(stream); }, error => { switch (error.code || error.name) { case 'PERMISSION_DENIED': case 'PermissionDeniedError': Recorder.throwError('用户拒绝提供信息。'); break; case 'NOT_SUPPORTED_ERROR': case 'NotSupportedError': Recorder.throwError('浏览器不支持硬件设备。'); break; case 'MANDATORY_UNSATISFIED_ERROR': case 'MandatoryUnsatisfiedError': Recorder.throwError('无法发现指定的硬件设备。'); break; default: Recorder.throwError( '无法打开麦克风。异常信息:' + (error.code || error.name), ); break; } }, ); }; // 异常处理 Recorder.throwError = function (message) { throw new Error(message); }; // 开始录音 Recorder.prototype.start = function () { try { // 清空数据 this.buffer.length = 0; this.size = 0; // audioInput 为声音源,连接到处理节点 recorder this.audioInput.connect(this.recorder); // 处理节点 recorder 连接到扬声器 this.recorder.connect(this.context.destination); // 设置压缩参数 this.inputSampleRate = this.context.sampleRate; // 获取当前输入的采样率 this.inputSampleBits = 16; // 输入采样数位 8, 16 this.outputSampleRate = this.config.sampleRate; // 输出采样率 this.oututSampleBits = this.config.sampleBits; // 输出采样数位 8, 16 } catch (error) { Recorder.throwError( '无法打开麦克风。异常信息:' + (error.code || error.name), ); } }; // 停止录音 Recorder.prototype.stop = function () { this.recorder.disconnect(); }; // 播放到audio标签中 // 参数表示audio元素 Recorder.prototype.play = function (audio) { audio.src = window.URL.createObjectURL(this.getWAVBlob()); }; // 获取PCM编码的二进制数据 Recorder.prototype.getPCM = function () { this.stop(); return this.encodePCM(); }; // 获取不压缩的PCM格式的编码 Recorder.prototype.getPCMBlob = function () { return new Blob([this.getPCM()]); }; // 获取WAV编码的二进制数据 Recorder.prototype.getWAV = function (isRecord) { this.stop(); return this.encodeWAV(isRecord); }; // 获取不压缩的WAV格式的编码 Recorder.prototype.getWAVBlob = function () { return new Blob([this.getWAV(true)], { type: 'audio/wav' }); }; // 数据合并压缩 // 根据输入和输出的采样率压缩数据, // 比如输入的采样率是48k的,我们需要的是(输出)的是16k的,由于48k与16k是3倍关系, // 所以输入数据中每隔3取1位 Recorder.prototype.compress = function (isReplay) { // 合并 var data = new Float32Array(this.size); var offset = 0; // 偏移量计算 // 将二维数据,转成一维数据 for (var i = 0; i < this.buffer.length; i++) { data.set(this.buffer[i], offset); offset += this.buffer[i].length; } const outputSampleRate = isReplay ? this.inputSampleRate : this.outputSampleRate; // 压缩 var compression = parseInt(this.inputSampleRate / outputSampleRate); var length = data.length / compression; var result = new Float32Array(length); var index = 0, j = 0; // 循环间隔 compression 位取一位数据 while (index < length) { result[index] = data[j]; j += compression; index++; } // 返回压缩后的一维数据 return result; }; /** * 转换到我们需要的对应格式的编码 * return {DataView} pcm编码的数据 */ Recorder.prototype.encodePCM = function (isReplay) { let bytes = this.compress(isReplay), sampleBits = Math.min( this.inputSampleBits, isReplay ? this.inputSampleBits : this.oututSampleBits, ), offset = 0, dataLength = bytes.length * (sampleBits / 8), buffer = new ArrayBuffer(dataLength), data = new DataView(buffer); // 写入采样数据 if (sampleBits === 8) { for (var i = 0; i < bytes.length; i++, offset++) { // 范围[-1, 1] var s = Math.max(-1, Math.min(1, bytes[i])); // 8位采样位划分成2^8=256份,它的范围是0-255; 16位的划分的是2^16=65536份,范围是-32768到32767 // 因为我们收集的数据范围在[-1,1],那么你想转换成16位的话,只需要对负数*32768,对正数*32767,即可得到范围在[-32768,32767]的数据。 // 对于8位的话,负数*128,正数*127,然后整体向上平移128(+128),即可得到[0,255]范围的数据。 var val = s < 0 ? s * 128 : s * 127; val = parseInt(val + 128); data.setInt8(offset, val, true); } } else { for (var i = 0; i < bytes.length; i++, offset += 2) { var s = Math.max(-1, Math.min(1, bytes[i])); // 16位直接乘就行了 data.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true); } } return data; }; Recorder.prototype.encodePCMFragment = function (fragment) { let data = new Float32Array(fragment); // 压缩 var compression = parseInt(this.inputSampleRate / this.outputSampleRate); var length = data.length / compression; var result = new Float32Array(length); var index = 0, j = 0; // 循环间隔 compression 位取一位数据 while (index < length) { result[index] = data[j]; j += compression; index++; } // 返回压缩后的一维数据 let bytes = result, sampleBits = Math.min(this.inputSampleBits, this.oututSampleBits), offset = 0, dataLength = bytes.length * (sampleBits / 8), buffer = new ArrayBuffer(dataLength), lastData = new DataView(buffer); // 写入采样数据 if (sampleBits === 8) { for (var i = 0; i < bytes.length; i++, offset++) { // 范围[-1, 1] var s = Math.max(-1, Math.min(1, bytes[i])); // 8位采样位划分成2^8=256份,它的范围是0-255; 16位的划分的是2^16=65536份,范围是-32768到32767 // 因为我们收集的数据范围在[-1,1],那么你想转换成16位的话,只需要对负数*32768,对正数*32767,即可得到范围在[-32768,32767]的数据。 // 对于8位的话,负数*128,正数*127,然后整体向上平移128(+128),即可得到[0,255]范围的数据。 var val = s < 0 ? s * 128 : s * 127; val = parseInt(val + 128); lastData.setInt8(offset, val, true); } } else { for (var i = 0; i < bytes.length; i++, offset += 2) { var s = Math.max(-1, Math.min(1, bytes[i])); // 16位直接乘就行了 lastData.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true); } } return lastData; }; Recorder.prototype.encodePCMFragment2Buffer = function (fragment) { let data = new Float32Array(fragment); // 压缩 var compression = parseInt(this.inputSampleRate / this.outputSampleRate); var length = data.length / compression; var result = new Float32Array(length); var index = 0, j = 0; // 循环间隔 compression 位取一位数据 while (index < length) { result[index] = data[j]; j += compression; index++; } // 返回压缩后的一维数据 let bytes = result, sampleBits = Math.min(this.inputSampleBits, this.oututSampleBits), offset = 0, dataLength = bytes.length * (sampleBits / 8), buffer = new ArrayBuffer(dataLength), lastData = new DataView(buffer); // 写入采样数据 if (sampleBits === 8) { for (var i = 0; i < bytes.length; i++, offset++) { // 范围[-1, 1] var s = Math.max(-1, Math.min(1, bytes[i])); // 8位采样位划分成2^8=256份,它的范围是0-255; 16位的划分的是2^16=65536份,范围是-32768到32767 // 因为我们收集的数据范围在[-1,1],那么你想转换成16位的话,只需要对负数*32768,对正数*32767,即可得到范围在[-32768,32767]的数据。 // 对于8位的话,负数*128,正数*127,然后整体向上平移128(+128),即可得到[0,255]范围的数据。 var val = s < 0 ? s * 128 : s * 127; val = parseInt(val + 128); lastData.setInt8(offset, val, true); } } else { for (var i = 0; i < bytes.length; i++, offset += 2) { var s = Math.max(-1, Math.min(1, bytes[i])); // 16位直接乘就行了 lastData.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true); } } return lastData; }; Recorder.prototype.encodeWAV = function (isReplay) { var sampleRate = Math.min( this.inputSampleRate, isReplay ? this.inputSampleRate : this.outputSampleRate, ); var sampleBits = Math.min( this.inputSampleBits, isReplay ? this.inputSampleBits : this.oututSampleBits, ); var bytes = this.encodePCM(isReplay); var buffer = new ArrayBuffer(44); var data = new DataView(buffer); var channelCount = 1; // 单声道 var offset = 0; // 资源交换文件标识符 writeString(data, offset, 'RIFF'); offset += 4; // 下个地址开始到文件尾总字节数,即文件大小-8 data.setUint32(offset, 36 + bytes.byteLength, true); offset += 4; // WAV文件标志 writeString(data, offset, 'WAVE'); offset += 4; // 波形格式标志 writeString(data, offset, 'fmt '); offset += 4; // 过滤字节,一般为 0x10 = 16 data.setUint32(offset, 16, true); offset += 4; // 格式类别 (PCM形式采样数据) data.setUint16(offset, 1, true); offset += 2; // 通道数 data.setUint16(offset, channelCount, true); offset += 2; // 采样率,每秒样本数,表示每个通道的播放速度 data.setUint32(offset, sampleRate, true); offset += 4; // 波形数据传输率 (每秒平均字节数) 单声道×每秒数据位数×每样本数据位/8 data.setUint32(offset, channelCount * sampleRate * (sampleBits / 8), true); offset += 4; // 快数据调整数 采样一次占用字节数 单声道×每样本的数据位数/8 data.setUint16(offset, channelCount * (sampleBits / 8), true); offset += 2; // 每样本数据位数 data.setUint16(offset, sampleBits, true); offset += 2; // 数据标识符 writeString(data, offset, 'data'); offset += 4; // 采样数据总数,即数据总大小-44 data.setUint32(offset, bytes.byteLength, true); offset += 4; // 给pcm文件增加头 data = combineDataView(DataView, data, bytes); return data; }; /** * 在data中的offset位置开始写入str字符串 * @param {TypedArrays} data 二进制数据 * @param {String} str 字符串 */ function writeString(data, offset, str) { for (var i = 0; i < str.length; i++) { data.setUint8(offset + i, str.charCodeAt(i)); } } /** * 合并二进制数据 * @param {TypedArrays} resultConstructor 需要合并成的数据类型 * @param {TypedArrays} ...arrays 需要合并的数据 */ function combineDataView(resultConstructor, ...arrays) { let totalLength = 0, offset = 0; // 统计长度 for (let arr of arrays) { totalLength += arr.length || arr.byteLength; } // 创建新的存放变量 let buffer = new ArrayBuffer(totalLength), result = new resultConstructor(buffer); // 设置数据 for (let arr of arrays) { // dataview合并 for (let i = 0, len = arr.byteLength; i < len; ++i) { result.setInt8(offset, arr.getInt8(i)); offset += 1; } } return result; }
效果图:

官方接入文档: http://ai-doc.unisound.com/sacalleval/WebSocket.html