数据截至 (上游 commit b21e54d6a845)
语音转文字:渐进转写与过期过滤
这一章讲什么: STT 阶段有两条并行的路——说话过程中每 500 ms 出一次「草稿」,说完后出一次「定稿」。这一章讲这两条路怎么互不干扰,以及为什么在算之前要花力气判断「这段音频还值不值得算」。
1. 两种模式:progressive 和 final
VAD 产出的 VADAudio 带一个 mode 字段(src/speech_to_speech/pipeline/messages.py:51),只有两个值:
| mode | 什么时候产出 | 音频内容 | 下游做什么 |
|---|---|---|---|
progressive | 说话过程中,每隔一段时间 | 当前累积的缓冲(不完整) | 出 PartialTranscription → 字幕增量 |
final | Silero 判定收尾时 | 完整语音段(含前缀拼接) | 出 Transcription → 触发 LLM |
渐进模式只在 --enable_live_transcription 开启时产出(默认开启,arguments_classes/module_arguments.py:53-58)。产出间隔不是固定的:_progressive_processing_pause(VAD/vad_handler.py:786-797)会随累积时长拉长——8 秒内是基准间隔,超过 30 秒变 6 倍,并硬顶在 2 秒。理由很实际:说得越久,每次重跑识别的成本越高,不能还按 500 ms 一次的频率烧算力。
2. 渐进转写:窗口怎么滑
它要解决的小问题
最朴素的做法是「每次拿到新音频就把从头到现在的全部重跑一遍」。说 30 秒就要重跑 30 秒的音频,而且每 500 ms 一次——算力爆炸。
思路:句子边界是天然的固化点
已经说完的完整句子几乎不会因为后面的话而改变。所以可以把它们「钉死」(fixed),之后只重跑最后那一小段(active)。
实际音频: [ 句子1 ][ 句子2 ][ 句子3 ][ 正在说的半句 ]
└────────────────┘└───────────────────────┘
钉死 fixed_text 重跑 active(末尾 2s 缓冲内的句子不钉死)
窗口 < 15s:fixed_text 为空,每次把整段重跑一遍
窗口 ≥ 15s:把 cutoff 之前的句子钉死,窗口起点前移,只重跑 active 段
原理演示
# 示意,非源码:固化-重跑的核心循环
fixed_sentences, fixed_end_time = [], 0.0 # fixed_end_time 是「绝对时间轴」上的窗口起点
def step(audio): # audio 是从头开始的完整缓冲
global fixed_end_time
base = fixed_end_time # 本轮窗口起点,循环期间不变
window = audio[int(base * SR):] # 只取「未固化」的部分
result = model.decode(window) # 识别这一段
if len(window) / SR >= 15.0 and len(result.sentences) > 1:
cutoff = len(window) / SR - 2.0 # 末尾留 2 秒不固化
new_end = base
for s in result.sentences: # 注意:s.end 是「窗口内的相对时间」
if s.end >= cutoff:
break
fixed_sentences.append(s.text) # 钉死这句
new_end = base + s.end # 绝对赋值,不是 += (累加会让时间轴越推越远)
fixed_end_time = new_end
result = model.decode(audio[int(fixed_end_time * SR):]) # 从新起点重跑
return " ".join(fixed_sentences), result.text # (固定部分, 活动部分)
两个重点:
s.end是相对于当前窗口起点的秒数,不是绝对时间。所以换算成新的窗口起点必须写成base + s.end这样的绝对赋值;写成fixed_end_time += s.end会把每句的相对时长反复叠加,几轮之后窗口起点就跑到音频末尾之后,active 段变空。- 固化后要立刻用新起点再解一次,否则返回的
active_text还包含刚被钉死的内容,拼起来会重复。
真实实现
SmartProgressiveStreamingHandler.transcribe_incremental(src/speech_to_speech/STT/smart_progressive_streaming.py:87-157)。参数默认:emission_interval=0.5、max_window_size=15.0、sentence_buffer=2.0(smart_progressive_streaming.py:40-46)。
源码里对应上面那段绝对赋值的是 sentence_abs_time = self.fixed_end_time + sentence.end,先攒进临时变量 new_fixed_end_time,整个循环跑完才一次性写回 self.fixed_end_time(smart_progressive_streaming.py:131-145)——循环期间 self.fixed_end_time 始终是本轮窗口的起点。
_decode_window(smart_progressive_streaming.py:69-85)兼容两种后端:MLX 版走 model.decode_chunk,nano-parakeet 版走 model.transcribe(timestamps=True) 再把 segment 时间戳包装成统一形状。
只有 Parakeet 后端接了这套渐进逻辑——注册表里只有 _create_parakeet 会把 enable_live_transcription 传进 handler(backend_registry.py:250-264)。其他 STT 后端只做 final。
3. 进/出双向闸门:算之前先问「还值得算吗」
它要解决的小问题
STT 推理可能要几百毫秒。这期间用户可能已经改口。三种浪费/错误:
- 队列里堆着的旧 progressive 请求,算完也没人要;
- 同一 revision 的 progressive 排在 final 后面,先算了纯属浪费;
- 算的时候还是最新的,算完就不是了。
做法:一个基类装四道闸
BaseSTTHandler(src/speech_to_speech/STT/base_stt_handler.py)覆写基类的两个钩子,一共四道判定:
┌─ should_process_input ─────────────────────────────────────┐
│ ① 这个 (turn,rev) 的 final 已经出过结果了? → 丢 │
│ ② 我是 progressive,但队列里已经有同 rev 的 final? → 丢 │
│ ③ 不是最新 revision?(final 还要多等一个稳定窗口)→ 丢 │
└────────────────────────────────────────────────────────────┘
│ 通过
▼
process() 跑推理(可能几百 ms)
│
┌─ should_emit_output ───────────────────────────────────────┐
│ ④ 算完了再查一次:还是最新 revision 吗? → 否则丢 │
└────────────────────────────────────────────────────────────┘
对应源码:should_process_input(base_stt_handler.py:24-61)、should_emit_output(base_stt_handler.py:63-71)。
第 ③ 道闸的特殊性:final 要等
# 真实源码,base_stt_handler.py:91-97
if wait_for_stability:
item_delay_s = max(0.0, getattr(item, "processing_delay_s", 0.0) - self._item_age_s(item))
is_latest = self.speculative_turns.is_latest_after_stability_window(
turn_id, turn_revision,
max(self.final_revision_settle_s, item_delay_s),
)
这里把 Smart Turn 塞进来的 processing_delay_s(见 02 章)减去消息在队列里已经等的时间,只补足剩余部分。也就是说:如果队列本来就堵着、消息已经躺了 600 ms,就不再额外等——延迟预算是「从产生算起」而不是「从取出算起」。