# 实时评测 边录边传的 WebSocket 会话,状态机,心跳,断线重连与音频缓冲策略。 来源:https://open.shengzhiai.com/sdk/v2/guides/streaming/ 实时评测在一个 WebSocket 连接上边录边传,读完发出结束帧后返回终评。连接意外断开时 SDK 用同一个幂等键重连,重放这一轮已发的音频,评分覆盖整段录音,平台不重复计费。 ## 会话时序 1. 应用代码 到 SDK 实时会话:streamEvaluate(config, listener) 2. SDK 实时会话 到 开放平台:握手 /api/v1/ws/evaluate?…&signature 3. 开放平台 到 SDK 实时会话:{"event":"connected"} 4. SDK 实时会话 到 开放平台:{"cmd":"start",…,"idempotencyKey"} 5. 开放平台 到 SDK 实时会话:{"event":"started"} 6. SDK 实时会话 到 应用代码:onStateChanged(STARTED) 7. 应用代码 到 SDK 实时会话:sendAudio(pcm) 8. SDK 实时会话 到 开放平台:二进制帧 640 字节,即 20 ms 9. 说明:心跳每 15 秒一次,网页与小程序用应用层 ping,平台回 pong 10. 应用代码 到 SDK 实时会话:end() 11. SDK 实时会话 到 开放平台:{"cmd":"end"} 12. 开放平台 到 SDK 实时会话:{"event":"result","eof":1,…} 13. SDK 实时会话 到 应用代码:onResult(result) ## 调用 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 async function readOnce(referenceText) { const session = client.streamEvaluate( { coreType: 'sentence', referenceText, language: 'zh-CN' }, { onStateChanged: (_old, state) => (statusLabel.textContent = state), onReconnecting: (attempt, delayMs) => console.log('重连中', attempt, delayMs), onResult: (result) => console.log('总分', result.overall), onError: (error) => console.warn('评测失败', error.code, error.message), }, ); await recorder.start({ session }); await waitForUserToFinish(); await recorder.stop(); session.end(); return session.waitForResult(); } ``` 小程序: ```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(); ``` 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,安卓,网页与小程序的 `streamEvaluate` 立即返回会话对象,在后台建立连接,iOS 在调用 `session.start()` 后建立连接。开始前送入的音频先排队,开始后按顺序发出。声通兼容实时评测用 `streamEvaluateCompat`。两种实时评测都可以打开朗读进度,见下一节。 ## 朗读进度 打开朗读进度后,平台在收音过程中逐字或逐词告诉客户端参考文本读到了哪里,中间结果回调 `onPartial` 收到的对象带 `progress`,每项有 `word`,`read` 与 `matched`,另有 `readUnits`,`matchedUnits` 与 `totalUnits`,`readUnits` 即当前朗读位置。 | 会话 | 打开方式 | |---|---| | 原生实时评测 | 评测配置设置 `realtimeFeedback` 为 true | | 声通兼容实时评测 | Java,安卓,iOS 与小程序设置 `realtimeFeedback`,网页在参数里传 `realtime_feedback: true` | | 安卓与 iOS 声通平替 | `RecordSetting` 或 `KYTestConfig` 的 `realtime_feedback` 打开后,录音过程中多次回调,`eof` 为 0 的结果沿用声通中间结果的结构,`result.words` 为新读到的字 | - `read` 与 `matched` 在一轮之内只会由 false 变为 true,界面可以直接按这两个字段着色。`read` 为 true 而 `matched` 为 false 的字,表示朗读已越过但识别结果与参考文本不一致,以终评为准。 - 进度一般比朗读晚 1 到 2 秒。读完发出结束帧后,平台对最后一段音频再识别一次,结果在终评之前送达。 - 进度帧不含评分,终评与不打开时相同,不额外计费。 - 断线重连后平台重新计算进度。音频缓冲策略为 `REPLAY` 时 SDK 只回调不倒退的进度,为 `DROP` 时每次重连成功后从零重新计算。 - 只对 16 kHz 16 位单声道 PCM 生效。 帧格式与字段见[朗读进度](https://open.shengzhiai.com/sdk/v2/api/ws-progress/)。 ## 音频帧 | 项 | 要求 | |---|---| | 格式 | PCM,16 位,小端,16 kHz,单声道 | | 帧长 | 推荐 640 字节,即 20 毫秒 | | 拆帧 | 单次送入超过 32000 字节时 SDK 拆成多帧发送 | | 单帧上限 | 128 KB,超出时平台以关闭码 1009 断开 | | 一轮上限 | 10 MB | 录音器直接产出这种格式,安卓 `recorder.start(session)`,网页 `recorder.start({ session })`,小程序 `recorder.pipeTo(session)` 把录音逐帧送进会话,iOS 在 `YuguRecorder` 的 `onFrame` 回调里调用 `session.sendAudio(frame)`。 ## 会话状态 - IDLE 到 CONNECTING:调用 streamEvaluate 后建立连接,iOS 为调用 start 后。 - CONNECTING 到 CONNECTED:平台发来 connected,SDK 随即发出开始帧。 - CONNECTED 到 STARTED:平台回 started,开始送音频。 - STARTED 到 ENDING:调用 end,结束帧已发出。 - ENDING 到 COMPLETED:收到终评。 - CONNECTING,CONNECTED,STARTED,ENDING 到 RECONNECTING:断线,心跳超时,连接超时,终评超时或收到可重试的错误帧。 - RECONNECTING 到 CONNECTING:退避等待后重新连接。 - 任一活动状态到 FAILED:不可重试的错误,或重连次数用尽,错误码 90006。 - 任一活动状态到 CANCELLED:调用方取消或关闭客户端。 - COMPLETED,FAILED,CANCELLED 到 CLOSED:连接释放,会话结束。 | 状态 | 含义 | |---|---| | `IDLE` | 会话已创建,尚未连接 | | `CONNECTING` | 正在建立连接 | | `CONNECTED` | 平台发出 connected,SDK 已发出开始帧 | | `STARTED` | 平台发出 started,音频开始传输 | | `ENDING` | 结束帧已发出,等待终评 | | `RECONNECTING` | 连接中断,等待重连 | | `COMPLETED` | 已收到终评 | | `FAILED` | 以错误结束 | | `CANCELLED` | 调用方取消或客户端关闭 | | `CLOSED` | 连接已释放,会话结束 | 回调约定: 1. 每个会话只触发一次终评回调或错误回调,调用方主动取消时两者都不触发。 2. 连接关闭回调总在最后。 3. 同一会话的回调按顺序执行,互不并发。 4. 任一时刻查询到的会话状态,与最近一次状态变化回调给出的新状态一致。 ## 断线重连 1. SDK 实时会话 到 开放平台:二进制音频帧 2. 应用代码 到 SDK 实时会话:end() 3. SDK 实时会话 到 开放平台:{"cmd":"end"} 4. 开放平台 到 SDK 实时会话:连接断开,终评未送达,未送达 5. SDK 实时会话 到 应用代码:onStateChanged(RECONNECTING) 6. 说明:退避等待 0.5,1,2,4 秒,最多连续 8 次 7. SDK 实时会话 到 开放平台:新握手,幂等键不变 8. 开放平台 到 SDK 实时会话:{"event":"connected"} 9. SDK 实时会话 到 开放平台:开始帧,参数与幂等键不变 10. 开放平台 到 SDK 实时会话:{"event":"started"} 11. SDK 实时会话 到 开放平台:重放这一轮的全部音频 12. SDK 实时会话 到 开放平台:{"cmd":"end"} 13. 说明:平台按幂等键重放首次结果,不重复评测,不重复计费 14. 开放平台 到 SDK 实时会话:{"event":"result",…,"replayed":true} 15. SDK 实时会话 到 应用代码:onResult(result) | 项 | 规则 | |---|---| | 触发条件 | 连接意外断开,心跳超时,连接超时,终评超时,可重试的错误帧 | | 不重连的情形 | 关闭码 1002,1003,1007 到 1010 与 4000 到 4999,会话以 90005 结束 | | 连续重连 | 最多 8 次,等待依次约为 0.5,1,2,4,4,4,4,4 秒,抖动正负 30% | | 一个会话累计 | 24 次 | | 用尽之后 | 会话以错误结束,错误码 90006 | | 结束帧之前断线 | 平台没有开始评测,新会话上评测一次,只计费一次 | | 结束帧之后断线 | 平台按幂等键重放首次结果,首次仍在评测时最多等 30 秒,结果带重放标记 | | 失败的情形 | `REPLAY` 缓冲超过 10 MB 时报 90008,`DROP` 策略在结束帧发出后断线即失败,平台以 1000 关闭却没有终评时报 90005 | 音频缓冲策略: | 策略 | 行为 | 评分覆盖 | |---|---|---| | `REPLAY`,默认 | 缓冲这一轮的全部音频,上限 10 MB,重连后整段重放 | 全部音频 | | `DROP` | 不缓冲,重连期间送入的音频丢弃 | 重连之后的音频 | | `FAIL` | 不重连,断线即以错误结束 | 无 | ## 心跳与超时 | 端 | 心跳 | 判定断线 | |---|---|---| | Java,安卓,iOS | WebSocket 协议层 ping,每 15 秒一次 | 30 秒收不到 pong | | 网页,小程序 | 应用层 `{"cmd":"ping"}`,每 15 秒一次,平台回 `{"event":"pong"}` | 30 秒收不到任何回复 | 网页与小程序的 `heartbeat` 默认取 `auto`:原生会话进入 STARTED 后先发一次探测 ping,平台回 pong 后才按 15 秒发送,同一客户端的声通兼容会话在收到过 pong 之后才发 ping。`heartbeat: true` 强制开启,`false` 关闭。 发出结束帧之后改由终评等待时长判断,默认 300 秒,超时触发重连。协议层细节见[心跳与断线重连](https://open.shengzhiai.com/sdk/v2/api/ws-reconnect/),帧格式见[原生实时评测](https://open.shengzhiai.com/sdk/v2/api/ws-evaluate/)。