# 原生实时评测 一个 WebSocket 连接承载一轮或多轮评测,音频以二进制帧送入,结束后返回终评。 来源:https://open.shengzhiai.com/sdk/v2/api/ws-evaluate/ `WSS wss://open.shengzhiai.com/api/v1/ws/evaluate` ## 帧序列,沙箱实录 ```text 0 ms 客户端 GET /api/v1/ws/evaluate?appKey=…&nonce=…×tamp=…&idempotencyKey=…&signature=… 5 ms 服务端 {"event":"connected","message":"stream channel ready"} 5 ms 客户端 {"cmd":"start","coreType":"sentence","referenceText":"今天天气很好","language":"zh-CN","idempotencyKey":"a0cc81cd…"} 449 ms 服务端 {"event":"started"} 449 ms 客户端 {"cmd":"ping"} 449 ms 客户端 二进制帧 96 个,每个 640 字节,共 61440 字节 892 ms 服务端 {"event":"pong","ts":1791483752768} 2481 ms 客户端 {"cmd":"end"} 6245 ms 服务端 {"event":"result","recordId":"eval_065b2c271342","eof":1,"result":{"overall":88.9,"pronunciation":100,"fluency":96,"integrity":100,"tone":83,"rhythm":78,"speed":225,"duration":"1.920","warning":[],"words":[{"word":"今","scores":{"overall":78}},{"word":"天","scores":{"overall":95}},{"word":"天","scores":{"overall":85}},{"word":"气","scores":{"overall":76}},{"word":"很","scores":{"overall":94}},{"word":"好","scores":{"overall":73}}]},"report":{"summary":"# 朗读评测报告\n\n**综合总分**:88.9 / 100\n\n## 各维度\n- **完整度**:100.0\n- **准确度**:100.0\n- **流利度**:96.0\n- **朗读技巧**:78.0\n- **声调**:83.0\n- **情感**:76.0\n\n## 诊断与建议\n- 朗读完整度很高,基本完成了全部内容。","dimensions":{"integrity":"完整度 100 分。","accuracy":"准确度 100 分,可结合逐字详情定位失分点。声调:整句调型判定 100 分;其中“很”(字级调型 1 分)的调型与标准读音有偏差,需对照标准音重点纠正,其余字词调型整体稳定。","fluency":"流利度 96 分,语速 225。","reading_skill":"朗读技巧 78 分(停连/重音/语调)。","emotion":"情感表达 76 分。"},"suggestions":["重音变化不够明显,关键词应当适度强调(音高提升 / 时长延长 / 加强气流)。","第三声(上声)调值偏低。练习时走 214 曲线,先降再升。推荐练习『你好/美好/小狗』等词。"],"dimensionSuggestions":{"integrity":[],"accuracy":[],"fluency":[],"reading_skill":["重音变化不够明显,关键词应当适度强调(音高提升 / 时长延长 / 加强气流)。","先标出停连位置、句子重音和语调走向,再按标记朗读并回听节奏是否清楚。"],"emotion":["第三声(上声)调值偏低。练习时走 214 曲线,先降再升。推荐练习『你好/美好/小狗』等词。","先判断语句的情绪基调,选定符合语义的表达方式朗读;录音回听情感是否与内容一致。"]},"rubricVersion":"v20260707b","scores":{"compositeScore":88.9},"dimensionScores":{"accuracy":100,"fluency":96,"integrity":100,"reading_skill":78,"affect":76,"speechRate":85,"readSpeedRaw":225},"dimensionEvidence":{"accuracy":[{"word":"很","type":"pitchLow","detail":"声调 1 分(低于 60)"}],"fluency":[],"integrity":[],"affect":[],"speechRate":[]},"scoringContext":{"agegroup":4,"weightProfile":"grade_advanced","weightPolicyVersion":"age-weight-v3-20260927"},"dimensionTree":{"rubricVersion":"v20260707b","contractMode":"t2_derived","curriculumFramework":"cn_curriculum_2022_correct_fluent_expressive","agegroup":4,"weightProfile":"grade_advanced","weightPolicyVersion":"age-weight-v3-20260927","weights":{"integrity":0.1,"accuracy":0.25,"fluency":0.2,"reading_skill":0.25,"affect":0.2},"overall":88.9,"dims":{"integrity":{"curriculumAxis":"correct","label":"完整度","score":100,"scored":true,"weightedContribution":10,"engineScore":100,"subs":[{"key":"completeness","label":"完整度","score":100,"weight":1,"scored":true}]},"accuracy":{"curriculumAxis":"correct","label":"准确度","score":100,"scored":true,"weightedContribution":25,"engineScore":100,"subs":[{"key":"pronunciation","label":"发音","score":100,"weight":1,"scored":true},{"key":"phoneme","label":"音素","score":100,"weight":0,"scored":false,"diagnostic":true},{"key":"initial","label":"声母","score":83.5,"weight":0,"scored":false,"diagnostic":true},{"key":"final","label":"韵母","score":83.5,"weight":0,"scored":false,"diagnostic":true},{"key":"retroflex","label":"平翘舌","weight":0,"scored":false,"diagnostic":true,"notAssessedReason":"source_metric_unavailable"},{"key":"nasalFinal","label":"前后鼻音","score":98,"weight":0,"scored":false,"diagnostic":true},{"key":"pitchPattern","label":"声调/调型","score":100,"weight":0,"scored":false,"diagnostic":true}]},"fluency":{"curriculumAxis":"fluent","label":"流利度","score":96,"scored":true,"weightedContribution":19.2,"engineScore":96,"subs":[{"key":"flow","label":"流畅度","score":96,"weight":1,"scored":true},{"key":"speechRate","label":"语速","score":85,"weight":0,"scored":false,"diagnostic":true},{"key":"naturalness","label":"自然度","score":100,"weight":0,"scored":false,"diagnostic":true},{"key":"readSpeedRaw","label":"实测语速","score":225,"weight":0,"scored":false,"diagnostic":true}]},"reading_skill":{"curriculumAxis":"expressive","label":"朗读技巧","score":78,"scored":true,"weightedContribution":19.5,"engineScore":78,"subs":[{"key":"skill","label":"朗读技巧","score":78,"weight":1,"scored":true},{"key":"pause","label":"停连","score":85,"weight":0,"scored":false,"diagnostic":true},{"key":"stress","label":"重音","score":32,"weight":0,"scored":false,"diagnostic":true},{"key":"intonation","label":"语气","score":100,"weight":0,"scored":false,"diagnostic":true},{"key":"rhythm","label":"节奏","weight":0,"scored":false,"diagnostic":true,"notAssessedReason":"source_metric_unavailable"},{"key":"innerSpeech","label":"内在语","weight":0,"scored":false,"diagnostic":true,"referenceOnly":true,"source":"report.skillInsights","notScoredReason":"reference_observation_only","notAssessedReason":"skill_insight_unavailable"},{"key":"sceneRecreation","label":"情景再现","weight":0,"scored":false,"diagnostic":true,"referenceOnly":true,"source":"report.skillInsights","notScoredReason":"reference_observation_only","notAssessedReason":"skill_insight_unavailable"},{"key":"audienceAwareness","label":"对象感","weight":0,"scored":false,"diagnostic":true,"referenceOnly":true,"source":"report.skillInsights","notScoredReason":"reference_observation_only","notAssessedReason":"skill_insight_unavailable"}]},"affect":{"curriculumAxis":"expressive","label":"情感表达","score":76,"scored":true,"weightedContribution":15.2,"engineScore":76,"subs":[{"key":"expressiveness","label":"情感表达","score":76,"weight":1,"scored":true}]}},"contractStatus":"verified"}},"asrText":{"text":"今 天 天 气 很 好","alignment":[{"char":"今","index":0,"read_status":"correct","start_time":18,"end_time":22,"asr_pinyin":"jin1","asr_tone":1,"asr_initial":"j","asr_final":"in","gop_score":100,"tone_pred":1,"p_neutral":0.007},{"char":"天","index":1,"read_status":"correct","start_time":36,"end_time":44,"asr_pinyin":"tian1","asr_tone":1,"asr_initial":"t","asr_final":"ian","gop_score":100,"tone_pred":1,"p_neutral":0.001},{"char":"天","index":2,"read_status":"correct","start_time":58,"end_time":64,"asr_pinyin":"tian1","asr_tone":1,"asr_initial":"t","asr_final":"ian","gop_score":100,"tone_pred":1,"p_neutral":0},{"char":"气","index":3,"read_status":"correct","start_time":78,"end_time":84,"asr_pinyin":"qi4","asr_tone":4,"asr_initial":"q","asr_final":"i","gop_score":100,"tone_pred":4,"p_neutral":0.004},{"char":"很","index":4,"read_status":"correct","start_time":94,"end_time":102,"asr_pinyin":"hen3","asr_tone":3,"asr_initial":"h","asr_final":"en","gop_score":100,"tone_pred":2,"p_neutral":0.003},{"char":"好","index":5,"read_status":"correct","start_time":124,"end_time":130,"asr_pinyin":"hao3","asr_tone":3,"asr_initial":"h","asr_final":"ao","gop_score":100,"tone_pred":3,"p_neutral":0.081}]},"warnings":[]} ``` ## 示例代码 Node.js,直连协议: ```js // 原生实时评测,直连协议。Node.js 18 及以上,依赖 npm install ws import crypto from 'node:crypto'; import { readFile } from 'node:fs/promises'; import WebSocket from 'ws'; const query = { appKey: process.env.YUGU_APP_KEY, idempotencyKey: crypto.randomBytes(16).toString('hex'), nonce: crypto.randomBytes(16).toString('hex'), timestamp: String(Math.floor(Date.now() / 1000)), }; // 握手签名:signature 之外的全部 query 参数按键名排序拼接 const payload = Object.keys(query).sort().map((k) => `${k}=${query[k]}`).join('&'); const signature = crypto.createHmac('sha256', process.env.YUGU_SECRET_KEY).update(payload, 'utf8').digest('base64'); const ws = new WebSocket('wss://open.shengzhiai.com/api/v1/ws/evaluate?' + new URLSearchParams({ ...query, signature })); const wav = await readFile('audio.wav'); const pcm = wav.subarray(wav.indexOf('data') + 8); // 16 kHz,16 位,单声道 PCM ws.on('message', (data, isBinary) => { if (isBinary) return; const msg = JSON.parse(data.toString()); if (msg.event === 'connected') { ws.send(JSON.stringify({ cmd: 'start', coreType: 'sentence', referenceText: '今天天气很好', language: 'zh-CN', idempotencyKey: query.idempotencyKey })); } else if (msg.event === 'started') { for (let i = 0; i < pcm.length; i += 640) ws.send(pcm.subarray(i, i + 640)); // 每帧 640 字节,即 20 毫秒 ws.send(JSON.stringify({ cmd: 'end' })); } else if (msg.event === 'result') { console.log('recordId', msg.recordId, '总分', msg.result.overall); ws.close(); } else if (msg.event === 'error') { console.error('错误', msg.code, msg.message); ws.close(); } }); ``` Java: ```java StreamSession session = client.streamEvaluate( new EvaluateConfig(EvaluateConfig.CORE_SENTENCE, "今天天气很好", "zh-CN"), new StreamListener() { @Override public void onStateChanged(SessionState from, SessionState to) { ui.show(to); } @Override public void onReconnecting(int attempt, long delayMs, YuguException cause) { ui.showReconnecting(attempt); } @Override public void onReconnected(int attempt, long droppedBytes) { ui.showConnected(); } @Override public void onResult(EvalResult result) { ui.showScore(result.getOverall()); } @Override public void onError(YuguException error) { ui.showError(error); } @Override public void onClosed(int code, String reason) { ui.release(); } }); for (byte[] frame : pcmFrames) { session.sendAudio(frame); } session.end(); EvalResult result = session.result().get(60, TimeUnit.SECONDS); ``` 安卓: ```kotlin val session = client.streamEvaluate( EvaluateConfig(CoreType.SENTENCE, "今天天气很好", Language.ZH_CN), object : StreamListener { override fun onStateChanged(oldState: SessionState, newState: SessionState) { stateView.text = newState.name } override fun onReconnecting(attempt: Int, delayMs: Long, cause: YuguException) { stateView.text = "网络中断,第 $attempt 次重连" } override fun onResult(result: EvalResult) { scoreView.text = "总分 ${result.overall}" } override fun onError(error: YuguException) { scoreView.text = "评测失败 ${error.code}" } }, ) recorder.start(session) // 每 20 毫秒送一帧 640 字节的 PCM // 读完以后 recorder.stop() session.end() ``` iOS: ```swift let handlers = YuguStreamHandlers() handlers.onStateChanged = { from, to in print(from, "->", to) } handlers.onReconnecting = { attempt, delayMs, cause in showBanner("网络中断,正在重连") } handlers.onReconnected = { attempt, droppedBytes in hideBanner() } handlers.onResult = { result in show(result) } handlers.onError = { error in showError(error) } handlers.onClosed = { code, reason in releaseUI() } let session = try client.streamEvaluate(config: config, listener: handlers) try session.start() // 每帧 640 字节,16 kHz 单声道 16 位小端 PCM try session.sendAudio(frame) // 读完 try session.end() ``` 小程序: ```js const session = client.streamEvaluate( { coreType: 'sentence', referenceText: '今天天气很好', language: 'zh-CN' }, { onResult(result) { console.log('总分', result.overall); }, onError(err) { console.log('评测失败', err.code, err.message); }, }, ); recorder.pipeTo(session); recorder.start(); // 用户读完后 recorder.stop(); ``` ## 握手参数 | query 参数 | 类型 | 必填 | 说明 | |---|---|---|---| | `appKey` | string | 否 | appKey,签名鉴权时必填,token 鉴权时不带 | | `timestamp` | string | 否 | 签名鉴权时必填,当前 Unix 时间戳,单位为秒 | | `nonce` | string | 否 | 随机串,防重放 | | `signature` | string | 否 | 签名鉴权时必填,被签名参数为 `signature` 之外的全部 query 参数 | | `idempotencyKey` | string | 否 | 幂等键,开始帧没带时取这里的值 | | `token` | string | 否 | token 鉴权时必填,代替以上签名参数 | ## 会话流程 `wss://open.shengzhiai.com/api/v1/ws/evaluate`,握手 query 带鉴权参数。 1. 服务端发 `{"event": "connected"}`。 2. 客户端发开始帧 `{"cmd": "start", "coreType": "sentence", "referenceText": "今天天气很好", "language": "zh-CN", "idempotencyKey": "..."}`,开始帧接受原生整段评测 `config` 的全部字段,另可带 `"realtimeFeedback": true` 打开朗读进度。服务端回 `{"event": "started"}`。 3. 客户端发音频:二进制帧,推荐 640 字节一帧即 16 kHz 16 位单声道 20 毫秒,或文本帧 `{"cmd": "audio", "data": ""}`。开始帧带 `realtimeFeedback` 时,服务端在收音过程中下发朗读进度帧,见[朗读进度](https://open.shengzhiai.com/sdk/v2/api/ws-progress/)。 4. 客户端发 `{"cmd": "end"}`,服务端回 `{"event": "result", "recordId": ..., "eof": 1, "result": {...}, "report": {...}, "asrText": {...}, "warnings": [...]}`。同一幂等键的重放结果带 `"replayed": true`。 一轮结束后要再发开始帧才能开始下一轮。错误帧为 `{"event": "error", "code": 40001, "message": "..."}`,`code` 在原因是服务端业务错误时出现。 ## 客户端消息 | 帧字段 | 类型 | 必填 | 说明 | |---|---|---|---| | `{"cmd":"start"}` | 文本帧 | 是 | 开始帧,接受原生整段评测 config 的全部字段,另带 `idempotencyKey`,带 `"realtimeFeedback": true` 时打开朗读进度 | | 二进制帧 | 音频 | 是 | PCM 16 位小端,16 kHz,单声道,推荐 640 字节一帧,单帧不超过 128 KB | | `{"cmd":"audio","data":""}` | 文本帧 | 否 | 音频的文本帧写法,与二进制帧二选一 | | `{"cmd":"end"}` | 文本帧 | 是 | 结束帧,发出后等待终评 | | `{"cmd":"ping"}` | 文本帧 | 否 | 应用层心跳,平台回 `{"event":"pong"}`,不改变会话状态 | ## 服务端消息 | 帧字段 | 类型 | 必填 | 说明 | |---|---|---|---| | `{"event":"connected"}` | 文本帧 | 是 | 连接建立 | | `{"event":"started"}` | 文本帧 | 是 | 开始帧已受理,可以送音频 | | `{"eof":0}` | 文本帧 | 否 | 进度帧,开始帧带 `realtimeFeedback` 时下发,格式见[朗读进度](https://open.shengzhiai.com/sdk/v2/api/ws-progress/),不含评分 | | `{"event":"result"}` | 文本帧 | 是 | 终评,带 `recordId`,`eof: 1`,`result`,`report`,`asrText`,`warnings`,重放时带 `"replayed": true` | | `{"event":"error"}` | 文本帧 | 否 | 错误,带 `message`,原因是服务端业务错误时另带 `code` | | `{"event":"pong"}` | 文本帧 | 否 | 心跳回复,带服务端毫秒时间戳 `ts` | 断线后的重连与重放见[心跳与断线重连](https://open.shengzhiai.com/sdk/v2/api/ws-reconnect/),`result` 字段见[评测结果字段](https://open.shengzhiai.com/sdk/v2/api/results/)。