上月在座舱语音实车验收中,遭遇了一起典型尴尬案例。
中间约 0.6 秒的停顿,被系统误判为“说完”。ASR 随即截断并下发意图,导航开始搜索泛指的“机场”,导致后半句关键信息丢失。用户质疑系统故意作对,团队当晚立即立项排查。
最终定位发现,问题根源并非 ASR 或大模型,而是两个常被忽视的组件:VAD(语音活动检测)与 EoT(话轮结束检测)。
本文将从原理到落地,完整解析这两个核心概念,并提供可直接复用的代码骨架。
VAD 是什么:声学层的“门卫”
VAD(Voice Activity Detection)的核心定义是:判断当前音频片段中是否存在人声。
它工作在声学层,仅分析声音信号而不理解语义。如同门口保安,只关心“是否有人”,不关心“说了什么”。
| 代际 |
原理 |
特点 |
| 第一代 |
能量阈值 |
依赖音量阈值,易受风噪干扰产生误判 |
| 第二代 |
频谱特征(如 WebRTC VAD) |
利用基频、频谱平坦度等特征,抗噪性提升,但难以区分音乐与人声 |
| 第三代 |
神经网络(如 Silero VAD) |
逐帧输出说话概率,抗噪能力最强,已成为座舱标配方案 |
座舱环境复杂,风噪、胎噪、空调声及后排干扰极大。因此,车载场景普遍采用神经 VAD,且必须配合 AEC(回声消除),防止系统将自身播报声误判为用户输入,导致“自问自答”。
VAD 输出为逐帧的 0/1 信号,仅回答“有没有”,不回答“完没完”。这一区别正是体验问题的根源。
EoT 是什么:语义层的“秘书”
EoT(End of Turn),即话轮结束检测(亦称语义端点检测)。其核心定义是:判断用户指令是否表达完整,系统可否执行。
EoT 工作在语义层,分析转写文本而非声音信号。优秀的 EoT 能像秘书一样,从措辞中判断事务是否交代完毕,无需等待长时间的沉默。
传统方案缺乏 EoT,仅依赖静音超时机制:当 VAD 检测到连续静音超过阈值(通常 500-800ms)即判定结束。这种机制无法区分“思考停顿”与“说完”,常导致用户在组织语言时被强行截断,体验崩塌。
语义 EoT 通过将 ASR 实时文本流输入小模型,预测“语句完整概率”。例如,“导航去机场”可能得分 0.62(不完整,继续等待),而补充“我说的是首都机场”后得分跳至 0.93(完整,执行)。
VAD 与 EoT 的核心差异
| 维度 |
VAD |
EoT |
| 全称 |
Voice Activity Detection |
End of Turn |
| 工作层级 |
声学层(音频信号) |
语义层(转写文本) |
| 核心问题 |
现在有人在说话吗? |
这句话说完了吗? |
| 输入数据 |
原始音频帧(10-30ms/帧) |
ASR 实时转写文本流 |
| 典型实现 |
神经分类器(如 Silero VAD) |
小模型分类器 / LLM 打分 |
| 常见错误 |
噪声误判、弱音漏检 |
过早截断(抢话)、判定延迟 |
| 失效后果 |
系统“耳聋”或“幻听” |
系统“抢话”或“反应迟钝” |
| 关键指标 |
漏检率、误检率 |
早截断率、判定延迟 |
VAD 决定系统是否“听到”,EoT 决定系统是否“等待”。前者管耳朵,后者管脑子。两者需协同工作:VAD 切分出有效语音段送交 ASR,EoT 判定转写内容完整性后送交下游执行。
代码实战:VAD 与 EoT 协同实现
首先是 VAD 状态机,管理“空闲→收音→静音观察”的流转:
# 流式 VAD 状态机(车载简化版)class VadStateMachine: """ 状态:IDLE(无人说话)→ SPEAKING(收音中)→ TRAILING(出现静音,观察期) - frame_ms: 单帧音频时长,通常 10-30ms - speech_thresh: 神经 VAD 输出的说话概率阈值 - silence_timeout_ms: 静音超时,传统方案的截断线 """ IDLE, SPEAKING, TRAILING = "idle", "speaking", "trailing" def __init__(self, speech_thresh=0.5, silence_timeout_ms=700): self.state, self.speech_thresh = self.IDLE, speech_thresh self.silence_timeout_ms = silence_timeout_ms self.silence_ms = 0 # 已累计静音时长 def on_frame(self, speech_prob: float, frame_ms: int) -> str: if speech_prob >= self.speech_thresh: # 有语音,静音清零 self.silence_ms = 0 self.state = self.SPEAKING return "keep_listening" # 无语音:累加静音,进入观察期 self.silence_ms += frame_ms self.state = self.TRAILING if self.silence_ms >= self.silence_timeout_ms: return "vad_timeout" # 静音超时,触发截断判定 return "keep_listening"
上述代码将“静音超时”从固定常数变为可调节的状态机参数。但需注意,vad_timeout仅是候选信号,最终是否截断由 EoT 决策:
# 语义 EoT 打分:判断转写文本是否构成完整指令(简化版)def eot_score(partial_text: str) -> float: """ 返回这句话"已说完"的概率,生产上由小模型输出,此处为规则示意: - partial_text: ASR 实时转写的累积文本 """ if not partial_text.strip(): return 0.0 # 悬空连接词/介词结尾:大概率没说完("导航去""放一首") dangling = ("去", "到", "放", "放一首", "搜索", "打开") if partial_text.rstrip().endswith(dangling): return 0.35 # 修正性表述("不是…我说的是…"):等待最终落点 if "我说的是" in partial_text and not partial_text.rstrip().endswith(("场", "站", "店")): return 0.5 # 有明确槽位(目的地/温度/歌名),判定完整 return 0.92EOT_THRESHOLD = 0.8 # 高于此值才允许截断下发def decide_endpoint(partial_text: str, vad_event: str) -> str: if vad_event == "vad_timeout" and eot_score(partial_text) >= EOT_THRESHOLD: return "fire" # 静音超时 + 语义完整 → 下发执行 if eot_score(partial_text) >= 0.95: return "fire" # 语义高度完整,可提前截断,抢回延迟 return "wait" # 继续等,给用户把话说完的机会
逻辑解读:采用“一票否决制”。若 VAD 提示停顿但 EoT 判定语义不完整(如得分 0.35),系统将继续等待。此外,当语义完整度极高(≥0.95)时,系统可不等静音超时直接截断,从而将首响延迟降低 200-400ms。
座舱场景的三大特殊挑战
1. 打断场景防误判:助理播报时用户插话,音频混有 TTS 回声。需先经 AEC 处理,确认打断后再切换至聆听模式,重置 EoT 判定。
2. 免唤醒场景阈值保守化:可见即可说场景无唤醒词标记起点,建议将 EoT 完整度阈值从 0.8 提升至 0.85-0.9,避免将闲聊半句误执行为指令。
3. 多音区独立管线:主副驾同时说话时,需部署并行的 VAD+ASR+EoT 实例。全局混合计算会导致不同座位的停顿相互干扰,引发误判。
避坑指南:三个关键参数策略
坑 1:静音超时全局一刀切。控车指令干脆,500ms 足矣;问路闲聊需边想边说,需 800ms 以上。应按意图类别动态调整超时时间。
坑 2:忽视早截断率。单纯压缩静音超时虽能降低平均延迟,但会导致早截断率飙升,造成灾难性体验。优化目标应是:在早截断率低于 3% 的前提下,最小化判定延迟。
坑 3:降级方案缺失。当端侧小模型不可用时(如隧道场景),应降级至“规则版 EoT"(如悬空词检测),而非直接回退至纯静音超时机制。
行业趋势与总结
目前头部方案正尝试将 VAD 与 EoT 整合为端到端模型,联合建模音频与文本以直接输出决策。但未来两三年内,"VAD 管听、EoT 管等”的两段式架构仍将是主流。因其具备可解释性强、可独立调优、支持分级降级等优势,更符合车规级产品对稳定性的严苛要求。
只有将“听没听到”与“等不等你”分开设计、度量与考核,才能根治座舱语音的“抢话”与“迟钝”顽疾。