# 字幕时间轴质量深度解析 本文档记录从 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 输入