whisper-transcribe/references/token-repetition-loop.md

128 lines
5.1 KiB
Markdown
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Token Repetition Loop循环幻觉
## 症状
全长转写中,某一段(几秒到几十秒)陷入自激循环,有两种表现:
**单字循环**——反复输出同一个字符:
```
23:31.420 --> 23:59.760 秀秀秀秀秀秀秀秀秀秀秀秀秀秀秀秀秀秀秀秀秀秀秀秀秀秀秀秀秀秀秀秀秀秀秀秀秀秀秀秀秀秀秀秀秀秀秀秀秀秀秀秀秀秀秀
```
**短句循环**——反复念叨同一个词组:
```
05:12.100 --> 05:25.300 谢谢大家谢谢大家谢谢大家谢谢大家谢谢大家谢谢大家
```
单字循环可通过字符频率检测(占比 >65%),但短句循环(如"谢谢大家"每个字只出现 25%)会漏网——需增加周期性检测。
而将问题段音频**单独提取转写**时,识别结果完全正确。
## 根因
### 触发条件
Whisper 的 autoregressive decoder 在**低置信度边界**处进入 token 自激循环:
1. 前一段结尾是犹豫、拖音或模糊发音(如「え…」)
2. KV cache 跨窗口传递时,这个低置信度状态污染了下一个处理窗口的初始预测
3. 第一个 token 猜错(如「秀」的声学嵌入恰好匹配了边界特征)
4. 自激放大:输出「秀」→ 下一帧看到「秀」+ 模糊声学特征 → 继续输出「秀」→ ...
持续到下一个清晰的语音边界才打断循环。
### 为什么单独跑片段就对了
**冷启动。** 没有前面 20+ 分钟的 KV cache 积累,模型在这个窗口开头没有低置信度的上下文污染,直接对准了语音。
### 为什么 turbo 模型更容易触发
`large-v3-turbo` 为了速度减少了 decoder 层数4 层 vs large-v3 的 32 层decoder 的纠错能力弱于完整模型,在模糊边界处进入循环的概率更高。
## 修复方案
不做手动分段(会引入切分边界丢字和重叠区幻觉),而是**后处理检测 + 局部重转写**
### 检测
两种并行的检测机制,覆盖单字和短句两种循环形态:
**A1. 单字频率检测**——某字符占比超过 `char_threshold`(默认 0.7
```python
if len(text) >= 5:
most_common = max(set(text), key=text.count)
if text.count(most_common) / len(text) > 0.7:
bad_indices.append(i) # → 判定为单字循环
```
**A2. 短句周期性检测**——字符串由某个子串重复构成:
```python
def is_phrase_repetition(text: str) -> bool:
"""检测短语/词组级别的幻听死循环"""
t = text.strip()
if len(t) < 6:
return False
# (t+t).find(t,1) < len(t) → t 由子串周期性重复
pos = (t + t).find(t, 1)
if pos != -1 and pos < len(t):
return True
# 前后两半完全相同或包含关系
mid = len(t) // 2
if t[:mid] == t[mid:] or (mid >= 3 and t[:mid] in t[mid:]):
return True
return False
```
例如 "谢谢大家谢谢大家谢谢大家"12 字):
- 单字检测:'谢' 出现 3/12 = 25%,远低于 70% 阈值 → 不触发
- 周期性检测:`(t+t).find(t, 1)` = 33 < 12 命中
### 合并
把连续受影响的段合并成一个时间范围`start` `end`)。
### 局部重转写
ffmpeg 切出该段音频调用 `transcribe()` 冷启动关键流程
1. 从原始音频 `ffmpeg -ss/-to` 切片 + 响度标准化`loudnorm` + 16kHz mono
2. `mlx_whisper.transcribe()` 不带前序上下文冷启动
3. 重转写结果做二次过滤空段丢弃依然循环的文本也丢弃
4. 重转写无有效输出 删除该段静音/纯噪不保留原始幻听
### 替换与索引安全
** 关键陷阱索引漂移Index Shifting**
重转写后的 segments 数量可能与原始不同2 个坏段 5 个好段 0 个好段 删除整段)。如果正向遍历 ranges第一次替换改变了列表长度后续 ranges 的索引全部错位——会覆盖到正确的字幕
**必须逆序遍历**`for seg_start_idx, seg_end_idx in reversed(ranges):`
尾部改动不影响前面尚未处理的元素索引彻底消灭索引漂移
## 工程陷阱总结
| 陷阱 | 表现 | 修复 |
|------|------|------|
| **索引漂移** | 多个幻听区间替换后字幕张冠李戴 | `reversed(ranges)` 逆序遍历 |
| **短句循环漏检** | "谢谢大家谢谢大家" 不触发单字检测 | `is_phrase_repetition()` 周期性检测 |
| **空段重试反噬** | 静音段冷启动重写逼出幻听 | 重试无结果 `del` 删除而非保留 |
| **ffprobe 冗余** | 循环内反复 `get_audio_duration()` | 外部传入 `audio_duration` 参数 |
## 适用场景与限制
| 场景 | 效果 |
|------|------|
| 英文/日文清晰对谈 | 几乎 100% 修复 |
| 广告/赞助商段BGM + 不同播音员 | 依赖音频复杂度 |
| 纯音乐/大量噪声 | 可能再次出现循环 |
## 与手动分段的对比
| 方案 | 优点 | 缺点 |
|------|------|------|
| 手动分段已移除 | 每段冷启无循环 | 切分边界丢字重叠区幻觉额外 I/O |
| 后处理检测 + 局部重转写 | 只在必要时冷启不影响全长 | 增加了 ~30s 的处理时间调用重转写时 |