本节摘要:剧本里明明有全部台词,为什么还要跑一遍语音识别?因为转写的目标不是"拿到文字",而是拿到"实际发出的声音的时间戳",并验证 TTS 真正读了什么。本节先给出这三个理由,然后落地本地 Whisper:一张模型规格选择表(从 tiny 到 large-v3 的显存与速度口径),一个带台本注入的转写脚本写法示意;接着集中处理中文转写质量——同音错字、专名漂移、数字口径三大错型,用 initial_prompt 喂台本风格、用术语表做后校正;最后辨析 segment 与 word 两档时间戳粒度的取舍与长音频的漂移对策。模型行为随时演进,一切以官方仓库文档为准。
第一个理由:字幕需要时间戳。台本知道每句话的内容,不知道每句话实际在第几秒——TTS 的实际时长、句间停顿、镜头间静音都会让它偏离估算。第二个理由:验证 TTS 实读内容。第 6.1 节的听检靠人,转写提供机器侧的对照:把成片音轨转回文字,与台本做差异比对,漏读、跳句、错读会直接暴露。第三个理由:适配临场改动。剪辑中删了一个镜头、补了一句气口台词,台本与成片脱节时,以转写结果为准重建字幕,比手工同步台本可靠。
转写的双重角色 台本(应然)──┐ ├──▶ 文本对账 ──▶ 差异清单 ──▶ 人工裁决:改台本还是重配 成片音轨 ──▶ ASR ──▶ asr_result.json(实然:文字+时间戳) │ └──▶ 7.2 字幕分段的时间轴来源
转写放在本地跑,理由与第 6.1 节本地 TTS 相同:音频不出网、边际成本为零、可重复。Whisper 家族的常用规格与经验口径如下(显存与速度为社区口径,以官方仓库为准):
| 规格 | 显存口径 | 速度口径 | 中文质量印象 | 适用 |
|---|---|---|---|---|
| tiny / base | 约 1 GB | 极快 | 错字多 | 调试流水线 |
| small | 约 2 GB | 快 | 勉强可用 | 快速预检 |
| medium | 约 5 GB | 中 | 错字明显减少 | 短剧主力选择 |
| large-v3 | 约 10 GB | 慢 | 更稳 | 机器富余时的终稿 |
工程建议:开发期用 small 以下规格快速迭代,出片用 medium 或 large-v3 重跑一遍。转写脚本的关键技巧是把台本节选作为 initial_prompt 注入——它不改变音频,只引导模型用词与标点风格向台本靠拢,对专名与标点的稳定性格外有效。
# transcribe.py —— 本地 Whisper 转写(写法示意,参数以官方仓库为准) import json, whisper MODEL = "medium" PROMPT = "以下是本集台本节选,供统一用词与标点风格:雨夜便当店,陈师傅,林小满,订单备注。" def transcribe(audio="audio/final_audio.wav", out="build/asr_result.json"): model = whisper.load_model(MODEL) result = model.transcribe(audio, language="zh", initial_prompt=PROMPT, word_timestamps=True, condition_on_previous_text=False) segs = [{"start": round(s["start"], 3), "end": round(s["end"], 3), "text": s["text"].strip()} for s in result["segments"]] with open(out, "w", encoding="utf-8") as f: json.dump({"audio": audio, "model": MODEL, "segments": segs}, f, ensure_ascii=False, indent=2) print(f"转写完成:{len(segs)} 段 → {out}") if __name__ == "__main__": transcribe()
两个参数值得解释:word_timestamps 打开词级时间戳(第四节详述);condition_on_previous_text 关闭后每段独立解码,避免前面段的错认滚雪球式污染后续段。若追求更低显存与更快速度,faster-whisper 等推理加速实现可作替代,接口思路一致,以各自文档为准。
中文 ASR 的错误高度模式化,认得出错型才治得了:
| 错型 | 表现 | 例(示意) |
|---|---|---|
| 同音错字 | 拼音相同或相近的字互换 | 台本"便当"转成"便裆" |
| 专名漂移 | 剧中自造名词被换成常见词 | "雨夜便当"转成"雨夜变蛋" |
| 数字口径 | 汉字数字与阿拉伯数字不稳 | "七幺幺"与"711"混用 |
| 标点缺失 | 长句无停顿标点 | 整段一逗到底 |
| 繁简混杂 | 个别字输出繁体 | "后臺""纪录"混入 |
第一道防线是上文的 initial_prompt,把台本里的专名、数字写法喂进去,防患于然。第二道防线是后校正:转写结果出来后过一遍术语表,整词替换。两道防线一前一后,覆盖绝大多数错型。
# apply_glossary.py —— 术语表后校正:中文整词替换,英文加词边界 import json, re def load(path="build/glossary.json"): return json.load(open(path, encoding="utf-8")) def fix(text, glossary): for wrong, right in glossary.items(): if re.fullmatch(r"[A-Za-z0-9 .-]+", wrong): text = re.sub(rf"\b{re.escape(wrong)}\b", right, text) else: text = text.replace(wrong, right) return text def main(src="build/asr_result.json", dst="build/asr_fixed.json"): glossary = load() doc = json.load(open(src, encoding="utf-8")) changed = 0 for seg in doc["segments"]: fixed = fix(seg["text"], glossary) changed += fixed != seg["text"] seg["text"] = fixed with open(dst, "w", encoding="utf-8") as f: json.dump(doc, f, ensure_ascii=False, indent=2) print(f"后校正完成:改动 {changed} 段 → {dst}") if __name__ == "__main__": main()
# glossary.json —— 术语表:错写在前、正写在后(随听检与对账滚动补充) { "雨夜变蛋": "雨夜便当", "便裆": "便当", "后臺": "后台", "七幺幺": "711" }
注意:后校正只改文字,不动时间戳;替换词与原词长度悬殊时(如"七幺幺"换"711"),阅读节奏几乎不受影响,因为字幕是按时间轴显示的。
Whisper 默认输出 segment 级:每个自然段一条起止时间,粒度一到数秒,对"一句一条字幕"的短剧场景刚好够用。word 级则给出每个词的时刻,代价是转写更慢、输出更大。选型标准:若字幕分段的切分点都落在句读处,segment 级足够;若要做卡拉OK式逐字、或需要把一条长 segment 精确切给多个 cue,才需要 word 级。
另一个现实问题是长音频的累计漂移:半小时以上的连续转写,时间戳可能整体偏移零点几秒。对策是分段转写——按第 6.3 节锚点表把音轨切成镜头级片段分别转写,时间戳加上片段起点再拼回全局,漂移被限制在单镜头内。静音较多的素材可配合 VAD(语音活动检测)先剔除长静音,转写又快又稳。
asr_fixed.json 是本节的最终产物:segments 数组,每段有 start、end、text。它向两个方向输出:向下交给第 7.2 节做字幕分段;向旁用于与台本对账——把 segments 顺序拼接成一整段文字,与台本全文做序列对比,差异清单里每一处都要人工裁决:多数是 ASR 错字(补术语表),少数是 TTS 漏读(回第 6.1 节重跑该句),个别是剪辑期改动(更新台本归档)。对账把第 06 章与本章焊成一个闭环:声音、文字、时间三者互相印证。
文字与时间戳都拿到了,下一节把它们加工成观众真正看到的字幕:按字数与停留规则切成 cue,套进样式模板,再决定烧进画面还是外挂交付。