173 lines
8.0 KiB
Markdown

# pdf-to-audiobook
> **Disclaimer:** this codebase was written with the assistance of a large
> language model (an AI assistant) and has NOT been reviewed or verified by
> a human. Read the code before relying on it with irreplaceable files.
Turn a PDF into a narrated MP3 audiobook: text extraction, OCR-defect
reporting and repair, voice sampling, resumable free edge-tts synthesis,
ffmpeg stitching, and ID3 chapter markers.
Tested end-to-end (by the AI that wrote it, not a human) on a 546-page
scanned book (14h MP3 out, 100+ chapters) and on a clean digital PDF, zero
cost.
## How it works
```
+----------+ +--------+ +-------+ +-------+ +--------+ +----------+
PDF -----+> | extract | ---> | scan | ---> | clean | ---> | chunk | ---> | synth | ---> | stitch |
+----------+ +--------+ +-------+ +-------+ +--------+ +----------+
raw.txt report + clean.txt chunks/ audio/*.mp3 audiobook.mp3
(pdftotext) template (+ chapters) (~2600 chars) (edge-tts) + chapters
```
- **extract** - `pdftotext` + a text-layer quality report (word count,
unreadable-glyph count, verdict).
- **scan** - enumerates every distinct OCR-suspect token with its count and
surrounding context, and can emit a fix-config template. This is the work
horse for scanned PDFs.
- **clean** - noise/page-header removal, hyphenation joining, and applies
your fix config (exact-string maps, regexes, keep/drop windows). Also
records chapter heading positions.
- **voices** - renders one identical sample in N voices, plus one combined
comparison MP3, so you can pick a voice in one playthrough.
- **chunk / synth / stitch** - sentence-aware chunking, resumable parallel
edge-tts synthesis (re-run to pick up stragglers), lossless concat with
RMS spot-checks.
- **chapters** - maps heading positions onto audio time and tags the MP3
with standard ID3v2.4 CHAP chapters (play in players with chapter
support).
## Install
```
pip install -r requirements.txt # edge-tts, mutagen
# plus system tools:
# ffmpeg/ffprobe (any recent build)
# pdftotext (poppler-utils / poppler)
```
## Quick start (clean digital PDF, no OCR damage)
```
python -m pdf2audiobook full book.pdf --voice en-GB-LibbyNeural
```
Everything lands in `book_work/`:
`raw.txt`, `clean.txt`, `chunks/`, `audio/`, `audiobook.mp3`,
`audiobook_chapters.mp3`.
A 250-page book at ~150 wpm is roughly 10h of audio; synthesis of ~100
chunks takes about 15-20 min on the free edge-tts endpoint.
## Step-by-step (scanned PDF, OCR damage, voice selection)
```
# 1. extract + quality report
python -m pdf2audiobook extract book.pdf # workdir: book_work/
# 2. see what the OCR broke
python -m pdf2audiobook --workdir book_work scan --top 200
python -m pdf2audiobook --workdir book_work scan --top 300 \
--template configs/book.json
# 3. fill in configs/book.json (see below), then clean
python -m pdf2audiobook --workdir book_work clean --config configs/book.json
# 4. pick a voice: render a sample in several candidates
python -m pdf2audiobook --workdir book_work voices \
en-GB-LibbyNeural en-GB-SoniaNeural en-US-AriaNeural en-US-ChristopherNeural
# -> book_work/samples/compare_all_voices.mp3 (one playthrough, all voices)
# listen, choose one
# 5. chunk + synthesize (resumable; safe to Ctrl-C and re-run)
python -m pdf2audiobook --workdir book_work chunk
python -m pdf2audiobook --workdir book_work synth en-GB-LibbyNeural
# quick check first: ... synth en-GB-LibbyNeural --limit 5
# 6. stitch + verify (RMS spot-checks for silent sections)
python -m pdf2audiobook --workdir book_work stitch
# 7. chapter markers
python -m pdf2audiobook --workdir book_work chapters
# -> book_work/audiobook_chapters.mp3
```
## Fixing OCR defects
Scanned PDFs break text in systematic ways. The OCR engine of a given scan
misreads the SAME glyph the SAME way (e.g. `;` for `s`, `J` for `th`, `9`
for `d`, and U+FFFD replacement characters for diacritics). So the reliable
tool is not a clever regex but an **exact-string map built from the actual
output**:
1. `scan --template configs/book.json` prints the distinct suspect tokens
with counts and context, and writes a config with every token as an empty
value.
2. Fill in the correct reading for the tokens that matter. Prioritize by
count: the high-frequency terms (names, technical vocabulary) are worth
mapping; hundreds of one-offs in an index or reference table usually
aren't. Leave the rest empty (empty values are ignored).
3. For a handful of pattern-like misreads, `regex_fixes` works, but only
with anchored patterns you have verified against the raw context. A loose
short-pattern "fix" will corrupt real words (this bit the reference
project: a catch-all rule corrupted `measure` six times).
4. `clean --config ...` applies everything and reports remaining U+FFFD
glyphs. Re-run `scan clean.txt` to eyeball what's left.
The config keys:
| key | meaning |
|---|---|
| `include.start` / `include.end` | regexes marking the first/last body lines (cuts front/back matter; `end_fallback` is a second try for `end`, `min_line_*` guard against early false hits) |
| `drop_blocks` | e.g. remove the table of contents: `start` + `end` line regexes, optional `end_followed_by` to require the block's end to be followed by specific lines |
| `page_header_patterns` | running headers to drop as whole lines (match at line start) |
| `drop_line_patterns` | other whole lines to drop |
| `strip_patterns` | inline removals applied to the joined text (plate/figure references, stray inline headers) |
| `raw_fixes` | exact-string fixes applied to the raw text before any structure is touched (typically U+FFFD glyph repairs that must happen before line filtering) |
| `regex_fixes` | `[pattern, replacement]` pairs, applied in order to the joined text |
| `fixes` | exact-string map applied longest-first (the main OCR repair table) |
| `word_fixes` | anchored word-boundary `[pattern, replacement]` pairs, applied last |
| `heading_pattern` | regex matching chapter heading lines (used for chapter markers) |
| `heading_exclude_patterns` | lines that must never count as headings (e.g. numbered instruction sentences) |
| `heading_next_pattern` / `heading_next_skip` | require a heading to be followed by this pattern within N lines (filters running headers that share a heading's shape) |
| `heading_count_max` | safety cap on chapter count |
A heavily-scanned book (foreign-language terms, diacritics, drop caps)
typically needs a few hundred exact-string `fixes` plus a smaller set of
`raw_fixes` (for U+FFFD glyphs) and a handful of anchored `regex_fixes`.
Start from a `scan --template` and fill in the high-frequency tokens first.
## Voices
`python -m edge_tts --list-voices` lists the full catalogue. Useful filters:
`en-GB-`, `en-US-`, `en-AU-`, `en-CA-`, `en-IN-`, `en-IE-`. Known-good as of
2026-08: en-US-{Aria,Jenny,Ava,Christopher,AndrewMultilingual,EmmaMultilingual},
en-GB-{Sonia,Libby,Ryan,Thomas}, en-AU-Natasha, en-CA-{Clara,Liam},
en-IN-{Neerja,Prabhat}. Some names 404 (Davis, Sinara, IE-Emma, ZA-James);
the sampler reports which ones failed.
edge-tts is free and unauthenticated. Keep concurrency at 2 (the default)
and let the built-in retries/backoff handle hiccups. A 546-page book is
~250 chunks in ~30-35 min.
## Output
- `audiobook.mp3` - one continuous file (24 kHz MP3, what edge-tts emits;
fine for speech)
- `audiobook_chapters.mp3` - same audio + ID3v2.4 CHAP chapters
- verify any time: `ffprobe -v error -show_chapters audiobook_chapters.mp3`
## Caveats
- One consistent voice; no expression/pacing control beyond SSML-free plain
text.
- OCR in reference tables, indices, and heavily illustrated pages will not
be fully repairable; the tool reports what remains instead of faking
100% clean.
- Chapters come from real headings in the text; if the scan ate the
heading numbers, some chapters will be missing (the tool would rather
leave a gap than mislabel a chapter).
- Respect copyright: use this for books you own or that are public domain.