跳到主要内容

VAD 与话轮检测:何时算"说完了"

30 秒导读: 麦克风送来的是没有边界的连续音频流;要让语音助手回话,先得知道用户"哪一句从哪开始、到哪结束"。本章讲 VADHandler 如何用 Silero VAD 逐块判断"这块是不是人声",把连续流切成一段段"一句话",再用一层迟滞与噪声过滤挡掉咳嗽、点击、半个字,最后给每段音频盖上 turn_id/turn_revision 交给下游。它是全双工语音的第一道闸门。

本章聚焦"核心机制一:把连续音频切成一句话"。上游的线程模型与消息队列见 01-pipeline-runtime;turn 被下游如何仲裁(reopen 确认、commit、打断)留给 03-speculative-turns——本章只负责把 turn_id/revision 生出来


1. 这是什么(零基础也能懂)

一句话定义

VAD(Voice Activity Detection,语音活动检测) 就是一个"这一小段音频里有没有人在说话"的判别器。VADHandler 在它外面包了一层状态机,把"有没有人声"的逐块判断,变成"一句完整的话从哪到哪"的分段。

它解决什么问题

假设你在做一个语音助手。麦克风每几十毫秒就吐一小块 PCM 音频(bytes),永不停止。你面对两个基本问题:

  • 什么时候该开始录? 用户还没开口时的环境底噪不该送进语音识别。
  • 什么时候算说完了? 用户说完一句会自然停顿——但停顿多久才算"轮到助手说话"?停太短会打断用户,停太长又显得反应迟钝。

这就是"话轮检测(turn detection)":判断对话的发言权什么时候从用户交回助手。

难点:真实世界很脏

如果只是"音量超过阈值就算说话",会被各种东西骗到:

干扰朴素 VAD 的错误反应
咳嗽、清嗓、鼠标点击误判成"用户开始说话",触发一次假的话轮
说话中的自然停顿(词与词之间)误判成"说完了",把一句话切成两半
环境底噪的零星尖峰多个小尖峰累加,凑够时长骗过阈值

VADHandler 的大半篇幅,都是在跟这些"脏"较劲。

用起来什么样

它是流水线里的一个 handler(见 01):从输入队列拿 bytes 音频块,吐出 VADAudio(一段切好的语音)到下游 STT。核心就一个 process 方法,逐块喂进来:

# 示意,非源码 —— 展示 VADHandler 对外的形态
handler = VADHandler(...)
handler.setup(should_listen, thresh=0.6, min_speech_ms=384, ...)

for pcm_bytes in microphone_stream: # 每块约几十 ms 的 int16 PCM
for out in handler.process(pcm_bytes): # 大部分时候什么都不 yield
# 只有当一整句说完,才 yield 一个 VADAudio(整句的音频)
stt_queue.put(out) # 送去语音识别

直觉类比: 把 VAD 想成一个"举手/放手"的裁判。用户开口举手,停顿够久放手;裁判还要会分辨"真举手"和"胳膊抽了一下"。


2. 顶层全景(它大概怎么转)

两层结构

VADHandler 分工很清楚,分成两层:

  • 底层 VADIterator(vad_iterator.py):无状态记忆的"逐块判别 + 攒 buffer",只回答"这一块的人声概率是多少、这句话攒够了没"。这是从 snakers4/silero-vad 改来的流式包装。
  • 上层 VADHandler(vad_handler.py):在 VADIterator 之上加噪声过滤、迟滞、短段合并、话轮元数据、事件旁路——所有"业务判断"都在这层。

一块音频的旅程

麦克风 bytes(int16)


process() ── int2float ──► torch.Tensor


VADIterator.__call__(x) ← 底层:Silero 打分 + 攒 buffer
│ speech_prob = model(x)

├─ 概率高、之前没触发 → 触发,开始攒 buffer,返回 None
├─ 触发中、概率还高 → 继续攒,返回 None
├─ 触发中、概率低 → 等静音够久(min_silence)→ 返回"整句 buffer"
└─ 没触发、概率低 → 记进 pre-speech 环形缓冲,返回 None


VADHandler 后处理 ← 上层:业务判断

├─ 触发且够长 → 延迟发 SpeechStartedEvent(旁路 text_output_queue)

└─ 收到整句 buffer:
├─ 太短/太吵 → 丢弃 or 暂存待合并(短段合并)
└─ 合格 → 盖 turn_id/rev → yield VADAudio(final) + SpeechStoppedEvent

部件职责

部件干什么位置
VADIteratorSilero 逐块打分,攒 buffer,判"静音够久=说完"VAD/vad_iterator.py:6
VADHandler.process入口:归一化音频、延迟确认 speech_started、分流两条处理路径VAD/vad_handler.py:492
_process_normal半双工原始路径:说完才 yield,并 should_listen.clear()VAD/vad_handler.py:739
_process_realtime全双工路径:周期释放 progressive 音频 + 保持 turn 可重开VAD/vad_handler.py:569
_PendingShortSegment + 合并逻辑把过短的段暂存,和后续段缝合,防止把一句切碎VAD/vad_handler.py:29,352-443
话轮元数据分配 turn_id/turn_revision,交给下游仲裁VAD/vad_handler.py:176

主线走一遍

用户开口 → VADIterator 检测到人声概率过阈,开始攒音频 → VADHandler 攒够 min_speech_ms确认"真的在说话",发 SpeechStartedEvent → 用户停顿 → VADIterator 等静音超过 min_silence 判定说完,把整句音频一次性返回 → VADHandler 检查够不够长、要不要和之前的短段缝合 → 合格就盖上 turn_id/revision,yield VADAudio(final) 送去识别,同时发 SpeechStoppedEvent


3. 底层:VADIterator —— 逐块判别与攒 buffer

先看底层。VADIterator 是一个有状态的流式迭代器:你一块一块喂音频,它内部维护"现在是不是在一句话中间",并在合适时机把整句吐回来。

3.1 加载 Silero VAD

模型在 VADHandler.setup 里通过 torch hub 拉下来,再包进 VADIterator:

# vad_handler.py:95-107,略删注释
self.model, _ = torch.hub.load(
"snakers4/silero-vad", "silero_vad",
trust_repo=True, skip_validation=True,
)
self.iterator = VADIterator(
self.model,
threshold=thresh, # 触发阈值,默认 0.6
sampling_rate=sample_rate, # 16000
min_silence_duration_ms=min_silence_ms, # 说完前要等的静音,默认 64ms
speech_pad_ms=speech_pad_ms, # 触发前多留一点前导音频,默认 30ms
)

Silero 模型对每一小块音频输出一个 0~1 的人声概率(vad_iterator.py:129 speech_prob = self.model(x, self.sampling_rate).item())。VADIterator 就围着这个概率做状态机。

3.2 几个阈值,先建立直觉

VADIterator.__init__(vad_iterator.py:7-54)把毫秒参数换算成"样本数",因为音频是按样本计的:

参数含义换算(vad_iterator.py)
threshold人声概率超过它才算"说话"直接用,默认 0.5(handler 传 0.6)
min_silence_samples说话中出现静音,要连续静音这么久才判"说完"sampling_rate * min_silence_ms / 1000(:52)
speech_pad_samples触发前保留一点前导音频,防止吃掉句首sampling_rate * speech_pad_ms / 1000(:53)
active_speech_samples触发后累计的"真人声"样本数(概率够高的块才算)逐块累加(:148)

active_speech_samples 是本章反复出现的关键量:它不是"buffer 有多长",而是"buffer 里真正有人声的部分有多长"。上层用它区分"真说了一句话"和"buffer 里大半是静音/噪声"。

3.3 状态机:触发、静音判定、迟滞

核心在 VADIterator.__call__(vad_iterator.py:110-170)。用 ASCII 看清四条分支:

speech_prob = model(x)

┌────────────┴─────────────┐
│ triggered == False? │
└────────────┬─────────────┘
prob≥thresh │ prob<thresh
┌────────────────┐│┌────────────────────┐
▼ ▼ ▼
[开始触发] (仍在句中) [记 pre-speech 环形缓冲]
prefix=pre-speech │ _remember_pre_speech(x)
buffer=[x] │ return None
active=win ▼
return None ┌──────────────────────────────┐
│ triggered == True: │
│ buffer.append(x) │
│ prob ≥ thresh-0.15? │ ← 迟滞:退出阈值比进入低 0.15
│ 是→ active += win;temp_end=0│
│ 否→ temp_end 计时静音 │
│ 静音 < min_silence → None│
│ 静音 ≥ min_silence → │
│ 返回整句 buffer,复位 │
└──────────────────────────────┘

三个巧妙点:

(1) 迟滞(hysteresis):进出用不同阈值。 触发要 speech_prob >= threshold(:131),但一旦在句中,只要 speech_prob >= threshold - 0.15 就算"还在说"(:147)。这 0.15 的滞后区避免了概率在阈值附近抖动时把一句话切得七零八落。

(2) 静音要"够久"才算说完。 概率掉到滞后阈值以下时,不是立刻结束,而是记下 temp_end 开始计时(:154-155);只有连续静音超过 min_silence_samples 才真结束(:156)。中途只要人声回来,temp_end 清零(:150),计时作废——这样词间的自然停顿不会被误判成句末。

(3) 前导音频不丢。 没触发时,每块音频进一个环形缓冲 _pre_speech_buffer,并按 speech_pad_samples 裁掉多余的旧音频(_trim_pre_speech_buffer :71-90);一旦触发,这段前导被 prefix_buffer 接住(:133),拼进最终返回的整句里(_speech_buffer :102-105)。没有它,句首那个辅音/元音会被削掉。

结束时返回整句、并把 active_speech_samples 转存到 last_utterance_active_speech_samples 供上层读(vad_iterator.py:161-168):

# vad_iterator.py:161-168 —— 判定说完,一次性交还整句
self.temp_end = 0
self.triggered = False
spoken_utterance = self.speech_buffer() # prefix + buffer
self.last_utterance_active_speech_samples = self.active_speech_samples
self.active_speech_samples = 0
self.buffer = []
self.prefix_buffer = []
return spoken_utterance

关键: VADIterator 只做"声学层"的判断,它不知道 turn、不知道队列、也不会把太短的段丢掉——那些都是上层 VADHandler 的活。


4. 上层:VADHandler.process —— 入口与延迟确认

process(vad_handler.py:492-567)是每块音频的入口。它做四件事:应用运行时配置、should_listen 门控、把音频喂给 VADIterator延迟确认 speech_started,最后分流到两条处理路径之一。

4.1 should_listen 门控

第一道闸:

# vad_handler.py:498-499
if not self.should_listen.is_set():
return

should_listen 是一个跨 handler 的 Event(半双工时由下游控制)。它没置位就直接丢弃这块音频——典型场景是助手正在说话,此时半双工模式不想听用户(见 §5 与 03 的全双工做法)。

4.2 延迟发出 speech_started(达到 min_speech 才确认)

这是本章第一个核心设计。"检测到人声"不等于"确认用户在说话"——咳嗽一声也会让 VADIterator.triggered 变 True。所以 handler 不在触发瞬间就宣布 speech_started,而是等累计的真人声时长够了才确认:

# vad_handler.py:510-524,略删日志
is_triggered_now = self.iterator.triggered
if is_triggered_now and not self._speech_started_emitted:
active_speech_duration_ms = self._current_active_speech_duration_ms() # 见 §3.2 active_speech
start_ms = max(0, self._audio_ms - int(self._speech_buffer_duration_ms()))
effective_start_ms, effective_active_ms = self._effective_active_speech_for_start(
start_ms, active_speech_duration_ms, # 短段合并会在这里加上暂存段的时长
)
self._begin_pending_reopen_if_needed(effective_start_ms) # reopen 相关,细节留给 03
active_speech_min_ms = self._active_speech_min_ms(effective_start_ms) # 迟滞:min_speech 或 continuation
if effective_active_ms >= active_speech_min_ms: # ★ 够长才确认
turn_id, turn_revision, reopened = self._ensure_turn_for_speech_start(effective_start_ms)
self._speech_started_emitted = True
if self.text_output_queue:
self.text_output_queue.put(SpeechStartedEvent(
audio_start_ms=effective_start_ms,
turn_id=turn_id, turn_revision=turn_revision, reopened=reopened,
))

要点:

  • _speech_started_emitted 是"这一句是否已经确认开始"的闩(latch),防止重复发。
  • _current_active_speech_duration_ms(:194-196)读的正是 §3.2 的 active_speech_samples——只有真人声超过门槛才确认,静音/噪声撑起来的 buffer 不算数。
  • SpeechStartedEventtext_output_queue 旁路,不走主音频队列(下面 §7 详述)。

4.3 min_speech_ms / min_speech_continuation_ms 迟滞

门槛不是常数,由 _active_speech_min_ms(vad_handler.py:214-218)决定:

# vad_handler.py:214-218
def _active_speech_min_ms(self, start_ms: int) -> float:
if self._pending_reopen_candidate is not None or self._should_reopen_current_turn(start_ms):
return self.min_speech_continuation_ms # 续接一个刚结束的话轮,门槛更低
return self.min_speech_ms # 全新的话轮,门槛更高

两个门槛,对应两种情况:

门槛用于默认
min_speech_ms全新话轮:要挡住咳嗽/噪声,门槛高384ms
min_speech_continuation_ms续接刚结束的话轮(用户短暂停顿后又开口补话):门槛低,别把补话又当噪声丢了192ms

continuation 值在 setup 里被 _resolve_min_speech_continuation_ms(:202-209)夹住:不超过 min_speech_ms,且不低于 _SHORT_SEGMENT_MIN_FRAGMENT_MS(100ms,:40)。这是一种迟滞:一旦你刚说过一句,系统对你"接着说"更宽容,减少把连续话语误切的概率。"这个话轮该不该续接/reopen"的判定逻辑本章不展开,交给 03


5. 两条处理路径:_process_normal 与 _process_realtime

process 末尾按模式分流(vad_handler.py:561-567):

# vad_handler.py:561-567
if self._uses_realtime_turn_handling(): # 开了实时转写 或 有 speculative_turns
yield from self._process_realtime(vad_output)
else:
yield from self._process_normal(vad_output) # 原始半双工

_uses_realtime_turn_handling(:211-212)= enable_realtime_transcription 为真, 挂了 speculative_turns。两条路径处理"整句 buffer"的骨架相似,但目标不同。

5.1 _process_normal:半双工,说完才发

_process_normal(vad_handler.py:739-808)是最朴素的路径:只有当 VADIterator 判定说完(返回非 None 的 buffer)时才动作。合格后:

# vad_handler.py:800-807,骨架
self.should_listen.clear() # ★ 停止监听:轮到助手说话,半双工闭麦
logger.info(f"Speech ended ({duration_ms:.0f}ms), stop listening")
self.text_output_queue.put(SpeechStoppedEvent(duration_s=..., audio_end_ms=end_ms))
yield VADAudio(audio=array) # 整句音频送去 STT(无 turn 元数据)

特征:should_listen.clear()——一说完就闭麦,等下游放行才继续听。这条路径不带 turn_id(VADAudio 用默认 None),因为半双工不需要话轮仲裁。

5.2 _process_realtime:全双工,边说边发

_process_realtime(vad_handler.py:569-724)是全双工的主战场,多做两件事:

(A) progressive 音频周期性释放。 用户还在说,就每隔一小段把"目前攒到的音频"先吐出去,让 STT 能实时出字幕:

# vad_handler.py:572-599,骨架
if self.enable_realtime_transcription and len(self.iterator.buffer) > 0:
duration_ms = self._speech_buffer_duration_ms()
progressive_pause = self._progressive_processing_pause(duration_ms) # 见下,退避
if (current_time - self.last_process_time) >= progressive_pause:
array = torch.cat(self.iterator.speech_buffer()).cpu().numpy()
if active_speech_duration_ms >= self._active_speech_min_ms(start_ms):
yield VADAudio(audio=..., mode="progressive",
turn_id=turn_id, turn_revision=turn_revision)
self.last_process_time = current_time

注意 mode="progressive"——下游据此知道这是"半成品预览",不是终稿。终稿是 mode="final"(:722)。

(B) 结束时不闭麦,而是开"宽限窗"。 与 normal 路径最大的不同(vad_handler.py:711-722):

# vad_handler.py:711-722,骨架
if self.speculative_turns:
self.speculative_turns.start_reopen_grace( # 不闭麦!开一个可重开窗口
turn_id, turn_revision, self.speculative_reopen_ms / 1000.0,
)
else:
self.should_listen.clear() # 无 speculative 时退回半双工
yield VADAudio(audio=output_array, mode="final", turn_id=turn_id, turn_revision=turn_revision)

半双工是"说完闭麦";全双工是"说完仍在听,并给这个话轮留一段可以被重开的宽限期"。宽限期内如何 reopen/commit,是 03 的主题,本章不展开。

5.3 progressive 退避:说得越久,发得越稀

_progressive_processing_pause(vad_handler.py:726-737)让释放间隔随句子变长而拉大——短句要响应快、长句没必要频繁刷:

已说时长间隔倍数说明
< 8s×1短句,勤刷
8~15s×2
15~30s×4
≥ 30s×6长篇大论,减少无谓刷新

最终间隔还被 min(base_pause * multiplier, 2.0) 封顶在 2 秒(:737)。这是一种自适应退避,兼顾"实时感"和"别刷太密"。

5.4 phantom trigger:空 buffer 的收尾

两条路径都要处理 VADIterator 返回空列表的情况(vad_handler.py:603-618742-748):触发过但没攒到有效音频(极短的抖动),被称为 "phantom trigger"。此时若已经发过 speech_started,就补一个 SpeechStoppedEvent 把"开始/结束"这对事件配平,并复位闩:

# vad_handler.py:603-618,realtime 版本
if len(vad_output) == 0:
logger.info("VAD: phantom trigger (empty buffer), closing speech pair")
if self._speech_started_emitted and self.text_output_queue:
self.text_output_queue.put(SpeechStoppedEvent(
audio_end_ms=self._audio_ms, turn_id=turn_id, turn_revision=turn_revision))
if not self._speech_started_emitted:
self._cancel_pending_reopen()
self._speech_started_emitted = False
self._discard_expired_pending_short_segment()
return

为什么要配平: 下游按 started/stopped 成对推进状态;漏一个 stopped 会让下游卡在"用户还在说"的假象里。


6. 短段合并:别把一句话切成碎片

这是本章第二个核心设计,也是代码最绕的部分。问题:用户说 "嗯……(停)……我想问一下",中间那个停顿被 VADIterator 判成句末,于是切出一个只有 "嗯" 的过短段。这段太短会被门槛(§4.3)判为噪声丢掉——但它其实是话的开头。

6.1 三个动作:hold / merge / discard

_PendingShortSegment(vad_handler.py:29-34)就是"暂存的过短段":

# vad_handler.py:29-34
@dataclass
class _PendingShortSegment:
audio: np.ndarray
active_ms: float
start_ms: int
end_ms: int

当一个段"太短不合格,但值得留"时,走三态逻辑(在 _process_realtime/_process_normal 的结算段,:638-665:767-785):

一个整句 buffer 结算

active < min_active_ms ?
┌──────┴───────┐
是 否 → 合格 → 尝试和暂存段缝合(_merge)→ yield

段够长(≤max)且不是噪声?
┌───┴────┐
是 否
│ └─ discard(纯丢弃)

_hold_short_segment ← 暂存,等下一段来缝
  • _hold_short_segment(:407-420):把这个过短段连同它的 active_ms、时间戳存进 _pending_short_segment,等待后续。
  • _merge_pending_short_segment(:383-405):下一个合格段来了,若和暂存段的间隔在合并窗口内,就把两段(中间补回静音,保持音频时长和音频时钟对齐)拼成一段,时长相加。
  • _discard_*(:422-442):超过合并窗口没等到后续,就丢弃暂存段(_discard_expired_pending_short_segment 按 gap 判过期)。

6.2 噪声地板:100ms

一个关键护栏 _SHORT_SEGMENT_MIN_FRAGMENT_MS = 100(vad_handler.py:37-40):真人声不足 100ms 的碎片,连"暂存"资格都没有。为什么?否则一串环境噪声的零星尖峰,每个都被暂存、然后累加,就能凑过 min_speech_ms 骗出一次假话轮。代码在结算前先卡这条线(:627:757):

# vad_handler.py:627,realtime 版
if raw_active_ms >= _SHORT_SEGMENT_MIN_FRAGMENT_MS:
array, active_speech_duration_ms, start_ms, stitched = self._merge_pending_short_segment(...)
else:
start_ms = self._segment_start_ms(array, end_ms) # 低于地板:不参与合并,自然过期

在 speech-start 一侧也对称处理:_effective_active_speech_for_start(:373-381)只有当当前碎片 >= 100ms 且能合并时,才把暂存段的时长加进来判门槛——保证"过噪声地板"这条规则在两处一致。

合并窗口大小由 short_segment_merge_ms(setup 参数,默认 0=关闭)控制,经 _short_segment_merge_window_ms(:352-353)读取。默认关闭,是个可选增强。


7. 话轮元数据与事件旁路

前两章的机制都为了一件事:给每个合格的 "final" 段盖上话轮身份,让下游能仲裁。

7.1 turn_id 与 turn_revision 从哪来

_start_new_turn(vad_handler.py:176-186)是唯一的"发号"处:

# vad_handler.py:176-186
def _start_new_turn(self) -> tuple[str, int]:
self._cancel_pending_reopen()
self._turn_counter += 1
self._current_turn_id = f"turn_{self._turn_counter}" # turn_1, turn_2, ...
self._current_turn_revision = 0 # 新话轮从 revision 0 起
self._speculative_audio_prefix = None
...
if self.speculative_turns:
self.speculative_turns.observe(self._current_turn_id, self._current_turn_revision)
return self._current_turn_id, self._current_turn_revision
  • turn_id 是单调递增的 turn_{n}——"这是第几句话"。
  • turn_revision 是同一句话的修订号,新话轮从 0 起。为什么需要 revision? 全双工里一句话可能被"重开"补充(用户停顿后又接着说),重开就把 revision +1,这样下游能分辨"同一句的新版本"和"全新一句"。

_ensure_turn_for_speech_start(vad_handler.py:318-342)决定"这次 speech-start 是复用当前话轮、reopen 旧话轮、还是全新话轮":已经发过 start 就复用;能确认 pending reopen 或该 reopen 就走 reopen;否则 _start_new_turnreopen/commit 的具体仲裁规则本章刻意不展开——本章只保证 turn_id/revision 被正确生出来并盖到 VADAudio 上。详见 03-speculative-turns

7.2 两条队列:音频走主线,事件走旁路

VADHandler两条队列写东西,别混淆:

输出走哪条队列内容谁消费
VADAudio主输出队列(yield,经 queue_out)切好的音频(progressive/final)+ turn 元数据下游 STT
SpeechStarted/StoppedEventtext_output_queue(旁路)话轮开始/结束的通知 + turn 元数据Realtime 服务器的发送循环(见 05)

事件用旁路的原因:它们是给协议层看的通知(告诉客户端"我听到你开口了/你说完了"),不该混进要送去识别的音频流。事件类型定义在 pipeline/events.py(SpeechStartedEventSpeechStoppedEvent),都带 turn_id/turn_revision,和音频对齐。

7.3 before_emit_output:丢弃过期音频

最后一个巧妙点。全双工里,_process_realtime 会连续吐 progressive 段;等 final 段来了,队列里可能还堆着同一句的旧 progressive 段——它们已经过时。before_emit_output(vad_handler.py:444-446)在每个 VADAudio 入队前,回头清理队列里被它取代的旧段:

# vad_handler.py:444-490,骨架
def before_emit_output(self, output):
if isinstance(output, VADAudio):
self._drop_superseded_vad_audio(output) # 就地清理 queue_out

def _vad_audio_is_superseded(self, queued_item, latest):
# 1) 该 turn/revision 已不是最新(被 speculative_turns 判过时)→ 丢
if self.speculative_turns and not self.speculative_turns.is_latest(
queued_item.turn_id, queued_item.turn_revision):
return True
# 2) 同一 turn/revision 里旧的 progressive 段,被新段取代 → 丢
return (queued_item.mode == "progressive"
and queued_item.turn_id == latest.turn_id
and queued_item.turn_revision == latest.turn_revision)

before_emit_outputBaseHandler 提供的钩子,在 queue_out.put 之前被调用(baseHandler.py:128-134)。_drop_superseded_vad_audio(:448-476)直接抓 queue_out.mutex 就地重写队列(:453-464),把过时段挑出去。净效果: 下游永远不会浪费算力去识别已被更新版本取代的旧音频片段。判"是否最新"依赖 speculative_turns.is_latest——这个仲裁器的内部逻辑属于 03


8. 巧妙之处(可带走的精华)

  • 进出用不同阈值(迟滞)。 触发要 >= threshold,维持只要 >= threshold-0.15(vad_iterator.py:131,147)。一个常数就压住了阈值抖动导致的切碎。

  • 延迟确认 speech_started。 检测到人声不马上宣布,等"真人声"累计过 min_speech_ms 才发事件(vad_handler.py:511-524)。用 active_speech_samples 而非 buffer 长度做判据,咳嗽/点击撑不起来。

  • 两级门槛做续接迟滞。 全新话轮门槛高(384ms 挡噪声),续接刚结束的话轮门槛低(192ms 别丢补话),_active_speech_min_ms(:214-218)按情境切换。

  • 短段合并 + 100ms 噪声地板。 把"嗯……我想问"这种被中途切开的碎片缝回去(_merge_pending_short_segment :383-405),同时用 _SHORT_SEGMENT_MIN_FRAGMENT_MS(:40)禁止亚阈值噪声累加成假话轮——两个方向的护栏配对出现。

  • progressive 自适应退避。 释放间隔随句长 ×1→×6 并封顶 2s(_progressive_processing_pause :726-737),短句响应快、长句不空刷。

  • 事件旁路 + 过期音频就地清理。 通知走 text_output_queue、音频走主队列;before_emit_output 在入队前抓锁重写队列丢掉过时段(:448-476),省下游算力。


9. 边界与局限

  • 依赖 torch hub 在线拉模型。 setuptorch.hub.load("snakers4/silero-vad", ...)(:95-100),首次运行需要网络或已缓存的模型。

  • 采样率只支持 8000/16000。 VADIterator.__init__ 显式拒绝其它采样率(vad_iterator.py:49-50)。

  • active_speech/start_ms 用的是音频时钟,不是墙钟。 时长由累计样本数换算(_audio_ms :140-143)。push-to-talk 式的音频中断会"冻住"这个时钟——代码在 reopen 判定处对此有注释说明(:233-236),影响的是 03 的 reopen 窗口。

  • 短段合并默认关闭。 short_segment_merge_ms 默认 0(setup 参数);不显式开启就没有缝合行为,只有丢弃。

  • 音频增强是可选依赖。 DeepFilterNet 缺失时静默降级(vad_handler.py:44-50,109-114),audio_enhancement 被强制关掉。


10. 代码地图(导航索引)

主题文件符号
Silero 流式迭代器主体src/speech_to_speech/VAD/vad_iterator.pyVADIterator, VADIterator.__call__
触发/静音/迟滞状态机src/speech_to_speech/VAD/vad_iterator.py__call__(:131 触发、:147 迟滞、:156 静音判定)
前导音频保留src/speech_to_speech/VAD/vad_iterator.py_remember_pre_speech, _trim_pre_speech_buffer, speech_buffer
加载模型 + 构造 iteratorsrc/speech_to_speech/VAD/vad_handler.pyVADHandler.setup(:95-107)
每块入口 + 延迟确认 startsrc/speech_to_speech/VAD/vad_handler.pyVADHandler.process(:492-567)
min_speech 迟滞门槛src/speech_to_speech/VAD/vad_handler.py_active_speech_min_ms, _resolve_min_speech_continuation_ms
半双工路径src/speech_to_speech/VAD/vad_handler.py_process_normal(:739)
全双工/progressive 路径src/speech_to_speech/VAD/vad_handler.py_process_realtime(:569), _progressive_processing_pause
短段合并三态src/speech_to_speech/VAD/vad_handler.py_PendingShortSegment, _hold_short_segment, _merge_pending_short_segment, _discard_expired_pending_short_segment
噪声地板常量src/speech_to_speech/VAD/vad_handler.py_SHORT_SEGMENT_MIN_FRAGMENT_MS(:40)
话轮发号src/speech_to_speech/VAD/vad_handler.py_start_new_turn, _ensure_turn_for_speech_start
过期音频清理src/speech_to_speech/VAD/vad_handler.pybefore_emit_output, _drop_superseded_vad_audio, _vad_audio_is_superseded
音频/事件消息类型src/speech_to_speech/pipeline/messages.py · pipeline/events.pyVADAudio, SpeechStartedEvent, SpeechStoppedEvent

下一章: 03-speculative-turns —— 本章生出的 turn_id/revision 如何被 SpeculativeTurnTracker 仲裁,实现边说边改、可打断的全双工。