Read OSS

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 行的初始化逻辑主要完成以下几件事:

  1. 完整音频计算 mel 频谱图,并在末尾填充 30 秒
  2. 从前 30 秒检测语言(如未指定)
  3. 创建 tokenizer
  4. 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_sizepatience 会从 kwargs 中移除)。同样,在 temperature == 0 时,best_of 采样也会被禁用。这确保了解码策略的一致性。

滑动窗口与 Seek 管理

第 272-399 行的主循环是 transcribe() 的核心。变量 seek 追踪当前在 mel 帧中的位置,并根据解码输出以不同幅度向前推进。

代码注释中坦然承认了这段逻辑的复杂性:"这个循环经过刻意扁平化处理,以便让 diff 更易读。" 它是一个针对 clip_idxseekwhile 循环,在逻辑上代表了对片段和窗口的嵌套迭代。

每次迭代的流程:

  1. 从当前 seek 位置提取 mel 片段
  2. 将其填充到恰好 N_FRAMES(3000)帧
  3. 从上一个上下文中设置 prompt
  4. 调用 decode_with_fallback()
  5. 解析时间戳 token 以确定片段边界
  6. 推进 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。