跳到主要内容

数据截至 (上游 commit b21e54d6a845)

断句、Smart Turn 与投机回合

这一章讲什么: 整个项目最原创、也最绕的部分。分三层讲:先讲 Silero 怎么切语音段,再讲 Smart Turn 怎么判断「这句话是不是说完了」,最后讲当判断错了时,turn_id/turn_revision 这套机制怎么把已经跑出来的回答无声无息地作废。


1. 先认识问题:停顿 ≠ 说完

人说话会停顿:「帮我订一张……嗯……明天去上海的票」。传统 VAD 只看有没有声音,遇到中间那个停顿就会认为你说完了,于是助手抢话。

三种应对,本项目全都用了:

层次手段代价
声学层要求静音持续 min_silence_ms 才收尾静音阈值调大 = 每句都变慢
语义层Smart Turn 模型判断「这段听起来完整吗」每次收尾多一次推理
系统层先当作说完了跑,留一个反悔窗口白跑的算力 + 一整套作废机制

第三条是本项目的核心赌注:用算力换延迟。下面依次拆。


2. Silero 层:怎么切出一段语音

迭代器的状态机

VADIterator.__call__(src/speech_to_speech/VAD/vad_iterator.py:111-170)每次吃一块音频,返回 None(还没完)或一个 tensor 列表(一段说完了)。

未触发(triggered=False)
│ 概率 ≥ threshold

已触发(triggered=True) ──── 概率 ≥ threshold-0.15 ──► 继续累积,重置 temp_end

│ 概率 < threshold-0.15

静音计时中(temp_end 记录起点)
│ 静音时长 ≥ min_silence_samples

收尾:返回整段 buffer,清空,回到未触发

三个值得抄的细节:

  • 迟滞(hysteresis):进入用 threshold,维持用 threshold - 0.15(vad_iterator.py:147153)。避免概率在阈值附近抖动导致反复切段。
  • 前置缓冲:未触发时的音频存进 _pre_speech_buffer 环形缓冲,触发时把最近 speech_pad_ms 的内容当作 prefix_buffer 前置进去(vad_iterator.py:131-143)。没有这个,每句话开头的辅音都会被吃掉。 默认 CLI 值是 500 ms(arguments_classes/vad_arguments.py:42-47),注意比 VADIterator 自身的默认 30 ms 大得多。
  • 区分「时长」和「有效语音时长」:active_speech_samples 只在概率高于维持阈值时累加,和 buffer 总长度是两个数。判断「这段够不够算一句话」用的是前者。

handler 层的额外判定

VADHandler.process(src/speech_to_speech/VAD/vad_handler.py:543-614)在 Silero 之上加了几道:

判定参数(默认)作用
有效语音下限min_speech_ms=384短于此的段直接丢,防噪声触发
续接下限min_speech_continuation_ms=192接着一个可重开的回合说话时,门槛减半
段长上限max_speech_ms=inf超长段丢弃(默认不启用)
碎片缝合short_segment_merge_ms=0>0 时把相邻短段拼起来再判

「续接门槛更低」是有意的:你刚说完一句、助手还没答,你补一句「哦对了」——那是同一个回合的续接,不该按新回合的严格门槛丢掉。实现见 _active_speech_min_ms(vad_handler.py:239-243)。

碎片缝合的防滥用:低于 _SHORT_SEGMENT_MIN_FRAGMENT_MS = 100(vad_handler.py:40)的碎片永远不缓存,否则一串亚阈值噪声累加起来就能凑够 min_speech_ms 触发假打断。

延迟发出的 speech_started

注意 speech_started 不是 Silero 一触发就发,而是等有效语音累积够门槛才发(vad_handler.py:562-594)。因为这个事件在协议层会触发「取消当前回答」,发早了等于被咳嗽一声打断。


3. Smart Turn 层:这句话听起来完整吗

它是什么

一个 ONNX 分类器(pipecat-ai/smart-turn-v3,v3.2 CPU 版),吃最多 8 秒音频,吐一个「这是完整回合」的概率(src/speech_to_speech/VAD/smart_turn.py:20-24130-153)。

关键设计:它不是每块音频都跑,只在 Silero 已经判定收尾之后跑一次(文件头 docstring 明确说明,smart_turn.py:1-7)。所以它对每块音频的开销是零。

输入预处理:重采样到 16 kHz、在左边补零到固定 8 秒、超长则取最后 8 秒(smart_turn.py:123-127)。也就是说它只看结尾——这符合「判断结尾语调/语义是否完整」的直觉。

它的输出被用来干什么

不是用来决定要不要处理,而是决定两个时长(VADHandler._smart_turn_timing_ms,vad_handler.py:509-541):

Smart Turn 判定重开宽限期处理延迟
complete(说完了)speculative_reopen_ms(800 ms)0
incomplete(没说完)smart_turn_max_wait_ms(2000 ms)smart_turn_incomplete_delay_ms(600 ms)
推理抛异常800 ms0

读懂这张表就读懂了整个设计:判定为「没说完」时,系统不停下,而是「慢一点开始 + 更久之后才敢把话说出口」。600 ms 的处理延迟给用户留出继续说的机会,2000 ms 的宽限期保证即使 STT/LLM 跑完了也先按住不发。

异常处理值得注意:分类器炸了就退回默认的短窗口,而不是让用户干等几秒(vad_handler.py:517-521)。


4. 投机回合:改口了怎么办

心智模型

每一次说话被赋予一个 turn_id(如 turn_3),每次「同一回合的续说」把 turn_revision 加一。 所有下游产物都带着这对标签走。跟踪器只记录每个 turn 的最新 revision;拿着旧 revision 的产物 = 过期 = 丢掉。

用户:「帮我订张票」 → turn_3 rev 0 ──► STT ──► LLM 开始生成「好的,请问……」
用户(700ms 后):「去上海的」 → turn_3 rev 1(重开!)

跟踪器把 turn_3 的最新 revision 改成 1

rev 0 的 LLM 输出走到闸门 → is_latest(turn_3, 0) 为假 → 静默丢弃
rev 1 拿到「帮我订张票 去上海的」完整音频重新跑

注意重开时音频是拼接的,不是只用新的一段:_combined_turn_audio 把上一次 final 段作为前缀拼在前面(vad_handler.py:359-367,前缀存于 _speculative_audio_prefix)。所以 rev 1 看到的是完整句子。

什么条件下允许重开

_should_reopen_current_turn(vad_handler.py:245-268)要求三条同时成立:

  1. 当前回合尚未 committed(还没有助手输出被放行);
  2. 距上次 final 在窗口内;
  3. 窗口用的是 音频时钟 而非墙钟——elapsed_ms = audio_start_ms - _last_final_audio_ms

第 3 点的注释写得很清楚:连续采集时音频时钟约等于墙钟,但按键说话(push-to-talk)式的空档会让它冻结,于是不会因为客户端没发音频就把回合判死。

窗口大小取 max(speculative_reopen_ms, unanswered_reopen_ms, smart_turn_max_wait_ms)(vad_handler.py:117-121),默认 7000 ms 封顶。理由:助手还没回答的回合,不该因为用户停了 1 秒就被判成新回合。

候选态:先占坑再确认

有个微妙的时序问题:VAD 刚检测到有声音、但还没确认「够得上有效语音」时,下游可能正好要放行 rev 0 的输出。等确认完再改 revision 就晚了。

解法是两阶段(SpeculativeTurnTracker.begin_reopen_candidate / confirm_reopen_candidate / cancel_reopen_candidate,pipeline/speculative_turns.py:226-306):

VAD 检测到疑似续说


begin_reopen_candidate(turn_3, 0) → 登记「pending_reopen: 0 → 1」
│ 此后所有查询 turn_3 rev 0 的下游
│ 会阻塞等待(最多 2 秒)
├── 确认是有效语音 → confirm_reopen_candidate → 最新 revision 变成 1
└── 只是噪声 → cancel_reopen_candidate → 恢复,rev 0 继续有效

等待逻辑在 _wait_for_pending_reopen_locked(speculative_turns.py:370-386),超时常量 _PENDING_REOPEN_WAIT_TIMEOUT_S = 2.0

四档查询强度,外加一组非阻塞变体

跟踪器暴露了一组名字很长但语义分明的方法。四档阻塞式查询按「愿意等多久」从弱到强排列,下游按「自己的输出有多不可撤回」挑一档;另有两个 try_* 非阻塞变体,给不能停下来等的调用方用。

方法等到什么才回答谁用
is_latest不等,当场答VAD 入队前清理、LLM 流中途检查
is_latest_after_pending_reopen等重开候选态出结果STT 的 progressive 输入闸门与输出闸门
is_latest_after_stability_window(settle_s)候选态之外再额外静默等 settle_sSTT 的 final 输入(叠加 Smart Turn 的处理延迟)
is_latest_after_reopen_grace等到重开宽限期结束所有面向客户端的输出(LM 处理器、TTS、协议层)
try_is_latest_after_pending_reopen / try_is_latest_after_reopen_grace一律不等,未决时返回 None协议层被发送循环同步调用时(不能阻塞 asyncio)

分界线很清晰: 内部计算用弱查询(错了就浪费点算力),对外发声用 after_reopen_grace(错了用户就听见不该听的话)。非阻塞变体不是第五档强度,而是 after_pending_reopenafter_reopen_grace 这两档的「不许阻塞」版本——拿到 None 的调用方要自己把消息放回去、下一轮再问。

commit:一旦说出口就不能反悔

commit(turn_id, revision) 把该 revision 标记为已提交,之后 _should_reopen_current_turn 就返回假,回合彻底关闭。调用点在 TTS 的 process(src/speech_to_speech/TTS/qwen3_tts_handler.py:831-832)——也就是真正要开口合成的那一刻才提交。这是整条链上最晚的可能时机。

_commit_locked(speculative_turns.py:319-339)有一个诚实的取舍注释:如果这个 turn 已经被 LRU 淘汰(_MAX_TRACKED_TURNS = 2048),提交照样报成功,理由是「丢掉一个跟踪器已经不认识的回合的输出,比多说一句更糟」。


5. 另一条正交的作废线:CancelScope

它和投机回合的分工

两套机制解决两个不同的问题,经常被混淆:

投机回合(turn/revision)取消世代(CancelScope)
触发者用户接着说(同一回合续说)用户打断 / 客户端 response.cancel
粒度每个用户回合每个助手响应
机制每 turn 一个最新 revision一个全局自增计数器
语义「你刚才那句话我重新听一遍」「刚才那个回答整个不要了」

实现

CancelScope(src/speech_to_speech/pipeline/cancel_scope.py)只有三个字段:世代号 _gen、丢弃开关 _discarding、被丢弃的世代 _discarded_generation

# 真实源码(略去 docstring),cancel_scope.py:24-33
def cancel(self) -> None:
...
# prevent overflow... after 4 billion generations, we'll wrap around xD...
self._discarded_generation = self._gen
self._gen = (self._gen + 1) & 0xFFFFFFFF
self._discarding = True

每个响应在开始时捕获当时的世代号,之后任何时刻 is_stale(gen) 就是 gen != self._gen。类 docstring 明确说明了线程安全假设:一个写者(asyncio 路由线程)+ 多个读者(handler 线程),靠 GIL 保证 int/bool 读写原子,因此不加锁(cancel_scope.py:8-11)。

打断的完整动作

发送循环看到 SpeechStartedEvent 且允许打断时,做六件事(websocket_router.py:879-904):

1. transport.discard_pending_audio() ← WebRTC 缓冲的未播音频也要丢
2. cancel_scope.cancel() ← 世代 +1,在途产物全过期
3. service.close_pending_responses() ← 协议层把排队响应墓碑化
4. 清空 text_prompt_queue ← 还没进 LLM 的请求
5. 清空 output_queue ← 已合成但没发的音频
6. 清空 text_output_queue ← 但保留用户侧事件

第 6 步的 preserve=_keep_user_text_event 很关键:用户的说话事件和转写不能被自己的打断动作清掉,否则刚触发打断的那次开口就丢了。

一个 subtle 的坑(源码注释已记录)

_generation_is_discardable(websocket_router.py:256-271)不能简单地「只要 discarding 为真就丢文本」。注释解释:如果某个被超越的投机回合的 TTS 从没吐出终结哨兵,response_done() 就永远不会清 discarding 标志,于是下一个全新响应的字幕会被静默吞掉。所以判定必须带上世代比较:discarding and generation != current_generation


6. 把三层串起来:一次「改口」的完整时间线

t=0.0s 用户开口
VAD 累积有效语音 ≥384ms → speech_started(turn_4, rev 0)
t=1.2s Silero 检测到 64ms 静音 → 收尾
Smart Turn 判定 incomplete(p=0.31)
→ 宽限期 2000ms、处理延迟 600ms
start_reopen_grace(turn_4, 0, 2.0s)
发出 VADAudio(mode=final, processing_delay_s=0.6)
t=1.8s STT 闸门等够 600ms,开始跑最终转写
t=2.0s 转写完成 → 协议层写历史 → LLM 开始生成
t=2.4s 用户又开口(距 final 1.2s < 7s,且 turn_4 未 committed)
→ begin_reopen_candidate(turn_4, 0) → 候选 rev 1
→ 确认有效语音 → confirm → turn_4 最新 revision = 1
t=2.5s LLM 生成完 rev 0 的回答,走到 LMOutputProcessor
→ is_latest_after_reopen_grace(turn_4, 0) = False
→ 静默丢弃,只放行一个 cleanup_only 的终结消息
t=3.6s rev 1 的完整音频(rev0 前缀 + 新段)重新走 STT → LLM → TTS
TTS 开口前 commit(turn_4, 1),回合关闭

用户听到的只有一次回答,而且是基于完整句子的。代价:rev 0 那次 STT + LLM 全部白跑。


7. 代码地图

主题文件路径符号名
Silero 流式迭代器src/speech_to_speech/VAD/vad_iterator.pyVADIterator.__call__, _remember_pre_speech
VAD 主逻辑src/speech_to_speech/VAD/vad_handler.pyVADHandler.process, _process_realtime
重开判定src/speech_to_speech/VAD/vad_handler.py_should_reopen_current_turn, _ensure_turn_for_speech_start, _active_speech_min_ms
碎片缝合src/speech_to_speech/VAD/vad_handler.py_merge_pending_short_segment, _hold_short_segment, _SHORT_SEGMENT_MIN_FRAGMENT_MS
队列内去重src/speech_to_speech/VAD/vad_handler.py_drop_superseded_vad_audio
Smart Turn 推理src/speech_to_speech/VAD/smart_turn.pySmartTurnAnalyzer.predict, _prepare_audio
Smart Turn 时长决策src/speech_to_speech/VAD/vad_handler.py_smart_turn_timing_ms
投机回合跟踪器src/speech_to_speech/pipeline/speculative_turns.pySpeculativeTurnTracker, begin_reopen_candidate, is_latest_after_reopen_grace, _commit_locked
取消世代src/speech_to_speech/pipeline/cancel_scope.pyCancelScope.cancel, is_stale, response_done
打断动作src/speech_to_speech/api/openai_realtime/websocket_router.py_send_loop_for, _generation_is_discardable, _keep_user_text_event
VAD 参数与默认值src/speech_to_speech/arguments_classes/vad_arguments.pyVADHandlerArguments