30 秒窗口:Whisper 的转录循环与失败恢复机制
前置知识
- ›第 1-4 篇文章
- ›理解解码系统
30 秒窗口:Whisper 的转录循环与失败恢复机制
第 4 篇中分析的解码器,每次只处理一个 30 秒的 mel 频谱图片段。而实际音频的时长千变万化——可能是 3 秒的语音备忘,也可能是长达 2 小时的播客。transcribe() 函数正是弥合这一差距的关键:它通过一个滑动窗口循环,将各个解码片段拼接在一起,优雅地处理解码失败,并检测幻觉输出。这个函数约有 475 行,是整个代码库中第二大的函数,也是实际使用场景中最核心的逻辑所在。
转录函数的初始化
第 38-56 行的函数签名展示了所有可用的控制参数:
def transcribe(
model: "Whisper",
audio: Union[str, np.ndarray, torch.Tensor],
*,
verbose: Optional[bool] = None,
temperature: Union[float, Tuple[float, ...]] = (0.0, 0.2, 0.4, 0.6, 0.8, 1.0),
compression_ratio_threshold: Optional[float] = 2.4,
logprob_threshold: Optional[float] = -1.0,
no_speech_threshold: Optional[float] = 0.6,
condition_on_previous_text: bool = True,
initial_prompt: Optional[str] = None,
carry_initial_prompt: bool = False,
word_timestamps: bool = False,
# ...
**decode_options,
):
第 127-178 行的初始化逻辑主要完成以下几件事:
- 为完整音频计算 mel 频谱图,并在末尾填充 30 秒
- 从前 30 秒检测语言(如未指定)
- 创建 tokenizer
- 将
clip_timestamps解析为 seek 定位点
flowchart TD
A["transcribe() called"] --> B["Compute mel for full audio\n+ 30s padding"]
B --> C{"Language\nspecified?"}
C -->|No| D["Detect from first 30s\nusing model.detect_language()"]
C -->|Yes| E["Use specified language"]
D --> F["Create tokenizer"]
E --> F
F --> G["Parse clip_timestamps\ninto seek_clips"]
G --> H["Enter sliding window loop"]
clip_timestamps 参数允许只处理音频的特定片段,非常适合已预先分段的内容,或跳过已知的非语音区域。这些时间戳会被转换为帧级别的 seek 定位点,以 (start, end) 元组的形式成对存储。
温度回退策略
decode_with_fallback() 是一个内部函数,实现了 Whisper 的核心失败恢复机制。默认的温度元组 (0.0, 0.2, 0.4, 0.6, 0.8, 1.0) 的含义是:先以贪心解码(T=0)尝试,若输出质量不佳,则依次以更高的随机性重试。
flowchart TD
A["Try T=0.0\n(greedy)"] --> B{"Check quality"}
B -->|"compression_ratio > 2.4\n(repetitive)"| C["Try T=0.2"]
B -->|"avg_logprob < -1.0\n(low confidence)"| C
B -->|"no_speech + low logprob\n(silence)"| D["Accept as silence"]
B -->|"Passes all checks"| E["Accept result"]
C --> F{"Check quality"}
F -->|"Still failing"| G["Try T=0.4, 0.6, ..."]
F -->|"Passes"| E
G --> H["Accept last result\n(even if poor)"]
回退机制由两个质量指标驱动:
-
压缩比(通过
utils.py中的compression_ratio()计算):len(text_bytes) / len(zlib.compress(text_bytes))。比值超过 2.4 说明文本存在大量重复——模型陷入了循环,不断生成同一个短语。这是自回归模型最常见的失败模式。 -
平均对数概率:低于 -1.0 意味着模型对自身输出缺乏信心。低置信度叠加高无声概率,通常表示这段音频是静音,而非解码失败。
这两个阈值之间的关系颇为微妙。如果 no_speech_prob > no_speech_threshold 且 avg_logprob < logprob_threshold,该片段会被视为静音,此时不会触发回退。这样可以避免在真正静音的片段上浪费计算资源。
提示: 当
temperature > 0时,beam search 会自动禁用(beam_size和patience会从 kwargs 中移除)。同样,在temperature == 0时,best_of采样也会被禁用。这确保了解码策略的一致性。
滑动窗口与 Seek 管理
第 272-399 行的主循环是 transcribe() 的核心。变量 seek 追踪当前在 mel 帧中的位置,并根据解码输出以不同幅度向前推进。
代码注释中坦然承认了这段逻辑的复杂性:"这个循环经过刻意扁平化处理,以便让 diff 更易读。" 它是一个针对 clip_idx 和 seek 的 while 循环,在逻辑上代表了对片段和窗口的嵌套迭代。
每次迭代的流程:
- 从当前
seek位置提取 mel 片段 - 将其填充到恰好
N_FRAMES(3000)帧 - 从上一个上下文中设置 prompt
- 调用
decode_with_fallback() - 解析时间戳 token 以确定片段边界
- 推进
seek
seek 推进逻辑分两种情况,取决于解码器是否产生了连续的时间戳 token:
情况一:找到连续时间戳(常见情况)。输出在每对连续时间戳处切分,每个切片成为一个具有精确起止时间的片段。如果序列以单个时间戳(非成对)结尾,则表示"此后无语音",seek 直接前进整个 segment_size。否则,seek 推进到最后一个时间戳的位置。
情况二:无连续时间戳。 整个输出作为单个片段处理。如果存在任意时间戳 token,则以其位置决定时长;否则使用完整的 segment_duration。seek 始终前进 segment_size。
这一区分至关重要:时间戳 token 提供了窗口内的精确定位。循环不必总是固定跳过 30 秒,而是可以按实际解码的音频量精准推进,从而避免间隙或重叠。
无声检测与 Prompt 条件化
第 298-310 行的无声检测是一个快速跳过路径:
if no_speech_threshold is not None:
should_skip = result.no_speech_prob > no_speech_threshold
if logprob_threshold is not None and result.avg_logprob > logprob_threshold:
should_skip = False # high confidence overrides no-speech
if should_skip:
seek += segment_size
continue
逻辑如下:如果模型判断没有语音(no_speech_prob > 0.6)且平均对数概率较低(确认不确定性),则跳过该片段。但如果 logprob 高,即便无声概率也高,则信任转录结果——模型可能正在捕捉轻柔但清晰的语音。
跨窗口的 prompt 条件化在第 503-505 行进行管理:
if not condition_on_previous_text or result.temperature > 0.5:
prompt_reset_since = len(all_tokens)
当 condition_on_previous_text 启用时(默认行为),所有已解码的 token 都会作为 prompt 传递给下一个窗口,从而保持上下文连贯性——专有名词、风格选择和语境信息得以延续。但如果某个窗口需要使用较高的温度(超过 0.5),prompt 会被重置,因为高温输出很可能包含不可靠的上下文。
第 288-293 行的 carry_initial_prompt 选项提供了能在 prompt 重置后仍然保留的持久上下文。启用后,initial_prompt 的 token 始终会被前置追加,确保领域专属词汇或格式化指令在整个转录过程中持续生效。
幻觉检测与恢复
当 word_timestamps 启用时,transcribe() 会在第 419-472 行激活幻觉检测逻辑,由 hallucination_silence_threshold 参数控制其行为。
系统使用两个局部函数对片段进行评分:
def word_anomaly_score(word: dict) -> float:
probability = word.get("probability", 0.0)
duration = word["end"] - word["start"]
score = 0.0
if probability < 0.15:
score += 1.0
if duration < 0.133:
score += (0.133 - duration) * 15
if duration > 2.0:
score += duration - 2.0
return score
满足以下任一条件的单词会被视为"异常":概率过低(< 0.15)、时长可疑地短(< 133ms)或可疑地长(> 2s)。is_segment_anomaly() 函数会对前 8 个非标点单词的异常分数求和,若总分超过 3 分或接近单词总数,则将该片段标记为异常。
flowchart TD
A["Segment decoded\nwith word timestamps"] --> B{"is_segment_anomaly?"}
B -->|No| C["Keep segment"]
B -->|Yes| D{"Surrounded by\nsilence?"}
D -->|Yes| E["Skip hallucinated\nsegment, re-seek"]
D -->|No| C
F["First segment\nanomaly?"] --> G{"Gap before\n> threshold?"}
G -->|Yes| H["Skip leading silence\nre-seek to gap"]
G -->|No| C
恢复策略会根据位置做出不同处理。如果窗口中第一个片段异常,且其前方存在超过阈值的间隙,循环会重新定位到间隙之后——跳过很可能触发幻觉的静音段。对于窗口中间出现的幻觉,系统会检查周围是否有静音,并截断片段列表,将 seek 重新定位到异常位置的起点。
这套机制本质上是启发式的,并非万无一失,但它能有效捕获最常见的幻觉模式:模型在静音或噪声段中生成听似合理的文本。
CLI 接口
cli() 函数在 transcribe() 之上封装了参数解析与输出写入逻辑。第 528-567 行的 argparse 配置将 transcribe() 的每个参数都映射为一个命令行标志。
CLI 的几个值得注意的行为:
- 温度递增:用户无需手动指定完整的温度元组,只需设置
--temperature(默认 0)和--temperature_increment_on_fallback(默认 0.2),CLI 会通过np.arange(temperature, 1.0 + 1e-6, increment)自动生成元组。 - 多文件处理:
audio参数支持同时传入多个文件,每个文件独立转录,处理失败时会跳过而非中止整个任务。 - 输出格式:
--output_format标志默认为"all",通过get_writer()中的组合模式,一次性调用所有写入器(TXT、VTT、SRT、TSV、JSON)。
提示: 纯英语模型(带
.en后缀)会自动强制设置language="en",即使指定了其他语言也会如此,并输出警告。这可以防止将模型用于错误语言时产生令人困惑的结果。
下一步
理解了转录循环之后,还有一个主要子系统有待探索:词级时间戳。在第 6 篇中,我们将深入研究 Whisper 如何提取交叉注意力权重,通过动态时间规整(Dynamic Time Warping)将其对齐到 token,并最终生成带词语高亮的字幕——包括那个能在运行时生成自身源代码的 Triton kernel。