实时评测在一个 WebSocket 连接上边录边传,读完发出结束帧后返回终评。连接意外断开时 SDK 用同一个幂等键重连,重放这一轮已发的音频,评分覆盖整段录音,平台不重复计费。
会话时序#
调用#
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);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()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()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();
}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 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 生效。
帧格式与字段见朗读进度。
音频帧#
| 项 | 要求 |
|---|---|
| 格式 | 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 |
正在建立连接 |
CONNECTED |
平台发出 connected,SDK 已发出开始帧 |
STARTED |
平台发出 started,音频开始传输 |
ENDING |
结束帧已发出,等待终评 |
RECONNECTING |
连接中断,等待重连 |
COMPLETED |
已收到终评 |
FAILED |
以错误结束 |
CANCELLED |
调用方取消或客户端关闭 |
CLOSED |
连接已释放,会话结束 |
回调约定:
- 每个会话只触发一次终评回调或错误回调,调用方主动取消时两者都不触发。
- 连接关闭回调总在最后。
- 同一会话的回调按顺序执行,互不并发。
- 任一时刻查询到的会话状态,与最近一次状态变化回调给出的新状态一致。
断线重连#
| 项 | 规则 |
|---|---|
| 触发条件 | 连接意外断开,心跳超时,连接超时,终评超时,可重试的错误帧 |
| 不重连的情形 | 关闭码 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 关闭。