扫描版 PDF → 中文有声书
把扫描书稿变成「分节 + 完整版」的有声书,并支持精确对齐参考音频的语速节奏。
核心原则(先读这几条,能省掉大部分返工)
- 术语纠错必须放在「段落合并之后」。OCR 会把「巳火」断成两行(上行以「已」结尾、下行以「火」开头),行级替换匹配不上,会静默漏改上百处。散文类稿件实测:行级无匹配、段落级命中 400+ 处。
- 标题必须自成一个「已终止」的段落。若标题末尾不补句号,下一步的拼接逻辑会把标题和紧随正文粘成一段,导致分节判据(要求整段 ≤26 字)失效、小节全部消失。
- 水印可能在行中间。扫描水印常与正文并成一行(
…看不见了。更多最新资料微信WORK12311),只在行首判定会漏掉,水印会被 TTS 念出来。必须先sub()剔除片段再判断剩余内容。 - 规则匹配必须用
re.search,不能用old in p。规则可能是正则(如王(?=[癸水…])),字面子串判断永远为 False,会让整条规则被静默跳过——症状是「规则命中 0 处」但文本里明明有错字。这是最隐蔽的一类 bug,曾导致 59 处漏改。 - 替换表必须保持「长串优先」并做前缀冲突自检。如
己已土必须排在己已之前,否则「己已土」会被当成「己巳+土」。改完表后跑一次前缀冲突检测(见book_text.py注释)。
适用场景与路线
用户给出 PDF 并要求转成音频。先探测类型:
python scripts/probe_pdf.py <input.pdf>
- 输出
TEXT(有文字层)→ 直接pypdf提取文本,跳到「清洗」 - 输出
IMAGE(纯图片,如Producer: Epson Scan 2)→ 走 OCR 流程
环境准备(Windows 本机)
PY="C:/Users/<user>/.workbuddy/binaries/python/versions/3.13.12/python.exe"
"$PY" -m venv "C:/Users/<user>/.workbuddy/binaries/python/envs/default"
PIP="C:/Users/<user>/.workbuddy/binaries/python/envs/default/Scripts/python.exe"
★ 必须显式指定官方源——本机全局 pip.ini 指向的清华源已失效(报 from versions: none):
"$PIP" -m pip install --index-url https://pypi.org/simple --no-cache-dir \
pypdf pymupdf rapidocr-onnxruntime edge-tts imageio-ffmpeg
# 需要复刻参考节奏时再加: faster-whisper
imageio-ffmpeg是免安装获取 ffmpeg 的最省事途径:imageio_ffmpeg.get_ffmpeg_exe()- 本机 Bash 的 coreutils 不可用(
ls/tail报 command not found,exit 127)。一切读写走 Python 脚本,不要用管道。
全流程(4 步)
Step 1 — OCR
python scripts/ocr_pdf.py <input.pdf> <ocr_dir> [start] [end] [dpi]
逐页写出 pNNN.txt(已按视觉行合并、还原阅读顺序)。默认 200dpi、约 2.7 秒/页。
已存在的页自动跳过(断点续跑);FORCE=1 强制重做。
★ RapidOCR 返回结构陷阱:res 元素为 [box, text, score],且 score 是字符串。
不要用 isinstance(x, str) 去猜哪个是文本字段——会把 score 当文本。
正确写法:box, txt, sc = item[0], item[1], float(item[2])。
Step 2 — 清洗 / 纠错 / 分节(先 DRY 验证)
DRY=1 python scripts/build_audiobook.py <ocr_dir> <out_dir> \
--pages 7 165 --book "<书名>" --page-header-key "<页眉关键片段>"
务必先跑 DRY=1。它会输出:术语纠错命中数(每条都要看)、残留可疑扫描、分节清单与各节字数,并落盘清洗后的全文。确认无误再实跑合成。
book_text.py 内含可复用的清洗内核,要点:
| 环节 | 关键做法 |
|---|---|
| 页眉页脚 | 短行含书名,或纯页码行 ^[\s\-—_·]*\d{1,3}[\s\-—_·]*$ |
| 水印 | 行内 sub 剔除(见核心原则 3);正则用 \S* 吞掉无空格后缀如 微信WORK12311 |
| 标题判据 | 分隔符必须用「非汉字非字母数字」——[^\u4e00-\u9fa5A-Za-z0-9],因 OCR 会把「、」认成「。」「.」「,」 |
| 断行合并 | 中文行末若无 。!?;:”』】) 则与下行直接拼接(不加空格) |
| 标题尾码 | re.sub(r'[\s\-—–..·。,,、]*\d+$', '', t) |
| 段落级纠错 | 合并之后再做替换(核心原则 1) |
| 分节 | 一级标题(中文数字开头)切分,过滤正文 <30 字的伪小节 |
★ 术语纠错表写法:长串优先、互不重叠、逐条打印命中数。
FIXES = [("王癸水", "壬癸水"), ("丁王合", "丁壬合"), ("甲己", "甲己"), ("已土", "己土"), ...]
hits = {old: 0 for old, _ in FIXES}
for old, new in FIXES:
if old in s:
hits[old] += len(re.findall(old, s)); s = re.sub(old, new, s)
命中 0 说明没匹配上(多半是被前一条短串先吃掉了,或本书确无此错字)——必须回头确认,否则以为改了其实没改。脚本会单独列出「未命中规则」供判定。
★「已」是最大坑,必须逐条按上下文判定,禁用宽泛正则:
| 上下文 | 正字 | 例 |
|---|---|---|
| 已 + 土 | 己 | 已土→己土、戊已土→戊己土 |
| 已 + 火/金/水/木 | 巳 | 已火→巳火 ← 最高频,实测单本 100+ 处 |
| 已 + 地支字 | 巳 | 已午→巳午、已酉丑→巳酉丑 |
| X已Y 固定搭配 | 巳 | 寅已穿→寅巳穿(实测 18+ 处)、已和申、禄在已、已为心、纳已、已向、加已、被已合、辰已空 |
| 天干 + 已 + 自合 | 巳 | 辛已自合→辛巳自合 |
| 甲 + 已 + 合 | 己 | 甲已合→甲己合(五合之一,不是甲巳合) |
| 自已 | 自己 | OCR 常见,须单独列规则 |
| 副词 | 不动 | 万不得已、情非得已、已经、而已、不鸣则已 |
⚠ 反面教训(实际踩过):用 (?<=[甲乙丙丁…])已 → 巳 这类宽泛 lookbehind,
会同时造成两类错误——把「甲已合」错改成「甲巳合」,又漏掉前面是标点的「已火」。
实测一次误改产生 9 处新错、漏改 105 处。
改完必做「残留扫描」(book_text.scan_residue),按小节逐条人工判定,能同时抓出漏改与误改。命理类高频错字:王→壬、葵→癸、千→干、卵→卯、奎刚→魁罡、戍→戌、绯蚌→诽谤、羊人→阳刃、丁任→丁壬。
Step 3 — 合成
两条路线,按是否需要复刻参考节奏选:
A. 常规分块合成(默认,速度快)
python scripts/build_audiobook.py <ocr_dir> <out_dir> --pages 7 165 \
--voice zh-CN-YunjianNeural --rate -22% --pitch -4Hz
- 分块约 360 字,按
(?<=[。!?])(?![”』】)])切句后累积;无标点长句按逗号硬切(180 字) - 并发 4 较稳;分批 40 块提交,每块重试 5 次(指数退避)
- 崩溃救场:合成中断后用
MERGE_ONLY=<临时目录> ...复用已合成块,直接进合并,省去重跑(实测省 7 分钟)
B. 逐句合成 + 分级停顿(用于精确对齐参考音频节奏)
DRY=1 python scripts/synth_rhythm.py <text_file> <out.mp3> \
--rate -22% --gap-comma 0.22 --gap-stop 0.42 --gap-title 0.70
先 DRY 看预估的总语速与停顿占比,调到贴近参考音频再实跑。
中文男声(edge-tts):
| 音色 | 特点 | 参数建议 |
|---|---|---|
zh-CN-YunjianNeural 云健 |
浑厚磁性男中音,讲书首选 | rate -12%~-22%, pitch -4Hz |
zh-CN-YunyangNeural 云扬 |
新闻播音腔,稳重 | rate -6%, pitch -2Hz |
zh-CN-YunxiNeural 云希 |
年轻清朗 | rate -6% |
zh-TW-YunJheNeural 云哲 |
台湾低音 | rate -8% |
女声:zh-CN-XiaoxiaoNeural(温暖)、zh-CN-XiaoyiNeural(活泼)。
建议先为用户生成 4 个候选音色的试听样本(同一段正文),再批量合成。
Step 4 — 校验交付
python scripts/verify_delivery.py <out_dir> --sections "一、五行论,二、..." --asr
最重要的验收标准:分节时长之和 == 完整版时长(证明无遗漏、无重复)。
另含文件清单、异常短节告警、文本首末段与小节齐全性、manifest 参数、(--asr)抽查术语读法。
复刻参考音频的语速与节奏(量化流程)
用户给出参考音视频("按这个语速和节奏")时,按下面的量化流程做,不要凭感觉调。
1. 测参考音频
python scripts/measure_rhythm.py <reference_audio_or_video>
输出四组指标,后续逐项对齐:总语速(汉字/总时长×60,最可靠口径)、净语速、停顿占比、停顿中位。
2. ★ VAD 的 speech_pad_ms 陷阱(必读)
from faster_whisper.vad import get_speech_timestamps, VadOptions
ts = get_speech_timestamps(audio, VadOptions(min_silence_duration_ms=200,
speech_pad_ms=0)) # ★ 必须设 0!
speech_pad_ms 默认 400,会在每段语音前后各扩展 400ms,把任何短于 800ms 的停顿全部吞掉——
实测让停顿占比从真实的 10.8% 掉到 0.0%,从而误判"音频没有停顿"。脚本已强制设 0,并用 silencedetect 交叉验证(参考音频有背景声时 silencedetect 会失效,以 VAD 为准)。
3. ★ edge-tts 每段自带 0.5–0.7s 头尾静音
逐句合成时这些会叠加:189 段实测额外多出 127s,停顿占比从预期 6% 飙到 16.3%。
必须裁剪(脚本已内置,ThreadPoolExecutor(max_workers=6) 并行约 1 分钟):
ffmpeg -i seg.mp3 -af "silenceremove=start_periods=1:start_threshold=-45dB:start_silence=0.02,\
areverse,silenceremove=start_periods=1:start_threshold=-45dB:start_silence=0.02,areverse" \
-ar 24000 -ac 1 -c:a libmp3lame -b:a 48k out.mp3
4. 分级插停顿
| 单元末尾 | 静音 | 说明 |
|---|---|---|
| 逗号/顿号/分号 | 0.20–0.28s | 句内换气 |
| 句号/问号/叹号 | 0.40–0.55s | 句间停顿 |
| 小节标题 | 0.70–0.90s | 章节留白 |
长句(>28 字)先在逗号处再切一刀(每段 ≤24 字),否则停顿密度不够。
静音片段规格须与语音一致:ffmpeg -f lavfi -i anullsrc=r=24000:cl=mono -t 0.25 -c:a libmp3lame -b:a 48k sil.mp3(mp3 静音约 -91dB 属正常)。
5. 经验值
- edge-tts 逐句合成的净语速约为分块合成的 0.8 倍。rate=-20% 逐句合成净语速约 215 字/分,rate=-22% 约 200 字/分
- 交付前回测成品,与参考逐项对齐(总语速 / 停顿占比 / 停顿中位)
- 中文 48kbps 约 4.6 分钟/MB,250 字/分钟(用于估算)
局部返修(只重做某一节)
用户反馈"某节某个词读错了"或"这节节奏不对"时,不要全量重跑:
python scripts/fix_section.py <ocr_dir> <out_dir> --section 三、 --pages 27 34 --dry
- 先
--dry:调出该节文本,逐字审查(人工过一遍,不要只依赖正则) - 看每条纠错规则的命中数,命中 0 的必须查明原因
- 单字逐处确认(
已/己会命中「万不得已」等正常词,属正常保留) - 覆盖前自动备份原音频到
<out_dir>/_backup/ - 合成后用 ffmpeg 比对时长,应与原版接近(差值来自被剔除的水印行)
- 注意:分节音频更新后,
00_全书完整版仍是旧版;如需同步则用MERGE_ONLY重跑,或重跑全书
合并与产物
concat demuxer + -c copy,失败退回重编码 -ar 24000 -ac 1 -b:a 64k:
[ff, "-y", "-f", "concat", "-safe", "0", "-i", listfile, "-c", "copy", out]
list.txt 写 file '<绝对路径,正斜杠>'(-safe 0 允许绝对路径)。
<out_dir>/
00_全书完整版_<书名>.mp3
01_<小节标题>.mp3 ... NN_<小节标题>.mp3
_全文文本.txt 清洗后全文(便于用户核对与二次利用)
_manifest.json 书名/音色/语速/节名/时长/大小/字数
_backup/ 局部返修时的原音频备份
常见问题
| 现象 | 原因与处理 |
|---|---|
from versions: none |
pip 源失效,加 --index-url https://pypi.org/simple |
文本全是 0.83… 这类数字 |
OCR 结果里 score 被当成 text,用 item[1] / float(item[2]) |
| 小节全部消失 / 只有 1 节 | 标题末尾未补句号,与正文粘连致分节判据失效(核心原则 2) |
| 术语改了但没生效 | 在行级而非段落级替换,术语被断行拆开(核心原则 1);或用 old in p 而非 re.search,正则规则被静默跳过(核心原则 4) |
| 某条规则命中 0 处 | 先怀疑核心原则 4(正则被字面判断跳过);再查是否被同前缀短串抢先(核心原则 5) |
| 音频里念出水印 | 水印在行中间没被剔除(核心原则 3) |
| 替换后出现新错字 | 前缀冲突:长串被短串先吃掉,调整 FIXES 顺序(长串优先) |
--rate -22% 报 expected one argument |
argparse 把负值当选项;脚本已内置改写为 --rate=-22%,自定义脚本需自行处理 |
| 停顿占比测成 0% | speech_pad_ms 默认 400 吞掉短停顿,设 0 |
| 停顿占比异常偏高 | edge-tts 片段头尾静音未裁剪(约 +10 个百分点) |
| 命令 rc=127 无输出 | 用了 tail/ls 等 coreutils,本机不可用,改用 Python |
| 合成中途崩溃 | MERGE_ONLY=<块目录> 复用已合成块直接合并 |