128 lines
5.1 KiB
Markdown
128 lines
5.1 KiB
Markdown
# 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)` = 3,3 < 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 的处理时间(调用重转写时) |
|