ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

前端直连科大讯飞语音评测:WebSocket鉴权与Vue集成实战

前端直连科大讯飞语音评测:WebSocket鉴权与Vue集成实战 1. 为什么语音评测这类需求值得前端自己啃下来朗读打分、逐词对错分析这两个功能在早教、语言培训、口语考试类产品里几乎是刚需。我最早接触这类需求是做一个少儿英语跟读的小程序当时第一反应是找后端同事要接口结果对方一句音频流你们前端直传第三方就行把我噎住了。后来一路摸下来才发现科大讯飞的语音评测ISEIntelligent 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 不是 1headers参与签名的头固定字符串host date request-linesignature签名结果用 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 做 HMACAPISecret 在部分老版本或 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 用beforeDestroyVue 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 参数组合的实战推荐我把几套常用参数组合整理成表方便直接抄场景categorysubgroupise_unite说明中文整句朗读read_sentencecnpupil/adult开启需要逐词分析时开启中文单字评测read_syllablecnpupil可关只评单字发音中文词语评测read_wordcnpupil可关评词语整体读音英文整句朗读read_sentenceenpupil/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 就白屏了。我现在的做法是给所有字段都加默认值和类型判断缺了就用占位符或隐藏该维度绝不让一个字段缺失把整个页面搞崩。另外用户朗读时环境噪音、设备质量都会影响结果产品文案上最好弱化绝对分数强调发音提升避免用户因为设备问题得了低分而对产品失去信任。这是我在做用户反馈时最深的一个体会技术给出的分是客观计算但怎么呈现给用户是主观的产品功夫。
返回列表