163 lines
7.0 KiB
Markdown
163 lines
7.0 KiB
Markdown
# 字幕时间轴质量深度解析
|
||
|
||
本文档记录从 PyTorch 方案(stable-ts / whisper-timestamped)向 MLX 增强方案迁移的决策过程与技术细节。
|
||
|
||
## 为什么不用 stable-ts / whisper-timestamped
|
||
|
||
| 框架 | 底层依赖 | 与 mlx-whisper 兼容性 |
|
||
|---|---|---|
|
||
| **stable-ts** | PyTorch + `openai-whisper` | ❌ 不兼容 |
|
||
| **whisper-timestamped** | PyTorch + `openai-whisper` | ❌ 不兼容 |
|
||
| **mlx-whisper 增强** | MLX 框架 | ✅ 原生兼容 |
|
||
|
||
这两个工具都是 PyTorch 生态的插件,直接修改/包装的是 OpenAI 官方 Whisper 模型。mlx-whisper 使用的是 MLX 框架和不同的模型格式,两者推理代码完全不同,无法混用。
|
||
|
||
如果硬要引入 stable-ts,意味着:
|
||
- 安装 PyTorch + openai-whisper(~2GB 额外依赖)
|
||
- 放弃 Apple Silicon 的 MLX 加速
|
||
- turbo 模型在 PyTorch 上跑得更慢
|
||
|
||
## mlx-whisper 内建方案:word_timestamps
|
||
|
||
mlx-whisper 本身支持 `word_timestamps=True`,只是默认关闭。打开后可以获得每个词的精确起止时间,用来:
|
||
|
||
1. 更精准的 SRT 断句(不在词中间切断)
|
||
2. 检测长停顿,自动拆分句子
|
||
3. 修复"黏连"(一句字幕占好几秒不消失)
|
||
|
||
### 出现黏连的根因
|
||
|
||
Whisper 的默认输出是 **segment-level**,一个 segment 可能包含多句话,但只有一个起止时间。当这个 segment 跨度很大时,字幕就会"黏在屏幕上"。
|
||
|
||
解决方法:用 word-level timestamps 重构断句逻辑。
|
||
|
||
## 智能断句算法
|
||
|
||
```python
|
||
# 核心逻辑
|
||
for 每个词:
|
||
检测词间停顿 pause = 下一个词.start - 当前词.end
|
||
|
||
如果 pause >= 0.3s 且当前块长度 >= 1.5s:
|
||
在这里切断,形成新的字幕行
|
||
否则如果当前块长度 >= max_line_duration(默认 5s):
|
||
强制切断
|
||
```
|
||
|
||
这比 stable-ts 的动态重构交叉注意力机制简单,但在大多数场景下足够有效。
|
||
|
||
## 幽灵字幕的根因与解决
|
||
|
||
**幽灵字幕**指背景音乐/静音期间出现的无意义字幕。
|
||
|
||
Whisper 是一个语音识别模型,它不区分"人声"和"背景音乐",只是尝试预测文字。在音乐声音中,模型可能会"幻想"出一些文字。
|
||
|
||
### VAD 预处理方案
|
||
|
||
不需要 PyTorch 的 Silero VAD,用 ffmpeg 内置的 `silencedetect` 即可:
|
||
|
||
```bash
|
||
ffmpeg -i 音频.wav -af silencedetect=noise=-40dB:d=0.5 -f null -
|
||
```
|
||
|
||
这会输出所有静音区域的起止时间。转写后,如果某段字幕 70% 以上落在静音区,则自动丢弃。
|
||
|
||
## 手动分段已移除
|
||
|
||
原先脚本里用 ffmpeg 做 10 分钟一段的手动分段 + 重叠合并,后来发现:
|
||
|
||
- **mlx-whisper 内部自带滑动窗口机制**,会自动处理长音频,不需要外部切块
|
||
- 手动分段导致 chunk 重叠区的**幻觉**(重复「うんうん」等)
|
||
- 额外 I/O 开销(ffmpeg 切 → 写盘 → 读回 → merge)
|
||
|
||
移除了 `segment_audio()`、`merge_segments()` 及 `--segment-minutes` / `--overlap-seconds` / `--no-segment` 参数。现在流程更简洁:预处理 → mlx-whisper 整段转写 → 后处理。
|
||
|
||
## 残留依赖清理注意事项
|
||
|
||
如果曾经尝试装过 PyTorch 后又卸载,可能留下空目录:
|
||
|
||
```bash
|
||
# 检查残留
|
||
ls ~/.local/venvs/default/lib/python*/site-packages/ | grep torch
|
||
|
||
# 清理
|
||
rm -rf ~/.local/venvs/default/lib/python*/site-packages/torch \
|
||
~/.local/venvs/default/lib/python*/site-packages/functorch \
|
||
~/.local/venvs/default/lib/python*/site-packages/torchgen
|
||
```
|
||
|
||
uv pip uninstall 对非标准 wheel 的清理可能不完整,需要手动检查。
|
||
|
||
## 现场对比:Whisper vs Deepgram Nova-2(日语播客 #379)
|
||
|
||
29 分钟日语广播节目,同一段音频,分别用 mlx-whisper turbo(有 prompt)和 Deepgram Nova-2(无提示)转写:
|
||
|
||
| 对比项 | Deepgram (Nova-2, 裸跑) | Whisper (mlx turbo, 有 prompt) |
|
||
|---|---|---|
|
||
| 分段数 | 296 段(偏长) | 635 段(偏细,智能断句后) |
|
||
| 首段开始 | 41.67s(错过片头音乐) | 8.20s(抓到片头标签) |
|
||
| 末尾幻觉 | 无 | 有「ご視聴ありがとうございました」重复 |
|
||
| 标点符号 | ✅ 有、。 | ❌ 基本没有 |
|
||
| 嘉宾名 | ❌ 浅見**春奈**(错字) | ✅ 浅見**春菜**(prompt 起作用) |
|
||
| 整体识别率 | 很高,自然流畅 | 高,但有小错 |
|
||
|
||
**结论:** 在人声清晰的广播场景下,mlx-whisper turbo + promt 并不比 Deepgram Nova-2 差。Deepgram 的优势在嘈杂环境、多人重叠、带 BGM 的现场录音。(注意:Nova-2 不支持 keyterm 提示,需 Nov-3 才有。)
|
||
|
||
## 模型量化信息(mlx-whisper)
|
||
|
||
`mlx-community/whisper-large-v3-turbo` 是 **fp16(float16 半精度)**,约 1.5GB。
|
||
|
||
| 量化 | 大小 | 说明 |
|
||
|---|---|---|
|
||
| 原始 fp32 | ~3GB | OpenAI 原版 |
|
||
| MLX fp16 | 1.5GB | Apple Silicon native 格式,计算效率最高 |
|
||
| 4-bit | ~400MB | 精度损失大,MLX 社区很少出 |
|
||
|
||
Apple Silicon 上 fp16 是原生格式,不需要进一步量化。想要更小模型应换 `small`(~500MB)或 `medium`(~1.5GB)。
|
||
|
||
## 快速诊断清单
|
||
|
||
当用户抱怨字幕质量时,按以下流程排查:
|
||
|
||
1. **黏连**:检查是否开启了 `word_timestamps` 和 `smart_split`
|
||
2. **幽灵字幕**:检查是否开启了 `vad_filter`
|
||
3. **幻觉复读**:检查是否开启了 `clean`
|
||
4. **断句不自然**:调整 `max_line_duration` 参数
|
||
5. **识别准确度低**:检查 `initial_prompt` 是否包含专有名词
|
||
|
||
## 代码审查修复记录(2026-07-06)
|
||
|
||
一轮完整代码审查发现并修复了 4 个问题:
|
||
|
||
### Bug 1:浮点精度导致 SRT 毫秒偏移(🔴 高影响)
|
||
|
||
**问题:** `format_timestamp_srt` 用 `int((seconds % 1) * 1000)` 提取毫秒。Python 浮点精度导致 `8.2 % 1 = 0.1999...`,`int()` 截断后变成 `199` 而不是 `200`。实测 642 段字幕中 623 段受影响(偏差 1ms)。
|
||
|
||
**修复:** 改用 `int(round(seconds * 1000))` 先转总毫秒再取模。
|
||
|
||
### Bug 2:detect_silence 尾部静音丢失(🔴 高影响)
|
||
|
||
**问题:** 音频以静音结尾时,ffmpeg 只输出 `silence_start` 没有 `silence_end`。原代码用 `zip(starts, ends)` 配对,`zip` 静默截断不匹配项,导致最后一段静音被丢弃,VAD 过滤失效。
|
||
|
||
**修复:** 改用索引配对,未闭合的 `silence_start` 用 `audio_duration` 参数补上结尾。
|
||
|
||
### Bug 3:format_timestamp_srt 负数乱码(🟡 防御性)
|
||
|
||
**问题:** `-0.1` 输出 `-1:59:59,900`。Whisper 正常不返回负数,但不防御。
|
||
|
||
**修复:** 开头加 `if seconds < 0: seconds = 0.0`。
|
||
|
||
### Bug 4:remove_vad_gaps 除零风险(🟡 防御性)
|
||
|
||
**问题:** 零长度段(`start == end`)时 `overlap / seg_len` 除零崩溃。
|
||
|
||
**修复:** 加 `if seg_len <= 0: continue`。
|
||
|
||
### 清理:删除未使用的 `import math`
|
||
|
||
### 教训
|
||
|
||
- **浮点取整陷阱:** 任何 `int(float_val * N)` 都应该用 `int(round(float_val * N))` 替代,避免 IEEE 754 精度问题
|
||
- **zip 配对陷阱:** 当两个列表长度可能不匹配时,不要用 `zip()`,用索引配对并处理 unmatched 项
|
||
- **代码审查应包含边界值测试:** 负数、零、空列表、unmatched 输入
|