项目

IELTS:复盘

七个月、744 个提交:从 C++/Qt 面试原型,到 IELTS 命令行,到网站,再到套网页的 Qt 桌面壳。C++ 这边,IELTS 版只复用了类、没复用线程模型;native_voice 只到设备层;10 月底改成先修连接层、再在上面写一层与内容无关的语音会话,第二天面试的追问压力就成了产品里第一处实时语音。网站这边,慢在 Codex 命令行冷启动,语音撕裂误诊两次,评分卡住是因为两个地方在判失败。

本页目录

这篇是 IELTS 的复盘。现在的代码是什么样、有哪些问题,结构篇第 5 节已经列全了,这里讲这些问题是怎么来的,以及下次会怎么做。先说结论:

  • 复用要复用线程模型,不能只复用类。 IELTS 命令行版复用了 RealtimeClient 和音频类,但没复用麦克风、播放两个线程和状态机,结果考官的语音发出去了,却没人播。
  • 新写的东西先打通一条最简单的端到端。 10 月初新写的 native_voice 把最底下的设备层写得很干净,但网络、识别、合成一样都没有,接不进产品。10 月底换了做法:先把旧原型的连接层修好,再在上面写一层只管"说一段、听一段"的语音会话,第二天就接进了桌面壳。
  • 省事的设计都有代价,要写下来。 半双工防住了回声,代价是不能打断;拿对话模型当朗读器,省掉了一个语音合成服务,代价是没法校验它有没有照读。新引擎改用了火山的指定文本接口,后一条才算解决。
  • 接口文档里的参数,要实测过才算数。 判停窗口要另开一个开关才生效,必传的字段旧代码一直没发,这两件都是对着文档逐项核、再实测才发现的。
  • 网站这边三条:慢在调用通道,不在逻辑;修 bug 先拿证据;“什么时候算失败"只能由一个地方判。

1. 七个月,换了四次样子

时间形态做了什么
3 月C++/Qt 桌面原型求职面试模拟:读简历 PDF,大模型出题,用火山引擎实时语音对话服务一问一答;最后用 cppcheck 和 ASan 查了一遍
5 月上旬C++ 命令行改成 IELTS 口语的三个部分,评分可以接 Codex、OpenAI 接口或 Claude
5 月中单文件 Python 网站网页录音、报告、钱包;做了评分校准和一次无人值守的长跑巡检
5 月中到月底Django迁到 Django,旧服务退役;接 HTTP 流式追问、WASM 音频预处理、实时识别
6 到 9 月补功能和收尾语料库、Takeaway、拼写间隔复习、公网提速、新手引导、P3 题库进度、部署文档
10 月Qt 桌面壳用 QtWebEngine 套网页;加了面试模式、AI 助手;新写 native_voice 设备层;对旧 C++ 语音做了一次审计
10 月底到 11 月初实时语音进桌面壳连接层改成一个 I/O 线程独占;新写与内容无关的 VoiceSession;面试追问压力改成实时语音,其余练习用它出字幕

提交数按月是:3 月 13 个,5 月 406 个,6 月 143 个,7 到 9 月一共 25 个,10 月 156 个,11 月到 1 日为止 1 个。从 5 月起,大部分代码是我写规格、Codex 或 Claude Code 实现、我验收。

2. 3 月的旧实时原型

2.1 蓝牙耳机读一块麦克风要 600 ms

麦克风每次读 3200 帧,16 kHz 下应该阻塞 200 ms。换成蓝牙耳机(OPPO Enco X3)以后,Pa_ReadStream 一次要阻塞 600 ms,麦克风循环每一轮要等三倍的时间,才拿到 200 ms 的音频。原因是打开输入流时把 framesPerBuffer 也设成了 3200,macOS 的 Core Audio 在这个设置下回调调度不正常。改成 0(让 PortAudio 自己选内部缓冲大小)以后,就恢复成 200 ms 了。

能看出来,是因为麦克风循环里一直打着"读用了几毫秒、发用了几毫秒"的日志。实时循环里最好常驻一行耗时日志,设备一换,问题马上就能看到。

2.2 半双工:防住了回声,也关掉了打断

面试官说话时,麦克风发静音,USER_START_SPEAKING 也直接忽略,为的是不让扬声器的声音被麦克风录进去。代价是不能打断。代码里还留着一段"候选人抢话就清空播放队列”,可是那个事件在播放期间已经被忽略了,所以这段永远起不了作用。要支持打断,得先解决回声(要求戴耳机,或者做回声消除),再放开这个事件;不能两边各写一半。

2.3 拿对话模型当朗读器

说什么由我们自己的大模型决定,再交给实时对话服务念出来。发过去的文本是:“直接朗读下面文字:……后续对应于面试者的回答都只回复[好的,我们继续],不要承接或评论”。这样一条连接就同时有了识别和合成,但全靠提示词约束,它有没有照读、有没有自己接话,客户端都不知道。

新引擎换了接口:火山本来就有让服务端念指定文字的事件,SayHello(300)和 ChatTTSText(500)。引擎只播这段文字的语音,模型自己生成的回复只记日志、不播(结构篇 2.5)。提示词那条路只留作兜底。

2.4 慢调用放在了播放线程里

给回答打分、生成总结,都是同步的 libcurl 调用,要等几秒,而它们是在播放线程里被调用的,因为"队列空了就处理回答"写在了播放循环里。这几秒里语音只能排队,点停止也要等请求结束。播放线程应该只管播放,处理回答交给单独的线程,结果带上代号,回来时如果这一轮已经作废,就直接丢掉。

2.5 审计补出来的几条

10 月那次审计又找出几条我自己读代码时没注意到的:TLS 设了 verify_none,不校验证书;麦克风线程和播放线程都直接写同一个 WebSocket,没有加锁;握手一完成就标成"已连接",没等服务端确认会话开始;结束时 detach 出去的线程捕获了 this,还不加锁就读队列。

这几条有一个共同点:单人跑通的时候都不会出事,要等到网络慢、退出快、两个会话接得紧,才会暴露出来。所以 ASan、TSan 不能只跑空会话的单测,得配上"快速开始、停止、再开始"这类测试。前三条在连接层,10 月 30 日已经修掉(结构篇 5.4)。

2.6 cppcheck 报了 541 条,自己的代码只有 47 条

报告里绝大多数来自 vcpkg 装的第三方头文件,落在自己代码里的 47 条全是风格类和提示类。ASan 那次提交只加了 13 行配置,没有记录查出了什么。扫描要限定在自己的代码上,跑完要写下结论。

3. IELTS 命令行版:只复用了一半

IELTS 命令行版每答一题,都是临时打开一次麦克风,再把响应回调换成一个只收识别结果的回调。题目打印在终端上;代码也照样发了"直接朗读",但服务端送回来的语音包被这个回调丢掉了,所以实际上没有声音。

问题不在少写了一个播放函数,而在复用的层次:类复用了,线程模型和状态机没复用。 旧原型里真正难写对的,是"什么时候能说、什么时候该听"这一套,可它都在 DialogSession 里,并且和面试内容写在一起,没法单独拿出来。

4. 10 月:桌面壳和实时语音

4.1 套网页的壳,不等于网页的能力都在

桌面壳用 QtWebEngine 套网页,一套界面同时服务浏览器和桌面,这个方向是省事的。但很快就发现,QtWebEngine 虽然暴露了浏览器听写,里面却没有语音服务,一调用渲染进程就会被杀掉,只好把它藏起来。结果是,桌面版的转写只能等录音上传后由后端识别,反而比在普通浏览器里还慢一步。套壳之前,要先把网页依赖的浏览器能力逐个在壳里试一遍。 这个缺口后来由实时引擎的字幕补上(4.4)。

4.2 另起一个新引擎,还是在旧原型上改

审计的结论是"语音引擎继续用 C++,旧原型留作参考和基准,新引擎单独写",于是有了 native_voice:回调式设备层、预分配的环形队列、代号打断,这些都写得很规整。可它只有一个提交,worker 只会把麦克风数据原样回放,计划里真正干活的那几片(连服务端、持续录音和打断、接进桌面壳)一直没开始。

回头看,另起炉灶的代价是:旧原型已经有的东西(协议、识别、念出来、轮流说话),新引擎都得重写一遍,而在重写完之前,产品里一处实时语音都没有。 旧原型的问题,大部分是局部能修的(见结构篇 5.3),真正要动结构的也就那几处。所以 10 月 30 日定下的计划反了过来:在旧原型上先修连接层,再把缓冲块改小、实测,然后拆开 DialogSession,接进桌面壳。

4.3 实际怎么走的:连接层、20 ms,然后另写一层

前两步照计划做了:

  • 10 月 30 日,连接层:改成一个 I/O 线程独占(结构篇 5.4)。
  • 10 月 31 日,读麦克风的块从 200 ms 改成 20 ms:麦克风线程不再被网络卡住,这一步才敢做。实测蓝牙耳机每次读 320 帧,中位 18.8 ms、最长 71 ms,5 秒 250 次没有溢出;2.1 那种"阻塞成三倍"的情况没有再出现。

第三步变了:没有去拆 DialogSession,而是在改好的 RealtimeClient 上另写了一层 VoiceSession,只会"念一段指定的文字"和"听一段、给出转写",旧原型原样不动。题目、追问、评分的逻辑早就在 Django 里,网页只需要一个会说会听的东西,用不着从 DialogSession 里把面试内容剥出来。这一层写完当天就接进了桌面壳,面试的追问压力成了产品里第一处实时语音。

第 7 节里"先写和内容无关的语音回合层"那一条,这次就是照着做的。native_voice 没有用上;它的回调式设备层留着,等实测证明阻塞读写的抖动不够用时再换进来。

4.4 接进产品时踩到的几处

  • 判停窗口要另开一个开关。 配置里把 end_smooth_window_ms 设成 2000 ms,可是停顿 900 ms 就被判成说完了。文档里这个参数属于"自定义判停参数",要 enable_custom_vad = true 才生效。打开以后,实测从停说到"识别结束"是 1995 ms,现在默认打开。另一处是 StartSession 里必传的 dialog.extra.model,旧代码一直没发。两件都是对着文档逐项核、再实测才发现的。
  • 蓝牙耳机当默认输入时出问题。 打开耳机的麦克风,耳机会切到 16 kHz 的通话模式,网页里正在放的声音也被切掉;PortAudio 报的输出延迟将近 1 秒,录到的麦克风信号几乎是静音。现在没有指定输入设备、而默认输入又是蓝牙耳机时,改用内置麦克风,和网页录音的做法一致。
  • 字幕只显示、没入库。 标准模式里引擎一开始只出字幕给人看。可是桌面壳里浏览器听写是关的,服务端识别也没开,这些回答提交上去,文字稿是空的。后来改成交卷时,把这一题的字幕当文字稿一起交,IELTS 各 Part 也一样。
  • 追问被跳过。 追问压力模式下,追问一次都没出现过。页面在本地已经有下一题时,会直接往下走,提交在后台完成,没等服务端插进来的追问。以前碰巧没事,是因为录音要先上传,提交被迫变成同步的;实时语音只交文字、不传音频,这个"碰巧"就没了。现在每一题都等面试官的决定再往下走。换了输入方式,要回头查哪些顺序原本是靠副作用碰巧成立的。

量延迟用的是 tools/voice_probe:把准备好的回答按数字方式混进真实的麦克风信号,房间里不出声,回答在哪一刻结束是已知的。判停和出字的计时,从此不用真人对着麦克风来量。

5. 网站这边

5.1 追问延迟:慢在 Codex 命令行冷启动

网站早期每次追问都要起一个 codex exec 子进程:冷启动再加推理,十几秒起步。追问的逻辑和提示词早就写好了,所以只换"怎么调",不换"问什么":先改成直连 OpenAI 兼容的 HTTP 流式接口(验收时 3 次全部成功,中位数约 4.66 秒,最快 1.46 秒);再改成 SSE 逐字推送,问句一完整就调火山合成语音,从停说到出第一个字实测约 2.11 秒。

有两点要照实说:4.66 秒那组只有 3 个样本,而且用的是当时的另一个接口,后来没有重测;规格里定的"首字 1 秒以内"没有达到。

5.2 考官语音撕裂:误诊两次

现象:P1 第一题的考官语音很顺,从追问开始就撕裂。前两次都是先猜原因、直接改代码:一次猜是播放状态的竞态,一次猜是 readyState 为 3 时开播太早,两次都没用。第三次只加日志,让人对着真麦克风说到撕裂出现,把日志拿回来看。

真因是前端直接播的是火山 TTS 返回的原始 mp3,后端写好的转码那条路没接上。最后换成 howler.js,音源只用转码后的地址,同时删掉了旧的那几套机制。“只有第一题不撕裂”,而第一题恰好是固定的录音,现象一开始就在提示问题出在音源上。

5.3 报告一直"评分中":两个地方在判失败

2026-06-19,worker 跟着终端一起被关掉了,看门狗当时也是关着的,任务一直停在 pending,页面一直转。之后修了三次:后端加了"卡住"判定;前端加了期限;后来前端的期限又改成了无穷大,改为以后端为准。可是后端的判定只放进了历史列表和报告详情,前端轮询的任务查询接口里没有,所以直到现在,worker 一掉线,正在等的页面还是会一直转。“什么时候算失败"只能由一个地方判,改一边漏一边,就是两处各判各的结果。

5.4 分数虚高,和越写越大的文件

  • 分数虚高:模型给分偏松,听起来流利、内容是套话的回答也容易拿 6.5、7 分。做法是先收紧提示词,再在模型出分之后加一道不靠模型的校准;没有真实发音评估时,发音分留空,总分设保守上限。这些规则都写成了测试。
  • 文件越写越大:05-12 的巡检就建议拆 app.js(当时 1149 行)。后端后来拆成了 Django 的几个应用;前端一直没拆,现在 app.js 将近 1.8 万行。拆分要趁功能还在长的时候做。

6. 和 Codex、Claude 分工

规格里固定写这几块:为什么做、做到什么程度算完、明确不做什么、红线。

  • 有用的:写明"不做什么”;把红线写进去,比如密钥只走环境变量、兜底必须带标记、不带上工作区里别人没提交的改动。
  • 不管用的:给它一个猜测的根因让它去修。撕裂那件事,它两次都照着改了,问题还在。诊断类的任务,第一步应该只让它加日志。还有,凡是要真人对着麦克风说话的验收,它都替不了;能做的是先把这类验收改成可以自动跑的,比如 4.4 里把回答混进真实麦克风信号来量计时。审计这种只读的活倒很适合交给它,2.5 节那几条就是它找出来的。

7. 下次会换的做法

  • 先打通一条最简单的端到端,再打磨某一层。
  • C++ 实时链路:先写和内容无关的"语音回合"层(线程、队列、状态机),面试和 IELTS 只往里填"下一句说什么"。
  • 慢调用不放进实时线程,实时线程里常驻一行耗时日志。
  • 并发和生命周期的测试要包括"快速开始、停止、再开始",用 TSan 跑。
  • 套壳之前,先把网页依赖的浏览器能力在壳里逐个试一遍。
  • 半双工、“照读"这类省事的设计,把代价写下来,能校验的就校验。
  • 接外部服务时,对着接口文档逐个字段核,关键参数实测一次。
  • 换了输入方式(比如从"录音上传"换成"只交文字”),回头查哪些顺序原本是靠副作用碰巧成立的。
  • 失败判定只放在后端一处,前端只照着显示。
  • 文件过了一两千行就拆。
  • 遇到怪 bug,先加日志拿证据,再动代码。