# 朗读进度 实时评测过程中逐字或逐词返回参考文本是否已读,是否对上。 来源:https://open.shengzhiai.com/sdk/v2/api/ws-progress/ ## 帧序列,沙箱实录 ```text 0 ms 客户端 GET /sent.eval.cn?appKey=…&nonce=…×tamp=…&signature=… 9 ms 服务端 {"coreType":"sent.eval.cn","event":"connected"} 9 ms 客户端 {"refText":"我是中国人,我是中国人。我们都是中国人。中华民族是一家人。","realtime_feedback":true,"idempotencyKey":"d8d32271…"} 458 ms 服务端 {"coreType":"sent.eval.cn","event":"started"} 458 ms 客户端 二进制帧 228 个,每个 640 字节,共 145920 字节,按录音速度每 20 毫秒一个 1571 ms 服务端 {"eof":0,"recordId":"","result":{"kernel_version":"1.0.0","resource_version":"1.0.0","words":[],"bytes":16000,"readUnits":0,"matchedUnits":0,"totalUnits":25,"progress":[{"word":"我","read":false,"matched":false},{"word":"是","read":false,"matched":false},{"word":"中","read":false,"matched":false},{"word":"国","read":false,"matched":false},{"word":"人","read":false,"matched":false},{"word":"我","read":false,"matched":false},{"word":"是","read":false,"matched":false},{"word":"中","read":false,"matched":false},{"word":"国","read":false,"matched":false},{"word":"人","read":false,"matched":false},{"word":"我","read":false,"matched":false},{"word":"们","read":false,"matched":false},{"word":"都","read":false,"matched":false},{"word":"是","read":false,"matched":false},{"word":"中","read":false,"matched":false},{"word":"国","read":false,"matched":false},{"word":"人","read":false,"matched":false},{"word":"中","read":false,"matched":false},{"word":"华","read":false,"matched":false},{"word":"民","read":false,"matched":false},{"word":"族","read":false,"matched":false},{"word":"是","read":false,"matched":false},{"word":"一","read":false,"matched":false},{"word":"家","read":false,"matched":false},{"word":"人","read":false,"matched":false}]}} 1902 ms 服务端 进度帧 16 个,readUnits 依次为 0,0,6,7,7,10,10,11,11,11,16,17,17,19,19,21 5059 ms 客户端 {"cmd":"end"} 6368 ms 服务端 {"eof":0,"recordId":"","result":{"kernel_version":"1.0.0","resource_version":"1.0.0","words":[{"charType":0,"scores":{"pronunciation":100,"skipped":0,"tone":100,"overall":100,"overall_pron":100},"word":"是","word_parts":[{"beginIndex":0,"charType":0,"endIndex":0,"part":"是"}]},{"charType":0,"scores":{"pronunciation":100,"skipped":0,"tone":100,"overall":100,"overall_pron":100},"word":"一","word_parts":[{"beginIndex":0,"charType":0,"endIndex":0,"part":"一"}]},{"charType":0,"scores":{"pronunciation":100,"skipped":0,"tone":100,"overall":100,"overall_pron":100},"word":"家","word_parts":[{"beginIndex":0,"charType":0,"endIndex":0,"part":"家"}]},{"charType":0,"scores":{"pronunciation":100,"skipped":0,"tone":100,"overall":100,"overall_pron":100},"word":"人","word_parts":[{"beginIndex":0,"charType":0,"endIndex":0,"part":"人"}]}],"bytes":145920,"readUnits":25,"matchedUnits":25,"totalUnits":25,"progress":[{"word":"我","read":true,"matched":true},{"word":"是","read":true,"matched":true},{"word":"中","read":true,"matched":true},{"word":"国","read":true,"matched":true},{"word":"人","read":true,"matched":true},{"word":"我","read":true,"matched":true},{"word":"是","read":true,"matched":true},{"word":"中","read":true,"matched":true},{"word":"国","read":true,"matched":true},{"word":"人","read":true,"matched":true},{"word":"我","read":true,"matched":true},{"word":"们","read":true,"matched":true},{"word":"都","read":true,"matched":true},{"word":"是","read":true,"matched":true},{"word":"中","read":true,"matched":true},{"word":"国","read":true,"matched":true},{"word":"人","read":true,"matched":true},{"word":"中","read":true,"matched":true},{"word":"华","read":true,"matched":true},{"word":"民","read":true,"matched":true},{"word":"族","read":true,"matched":true},{"word":"是","read":true,"matched":true},{"word":"一","read":true,"matched":true},{"word":"家","read":true,"matched":true},{"word":"人","read":true,"matched":true}]}} 9411 ms 服务端 {"recordId":"eval_56edfae11729","result":{"overall":78,"pronunciation":82.33,"fluency":60.96,"integrity":93.77,"tone":83,"rhythm":81,"speed":329,"duration":"4.560","warning":[],"words":[{"word":"我","scores":{"overall":40}},{"word":"是","scores":{"overall":100}},{"word":"中","scores":{"overall":78}},{"word":"国","scores":{"overall":70}},{"word":"人","scores":{"overall":65}},{"word":"我","scores":{"overall":90}},{"word":"是","scores":{"overall":100}},{"word":"中","scores":{"overall":85}},{"word":"国","scores":{"overall":82}},{"word":"人","scores":{"overall":75}},{"word":"我","scores":{"overall":100}},{"word":"们","scores":{"overall":70}},{"word":"都","scores":{"overall":80}},{"word":"是","scores":{"overall":94}},{"word":"中","scores":{"overall":100}},{"word":"国","scores":{"overall":76}},{"word":"人","scores":{"overall":85}},{"word":"中","scores":{"overall":85}},{"word":"华","scores":{"overall":85}},{"word":"民","scores":{"overall":92}},{"word":"族","scores":{"overall":100}},{"word":"是","scores":{"overall":88}},{"word":"一","scores":{"overall":100}},{"word":"家","scores":{"overall":76}},{"word":"人","scores":{"overall":82}}]},"eof":1} ``` ## 示例代码 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 refText = '我是中国人,我是中国人。我们都是中国人。中华民族是一家人。'; const query = { appKey: process.env.YUGU_APP_KEY, nonce: crypto.randomBytes(16).toString('hex'), timestamp: String(Math.floor(Date.now() / 1000)), }; 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/sent.eval.cn?' + new URLSearchParams({ ...query, signature })); const wav = await readFile('audio.wav'); const pcm = wav.subarray(wav.indexOf('data') + 8); ws.on('message', (data, isBinary) => { if (isBinary) return; const msg = JSON.parse(data.toString()); if (msg.event === 'connected') { const idempotencyKey = crypto.randomBytes(16).toString('hex'); ws.send(JSON.stringify({ refText, realtime_feedback: true, idempotencyKey })); } else if (msg.event === 'started') { // 按录音的速度送音频,每 20 毫秒 640 字节,读完发结束帧 let i = 0; const timer = setInterval(() => { if (i < pcm.length) { ws.send(pcm.subarray(i, i + 640)); i += 640; return; } clearInterval(timer); ws.send(JSON.stringify({ cmd: 'end' })); }, 20); } else if (msg.eof === 0 && msg.result.words) { // 已读且对上的字原样输出,已读未对上的字加方括号,未读的字输出为点 const line = msg.result.words.map((w) => (w.matched ? w.word : w.read ? `[${w.word}]` : '·')).join(''); console.log(`${msg.result.readUnits}/${msg.result.totalUnits}`, line); } else if (msg.eof === 1) { 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") .realtimeFeedback(true), new StreamListener() { @Override public void onPartial(StreamPartial p) { if (!p.hasProgress()) { return; } StringBuilder html = new StringBuilder(); for (ReadingWord w : p.getProgress()) { String color = w.isMatched() ? "green" : w.isRead() ? "red" : "gray"; html.append("").append(w.getWord()).append(""); } ui.showReference(html.toString(), p.getReadUnits() + "/" + p.getTotalUnits()); } @Override public void onResult(EvalResult result) { ui.showScore(result.getOverall()); } @Override public void onError(YuguException error) { ui.showError(error); } }); ``` 安卓: ```kotlin val refText = "我是中国人,我是中国人。我们都是中国人。中华民族是一家人。" val session = client.streamEvaluate( EvaluateConfig(CoreType.SENTENCE, refText, Language.ZH_CN, realtimeFeedback = true), object : StreamListener { override fun onPartial(partial: PartialResult) { if (!partial.hasProgress) return val text = SpannableString(refText) var from = 0 for (w in partial.progress) { val start = refText.indexOf(w.word, from) // progress 去掉了标点,按顺序在原文里找 if (start < 0) break from = start + w.word.length val color = when { w.matched -> Color.rgb(46, 125, 50) // 已读,识别一致 w.read -> Color.rgb(198, 40, 40) // 已读,识别不一致 else -> Color.GRAY // 未读 } text.setSpan(ForegroundColorSpan(color), start, from, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) } refTextView.text = text } override fun onResult(result: EvalResult) { scoreView.text = "总分 ${result.overall}" } override fun onError(error: YuguException) { scoreView.text = "评测失败 ${error.code}" } }, ) ``` iOS: ```swift let refText = "我是中国人,我是中国人。我们都是中国人。中华民族是一家人。" let config = EvaluateConfig(coreType: .sentence, referenceText: refText, language: "zh-CN", realtimeFeedback: true) let handlers = YuguStreamHandlers() handlers.onPartial = { partial in guard partial.hasProgress else { return } label.attributedText = colored(refText, partial.progress) } handlers.onResult = { result in show(result) } handlers.onError = { error in showError(error) } let session = try client.streamEvaluate(config: config, listener: handlers) // 已对上的字为绿色,读过但没对上的为橙色,还没读到的与标点为灰色 func colored(_ refText: String, _ progress: [ReadingWord]) -> NSAttributedString { let text = NSMutableAttributedString(string: refText, attributes: [.foregroundColor: UIColor.systemGray]) let ns = refText as NSString var from = 0 for w in progress { let range = ns.range(of: w.word, options: [], range: NSRange(location: from, length: ns.length - from)) if range.location == NSNotFound { break } let color: UIColor = w.matched ? .systemGreen : (w.read ? .systemOrange : .systemGray) text.addAttribute(.foregroundColor, value: color, range: range) from = range.location + range.length } return text } ``` 网页: ```js const referenceText = '我是中国人,我是中国人。我们都是中国人。中华民族是一家人。'; const session = client.streamEvaluate( { coreType: 'sentence', referenceText, language: 'zh-CN', realtimeFeedback: true }, { onPartial: (p) => { if (!p.hasProgress) return; textBox.replaceChildren(...p.progress.map((w) => { const span = document.createElement('span'); span.textContent = w.word; span.style.color = w.matched ? 'green' : w.read ? 'red' : 'gray'; return span; })); countLabel.textContent = `${p.readUnits} / ${p.totalUnits}`; }, onResult: (result) => console.log('总分', result.overall), onError: (error) => console.warn('评测失败', error.code, error.message), }, ); await recorder.start({ session }); ``` 小程序: ```js Page({ data: { units: [], overall: null }, start() { this.session = this.client.streamEvaluate( { coreType: 'sentence', referenceText: '我是中国人,我是中国人。我们都是中国人。中华民族是一家人。', language: 'zh-CN', realtimeFeedback: true }, { onPartial: (p) => { if (!p.hasProgress) return; this.setData({ units: p.progress.map((w, i) => ({ i, text: w.word, state: w.matched ? 'matched' : w.read ? 'mismatched' : 'unread' })), }); }, onResult: (result) => this.setData({ overall: result.overall }), onError: (err) => wx.showToast({ title: err.message, icon: 'none' }), }, ); this.recorder.pipeTo(this.session); this.recorder.start(); }, }); ``` ## 打开方式 | 接口 | 打开朗读进度 | |---|---| | [原生实时评测](https://open.shengzhiai.com/sdk/v2/api/ws-evaluate/) | 开始帧带 `"realtimeFeedback": true` | | [声通兼容实时评测](https://open.shengzhiai.com/sdk/v2/api/ws-compat/) | 参数帧带 `"realtime_feedback": true` | ## 进度帧 原生开始帧带 `"realtimeFeedback": true`,或兼容参数帧带 `"realtime_feedback": true` 时,服务端在收音过程中下发进度帧。 兼容通道的进度帧沿用声通 `realtime_feedback` 中间结果的结构: ```json {"eof": 0, "recordId": "", "result": {"kernel_version": "1.0.0", "resource_version": "1.0.0", "words": [{"charType": 0, "scores": {"pronunciation": 100, "skipped": 0, "tone": 100, "overall": 100, "overall_pron": 100}, "word": "华", "word_parts": [{"beginIndex": 0, "charType": 0, "endIndex": 0, "part": "华"}]}], "bytes": 96000, "readUnits": 19, "matchedUnits": 18, "totalUnits": 25, "progress": [{"word": "我", "read": true, "matched": true}, {"word": "民", "read": false, "matched": false}]}} ``` 原生通道的进度帧: ```json {"eof": 0, "result": {"bytes": 96000, "readUnits": 19, "matchedUnits": 18, "totalUnits": 25, "progress": [{"word": "我", "read": true, "matched": true}, {"word": "民", "read": false, "matched": false}]}} ``` | 字段 | 通道 | 说明 | |---|---|---| | `recordId` | 兼容 | 空串,评测记录编号在终评中给出 | | `result.kernel_version`,`result.resource_version` | 兼容 | 与终评相同 | | `result.words` | 兼容 | 本帧新读到的字或词,按参考文本顺序,覆盖第 `readUnits - words.length` 到第 `readUnits - 1` 个单位,没有新读到的字时为空数组。每项结构同终评的 `words`,分数只表示识别结果与参考文本是否一致,详见下文 | | `result.bytes` | 两条 | 这一轮服务端已收到的音频字节数 | | `result.progress` | 两条 | 参考文本按原文顺序逐字或逐词列出的朗读状态。中文按字,去掉标点与空白。英文按空白分词,去掉词首词尾的标点,词内的撇号与连字符保留 | | `result.progress[].word` | 两条 | 字或词,取自参考文本 | | `result.progress[].read` | 两条 | 朗读位置已越过这个单位 | | `result.progress[].matched` | 两条 | 识别结果与这个单位一致 | | `result.readUnits` | 两条 | `read` 为 true 的单位数,即当前朗读位置 | | `result.matchedUnits` | 两条 | `matched` 为 true 的单位数 | | `result.totalUnits` | 两条 | 单位总数,等于 `progress` 的长度 | - 一轮之内 `read` 与 `matched` 只会由 false 变为 true,不会退回。`matched` 为 true 时 `read` 也为 true。`read` 为 true 而 `matched` 为 false,表示朗读已越过这个位置但识别结果与参考文本不一致,可能是读错,漏读或识别误差,以终评为准。 - 兼容通道的 `words` 是增量:每个单位只在朗读位置刚越过这个单位的那一帧出现一次,把各帧的 `words` 依次拼起来就是已读的单位。识别结果一致的单位各项分数为 100,`skipped` 为 0,不一致的各项分数为 0,`skipped` 为 1,英文的 `tone` 为 0。下发之后不再更新,发音与声调的实际得分以终评为准。界面跟随朗读位置时,可以累加 `words` 的个数,也可以直接取 `readUnits`。 - 进度变化时立即下发一帧,此外每收约 0.5 秒音频下发一帧。进度一般比朗读晚 1 到 2 秒,随网络情况变化。结束帧发出后,服务端对最后一段音频再识别一次,结果以进度帧在终评之前送达,终评帧是这一轮的最后一帧,之后没有进度帧。 - 进度帧不含发音评分。终评与不打开朗读进度时相同,不额外计费。 - 按字还是按词由语言决定:原生开始帧的 `language` 以 zh 开头,或兼容 coreType 以 `.cn` 结尾或为 `pinyin` 时按字,其余按词。兼容参数帧带 `language` 时按 `language` 判断。 - 进度只对 16 kHz 16 位单声道 PCM 生效,一轮开头带 WAV 头的同样生效。其他封装格式的音频照常出终评,进度帧只有 `bytes`。 - 进度字段只在这一轮能跟踪进度时出现。参考文本为空,音频不是 PCM,或服务端当时无法跟踪时,进度帧为 `{"eof": 0, "result": {"bytes": n}}`,终评不受影响。一旦出现,这一轮之后的进度帧都会带上,内容为最新状态。 - 断线重连后,服务端在新会话上重新计算进度,重放音频期间进度从零追上来,兼容通道的 `words` 也从第一个单位重新下发。SDK 只回调 `readUnits` 与 `matchedUnits` 都不小于上一次回调的进度帧,音频缓冲策略为 DROP 时每次重连成功后从零重新计算。声通平替层按位置去掉已经回调过的单位。