1. 为什么语音评测这类需求值得前端自己啃下来
朗读打分、逐词对错分析,这两个功能在早教、语言培训、口语考试类产品里几乎是刚需。我最早接触这类需求是做一个少儿英语跟读的小程序,当时第一反应是找后端同事要接口,结果对方一句"音频流你们前端直传第三方就行"把我噎住了。后来一路摸下来才发现,科大讯飞的语音评测(ISE,Intelligent Speech Evaluation)本来就是设计成可以前端直接调用的,只是它的鉴权方式和普通 REST 接口不太一样,导致很多人第一次接入时卡在签名那一步,误以为"必须后端代理"。
这篇东西想解决的问题很具体:把科大讯飞语音评测的 WebAPI 用纯 JavaScript 接进来,拿到总分、流畅度、完整度,以及每个字的发音对不对、声调准不准、每个词的分值,最后再把这套 demo 干净地塞进一个 Vue 项目里,而不是写成一坨只能跑一次的一次性脚本。
适合谁看?我按基础分一下:完全没接触过第三方语音评测的前端,可以顺着第 2、3 节从零把链路跑通;已经能出分数但卡在 Vue 集成、录音格式、跨域、重复调用这些坑上的,直接跳到第 4、5、6 节会更有收获。文章里所有代码都是我实际跑通过的结构,参数含义也都会解释清楚,不是贴一段就完事。
先给一个整体认知,避免后面看得云里雾里。整个流程分成四段:录音采集(浏览器录音,拿到 PCM 或 MP3)、参数组装与签名(把音频和评测文本、评测类型等信息组合,并按讯飞规则生成鉴权 URL)、WebSocket 传输(讯飞语音评测走的是 WebSocket 长连接,不是普通的 POST)、结果解析(服务端返回 JSON 和 XML 混合结构,需要拆开处理)。很多人以为它是 HTTP 接口,结果 POST 半天没反应,根子就在第三段。
提示:科大讯飞语音评测 WebAPI 对音频有明确要求,采样率、编码格式、位深不匹配会直接返回错误码,后面第 3 节会给出对照表,建议先对照自己的录音配置再动手。
另外要提前说明一件事:评测文本(也就是参考文本)是有长度和内容限制的,中文单次一般不超过一定的字数,标点、数字、英文的处理方式和自然朗读不完全一致。这块在第 3 节会展开,因为它直接决定你的分数是否符合预期。
2. 接入前必须搞清楚的鉴权机制与参数体系
2.1 鉴权 URL 到底是怎么算出来的
讯飞 WebAPI 的鉴权不是简单塞一个 APIKey 在请求头里,而是要用 HMAC-SHA1 对一串特定顺序的字段做签名,再把签名结果拼进 WebSocket 的 URL。第一次看到这段逻辑的人普遍会懵,因为字段顺序错了、时间戳单位弄错了,都会导致握手失败,而错误提示往往很含糊。
核心字段有这么几个,我按实际拼装顺序列一下:
| 字段 | 含义 | 容易踩的坑 |
|---|---|---|
| host | 接口域名 | 必须是 WebAPI 对应的域名,不能带协议头 |
| date | 请求时间 | 必须是 RFC1123 格式的 GMT 时间,不是本地时间 |
| request-line | 请求行 | 固定为GET /v2/iat HTTP/1.1这种形式,路径要对 |
| algorithm | 签名算法 | 固定hmac-sha256,注意是 256 不是 1 |
| headers | 参与签名的头 | 固定字符串host date request-line |
| signature | 签名结果 | 用 APIKey 对上面拼好的串做 HMAC,再 base64 |
拼装逻辑是这样的:先把host、date、request-line三行用换行拼成一个待签名字符串,然后用 APIKey 作为密钥做 HMAC-SHA256,结果做 Base64。最后把authorization字段(里面放api_key、algorithm、headers、signature)以及date、host作为 URL query 拼到wss://地址后面。
这里有个老生常谈但每次都有人栽的点:date 一定要是 GMT 格式。JS 的toUTCString()出来的就是标准 GMT 串,可以直接用;但如果你用toLocaleString()或者手动拼YYYY-MM-DD HH:mm:ss,签名一定对不上。
2.2 APIKey、APISecret、APPID 三者的分工
这三个值在讯飞控制台申请后都会给,很多人复制完就一股脑全塞进签名,结果报错。实际分工是:
- APPID:标识你的应用,在建立连接后发送的第一帧数据里带上,不参与签名。
- APIKey:用作 HMAC 的密钥,参与签名计算。
- APISecret:语音评测的 WebSocket 鉴权体系里,主要用的是 APIKey 做 HMAC;APISecret 在部分老版本或 HTTP 接口中有用,WebSocket 这条链路以 APIKey 为主。
我在实际项目里踩过一次坑:先把 APISecret 当密钥用了,死活握手不成功,换成 APIKey 立马通了。所以如果你按文档写完仍然连不上,第一件事就是检查密钥用的是不是 APIKey。
注意:这三个值都涉及账号安全,实际项目里绝对不要直接硬编码在前端源码里打包上线。常见做法是放在后端做一次中转签名,或者用环境变量在构建时注入并配合域名白名单。这里为了讲原理,demo 阶段可以先写死,但上线前必须处理。
2.3 评测文本与评测类型的参数含义
语音评测不是"给一段音频打个分"这么简单,它需要你告诉服务端"这段音频应该读什么内容",也就是参考文本。参数里几个关键的:
- text:参考文本,带不带标点影响断句和评分维度。
- category:评测类型,常见的有朗读(read_sentence)、单字(read_syllable)、词语(read_word)等,不同 category 对 text 的长度和内容要求不同。
- sub:细分类型,比如英文、中文、是否带声调等。
- group:评分粒度,
pupil是少儿模式,评分相对宽松,adult是成人模式,更严格。 - ise_unite:是否开启联合评测,开启后会额外返回逐词、逐字的结果。
- extra_ability:开启附加能力,比如
syll_phone_err_msg打开后能拿到音节级别的错误提示。
这几个参数组合起来,直接决定你拿到的是"一句总分"还是"每个字对错的详细报告"。想要逐词分析,ise_unite和对应粒度参数必须开对,这也是很多人做了半天只有总分、没有逐字结果的原因。
2.4 为什么评测要走 WebSocket 而不是 HTTP
这是概念层面最需要掰扯清楚的一点。普通接口是"传参—等结果",一问一答。语音评测的数据量大,而且可以边录边传,用 WebSocket 长连接的理由是:
- 流式传输:音频可以分帧发送,不用等录完再一次性发,降低延迟。
- 双向通信:服务端可以在接收过程中就返回中间结果或状态。
- 协议本身:讯飞语音评测 WebAPI 定义的就是 WebSocket 协议,帧格式有定制,不是标准 HTTP 能覆盖的。
所以你在前端必须用浏览器原生WebSocket,而且要注意讯飞的帧结构是"每一帧带一个 status 状态位",第一帧 status 为 0,中间帧为 1,最后一帧为 2。发错了顺序,服务端不会给你结果,或者只给你一个空响应。
3. 把录音、传输、解析这条链路亲手跑通
3.1 浏览器录音的三种方案与选型逻辑
前端的音频采集无非三条路:MediaRecorder、AudioContext + ScriptProcessorNode、AudioWorklet。这三者拿到的音频格式和适用场景差别很大,选错了后面全得返工。
MediaRecorder最省事,几行代码就能录出一个 Blob,但默认输出是浏览器编码后的格式(Chrome 一般是 webm/opus),而讯飞语音评测对音频格式有要求,虽然部分格式支持,但编码转换经常带来兼容问题。我一开始图省事用了它,webm 传上去直接报格式错误,转码又得引入额外的库。
ScriptProcessorNode是老牌的音频处理方案,通过AudioContext拿到原始 PCM 数据,直接把 Float32 转成 16 位 PCM 发出去。它的缺点是已被标记为废弃,且在部分浏览器上性能一般,但胜在代码简单、格式可控,适合快速验证。
AudioWorklet是官方推荐替代 ScriptProcessorNode 的方案,在独立线程里处理音频,性能好、不阻塞主线程,代价值的代价是代码复杂一些,需要额外加载一个 worklet 处理器文件。
我的建议是:先求跑通,再求优雅。验证阶段用 ScriptProcessorNode,把 PCM 流转起来的链路先打通;正式项目再迁移到 AudioWorklet。两者的 PCM 转换逻辑是一样的,迁移成本不高。
3.2 把 Float32 音频转成讯飞要的 PCM
浏览器AudioContext采样默认是 44.1kHz 或 48kHz 的 Float32,讯飞语音评测一般要求16kHz、16 位、单声道 PCM。所以要处理三件事:降采样、格式转换、声道合并(或取单声道)。
降采样我用的思路是"按比例抽样",因为 48000 到 16000 正好是 3 倍关系,直接每 3 个点取 1 个。这个过程会损失一些高频信息,但对语音评测影响不大,因为人声主要能量集中在中低频。更严谨的做法是先做一次低通滤波再抽样,防止混叠,但对朗读评测这种场景,直接抽样已经够用。
Float32 转 Int16 要注意范围映射:Float32 的取值是 [-1, 1],要映射到 Int16 的 [-32768, 32767]。核心代码逻辑是:
function floatTo16BitPCM(input) { const output = new Int16Array(input.length); for (let i = 0; i < input.length; i++) { const s = Math.max(-1, Math.min(1, input[i])); output[i] = s < 0 ? s * 0x8000 : s * 0x7fff; } return output; }这里用Math.max/Math.min做钳位是必须的,音频数据在极端情况下会超出 [-1, 1],不钳位会溢出成噪音,听起来是刺啦声,评测分数会莫名其妙变低。
发送时还要做一个Base64 编码,因为讯飞要求的音频字段是 Base64 字符串。这里有个性能坑:不要对每一小段都调用String.fromCharCode.apply,参数太长会爆栈,正确做法是分块转换。
3.3 帧结构:status 状态位与分帧发送
前面提到过,讯飞的帧有 status 字段,这是整个传输里最容易出错的地方。规则我总结成一张表:
| 发送阶段 | status 值 | 数据内容 | 说明 |
|---|---|---|---|
| 第一帧 | 0 | 第一段音频 + 业务参数 | 业务参数只在第一帧带 |
| 中间帧 | 1 | 中间音频 | 可反复发送 |
| 最后一帧 | 2 | 最后一段音频 | 发完后服务端开始给结果 |
很多人犯的错是把业务参数(text、category 这些)放到了每一帧里,其实只有第一帧需要带common和business节点,后面几帧只需要带data节点。结构大概是:
// 第一帧 { common: { app_id: "你的APPID" }, business: { category: "read_sentence", sub: "cn", ... }, data: { status: 0, audio: "base64..." } } // 中间帧 { data: { status: 1, audio: "base64..." } } // 最后一帧 { data: { status: 2, audio: "base64..." } }还有一个隐性规则:帧之间要有间隔,不能一瞬间全发出去。讯飞文档里提到建议每帧间隔 40ms 左右,配合 1280 字节左右的音频长度。我用setInterval按 40ms 发一帧,实测最稳。如果一股脑发,服务端可能来不及处理,直接返回错误或评分异常。
3.4 解析返回结果:JSON 与 XML 混合结构
拿到结果的一刻,很多人会愣住,因为返回的既不是纯 JSON 也不是纯 XML。实际是外层一个 JSON,里面某个字段又嵌了一层 XML 字符串。典型结构:
{ "code": 0, "data": { "status": 2, "data": "<xml>...</xml>" } }那个 XML 里才是真正的评分详情。你需要先从 JSON 里取出 XML 字符串,再用DOMParser解析成 DOM,然后按节点名取数据。
XML 里的关键节点大致包括:
total_score:总分。fluency_score:流畅度。integrity_score:完整度。phone_score:声韵分。tone_score:声调分。sentence、word、syll、phone层级结构,对应句子、词、音节、音素。- 每个词/字节点上的
content(内容)和total_score(分值),用来做逐词对错展示。
解析时要注意 XML 里可能有多个同名的word节点,得用getElementsByTagName循环取,而不是querySelector只拿第一个。我第一次就是只拿到第一个词的分,后面全是空的,排查了半天。
提示:逐词分析需要业务参数里开启
ise_unite,否则 XML 里只有句子级别的分,没有 word 层级的节点。这个开关是"有没有逐词结果"的总闸。
4. 把这套流程装进 Vue 项目里的正确姿势
4.1 别把 SDK 逻辑写进组件,先抽成独立模块
Vue 项目里最容易犯的错,是把录音、鉴权、WebSocket、解析全部怼进一个.vue文件,结果这个组件几百行,换个页面想复用就得复制粘贴。我的做法是抽成一个独立的 JS 模块,比如ise.js,对外只暴露几个方法:
startRecord():开始录音。stopAndEvaluate(text, options):停止录音并发起评测,返回 Promise。onResult(callback):注册结果回调。destroy():释放资源。
组件只负责调用和渲染,业务逻辑全在模块里。这样既好测试,又好迁移。抽模块时要注意:WebSocket 实例、AudioContext 实例、录音节点都放在模块的闭包里,不要挂在全局或组件 data 上,否则页面销毁后资源没释放,反复进出页面会累积一堆僵尸连接。
4.2 Vue 2 和 Vue 3 在集成上的差异
差异其实不大,主要两点:
一是生命周期钩子命名。Vue 2 用beforeDestroy,Vue 3 用beforeUnmount。在这个钩子里一定要调用模块的destroy(),关闭 WebSocket 和释放麦克风,否则用户离开页面后浏览器地址栏的录音小图标还亮着,体验很差,而且下次进来录音会串音。
二是响应式包裹。Vue 3 的ref和reactive对普通对象是深代理的,如果把整个评测结果对象丢进ref,结果对象里嵌套的 XML 解析后的数据可能会被过度代理,性能下降。评测结果我一般用shallowRef或者干脆用普通变量加手动触发更新。Vue 2 这边要注意data里别放 WebSocket 实例这类非响应式对象,放进去反而拖慢更新。
4.3 用组合式函数封装评测逻辑
Vue 3 项目里,我更推荐用组合式函数(composition function)的方式封装,形如useIseeEvaluate(),返回响应式的状态和方法:
export function useIseeEvaluate() { const isRecording = ref(false); const result = shallowRef(null); const errorMsg = ref(''); let engine = null; function init() { engine = createIseeEngine(); engine.onResult((data) => { result.value = data; }); engine.onError((msg) => { errorMsg.value = msg; }); } function start() { isRecording.value = true; engine.startRecord(); } function stop(text, options) { isRecording.value = false; return engine.stopAndEvaluate(text, options); } return { isRecording, result, errorMsg, init, start, stop }; }这样在组件里就是const { isRecording, result, start, stop } = useIseeEvaluate(),逻辑清晰,多个页面共用一套没问题。Vue 2 也可以仿照这个思路写成一个 mixin,只是写法上没组合式函数灵活。
4.4 麦克风权限与用户交互的配合
浏览器录音必须由用户手势触发,不能页面一加载就偷偷开麦克风,否则会被浏览器拦截,控制台报NotAllowedError。所以流程上一般是:用户点"开始朗读"按钮 → 调getUserMedia申请权限 → 拿到 stream 后开始录音。如果用户在弹窗里点了拒绝,要给出明确提示,引导去浏览器设置里重新开启,而不是默默失败。
还有个细节:采样率适配。getUserMedia的约束里可以指定sampleRate: 16000,但并非所有设备都支持直接采到 16kHz,有些设备会忽略这个值,还是按 44.1kHz 给。所以稳妥做法是拿到实际采样率后,在代码里做一次重采样,不要假设拿到的就是 16kHz。我吃过这个亏,在录音笔上测好好的,换了个老式笔记本内置麦,采样率不对,分数直接异常。
5. 逐词分析、评分维度与调参的那些门道
5.1 逐词对错是怎么算出来的
逐词分析的底层是音素级别的比对。服务端会把你的发音切分成音节和音素,和参考文本的音素序列对齐,再逐个比对,给出每个音素是"正确""错误"还是"漏读"。最后聚合成词、句的分数。
所以你会发现一个现象:有时候整句读得挺溜,但某个词的分数很低,往往是那个词的某个声母或韵母发得不标准。做逐词高亮时,前端只要按 word 节点把内容渲染出来,根据分值区间上色就行。我一般分三档:分值高标绿、中等标黄、低标红。阈值不是固定的,少儿模式和成人模式要区别对待,我在少儿场景里把及格线往下调了,不然小朋友几乎全是红的,家长看着焦虑。
5.2 主要评分维度到底在评什么
讯飞返回的维度不少,我挑几个常用的解释一下实际含义:
- 总分(total_score):综合评分,一般是最常展示的大数字。
- 流畅度(fluency_score):看停顿、语速是否均匀,读得磕磕巴巴分就低。
- 完整度(integrity_score):看有没有漏读,漏字多会掉分。
- 声韵分(phone_score):声母韵母的发音准确度。
- 声调分(tone_score):这个对汉语很重要,四声读错直接扣。
做产品时不要一股脑把五个分全展示,会让人眼花。朗读练习类产品通常展示总分 + 流畅度 + 完整度,逐字反馈里用声韵和声调。我见过有产品把五个分做成雷达图,视觉上挺唬人,但用户其实看不懂。
5.3 影响评分结果的几个隐形因素
这里说几个文档里不太强调、但实际影响很大的点:
一是文本标点。参考文本带标点和不带标点,断句结果不同,流畅度评分会有差异。比如一句长句你全不带标点,服务端可能不知道在哪里断,导致判定为"不停顿",反而扣分。我的做法是尽量带上合理的逗号、句号,和用户实际朗读习惯一致。
二是静音段。录音前后如果有大段沉默,会影响流畅度评分。所以我在停止录音后,会先做一次简单的静音裁剪,把开头结尾低于阈值的数据切掉,再去送评。这个小优化让分数稳定性提升了不少。
三是设备差异。不同麦克风的频响不同,同一个人的同一句话,换个设备可能差好几分。这个没法彻底消除,但可以通过固定场景(比如固定在 App 内录)来减少波动。产品层面最好给用户一个"环境安静、靠近麦克风"的引导。
5.4 参数组合的实战推荐
我把几套常用参数组合整理成表,方便直接抄:
| 场景 | category | sub | group | ise_unite | 说明 |
|---|---|---|---|---|---|
| 中文整句朗读 | read_sentence | cn | pupil/adult | 开启 | 需要逐词分析时开启 |
| 中文单字评测 | read_syllable | cn | pupil | 可关 | 只评单字发音 |
| 中文词语评测 | read_word | cn | pupil | 可关 | 评词语整体读音 |
| 英文整句朗读 | read_sentence | en | pupil/adult | 开启 | 英文不带声调分 |
选pupil还是adult,看你的目标用户。给小学生用adult会被虐得很惨,给成人考试用pupil又会普遍虚高,没有区分度。
6. 那些把我卡了半天的坑和排查思路
6.1 握手就失败:签名问题的排查链路
第一次接入最常遇到的报错是 WebSocket 连接直接关闭,没进到数据帧阶段。这时候别急着怀疑代码,按下面顺序查:
第一,确认date是不是 GMT 格式。打印出来看看,如果不是Mon, 01 Jan 2026 00:00:00 GMT这种长相,就是格式错了。
第二,确认签名用的密钥是 APIKey 不是 APISecret。这个我前面提过,吃过亏。
第三,确认request-line的路径和接口对得上。语音评测和语音听写是两个不同的接口路径,拿错路径签名一定不对。
第四,确认服务端的服务器时间没差太多。签名里带的时间戳和服务端校验时间差太大也会失败,这种情况少见但存在。
一条条过下来,握手问题基本都能定位。
6.2 连接成功但拿不到结果:帧发送的常见错误
如果连接建立了,日志里能看到 open,但没有结果或结果异常,通常是帧的问题:
- status 顺序错了:必须 0 → 1 → 2,哪怕你只有一帧数据,也要先发 0 再发 2,或者至少把最后帧标成 2。
- 业务参数放错位置:只在第一帧带,重复带可能报错。
- 音频格式不符:采样率、位深、编码不对,服务端解析不了,返回错误码。
- 发送太快:没按节奏分帧,一次性全推。
- 最后一帧没标 2:服务端不知道你发完了,一直等,结果超时。
我当时遇到的是最后一帧忘了标 status 2,连接一直挂着不给结果,加了个超时打印才看出来。
6.3 麦克风在 Vue 里的资源泄漏
前面提过,页面切换如果没释放资源,会出问题。具体表现是:来回进出评测页几次后,录不了音了,或者录音变成上一条的内容。原因就是AudioContext和MediaStream没关闭。
正确的释放逻辑应该放在组件的卸载钩子里:
onBeforeUnmount(() => { if (mediaStream) { mediaStream.getTracks().forEach(track => track.stop()); } if (audioContext && audioContext.state !== 'closed') { audioContext.close(); } if (socket) { socket.close(); } });MediaStream一定要getTracks().forEach(track => track.stop()),只关AudioContext是不够的,麦克风指示还亮着。
6.4 连续评测与并发调用的处理
产品里经常是"用户读一句,评一句,再读下一句"。如果上一句的 WebSocket 还没关闭,下一句又建新的,会累积大量连接,最终浏览器限制并发。我的做法是:每次评测前先确保上一个连接已关闭,或者干脆复用一个长连接,但复用要注意服务端的状态复位问题。
我最终选择的是每句新建连接、评完即关的方式,配合一个简单的状态锁(isEvaluating),防止用户在上一句没评完时连点按钮。这个锁看起来简单,但少了它,测试阶段被连点搞崩过好几次。
6.5 线上环境与本地环境的差异
本地跑通不代表线上没问题。我遇到过两个线上才暴露的问题:
一是域名白名单。讯飞控制台申请的应用通常会配置允许的域名,本地localhost能跑,换成线上域名就连不上。上线前要去控制台把域名加上。
二是HTTPS 限制。浏览器要求getUserMedia必须在 HTTPS 或 localhost 下才能用,纯 HTTP 的线上域名会直接申请不到麦克风权限。这个坑很隐蔽,本地用 http 测得好好的,部署到 http 的测试环境就录不了音。
还有个相关的点:WebSocket 的 wss 与页面协议要一致。HTTPS 页面里连ws://会被浏览器拦截。所以线上一定用wss://。
6.6 给结果展示层留好容错
最后说一个产品体验上的坑。评测返回的分数不是每次都有完整的维度,偶尔某些字段会缺失,比如网络抖动导致结果被截断。如果前端直接data.total_score.toFixed(1),遇到 undefined 就白屏了。我现在的做法是给所有字段都加默认值和类型判断,缺了就用占位符或隐藏该维度,绝不让一个字段缺失把整个页面搞崩。
另外,用户朗读时环境噪音、设备质量都会影响结果,产品文案上最好弱化"绝对分数",强调"发音提升",避免用户因为设备问题得了低分而对产品失去信任。这是我在做用户反馈时最深的一个体会:技术给出的分是客观计算,但怎么呈现给用户,是主观的产品功夫。