语音交互(流式 TTS 打断):让车听话、会插嘴也接得住
语音链路"唤醒 → 录音 → ASR → LLM/指令 → TTS"任一环节卡壳,整台车就变哑巴或话痨。核心矛盾有三个:抢话(播报被麦克风重新识别成用户说话)、迟到播报(用户已经打断,旧回复才姗姗来迟)、空转(唤醒后没人说话,录音线程永远听下去)。解法是一台 三态状态机 VoiceLoop:IDLE 等唤醒 → LISTENING 识别(10s 无话自动关窗)→ SPEAKING 播报(playback_event 播报互斥防回声,新唤醒 epoch+1 让 LLM worker 丢弃迟到指令"epoch 已变,丢弃迟到指令")。流式 TTS 按 _SENTENCE_RE 切句首句即播,不等整段合成完。
01问题引入:抢话、回声与迟到播报
EXP-05/06 把"话怎么变成锁定动作"讲透了,但都是键盘敲字喂进去的。接上真麦克风后,三个新麻烦立刻出现:
02原理通俗讲解:三态接力与取餐号
IDLE 是前台低头待机;听到"小飞小飞"像客人按铃 → 抬头接待(LISTENING,开录音听需求);听清了要办事 → 转身去办并回复(SPEAKING,播报)。办完(播完 + 双次排空)再低头待机。一个前台同一时刻只做一件事,所以永远不会既听又说。
每次打断就 _epoch += 1(voice_loop.py L398-404),如同奶茶店把取餐号往上翻一位。LLM worker 办事前抄下了入场时的号,回来一比对"号变了"(epoch_getter() != ep,track_app L256-259)——说明期间用户已经打断重来,这份迟到回复直接丢弃,绝不播出去。
类似打电话时把听筒静音:TTS 播报期间置位 playback_event(_build_speech L105-115 同时传给 Speaker 与 VoiceLoop),SPEAKING 态不喂 ASR——扬声器再响麦克风也不录,回声进不了解码器;播完 interrupt() 清 event 放开。
同声传译不会等讲者全说完才开口。LLM 每吐几个 token,_SENTENCE_RE 就按 。!?;,、 等标点切出一句,Matcha-TTS 立刻合成一句播一句(max_num_sentences=1)——首句即播,用户听到的第一个字提早了好几秒。
开听后 10 秒没有 partial 识别结果 → _close_stream() 关窗回 IDLE(_handle_listening L361-367)。类似拨通电话那头一直没声音就挂断,防止录音线程空转与误触发。
kws.threshold=0.25 是门卫认人脸的相似度门槛:调低(更松)容易把路人甲当熟人放进来(误报),调到 0.4(更严)误报降了但自家主人可能被拦(漏报)。端点检测 rule1=4.5s / rule2=1.5s / rule3=300ms 则像靠"停顿多久"判断话说完了,不是死等固定时长。
03关联知识点:语音链路三段
| 编号 | 知识点 | 本实验对应内容 |
|---|---|---|
| 5.1.1 | 唤醒词(软件 KWS / XFM 六麦 aiui 串口)→ 录音线程启动 plughw:4,0 | notify_wake L149-158 才 _ensure_capture;词表 ["小飞小飞","小飞同学"];XfMicArray 3s 防抖 |
| 5.1.2 | 流式 ASR(zipformer_onnx)增量解码 + 端点检测 | 每帧 0.1s@16k feed() 增量解码,返回 (partial, final);[ASR 实时] 逐字回显来自 partial |
| 5.1.3 | 流式 TTS 按句合成播放 + 播报互斥 + 打断丢弃 | _SENTENCE_RE 切句首句即播;playback_event 互斥;epoch+1 丢弃迟到回复 |
| — | VoiceLoop 单状态机三态 | IDLE → LISTENING → SPEAKING;feed_text L164-196 与音频走同一回调链 |
| — | 热词与解码方法 | hotwords 仅 modified_beam_search 生效(asr.py L36-37/L58-59);默认 greedy_search 不支持 |
| — | 录音健康自愈 | read(1600)=0.1s/帧,5s 期望 50 帧;HEALTH_LOW_FPS=30(L38)、连续 2 窗口重开、上限 3 次冷却 30s |
前置:EXP-02 的 CommandWorker 与 EXP-05 的指令门控是本实验的下游——语音只是换了入口,喂进的仍是同一条 _start_listening → _handle_listening → _invoke_dialog 回调链。
04实验目标
跑通唤醒 → ASR → LLM 对话/指令 → TTS 流式播报闭环,完成 V1-V10 语音用例。
录音线程为何必须硬件唤醒后才启动(XFM 时序硬约束);playback_event 互斥与 epoch 打断丢弃的分工。
唤醒 / 打断 / 流式 TTS / 无话超时 / 热词五个方向,逐条留日志证据。
调整 kws.threshold 观察误报变化,理解"阈值-误差"权衡的记录方法。
--text 文本模式驱动同一状态机——无麦克风先验证逻辑的"硬件后置"调试法。
05实验环境与准备
| 类别 | 内容 |
|---|---|
| 硬件 | 具身智能实验箱 B2 / 机器人小车 C1 + 讯飞六麦阵列 XFM + 扬声器 |
| 系统 | Ubuntu 22.04 + ROS 2 Humble,Python 3.10 |
| 核心库 | sherpa_onnx(ASR/KWS)、Matcha-TTS(TTS)、RKLLM |
| 模型 | 板端 /home/iflytek/models/{zipformer_kws, zipformer_onnx, tts} |
| 参考文档 | docs/07_语音测试用例.md(V1-V10) |
先做清理,占用播放/串口/LLM 端口的残留进程是本实验第一大坑:
cd ~/ros2_ws/src/xflz_eai_car1/app/object_tracking pkill -9 aplay; pkill -f llm_server # 清播放设备与 8080 端口 grep -n "kws.enabled\|threshold\|hardware_wakeup" config/track_config.yaml # 确认唤醒配置
06动画讲解:三态流转与 epoch 打断
图:三态互斥保证"不同时听说";底部红线是无话关窗,顶部粉线是打断折返——epoch 就在折返那一下 +1。
07互动讲解:语音状态机步进台
← 按唤醒 → 说话 → 定稿 → 播报的顺序点击;再到 SPEAKING 时点"新唤醒"看 epoch 打断
七条操作覆盖 V 用例主干:唤醒开录音(XFM 路径先收 aiui_event 再 _ensure_capture);10s 超时无条件关窗回 IDLE;播报中唤醒 epoch+1,迟到回复被 worker 拦截;回声尝试演示 playback_event 挡下自录。
08实操步骤:五步语音用例
步骤 1 · 文本模式状态机(同一 VoiceLoop):
cd ~/ros2_ws/src/xflz_eai_car1/app/object_tracking python3 apps/track_app.py --video /home/iflytek/ros2_ws/src/xflz_eai_car1/vision/07_multi_object_tracking/test.mp4 --text 小飞小飞 → [文本] 唤醒 → 开始聆听(mode=DIALOG) 追踪红色皮卡 → LLM →(描述无 id)→ VLM 选车 ;你好 → 闲聊 ;清除 → 解除 # _merge_args L74-93:--text 置 speech.text_mode=True,关 kws.enabled / hardware_wakeup.enabled
步骤 2 · 语音全链路 + XFM 串口排障三板斧:
pkill -9 aplay; pkill -f llm_server # 另终端起相机: source /opt/ros/humble/setup.bash && source ~/ros2_ws/install/setup.bash ros2 launch car1_peripherals depth_camera.launch.py python3 apps/track_app.py --camera --mjpeg # 喊 小飞小飞 → 检测到唤醒 → 录音线程: plughw:4,0 # 报"XFM 串口无响应"时按序排查: sleep 120 && grep -n "XFM\|xfm_wake" runs/logs/track_app.log # ① 等冷启动自愈 python /home/iflytek/ros2_ws/src/xflz_eai_car1/interaction/car1_speech/tools/xf_mic_array.py # ② 独立探测固件 pkill -f log_server; ls -l /dev/ttyACM0 # ③ 停固件 log 占用(VID:PID=1a86:55d4)
步骤 4 · 流式 TTS 与打断断言:
[ASR 结果] 追踪红色皮卡 → [cmd] track_vlm(描述带色无 id → VLM 网格选车) [tts] 合成第 1 句 → 立即 aplay 播出"好的"(不等整段合成) # _SENTENCE_RE 切句、max_num_sentences=1 # 播报中再喊"小飞小飞":_interrupt L398-404 → epoch+=1 → 杀 aplay 清队列 [track_app] epoch 已变,丢弃迟到指令 # worker 在 apply 副作用前校验(L256-259)
步骤 5 · kws 单变量调参 + 热词解码确认:
cp config/track_config.yaml /tmp/tc.bak sed -i 's/kws.threshold: 0.25/kws.threshold: 0.4/' config/track_config.yaml python3 apps/track_app.py --camera --mjpeg # 固定场景重跑,统计误报/漏报 grep -n "decoding_method\|hotwords" config/track_config.yaml # greedy_search 下热词不生效! cp /tmp/tc.bak config/track_config.yaml # 实验完还原
09重点与易错点
- 录音线程唤醒后才启动不是简化是硬约束:XFM 唤醒事件后才出可用音频流,提前常开会把设备停在监听态稀疏流且唤醒后不恢复。
- --text 首行必须喂唤醒词:stdin 第一行"小飞小飞"才进 DIALOG;text_mode 同时关掉 KWS/硬件唤醒,直接输指令会被当闲聊。
- 播报互斥失效 = 播报被识别:若扬声器声音进了 ASR,说明 playback_event 未生效——重启 track_app 复验,不要在识别词表里"抵消"。
- 打断丢弃靠 epoch 不靠 kill 识别:SPEAKING 中新唤醒 → epoch+1 → LLM worker 返回后自校验丢弃;进程还在、号已作废。
- 热词先看解码方法:config/hotwords.txt 仅在 modified_beam_search + garbage_score 下生效,默认 greedy_search 完全忽略热词。
- 录音健康阈值:[diag] 录音 X 帧/5s,X≈50 正常;<30 连续 2 窗口触发重开(≤3 次、冷却 30s);X≈10 持续则查设备与 ASR 线程,[diag] 队列 0 = 消费正常。
- XFM 冷启动耐心等:串口无响应多为固件未就绪,后台自愈 1~2 分钟自动接管;持续不通再走 step2 三板斧。
| 问题 | 原因 | 解决 |
|---|---|---|
| XFM 串口无响应(第 N 轮) | 固件冷启动/未就绪,后台自愈探测 | 等几分钟自动接管;或 xf_mic_array.py 手动验证固件 |
| 唤醒后无录音 | XFM 事件未到 / ttyACM0 被占 | pkill -f log_server 停固件 log,查串口枚举 |
| ASR 无实时回显 | ASR 模型/线程异常 | 文本模式先确认模型文件与线程 |
| 播报无声 | aplay 被残留进程占用 | pkill -9 aplay 后重跑 |
| 误报多 | kws.threshold 过低(0.25 太松) | 单变量调到 0.4,统计误报/漏报取交点 |
| 播报被识别进用户话 | playback_event 互斥失效 | 重启 track_app 复验,不改词表 |
10总结与思考
本实验把语音链路从"能出声"推进到"接得住话":三态状态机是唯一时序控制点,playback_event 解决声学回声,epoch 解决时间竞态,流式切句解决响应延迟,10s 关窗解决空转——每个机制各钉一颗钉子。--text 与音频同链的设计也演示了"逻辑先行、硬件后置"的调试方法论。产出:语音链路日志、打断丢弃观察、kws 调参记录、V1-V10 用例表。