所属合集 Paipop AI 对话玩具源码解析 第 10 / 10 篇
- 1 杰理 AC79 应用启动与注册机制详解:从 REGISTER_APPLICATION 到 start_app
- 2 paipopAi对话玩具解析(一):从 APP_STA_START 到眼睛任务与摇一摇服务
- 3 paipopAi对话玩具解析(二):从按键与摇一摇到对话打断
- 4 paipopAi对话玩具解析(三):从按键启动到云端语音回答
- 5 paipopAi对话玩具解析(四):PaipopSDK 如何封装 LingXinSDK
- 6 paipopAi对话玩具解析(五):AI 播放时如何实现自然语音打断
- 7 paipopAi对话玩具解析(六):从开机联网到 AP 网页配网
- 8 paipopAi对话玩具解析(七):从版本检查到双备份 OTA 安全升级
- 9 paipopAi对话玩具解析(八):本地音量与云端动作指令
- 10 paipopAi对话玩具恢复延迟的定位与优化 正在阅读
paipopAi对话玩具恢复延迟的定位与优化
在 Paipop AI 对话玩具的联调过程中,我遇到过一个很影响连续对话体验的问题:普通提问已经比较流畅,但在 AI 播放时用按键或语音打断,再提出新问题,停嘴以后仍然要等大约五秒才听到回答。
旧回答能停下来,新问题也能识别,功能看起来是通的,但两种入口的等待明显不同。有时用户已经说完,麦克风图标还保持一段时间,随后才进入思考和播放。
这不是单纯的界面问题。真正需要缩短的是设备仍在收音、尚未提交本轮输入的时间,而不是提前把图标隐藏。
整个过程经过了几轮优化:本地自动收尾、上传合包、TCP 发送缓冲、空识别保护和旧任务快速交接都处理过。最终改善这次残余差异的关键,是把历史预录音影响过的 VAD 模型状态,与新录音的判停状态隔离开,同时保留已经出现过的语音信息。
改完并烧录后,再次交替测试普通、按键打断和语音打断,主观上两类对话的等待已经基本一致。下面按现象、证据、实现和验证展开记录。
阅读导航
- 第三篇:从按键启动到云端语音回答
- 第四篇:PaipopSDK 如何封装 LingXinSDK
- 第五篇:AI 播放时如何实现自然语音打断
- 第八篇:本地音量与云端动作指令
- 本文:一次从端到端等待追到音频历史状态的优化记录。
一、先把“打断慢”分成两个问题
项目使用杰理 AC791N/WL82。玩具业务层负责状态机、统一麦克风、预录音和播放控制;自研 PaipopSDK 封装第三方 LingXinSDK,对接云端对话能力;本地端点检测使用仓库中已有的 WebRTC VAD 核心。
这里有两个容易混在一起的检测过程:
| 检测过程 | 发生阶段 | 要做出的决定 |
|---|---|---|
| 播放期语音打断检测 | AI 正在讲话 | 是否真有近端用户抢话,可以停止旧回答 |
| 输入期端点检测 | 新一轮正在录音 | 用户这一句是否已经说完,可以提交输入 |
前者慢,表现为喊了几次玩具才停下来;后者慢,表现为玩具已经停止旧回答,自己也说完了,却还在等待。
本次重点处理的是后者。降低播放期的触发门限,可以让打断更容易发生,但不能直接保证打断后的录音更早结束。
按键打断也有同样的等待,是一条很重要的线索:按键不需要经过“是否有人抢话”的声学确认,如果它也慢,就不能只盯着语音打断灵敏度。
二、先建立分段时间线,而不是直接判断“云端慢”
从用户说完到听到回复,中间至少经过:
实际最后一个字结束 ↓本地端点检测认定说完 ↓停止录音,排空已采集音频,发送 end_audio ↓云端完成识别和回答处理,返回首段音频 ↓接收缓冲、解码、音量处理、DAC、扬声器出声我在这些边界增加了分段日志,包括:
pcm_sink_ready:新录音接收通道就绪。task_started:云端确认新任务启动。local_input_end:本地正式判停。upload_stop_begin、upload_sender_done:停止时剩余多少音频,什么时候排空。end_audio finish、asr_final、ai_text_first:结束指令、最终识别、首段回答文本。decoder_first_pcm、decoder_first_signal_pcm:首个解码 PCM 和首个达到诊断能量门限的 PCM。
打断场景还需要观察旧任务退役、新连接建立和新任务确认,但这些操作可能与用户说话并行,不能把所有阶段耗时不加区分地直接相加。
另一个容易误读的地方是时间戳的含义:
“VAD 最后一次判成语音的处理时间”不是“用户实际闭嘴的时间”;“首段 PCM 解码出来”也不是“扬声器实际出声”。
因此,日志用于定位软件中的等待区间,体感用于确认体验是否改善;如果要报告严格的停嘴到声学首声延迟,还需要同步音频测量。
三、前几轮尝试分别解决了什么
这些尝试不是全部无效,而是逐步消除了不同位置的开销。不能因为最终问题还在,就否认前面测到过的真实阻塞;也不能因为一个指标变好了,就宣布整体问题已经解决。
| 尝试 | 处理的问题 | 结果与限制 |
|---|---|---|
| 本地 VAD 主动收尾,尾静音从早期 700ms 调到 400ms | 不再只等待云端通知说完 | 普通对话体验改善,但 400ms 不代表实际闭嘴后一定只等 400ms |
| 有积压时合包,最多合并 16 个 20ms 帧 | 减少发送调用开销,提高追赶积压的能力 | 不等待凑包、不丢尾音;仅合包仍没有消除底层慢发送 |
| 显式启用 TCP_NODELAY | 排查小包延迟因素 | 选项读回生效,但当时积压依然存在,不能把问题简单归为 Nagle |
| 重编玩具专用 lwIP,把发送缓冲从 2400B 扩为 19200B | 解决实际链接网络库发送缓冲较小的问题 | 后续多轮停止时队列已为空,但整体体感仍未达到预期 |
| 区分 VAD 真正判声与内部拖尾,增加明确空 ASR 取消 | 避免重复计入拖尾和无输入后自行回答 | 修复了另一类误触发/回复问题,残余打断等待仍需定位 |
| 显式打断时快速隔离旧连接,再建立新任务 | 避免继续等待旧轮终止 ACK | 新任务确认约为 0.27~0.40s;原来部分样本约需 1.2s,但用户仍感到打断后慢 |
网络缓冲这一轮还有一个值得记录的细节:修改配置必须对应最终真正链接的库。当时玩具使用的是 SFC 版本的 lwIP,另一个头文件中较大的缓冲宏,并不代表当前固件真的使用了那个值。我核对了链接目标和最终产物,再构建玩具专用归档,没有覆盖其他工程使用的原厂库。
快速交接也不是简单关闭 socket 就结束。旧连接的业务事件需要失效,但销毁回调仍要能够释放资源;新轮必须有自己的代际校验,避免旧文本、旧音频或旧错误进入新对话。
这些处理保留在最终版本中。不过,当上传队列已经为空、新任务也早已启动,剩余的五秒等待就必须继续往别处找。
四、真正改变排查方向的是“提交之后并没有更慢”
在最终修改之前,我让普通和打断场景尽量使用同一句“你给我背一首诗吧”,得到下面一组记录:
| 场景 | 录音通道就绪→本地判停 | 本地判停→首段有信号 PCM |
|---|---|---|
| 普通对话 | 3.56s | 2.33s |
| 按键打断样本 A | 5.57s | 1.93s |
| 按键打断样本 B | 4.78s | 2.13s |
| 按键打断样本 C | 4.82s | 2.11s |
其中个别句子带有“你来给我”或“好”等差异,不是严格实验室条件下的完全相同输入。
右边这一列非常关键:**一旦本地真正提交,打断后的后半程并没有额外多等几秒。**这些轮次停止录音时的上传队列也都是空的。
左边一列则包含了实际说话和开始说话前的等待,不能把差值全部当成尾静音。但是结合用户反馈,它明确提示:应该重点看录音提交之前发生了什么。
另一次按键打断的记录更直接:新任务在打断后约 360ms 已经确认,本地判停后仅约 18ms 就完成结束指令发送,随后约 1.80s 解码出首段有信号 PCM。旧任务交接不是这轮残余几秒等待的主要解释。
五、400 毫秒门限为什么还能拖很久
输入期端点检测的核心逻辑可以简化为:
if (raw_vad == 1) { last_voice_ms = audio_ms; silence_ms = 0;} else if (speech_seen) { silence_ms += 20; if (allow_end && silence_ms >= 400) { ended = 1; }}这里的 400ms,是连续被算法判断为非语音的时长,不是从人的物理停声时刻自动开始的倒计时。
如果背景音一直被判断为语音,或者中间偶尔冒出一个语音判断,silence_ms 就会被清零。
一轮按键打断后的日志中,音频平均幅度已经从 10053 降到几百甚至一百多,但仍持续出现:
时间/s mean raw_vad silence_ms115.339 10053 1 0115.839 899 1 0116.342 236 1 0116.839 161 1 0117.339 813 1 0118.839 417 0 400累计的 voiced_ms 也在相应区间持续增长,不只是抽样时恰好碰到了几个语音帧。等到算法最后一次判声之后,400ms 尾静音逻辑本身是正常工作的;问题在于“最后一次判声”被推迟了。
必须强调,mean 是 PCM 幅度指标,不是声压级;幅度低也不能单独证明没有轻声人声。这里的日志提供的是一个应当追查的模式,而不是直接证明“这些全是回声”。
此前已经处理过另一个拖尾问题:当前 vendored core 的原始结果中,1 表示本帧被分类为语音,>1 表示内部保持产生的拖尾。包装后的 fvad_process() 会把二者都压成 1。端点已改用原始结果,避免把内部保持再次计入有效语音或叠加在自己的 400ms 上。
所以,这一次看到的持续 raw_vad=1,不是那个已经排除的包装层拖尾问题。
六、普通和打断的关键差别:VAD 先听到了什么
本地端点 VAD 每轮都会初始化。问题并不是“忘了初始化”,而是初始化以后,它先处理了哪一段音频。
普通对话的输入历史较简单
没有预录音的普通轮,新的端点 VAD 直接接收本轮原生 24kHz 音频。检测副本经过 FIR 低通和降采样,送入 8kHz VAD;上传给云端的 24kHz PCM 不变。
从开始讲话到结束,送入端点的音频处理方式相对一致。
打断必须接上预录音
如果等确认打断、新录音器准备好以后才收音,用户开头几个字可能已经说完了。因此,系统会先保存历史 PCM,再补给新一轮。
本次样本中,按键打断的历史前缀大约为 480~500ms,语音打断的前缀大约为 1.3s,后者还包括候选确认和交接期间采到的声音。
播放期的采音使用 16kHz AEC 路径,带回声消除、非线性处理和降噪;新一轮正式录音切换为没有这些处理的原生 24kHz 采音。
原来的端点处理相当于:
VAD 初始化 ↓播放期间的 AEC/SRC 预录音,更新分类器统计 ↓新一轮原生录音,继续沿用同一套分类器统计 ↓根据这套连续状态判断句尾历史前缀不是按原始时长慢慢播放给算法,而是可以快速补入。保存 1.3s 预录音,不等于固定多等 1.3s。这里需要关注的是音频造成的状态影响,不是仅看缓存长度。
七、自适应 VAD 为什么会受历史影响
这个 VAD 不是简单判断“音量有没有超过固定值”。它会提取不同频带的特征,比较语音和噪声模型,并随输入更新部分统计量。
因此,同样一段尾部声音,前面输入历史不同,内部模型不同,分类结果就可能不同。经过降噪的历史输入与未经同样处理的新输入,在背景能量和频谱分布上可能存在变化。
本次排查形成的机制解释是:
打断前缀先影响了新建端点的自适应统计,再接入处理方式不同的新录音;这种历史状态可能使尾部背景声继续被判成语音,反复清零静音计时。
代码确认了“历史参与模型更新”和“采音处理方式切换”;日志确认了持续判声;最终隔离这些状态后,打断的体感等待恢复到接近普通对话。这些证据支持沿着历史状态处理问题。
不过,最终修改同时清理了分类器状态和 FIR 历史,还重启了尾静音计数,没有逐一拆开做消融实验,也没有同步录到所有实际尾音。因此,不应进一步断言某个具体频带、某个 GMM 参数或某一段扬声器回声就是唯一原因。
八、最终方案:保留内容,隔离模型
最终版本的原则是:音频内容不能丢,但算法不必继承所有历史状态。
8.1 给每个递交给录音器的帧标明来源
我在 paipop_sink_call 中增加 historical 标记:
struct paipop_sink_call { paipop_voice_pcm_sink_t callback; void *user_data; u32 generation; u8 historical;};从 pending FIFO 取出的帧,以及切换期间直接递交的 16kHz AEC/SRC 帧,标记为历史;直接递交的原生 24kHz 帧进入新段。
这个划分发生在实际音频处理顺序上,不是在异步的“打开麦克风”事件里直接清模型。否则采音模式可能已经切换,但队列里仍有历史帧,刚清好的模型又会被旧数据更新。
这里的“历史”指递交顺序中的前缀,不保证全部来自扬声器播放期间。交接中积压的音频也可能在 pending 中;诊断日志里的 history_ms 必须按这个含义理解。
8.2 历史前缀只积累语音信息,不直接提交结束
历史 PCM 仍完整上传,也仍经过端点分类,以保留已达到最短确认要求的语音片段。
但处理历史时,禁止锁定结束结果:
result = paipop_input_endpoint_process_gated( ep, pcm, samples, now_ms, !historical && allow_end);如果历史中恰好有“语音+静音”,后面还有新的一句话,不能在读到历史静音时就结束整轮。
这里保留的是算法已经确认的语音信息,并不等于证明每个历史语音帧都来自用户。明确空 ASR 的取消保护仍然需要保留。
8.3 第一帧新段音频到来前,只清分类器和滤波历史
核心处理可概括为:
if (!ep->native_started && ep->history_audio_ms) { WebRtcVad_InitCore(ep->vad); WebRtcVad_set_mode_core(ep->vad, 1);
memset(ep->history, 0, sizeof(ep->history)); ep->write_pos = 0; ep->decimate_phase = 0; ep->last_raw_vad = 0; ep->silence_ms = 0;}ep->native_started = 1;上面省略了实际实现中的参数、返回值和结束状态检查。对应入口是 paipop_input_endpoint_begin_native(),外围由 paipop_input_endpoint_process_stream() 管理来源顺序。
清理的是 WebRTC core 内部的自适应与滤波状态,以及端点自己的 FIR/抽取历史;FIR 中不能继续残留前一段处理方式不同的样本。
这不是新建一个 VAD 再与旧 VAD 并行运行,而是复用原实例,在边界上重新初始化。native_started 保证一轮只执行一次;普通无历史输入不会额外重置模型。
8.4 不把整个 endpoint 清零
如果在切换点直接清空整个端点结构,会引入另一个问题:用户只说了一个短词,整句话恰好都在预录音里。云端已经收到了这句话,本地却忘记出现过语音,后面一直等用户重新开口,甚至走到无输入超时。
所以必须区分两类状态:
| 状态 | 切换时怎么处理 | 原因 |
|---|---|---|
| VAD 的语音/噪声模型与内部滤波状态 | 重建 | 隔离历史采音条件的影响 |
| 端点 FIR 历史与抽取位置 | 清理 | 避免旧样本进入新段滤波 |
silence_ms | 清零 | 不能把历史静音直接当成新段结束依据 |
speech_seen、部分语音候选信息 | 保留 | 不遗忘预录音中的短句或跨边界语音 |
| 已处理音频时长、最后语音记录 | 保留 | 维持完整输入时间线和诊断信息 |
| 继承的无输入剩余预算 | 保留 | 不能因为切换或重听再延长一个 30 秒窗口 |
“保留语音证据,清除模型历史”就是这次修改最重要的边界。
8.5 新段重新观察 400ms 静音,并继续检查实时队列边界
切换时把静音计时清零,是为了防止历史尾部已经累计足够静音,新录音一进来就立刻提交。
短句完全在历史里时,由于 speech_seen 仍然保留,只要新段继续被判为非语音,就可以正常完成尾静音,不需要重新说一遍。
代价也很明确:相对“历史里已经有足够静音就立即结束”的旧行为,可能需要重新观察一段 400ms 静音。若新段仍被判为语音,计时仍会重新开始,它不是物理停声后绝对 400ms 的保证。
另外,端点达到门限还不够。必须确认已经处理到最新完整音频帧:pending 队列没有遗留后续帧,raw FIFO 中也没有尚未处理的完整帧,原生采音已就绪。
否则,“历史旧句子后面还有新语音”的情况仍然可能被提前截断。
最终端点事件继续带着当前录音 generation 投递到 app_core,而不是在 PCM 回调里直接停止 SDK。音频处理、异步事件和新旧轮次的保护关系没有被绕过。
九、另外一项小调整:让语音打断更容易通过确认
排查期间还抓到一次独立的触发问题:候选声音的强度、持续语音和其他条件都满足日志中的要求,但确认窗口里只有 15/24 个有效帧,占比取整后为 62%,低于原先 65% 的要求,因此被拒绝。约两秒后,下一次候选才确认成功。
针对这个现象,我把 active_pct 从 65 调到 60,保留绝对音量门限、降音复核、保持比例及其他防回声条件。通过已有调参接口应用、读回并保存,重启后也确认保留。
这两个改动解决的是不同阶段:
- 65%→60%:降低播放期打断确认的难度。
- 历史模型隔离:处理成功打断以后,新一轮录音迟迟不结束的问题。
降低占比可能增加误打断概率,因此没有顺带降低全部音量门限,也没有把触发条件改成“检测到一点声音就停播”。
十、验证不只看“结束得更快”
这次修改最危险的回归不是仍然慢,而是看起来很快,实际上把句子截断了。因此主机测试与实机测试关注不同层次。
主机测试验证状态和数据契约
测试直接编译实际端点代码和仓库里的 VAD 核心,覆盖:
- 普通无历史输入与旧逻辑逐帧判定一致。
- 用历史音频更新过真实 VAD 后,切换时模型和 FIR 恢复到新状态;新段分类结果与全新模型对齐。
- PCM 输入内容不被修改。
- 短句全部在预录音里,仍能结束。
- 部分语音跨越历史/新段边界,不丢掉已累计候选。
- 历史静音之后仍有排队的新语音,不提前提交。
- 内部 hangover 不重复算成真语音。
- 无输入预算、计时回绕、非法帧和完整重置行为保持正确。
实机测试验证真实链路和体验
整机编译后,通过 USB 烧录 EP8,检查启动版本、联网、SDK 初始化和调参恢复,再交替测试两种打断与普通对话。
初步有效样本如下:
| 场景 | 识别内容 | 本地判停→首段有信号 PCM |
|---|---|---|
| 普通对话 | 你可以给我背一首诗吗? | 2.11s |
| 按键打断 | 嗯。你可以给我背一首诗吗? | 2.15s |
| 语音打断 | 换一个可以吗? | 2.18s |
新增 history_split 日志确认隔离路径在设备上真正执行。语音打断样本切换时 edge=0,后续仍处理新段排队音频,没有在历史前缀里立即结束。
这些数字主要证明后半程的局部时序相近,不是用来计算这次总共降低了多少秒:旧版本提交后的耗时本来就相近,最终改善的重点是提交之前。
完成交替测试后,主观反馈是普通与打断两类对话的等待基本一致,原来的明显差异不再出现。
同时,测试中仍记录过空 ASR 取消,不能把每一轮短录音都当成成功样本。测试版虽然启用了原始 PCM 导出接口,但当时电脑到设备的连接超时,没有得到同步 WAV。因此,本次结果属于日志、代码对照和实机体感支持的工程验证,还不是严格声学测量,也不能覆盖全部噪声与说话习惯。
十一、需要继续保留的边界
400ms 是产品交互取舍,不是通用最优值。长句中的自然停顿如果超过判停条件,仍可能被分成两轮;轻声尾字、远场说话、高播放音量和持续背景噪声都值得继续回归。
新段中出现语音后,静音计时必须仍然清零,不能为了让结果更快而忽略真实的续说。上传仍要发送已采到的完整音频,不能通过直接丢队列掩盖等待。
原始 PCM 导出是受信局域网内的开发诊断能力,会占用额外资源,连接导出时还会增加网络负载。正式版本应关闭这类测试接口,不应把它作为无认证的常驻对外能力发布。
另外,本次没有通过关闭 ASR 来优化。关闭识别文本上报不等于云端不再做语音理解,也不改变本地什么时候发送结束指令;在已经观察到“本地还没提交”的情况下,这不是解决当前差异的直接路径。
十二、源码与排障记录索引
以下位置相对 AC79 SDK 根目录,描述的是 EP8 联调工作区,不代表第三方原始 SDK 默认就具备这些行为。
| 内容 | 位置 |
|---|---|
| 统一采音、预录音、帧来源标记、实时边界和事件投递 | apps/Paipop_YP_Toy/src/services/paipop_voice_activity.c |
| 端点状态、模型隔离、400ms 判停与无输入预算 | apps/Paipop_YP_Toy/src/services/paipop_input_endpoint.c |
| 端点结构与接口契约 | apps/Paipop_YP_Toy/include/paipop_input_endpoint.h |
| WebRTC VAD 分类与自适应统计实现 | lib/net/device_vad/src/vad_core.c |
| 实际端点与 VAD 主机测试 | apps/Paipop_YP_Toy/tools/test_input_endpoint.c、test_input_endpoint.ps1 |
| 历轮构建、烧录、时序及验证边界 | apps/Paipop_YP_Toy/doc/local_endpoint_EP1.md 至 local_endpoint_EP8.md |
总结
这次优化没有通过提前隐藏麦克风、删除尾部 PCM 或取消安全检查来制造“更快”的表现。我先沿着录音、上传、任务交接、云端返回和播放建立分段计时,处理掉真实存在的上传积压和旧任务等待,再用普通与打断的对照样本,把剩余问题缩小到本地提交之前。
最后的关键发现是:相同的 VAD 参数,不代表相同的输入历史,也不代表相同的实际判停时间。打断为了保住用户句首而引入了历史预录音,而端点又把这些经过不同前处理的音频连续用于更新同一套模型。
我在音频处理边界上清理分类器和滤波历史,保留短句、候选语音和无输入预算,并继续用实时队列边界限制提交。这样既没有牺牲上传内容,也避免了让历史模型状态一直影响新录音的收尾。经过主机回归、整机烧录和交替实测,两种入口的主观等待恢复到基本一致。
这次排障留下的一个重要经验是:实时音频系统中的延迟,不仅藏在函数执行、网络传输和队列长度里,也可能藏在跨阶段沿用的算法状态里。
Some information may be outdated