语音机器人打断不进去:VAD 双阈值迟滞里一个共用的 bool,和它引发的连锁误判

语音机器人"打断不进去":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 接听与应答流程)。


本文为真实生产环境的缺陷复盘,涉及的实现均已做脱敏处理,重点在排查思路而非配置数值。

相关推荐
honsor1 小时前
机房IP化改造首选:以太网温湿度传感器,PoE供电+Modbus TCP一网搞定
linux·运维·服务器
默|笙2 小时前
【Linux网络】_udp_socket(认识)
linux·网络·udp
程序员梅雨2 小时前
ZooKeeper面经总结
java·大数据·linux·分布式·后端·zookeeper·中间件
lingran__2 小时前
Linux进程万字详解(二)|进程状态深度剖析:调度队列、内核链表、D状态设计哲学、僵尸孤儿进程与Slab机制
linux·进程管理·内核链表·pcb·僵尸进程·孤儿进程·进程状态
GeW3 小时前
以Linux为基、数据库为核:新质生产力路线图下的企业架构升级
linux
忆挽篱笙歌3 小时前
进程的状态
linux·ubuntu
kyle~4 小时前
Linux --- Socket 用户进程与内核网络协议栈之间的统一接口
linux·arm开发·网络协议
重生之小比特4 小时前
【Linux系统】07 进程状态
linux·运维·服务器
张洛闻Eren5 小时前
Ansible【第二课】:实施 Ansible Playbook
linux·运维·服务器·云原生·kubernetes·k8s·ansible