语音机器人"打断不进去":VAD 双阈值迟滞里一个共用的 bool,和它引发的连锁误判
一句话摘要:为了抑制回声,代码在 AI 说话时把 VAD 判定阈值抬高 0.2;但迟滞判断的"上一帧结果"只有一份,两条判定路径共用它。结果 AI 越说得响,用户越插不进话------而这段代码一行语法错误都没有。
一、先说现象
电话机器人接起来很自然,播完一段话之后,用户想插话打断,却发现要等 AI 把整段话说完才能应答。
第一反应通常是调参数:
python
barge_in_min_frames = 2 # 再小一点
barge_fallback_frames = 3 # 再激进一点
barge_in_vad_threshold_boost = 0.1 # 抬得太高了,压回去
参数改了一圈,现象时好时坏,改一次好一次,改完过两天又复现。
因为问题根本不在参数上。
二、这个系统是怎么做打断的
打断(barge-in)的判定链路大致是三层:
arduino
音频帧 → VAD 判"这一帧有人声吗" → 滑窗投票 → 连续 N 帧达标 → 触发打断
VAD 用的是 Silero(ONNX 推理),逐帧输出一个 0~1 的人声概率 prob。
问题在于 Silero 的输出在阈值附近会抖 :一个安静到正常说话的连续音流,prob 会在 0.48 / 0.52 之间来回跳。如果直接用 prob > 0.5 做布尔化,语音会被切成碎片。
所以这里用了业界常见的解法------双阈值迟滞(hysteresis):
python
# vad.py
def _frame_is_voice(self, prob: float) -> bool:
"""Dual-threshold hysteresis (xiaozhi-style): reduces prob jitter."""
th = self._effective_threshold
if prob >= th:
is_voice = True # 高于高阈值 → 一定是人声
elif prob <= self._threshold_low:
is_voice = False # 低于低阈值 → 一定不是
else:
is_voice = self._last_is_voice # 落在中间 → 沿用上一帧结论
self._last_is_voice = is_voice
return is_voice
高阈值 0.5 负责"确信进入语音",低阈值 0.3 负责"确信退出",中间地带靠"跟随上一帧"保持连贯。默认 threshold=0.5 / threshold_low=0.3。
三、回声问题与"抬阈值"策略
AI 用 TTS 说话时,扬声器放的声音会被麦克风收回去。如果这时候还用 prob > 0.5 判定,用户还没开口,系统就认为"检测到人声"→ 直接被自己的声音打断。
解法是在 AI 说话期间把阈值整体抬高:
python
def set_speaking_mode(self, *, active: bool, threshold_boost: float = 0.2) -> None:
"""Raise detection threshold while assistant TTS plays (reduces echo barge-in)."""
self._threshold_boost = threshold_boost if active else 0.0
@property
def _effective_threshold(self) -> float:
return min(0.95, self.threshold + self._threshold_boost)
也就是 AI 说话时有效阈值变成 0.7,需要用户喊到 prob ≥ 0.7 才算数。
这个设计本身是对的,业界都这么干。
四、缺陷:迟滞的"记忆"只有一份,却有两条路径在读
为了在抬阈值的同时不彻底丧失打断能力,代码又提供了一个"放宽版"判定:
python
# vad.py
@property
def relaxed_frame_is_voice(self) -> bool:
"""Current frame at base threshold (ignores SPEAKING ``threshold_boost``)."""
prob = self._last_voice_prob
th = self.threshold # 注意:用的是基础阈值,不是 _effective_threshold
if prob >= th:
return True
if prob <= self._threshold_low:
return False
return self._last_is_voice # ⚠️ 又是这个字段
两条路径:
| 路径 | 阈值 | 用途 |
|---|---|---|
_frame_is_voice(prob) |
_effective_threshold(抬过) |
正常语音分段(speech_start / speech_end) |
relaxed_frame_is_voice |
self.threshold(基础 0.5) |
AI 说话期间的打断兜底 |
问题出在最后一行。
_last_is_voice 是 _frame_is_voice() 的副作用 :它被写入,且只在 _frame_is_voice() 里写。但 relaxed_frame_is_voice 只读它,不写它。
这意味着 relaxed_frame_is_voice 返回的"中间地带结论",反映的是抬高阈值下上一帧的判定结果,而不是基础阈值下的。
状态污染的具体路径
假设用户在 AI 说话的中途插话:
ini
帧 N prob = 0.42 (用户在说话,但音量不大)
_frame_is_voice: 0.42 < 0.7(抬后) 且 > 0.3 → 沿用 _last_is_voice
如果上一帧 _last_is_voice 是 False(用户在插话的起点,或前几帧确实是 AI 的回声被判为非语音),这一帧就是 False。
python
relaxed_frame_is_voice: 0.42 < 0.5 同样进入中间地带
→ return self._last_is_voice ← 读到的是「抬后阈值」留下的 False
于是 relaxed 判定退化成了和严格判定一样的结果 ------"放宽"这条兜底路径,在最需要它的场景(AI 正在说话、用户轻声插话)恰好失效。
更糟的是它会来回震荡:
- 用户声音再大一点 →
prob = 0.68→_frame_is_voice判True,写入_last_is_voice = True - 下一帧回落到
prob = 0.46→relaxed进入中间地带 → 读到刚被写进去的True→ 判"有人声" - 再下一帧
prob = 0.44→ 又变成False
这个 True/False 抖动会直接喂给打断计数的两条 streak:
python
# session_uplink.py
if have_voice:
self._barge_voice_frame_streak += 1
self._barge_fallback_streak = 0
elif relaxed:
self._barge_fallback_streak += 1
self._barge_voice_frame_streak = 0 # ⚠️ 互相清零
else:
self._barge_voice_frame_streak = 0
self._barge_fallback_streak = 0 # ⚠️ 一次 False 全部清零
注意 else 分支:任何一帧 relaxed 为 False,两个计数器一起清零 。所以"抖动"不是"偶尔多加一帧",而是直接把累积的进度归零。
这就是为什么调参数"改一次好一次"------你只是把抖动的相位挪开了,没有消除抖动源。
五、还有第二个隐患:_last_is_voice 与滑窗投票的耦合
python
# vad.py process_chunk
self._last_voice_prob = prob
self._voice_window.append(self._frame_is_voice(prob))
have_voice = self._window_have_voice()
滑窗 deque(maxlen=frame_window_size)(默认 5,投票门槛 3)装的也是抬阈值后的结果。
所以在 SPEAKING 期间:
- 滑窗投票
have_voice天然偏严(阈值 0.7) relaxed兜底因为状态污染也偏严(读到的是抬阈值的结论)
两条本该互补的路径,在 AI 说话期间同时变严。设计上"严格路径过滤回声 + 放宽路径兜底真人的"这个组合,在实现层面退化成了一条严路径。
六、修复
修法 1:让迟滞记忆分离
根本修复是两条路径各有一份记忆:
python
def __init__(self, ...):
...
self._last_is_voice = False # 抬阈值路径的记忆(保持不变)
self._last_is_voice_base = False # 基础阈值路径的记忆(新增)
def _frame_is_voice(self, prob: float) -> bool:
th = self._effective_threshold
if prob >= th:
is_voice = True
elif prob <= self._threshold_low:
is_voice = False
else:
is_voice = self._last_is_voice
self._last_is_voice = is_voice
return is_voice
@property
def relaxed_frame_is_voice(self) -> bool:
prob = self._last_voice_prob
th = self.threshold
if prob >= th:
is_voice = True
elif prob <= self._threshold_low:
is_voice = False
else:
is_voice = self._last_is_voice_base # ← 用自己那份记忆
self._last_is_voice_base = is_voice # ← 并写回自己那份
return is_voice
注意 relaxed_frame_is_voice 从 @property(只读)变成了带副作用的读属性。这不是好设计------更好的做法是把它改成显式方法,在 process_chunk 里和 _frame_is_voice 一起算:
python
# process_chunk 内
self._voice_window.append(self._frame_is_voice(prob))
self._last_voice_base = self._frame_is_voice_base(prob) # 并行计算
修法 2:计数不要被单帧清零
else 分支的双清零改成带衰减的衰减计数:
python
else:
self._barge_voice_frame_streak = max(0, self._barge_voice_frame_streak - 1)
self._barge_fallback_streak = max(0, self._barge_fallback_streak - 1)
抖动只会让进度退一格 ,不会归零。同时 have_voice / relaxed 的互斥清零也改成"不清对方,只加自己",避免两条路径互相拆台。
修法 3:抬阈值不等于放弃打断
更稳的做法是让抬阈值只作用于"起播判定",不作用于"维持判定":
python
# 起播要严(防回声误触发)
if prob >= th_boosted:
is_voice = True
# 维持要松(真人说话是持续的,不会只有一帧响)
elif prob >= self.threshold:
is_voice = self._last_is_voice or True
即:回声不会连续多帧都超过基础阈值,真人会。用一个不需要迟滞记忆的判据,既避开了状态污染,又保住了真人打断。
七、怎么验证修好了
别看日志,看两个可观测量:
① 打断延迟 P50/P95 在 _start_barge_candidate() 处打点,统计"用户起音 → 进入 barge_candidate"的帧数乘帧长。修复后 P95 应显著下降。
② _last_is_voice 与 relaxed_frame_is_voice 的不一致率
python
# 加一段临时统计
if 0.3 < self._last_voice_prob < self._effective_threshold:
self._stats['midband_frames'] += 1
if self.relaxed_frame_is_voice == self._last_is_voice:
self._stats['midband_shadowed'] += 1 # 修复前这里接近 100%
中间地带(0.3 < prob < 0.7)的帧里,relaxed 结果如果和严格路径完全一致 ,就说明它被污染了。修复后这个比例应显著下降------因为真正落在 0.3~0.5 的真人语音帧,现在能正确判成 True。
八、这类缺陷的通用识别法
回到最开始那句判断:
布尔门控如果在全仓库只出现在
__init__,它几乎一定是设计缺口。
这个缺陷符合该特征:_last_is_voice 全仓库只出现在 __init__(初始化)、_frame_is_voice(唯一的合法写入点)、relaxed_frame_is_voice(只读)三处。
而 relaxed_frame_is_voice 里那行 return self._last_is_voice 出现在一个标着 @property 的只读访问器里------一个"读属性"读出的是"别的函数在别的阈值下算出来的中间状态"。
这就是可读性给出的信号 :当我写 def relaxed_frame_is_voice(self) -> bool 却发现函数体里有一个赋值目标不是我自己的字段时,就该停下来问一句:这个函数到底是不是只读的?
附:环境相关的一行说明
上面这些阈值(0.5 / 0.3 / boost 0.2 / 窗 5 票 3 / min_speech 250ms / min_silence 300ms)都是本机实测调出来的, 换硬件(不同麦克风、不同 TTS 音色)需要重调,但结构性的缺陷与参数无关------只要共用一份迟滞记忆, 换什么阈值都会重现。
想实测这条链路的完整效果,可以拨 165-1124-4888(24 小时待机,呼入即演示 AI 接听与应答流程)。
本文为真实生产环境的缺陷复盘,涉及的实现均已做脱敏处理,重点在排查思路而非配置数值。