commit a1796e82aade5352c79fd93b6126f438c7e37ed2 Author: bitscuit Date: Tue Aug 25 14:15:50 2026 +0200 pdf-to-audiobook: PDF -> narrated MP3 via edge-tts Pipeline: extract (pdftotext) -> scan (OCR-defect report + fix template) -> clean (noise/header removal, hyphenation, exact-string/regex OCR repairs, chapter-heading detection) -> voices (sample + comparison) -> chunk (sentence-aware) -> synth (resumable edge-tts) -> stitch (ffmpeg concat + RMS verify) -> chapters (ID3v2.4 CHAP). Config-driven per book; validated end-to-end on a clean digital PDF and on a 546-page scanned book (byte-identical clean output to the reference run, 95 auto-detected chapters). diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bd07189 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.pyc +*_work/ +*.mp3 +*.raw.mp3 +samples/ +audio/ +chunks/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..2e11eb7 --- /dev/null +++ b/README.md @@ -0,0 +1,167 @@ +# pdf-to-audiobook + +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. + +Proven on a 546-page scanned book: 546 pages in, a 14h MP3 out, 100+ +chapters, 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. diff --git a/configs/example.json b/configs/example.json new file mode 100644 index 0000000..86a0341 --- /dev/null +++ b/configs/example.json @@ -0,0 +1,38 @@ +{ + "include": { + "start": "^(Foreword|Preface|Introduction)$", + "end": "^(Glossary|Index|Bibliography|Acknowledg(?:ements)?)$", + "end_fallback": "^Appendix", + "min_line_start": 0, + "min_line_end": 2000 + }, + "drop_blocks": [ + { + "start": "^Contents$", + "end": "^PART I$", + "end_followed_by": ["Introduction", "INTRODUCTION", "INTRO"] + } + ], + "page_header_patterns": [ + "^\\d{1,3}\\s+(Book title|Part [IVX]+)$", + "^(Book title)\\s*\\d{0,3}$" + ], + "drop_line_patterns": [ + "^(?:[Aa]sana|[Cc]hapter|[Ss]ection)\\s+\\d{1,3}$" + ], + "strip_patterns": [ + "\\s*\\((?:Plates?|Figure[s]?) [0-9IVXLC]+(?:\\s*(?:to|and) [0-9IVXLC]+)*\\)\\.?\\s*", + "\\s*(?:One|Two|Three|Four|Five|Six)\\*$" + ], + "raw_fixes": { + "U+FFFD-containing token from `scan`": "corrected reading" + }, + "regex_fixes": [ + ["\\bpalance\\b", "balance"] + ], + "fixes": { + "exact token as it appears": "corrected text" + }, + "heading_pattern": "^(?:PART [IVX]+\\b|\\d{1,3}\\.\\s+\\w|[A-Z][a-z]+\\s+[A-Z][a-z]+)$", + "heading_count_max": 150 +} diff --git a/pdf2audiobook/__init__.py b/pdf2audiobook/__init__.py new file mode 100644 index 0000000..5837344 --- /dev/null +++ b/pdf2audiobook/__init__.py @@ -0,0 +1,8 @@ +"""pdf2audiobook: turn a PDF into a narrated MP3 audiobook. + +Pipeline: extract text -> scan/repair OCR defects -> voice sampling -> +sentence-aware chunking -> resumable edge-tts synthesis -> ffmpeg stitch -> +ID3v2.4 CHAP chapter markers. +""" + +__version__ = "0.1.0" diff --git a/pdf2audiobook/__main__.py b/pdf2audiobook/__main__.py new file mode 100644 index 0000000..2f7ccf8 --- /dev/null +++ b/pdf2audiobook/__main__.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +"""pdf2audiobook - turn a PDF into a narrated MP3 audiobook. + +Subcommands (run in order for a full book): + + extract PDF -> raw.txt (pdftotext + quality report) + scan raw.txt -> suspect token report (+ optional fix template) + clean raw.txt -> clean.txt + clean.chapters.json (+ .chunk_offsets.json) + voices -> samples/*.mp3 + compare_all_voices.mp3 + chunk clean.txt -> chunks/*.txt + clean.chunk_offsets.json + synth -> audio/*.mp3 (resumable; --limit N for checks) + stitch audio/ -> .mp3 (concat + RMS verification) + chapters clean.txt -> _chapters.mp3 (ID3v2.4 CHAP markers) + full PDF -> runs the whole pipeline with --voice/--config + +All files land in the --workdir (default: _work/). +""" +import argparse +import json +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from pdf2audiobook import chunk as chunk_mod +from pdf2audiobook import clean as clean_mod +from pdf2audiobook import chapters as chapters_mod +from pdf2audiobook import extract as extract_mod +from pdf2audiobook import scan as scan_mod +from pdf2audiobook import stitch as stitch_mod +from pdf2audiobook import synth as synth_mod +from pdf2audiobook import voices as voices_mod + + +def workdir_for(args) -> str: + wd = args.workdir + if wd is None: + base = os.path.basename(args.pdf or "book").rsplit(".", 1)[0] + wd = f"{base}_work" + os.makedirs(wd, exist_ok=True) + return wd + + +def cmd_extract(a): + wd = workdir_for(a) + info = extract_mod.extract(a.pdf, os.path.join(wd, "raw.txt")) + print(json.dumps(info, indent=1)) + print(f"next: pdf2audiobook --workdir {wd} scan (if the report shows OCR damage)") + + +def cmd_scan(a): + scan_mod.cmd_scan(os.path.join(a.workdir, "raw.txt"), top=a.top, + template=a.template) + + +def cmd_clean(a): + info = clean_mod.clean(os.path.join(a.workdir, "raw.txt"), + cfg_path=a.config, + out_path=os.path.join(a.workdir, "clean.txt")) + print(json.dumps(info, indent=1)) + if info["fffd_left"]: + print(f"NOTE: {info['fffd_left']} U+FFFD glyphs remain in clean.txt; " + f"review them with `scan clean.txt` if they matter") + + +def _sample_text(path, chars=1500): + text = open(path, encoding="utf-8").read() + # sample from ~1/3 in, skip title pages/front matter + start = len(text) // 3 + return text[start:start + chars].strip() + + +def cmd_voices(a): + import asyncio + + path = a.text or os.path.join(a.workdir, "clean.txt") + if not os.path.exists(path): + raise SystemExit(f"sample text file not found: {path} " + f"(run extract/clean first, or pass --text)") + text = _sample_text(path, a.chars) + outdir = os.path.join(a.workdir, "samples") + print(f"sample text ({len(text)} chars): {text[:120]}...") + asyncio.run(voices_mod.sample(text, a.voices, outdir, compare=not a.no_compare)) + + +def cmd_chunk(a): + n = chunk_mod.chunk_text( + open(os.path.join(a.workdir, "clean.txt"), encoding="utf-8").read(), + os.path.join(a.workdir, "chunks"), + chunk_size=a.size, + offsets_path=os.path.join(a.workdir, "clean.chunk_offsets.json"), + ) + print(f"{n} chunks written to chunks/ (avg {a.size} chars)") + + +def cmd_synth(a): + synth_mod.main(os.path.join(a.workdir, "chunks"), + os.path.join(a.workdir, "audio"), + a.voice, limit=a.limit) + + +def cmd_stitch(a): + out = a.out or os.path.join(a.workdir, "audiobook.mp3") + info = stitch_mod.stitch(os.path.join(a.workdir, "audio"), out) + print(json.dumps(info, indent=1)) + if info.get("silent_sections"): + sys.exit(1) + + +def cmd_chapters(a): + out = a.out or os.path.join(a.workdir, "audiobook_chapters.mp3") + chapters_mod.main(os.path.join(a.workdir, "clean.txt"), + os.path.join(a.workdir, "audio"), + os.path.join(a.workdir, "audiobook.mp3"), out) + + +def cmd_full(a): + wd = workdir_for(a) + print(f"== extract ==") + extract_mod.extract(a.pdf, os.path.join(wd, "raw.txt")) + print(f"== clean ==") + clean_mod.clean(os.path.join(wd, "raw.txt"), cfg_path=a.config, + out_path=os.path.join(wd, "clean.txt")) + print(f"== chunk ==") + chunk_mod.chunk_text(open(os.path.join(wd, "clean.txt"), encoding="utf-8").read(), + os.path.join(wd, "chunks"), chunk_size=a.size, + offsets_path=os.path.join(wd, "clean.chunk_offsets.json")) + print(f"== synth ({a.voice}) ==") + synth_mod.main(os.path.join(wd, "chunks"), os.path.join(wd, "audio"), + a.voice, limit=a.limit) + print(f"== stitch ==") + out = os.path.join(wd, "audiobook.mp3") + stitch_mod.stitch(os.path.join(wd, "audio"), out) + print(f"== chapters ==") + try: + chapters_mod.main(os.path.join(wd, "clean.txt"), os.path.join(wd, "audio"), + out, os.path.join(wd, "audiobook_chapters.mp3")) + except SystemExit as e: + print(f"chapters skipped: {e} (audiobook.mp3 is still complete)") + print(f"\nDONE: {wd}/audiobook.mp3 (and audiobook_chapters.mp3 if tagged)") + + +def main(): + p = argparse.ArgumentParser( + prog="pdf2audiobook", + description="Turn a PDF into a narrated MP3 audiobook (edge-tts).") + p.add_argument("--workdir", default=None, + help="work directory (default: _work/)") + sub = p.add_subparsers(dest="cmd", required=True) + + s = sub.add_parser("extract", help="extract text layer with pdftotext") + s.add_argument("pdf") + + s = sub.add_parser("scan", help="report suspect/OCR-damaged tokens") + s.add_argument("--top", type=int, default=200) + s.add_argument("--template", default=None, + help="write a fix-config JSON template with the tokens as empty values") + + s = sub.add_parser("clean", help="apply cleaning passes + optional fix config") + s.add_argument("--config", default=None, help="fix config JSON (see configs/example.json)") + + s = sub.add_parser("voices", help="render a sample in several voices for comparison") + s.add_argument("voices", nargs="+", help="voice names, e.g. en-GB-LibbyNeural en-US-AriaNeural") + s.add_argument("--text", default=None, help="sample text file (default: clean.txt)") + s.add_argument("--chars", type=int, default=1500) + s.add_argument("--no-compare", action="store_true", help="skip the combined comparison MP3") + + s = sub.add_parser("chunk", help="split clean.txt into synthesis chunks") + s.add_argument("--size", type=int, default=2600) + + s = sub.add_parser("synth", help="synthesize chunks (resumable)") + s.add_argument("voice") + s.add_argument("--limit", type=int, default=None, help="only the first N missing chunks") + + s = sub.add_parser("stitch", help="concat chunks into one MP3 + verify") + s.add_argument("--out", default=None) + + s = sub.add_parser("chapters", help="tag the MP3 with ID3v2.4 CHAP chapters") + s.add_argument("--out", default=None) + + s = sub.add_parser("full", help="run extract..chapters end to end") + s.add_argument("pdf") + s.add_argument("--voice", default="en-US-AriaNeural") + s.add_argument("--config", default=None) + s.add_argument("--size", type=int, default=2600) + s.add_argument("--limit", type=int, default=None, help="limit chunks for a quick test render") + + a = p.parse_args() + if a.cmd in ("scan", "clean", "voices", "chunk", "synth", "stitch", "chapters") \ + and not a.workdir: + raise SystemExit("these steps need --workdir (or use `full ` for the whole pipeline)") + + { + "extract": cmd_extract, "scan": cmd_scan, "clean": cmd_clean, + "voices": cmd_voices, "chunk": cmd_chunk, "synth": cmd_synth, + "stitch": cmd_stitch, "chapters": cmd_chapters, "full": cmd_full, + }[a.cmd](a) + + +if __name__ == "__main__": + main() diff --git a/pdf2audiobook/chapters.py b/pdf2audiobook/chapters.py new file mode 100644 index 0000000..556245a --- /dev/null +++ b/pdf2audiobook/chapters.py @@ -0,0 +1,106 @@ +"""Map chapter text positions onto audio time and tag the MP3 (ID3v2.4 CHAP). + +Mapping: probe each chunk MP3's duration once (ffprobe) to get chunk start +times in ms; use chunk_offsets.json (start char-offset of each chunk in the +cleaned text) to binary-search each chapter's position. Each chapter spans +from its start to the next chapter's start (last: end of file). + +Uses mutagen's CHAP class (1.x kwargs: start_time/end_time in ms, +element_id, sub_frames). Hand-rolling the CHAP bytes is error-prone. + +Pitfalls baked in (all hit in the reference 2026-08 session): + - work on a COPY of the audio (ID3() + save() to a missing path writes a + tag-only file that ffprobe cannot read) + - TXXX uses text= (value= silently stores empty) + - many players display element_id, not the TXXX subframe, so the title + goes into element_id too (Latin1, CHnnn fallback for non-Latin1) +""" +import json +import os +import shutil +import subprocess +import sys +from bisect import bisect_right + + +def probe_chunk_times(audio_dir: str) -> tuple: + """Return (offsets_ms, total_ms): start time of each chunk in ms.""" + files = sorted(f for f in os.listdir(audio_dir) if f.endswith(".mp3")) + offsets = [] + t = 0 + for fn in files: + offsets.append(t) + r = subprocess.run( + ["ffprobe", "-v", "error", "-show_entries", "format=duration", + "-of", "default=nw=1:nk=1", os.path.join(audio_dir, fn)], + capture_output=True, text=True, check=True, + ) + t += int(float(r.stdout.strip()) * 1000) + return offsets, t + + +def main(clean_path: str, audio_dir: str, in_mp3: str, out_mp3: str): + # clean.py writes .chapters.json / .chunk_offsets.json + stem = os.path.splitext(clean_path)[0] + chapters = json.load(open(stem + ".chapters.json")) + offsets_path = stem + ".chunk_offsets.json" + if os.path.exists(offsets_path): + char_offsets = json.load(open(offsets_path)) + else: + # fallback: even distribution (crude, but keeps the tool working) + total_chars = len(open(clean_path, encoding="utf-8").read()) + n = len([f for f in os.listdir(audio_dir) if f.endswith(".mp3")]) + char_offsets = [int(i * total_chars / n) for i in range(n)] + print("WARNING: chunk_offsets.json missing; using even char distribution") + + offsets_ms, total_ms = probe_chunk_times(audio_dir) + r = subprocess.run( + ["ffprobe", "-v", "error", "-show_entries", "format=duration", + "-of", "default=nw=1:nk=1", in_mp3], + capture_output=True, text=True, check=True, + ) + file_end_ms = int(float(r.stdout.strip()) * 1000) + + entries = [] + for pos, title in chapters: + i = bisect_right(char_offsets, pos) - 1 + i = max(0, min(i, len(offsets_ms) - 1)) + entries.append([offsets_ms[i], title]) + entries.sort(key=lambda e: e[0]) + # drop two chapters that land in the same chunk (zero-length span) + dedup = [] + for st, title in entries: + if dedup and dedup[-1][0] == st: + continue + dedup.append([st, title]) + entries = dedup + if not entries: + raise SystemExit("no usable chapters; check heading_pattern in the config") + + from mutagen.id3 import CHAP, ID3, ID3Tags, TXXX + + shutil.copy(in_mp3, out_mp3) + tags = ID3(out_mp3) + for n, (st, title) in enumerate(entries, 1): + en = entries[n][0] if n < len(entries) else file_end_ms + elem_id = title if title.encode("latin-1", "replace") == title.encode("latin-1") \ + else f"CH{n:03d}" + sub = ID3Tags() + sub.add(TXXX(desc="CHAPTER", text=title)) + tags.add(CHAP(element_id=elem_id, start_time=int(st), + end_time=int(en), sub_frames=sub)) + tags.save(out_mp3, v2_version=4) + + print(json.dumps({ + "out": out_mp3, + "chapters": len(entries), + "total_ms": total_ms, + "file_end_ms": file_end_ms, + "first": entries[0], + "last": entries[-1], + }, indent=1)) + print("verify: ffprobe -v error -show_chapters " + out_mp3) + + +if __name__ == "__main__": + main(sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]) diff --git a/pdf2audiobook/chunk.py b/pdf2audiobook/chunk.py new file mode 100644 index 0000000..46a9f85 --- /dev/null +++ b/pdf2audiobook/chunk.py @@ -0,0 +1,63 @@ +"""Split cleaned text into sentence-aware chunks for edge-tts. + +~2600 chars per chunk keeps each synthesis call comfortably under the +service's limits while preserving sentence boundaries. Overlong sentences +are sub-split on commas. Also records each chunk's start char-offset (chunk_offsets.json) +so chapter positions can later be mapped onto audio time. +""" +import json +import os +import re + + +def chunk_text(text: str, outdir: str, chunk_size: int = 2600, + offsets_path: str = None) -> int: + text = re.sub(r"\s+", " ", text) + parts = re.split(r"(?<=[.!?;:])\s+", text) + + chunks = [] + cur = "" + for p in parts: + if len(p) > chunk_size: + if cur: + chunks.append(cur.strip()) + cur = "" + sub = re.split(r",\s+", p) + s = "" + for x in sub: + if s and len(s) + len(x) + 1 > chunk_size: + chunks.append(s.strip()) + s = x + else: + s = (s + ", " + x) if s else x + cur = s + continue + if cur and len(cur) + len(p) + 1 > chunk_size: + chunks.append(cur.strip()) + cur = p + else: + cur = (cur + " " + p) if cur else p + if cur.strip(): + chunks.append(cur.strip()) + + # start char-offset of each chunk within the whitespace-normalized text + offsets = [] + c = 0 + for ch in chunks: + offsets.append(c) + c += len(ch) + 1 # +1: chunks are joined with a single space + + os.makedirs(outdir, exist_ok=True) + # wipe stale chunks from a previous run with a different chunk size + for f in os.listdir(outdir): + if f.endswith(".txt"): + os.remove(os.path.join(outdir, f)) + + for i, ch in enumerate(chunks): + with open(os.path.join(outdir, f"{i:04d}.txt"), "w", encoding="utf-8") as f: + f.write(ch) + + if offsets_path: + with open(offsets_path, "w", encoding="utf-8") as f: + json.dump(offsets, f) + return len(chunks) diff --git a/pdf2audiobook/clean.py b/pdf2audiobook/clean.py new file mode 100644 index 0000000..e627a30 --- /dev/null +++ b/pdf2audiobook/clean.py @@ -0,0 +1,207 @@ +"""Clean raw pdftotext output into TTS-ready text. + +The pass order mirrors the pipeline proven on a 546-page scanned book: + + 1. exact-string raw fixes (typically U+FFFD glyph repairs) + 2. keep window [include.start, include.end) + 3. drop blocks (e.g. table of contents) + 4. form feeds -> newlines + 5. join hyphenated line breaks + 6. line filter (noise lines, page headers, custom drop patterns) + 7. join lines into one string + 8. strip inline patterns (plate refs, running headers wedged mid-sentence) + 9. regex fixes (order preserved from config) + 10. exact-string fixes (longest first) + +All patterns come from an optional JSON config; without one you still get +the generic noise/header removal and hyphen joining, which is enough for +digitally-born PDFs. +""" +import json +import os +import re + +FFFD = "\ufffd" + +# Lines that are pure layout noise: lone characters, dots, page numbers, +# roman numerals, bullet/dash lines. +NOISE_RE = re.compile( + r"^(\s*[\u2022\u00b7.]+|\s*[0-9]{1,3}\s*|\s*[IVXLCDM]{1,6}\s*" + r"|\s*I\s*|\s*\u00a7\s*|\s*[\-\u2013]\s*)$" +) + +# Headings are expected to be short standalone lines; longer "headings" are +# usually corrupted body text. +MAX_HEADING_LEN = 90 + + +def unhyphenate(t: str) -> str: + t = t.replace("\u00ad\n", "") # soft hyphen + t = t.replace("\u2010\n", "") # hyphen char + t = re.sub(r"(\w)-\n(\w)", r"\1\2", t) + t = re.sub(r"(\w)\\\-\n(\w)", r"\1\2", t) # OCR backslash-hyphen + t = re.sub(r"(\w)\\ \\\\\n", r"\1", t) # OCR backslash-space-backslash + return t + + +def _find_line(lines, pattern, min_line=0): + rx = re.compile(pattern) + for i in range(min_line, len(lines)): + # match on the stripped line: pdftotext prefixes the first line of + # each page with a form feed, which would defeat ^...$ anchors + if rx.search(lines[i].strip()): + return i + return None + + +def clean(raw_path: str, cfg_path: str = None, out_path: str = "clean.txt") -> dict: + cfg = {} + if cfg_path: + with open(cfg_path, encoding="utf-8") as f: + cfg = json.load(f) + + text = open(raw_path, encoding="utf-8").read() + + # 1. raw-level exact fixes (applied before any structure is touched) + rawfix = cfg.get("raw_fixes", {}) + for k in sorted(rawfix, key=len, reverse=True): + if k in text: + text = text.replace(k, rawfix[k]) + + lines = text.split("\n") + + # 3. drop blocks FIRST (a TOC entry can look like the include.start + # anchor; the old LOY run removed the TOC before cutting front matter) + for block in cfg.get("drop_blocks", []): + s = _find_line(lines, block["start"], block.get("min_line_start", 0)) + if s is None: + continue + e = len(lines) + if block.get("end"): + followed = block.get("end_followed_by") + for i in range(s + 1, len(lines)): + if re.search(block["end"], lines[i]): + if followed and not any( + lines[k].strip() in followed + for k in range(i + 1, min(i + 4, len(lines))) + ): + continue # keep looking + e = i + break + lines = lines[:s] + lines[e:] + + # 2. include window + inc = cfg.get("include") + if inc and inc.get("start"): + s = _find_line(lines, inc["start"], inc.get("min_line_start", 0)) + if s is None: + raise SystemExit(f"include.start pattern not found: {inc['start']!r}") + e = _find_line(lines, inc["end"], max(inc.get("min_line_end", 0), s + 1)) + if e is None and inc.get("end_fallback"): + e = _find_line(lines, inc["end_fallback"], max(inc.get("min_line_end", 0), s + 1)) + if e is None: + e = len(lines) + lines = lines[s:e] + + # 4. form feeds + text = "\n".join(lines).replace("\f", "\n") + + # 5. hyphenated line breaks + text = unhyphenate(text) + + # 6. line filter (and record chapter heading positions) + hdr_pats = [re.compile(p) for p in cfg.get("page_header_patterns", [])] + drop_pats = [re.compile(p) for p in cfg.get("drop_line_patterns", [])] + heading_re = re.compile(cfg["heading_pattern"]) if cfg.get("heading_pattern") else None + # optional guard: a heading line only counts if it is followed (within a + # few lines) by this pattern. Filters running headers that share the + # heading's shape ("Appendix I" on every page of the appendix). + heading_next_re = re.compile(cfg["heading_next_pattern"]) \ + if cfg.get("heading_next_pattern") else None + heading_next_skip = cfg.get("heading_next_skip", 3) + # lines matching these are never headings (but stay in the text): + # numbered instruction sentences that look like "N. Name" headings + heading_excl = [re.compile(p) for p in cfg.get("heading_exclude_patterns", [])] + heading_count_max = cfg.get("heading_count_max", 150) + + out_lines = [] + pos = 0 # char offset of the current line in the joined text + chapters = [] # (char_offset_in_clean_text, title) + lines2 = text.split("\n") + for i2, line in enumerate(lines2): + s = line.strip() + if not s: + continue + if NOISE_RE.match(s): + continue + if any(p.match(s) for p in hdr_pats): + continue + if any(p.match(s) for p in drop_pats): + continue + # (heading patterns are written to accept only proper-case or + # numbered lines, so no extra case check is needed here) + if (heading_re and len(chapters) < heading_count_max + and len(s) <= MAX_HEADING_LEN + and heading_re.match(s) + and not any(p.search(s) for p in heading_excl)): + if heading_next_re: + ok = any( + heading_next_re.search(lines2[k].strip()) + for k in range(i2 + 1, min(i2 + 1 + heading_next_skip, len(lines2))) + ) + if not ok: + continue + title = s.rstrip(" .:;-") + chapters.append((pos, title)) + out_lines.append(s) + pos += len(s) + 1 + + joined = re.sub(r" +", " ", " ".join(out_lines)) + + # 8. inline strips (applied on the joined text) + for p in cfg.get("strip_patterns", []): + joined = re.sub(p, " ", joined) + + # 9. regex fixes, in config order (runs BEFORE exact-string fixes, + # matching the reference pipeline: regexes may leave tokens that the + # exact map then clobbers correctly) + for pat, rep in cfg.get("regex_fixes", []): + joined = re.sub(pat, rep, joined) + + # 10. exact-string fixes, longest first + for k in sorted(cfg.get("fixes", {}), key=len, reverse=True): + if k in joined: + joined = joined.replace(k, cfg["fixes"][k]) + + # 11. word-boundary regex fixes (anchored, last, like the reference run) + for pat, rep in cfg.get("word_fixes", []): + joined = re.sub(pat, rep, joined) + + joined = re.sub(r" +", " ", joined).strip() + with open(out_path, "w", encoding="utf-8") as f: + f.write(joined) + + # Re-anchor chapter positions in the final text: the string fixes above + # can shift offsets, so re-find each title near its recorded position. + # Also drop duplicate titles (running headers that share a heading's + # shape would otherwise create a chapter on every page). + seen_titles = set() + final_chapters = [] + for pos, title in chapters: + if title in seen_titles: + continue + seen_titles.add(title) + i = joined.find(title, max(0, pos - 600)) + final_chapters.append([i if i >= 0 else pos, title]) + chapters_path = os.path.splitext(out_path)[0] + ".chapters.json" + with open(chapters_path, "w", encoding="utf-8") as f: + json.dump(final_chapters, f, indent=1, ensure_ascii=False) + + return { + "out": out_path, + "chars": len(joined), + "words": len(joined.split()), + "fffd_left": joined.count(FFFD), + "chapters": len(final_chapters), + "chapters_path": chapters_path, + } diff --git a/pdf2audiobook/extract.py b/pdf2audiobook/extract.py new file mode 100644 index 0000000..fade290 --- /dev/null +++ b/pdf2audiobook/extract.py @@ -0,0 +1,43 @@ +"""Text extraction from PDF (poppler pdftotext).""" +import os +import subprocess + +FFFD = "\ufffd" + + +def extract(pdf_path: str, out_path: str) -> dict: + """Run pdftotext and report basic text-layer statistics. + + Returns a dict with path, chars, words, fffd_count, and a short + quality verdict (useful for deciding how much cleanup a book needs). + """ + if not os.path.exists(pdf_path): + raise FileNotFoundError(pdf_path) + if not os.path.exists(out_path): + subprocess.run( + ["pdftotext", pdf_path, out_path], + check=True, + capture_output=True, + text=True, + ) + text = open(out_path, encoding="utf-8").read() + words = len(text.split()) + fffd = text.count(FFFD) + if words == 0: + verdict = "NO TEXT LAYER: the PDF appears to be a pure image scan. " \ + "OCR it first (e.g. ocrmypdf) or the pipeline has nothing to read." + elif fffd > words * 0.01: + verdict = "HEAVY OCR DAMAGE: many unreadable glyphs (U+FFFD). " \ + "Run `scan` and build a fix config before synthesizing." + elif fffd > 0: + verdict = "MINOR OCR DAMAGE: some unreadable glyphs. " \ + "Run `scan` to see them; a fix config is recommended." + else: + verdict = "CLEAN TEXT LAYER: no replacement characters found." + return { + "path": out_path, + "chars": len(text), + "words": words, + "fffd": fffd, + "verdict": verdict, + } diff --git a/pdf2audiobook/scan.py b/pdf2audiobook/scan.py new file mode 100644 index 0000000..4927a9e --- /dev/null +++ b/pdf2audiobook/scan.py @@ -0,0 +1,99 @@ +"""Scan extracted text for OCR defects and generate a fix-config template. + +Scanned PDFs mangle text in systematic ways: + - U+FFFD (replacement character) where the OCR engine gave up, usually on + diacritics and unusual consonants (foreign-language terms, umlauts, ...) + - Confused characters: ; -> s/h, J -> th/v, 9 -> d, ! -> l, < -> sh, ... + +The OCR engine of a given scan misreads the SAME glyph the SAME way, so the +right tool is an exact-string replacement map built from the actual output. +`scan` enumerates the distinct suspicious tokens with counts and surrounding +context; you (or a language model) then fill in the correct reading, and +`clean` applies the map. + +Output of scan: + - a human-readable report on stdout + - optionally a fix-config template JSON with the suspicious tokens as + empty values, ready to be filled in: {"fixes": {"mangled-token": ""}} +""" +import json +import re +import sys + +FFFD = "\ufffd" + +# Characters that rarely appear inside a real English word. A token that has +# one of these in the MIDDLE of the word (or FFFD anywhere) is suspect. +SUSPECT = set(";<>![]\\_`|~^@#%&*+=") + +TOKEN_RE = re.compile(r"[\w" + FFFD + r";<>!\\[\]_`\-]+") + + +def is_suspect(token: str) -> bool: + if FFFD in token: + return True + body = token.strip("-_") + if len(body) < 3: + return False + # suspect char inside the word (not a leading/trailing punctuation) + return any(c in SUSPECT for c in body[1:-1]) + + +def scan(text: str, top: int = 300) -> list: + """Return [(count, token, context)] sorted by count desc, then token.""" + counts = {} + context = {} + for m in TOKEN_RE.finditer(text): + tok = m.group(0) + if not is_suspect(tok): + continue + counts[tok] = counts.get(tok, 0) + 1 + if tok not in context: + s = max(0, m.start() - 40) + e = min(len(text), m.end() + 40) + ctx = text[s:e].replace("\n", " ") + context[tok] = ctx + items = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0])) + return [(c, t, context[t]) for t, c in items[:top]] + + +def cmd_scan(path: str, top: int = 300, template: str = None) -> None: + text = open(path, encoding="utf-8").read() + total_fffd = text.count(FFFD) + words = len(text.split()) + items = scan(text, top=top) + + print(f"text: {path}") + print(f"words: {words} U+FFFD glyphs: {total_fffd} " + f"suspect tokens (distinct): {len(items)}") + print() + print(f"{'count':>6} {'token':<32} context") + print("-" * 100) + for count, tok, ctx in items: + print(f"{count:>6} {tok!r:<32} ...{ctx}...") + + if template: + fixes = {tok: "" for _, tok, _ in items} + cfg = { + "include": {"start": None, "end": None}, + "drop_blocks": [], + "page_header_patterns": [], + "drop_line_patterns": [], + "strip_patterns": [], + "raw_fixes": {}, + "regex_fixes": [], + "word_fixes": [], + "fixes": fixes, + "heading_pattern": None, + "heading_count_max": 150, + } + with open(template, "w", encoding="utf-8") as f: + json.dump(cfg, f, indent=2, ensure_ascii=False) + print(f"\nfix-config template written to {template} " + f"({len(fixes)} tokens to review). Fill in the empty values " + f"with the correct reading; empty values are ignored by clean.") + print("See README 'Fixing OCR defects' for the workflow.") + + +if __name__ == "__main__": + cmd_scan(sys.argv[1]) diff --git a/pdf2audiobook/stitch.py b/pdf2audiobook/stitch.py new file mode 100644 index 0000000..5406770 --- /dev/null +++ b/pdf2audiobook/stitch.py @@ -0,0 +1,80 @@ +"""Stitch chunk MP3s into one audiobook and verify it. + +All chunks share edge-tts codec parameters (24 kHz MP3), so a stream-copy +concat (-c copy) is lossless and fast. Verification uses astats RMS at +several timestamps to catch dead/silent sections early. +""" +import json +import os +import subprocess +import sys + + +def stitch(audio_dir: str, out_path: str, verify: bool = True) -> dict: + files = sorted(f for f in os.listdir(audio_dir) if f.endswith(".mp3")) + if not files: + raise SystemExit(f"no mp3 files in {audio_dir}") + + # the list file sits NEXT TO the chunks; the concat demuxer resolves + # names relative to the list file's directory and does NOT understand + # JSON-style double quotes (it treats them as part of the filename) + listfile = os.path.join(audio_dir, "_concat_list.txt") + with open(listfile, "w") as f: + for fn in files: + f.write(f"file '{fn}'\n") + + raw = out_path + ".raw.mp3" + if os.path.exists(raw): + os.remove(raw) + subprocess.run( + ["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", listfile, "-c", "copy", raw], + check=True, + ) + os.replace(raw, out_path) + os.remove(listfile) + + info = {"out": out_path, "chunks": len(files)} + + if verify: + r = subprocess.run( + ["ffprobe", "-v", "error", "-show_entries", "format=duration", + "-of", "default=nw=1:nk=1", out_path], + capture_output=True, text=True, check=True, + ) + dur = float(r.stdout.strip()) + info["duration_s"] = dur + info["size_mb"] = round(os.path.getsize(out_path) / 1e6, 1) + # spot-check RMS at a few positions + checks = [] + for frac in (0.05, 0.3, 0.6, 0.9): + t = dur * frac + a = subprocess.run( + ["ffmpeg", "-hide_banner", "-ss", f"{t:.1f}", "-t", "4", "-i", out_path, + "-af", "astats=metadata=1", "-f", "null", "-"], + capture_output=True, text=True, + ) + rms = None + for line in a.stderr.splitlines(): + if "RMS level dB:" in line: + try: + rms = float(line.split("RMS level dB:")[1].strip()) + except ValueError: + pass + break + checks.append((round(frac, 2), t and round(t), rms)) + info["rms_checks"] = checks + silent = [c for c in checks if c[2] is not None and c[2] < -50] + info["silent_sections"] = len(silent) + return info + + +def main(audio_dir, out_path): + info = stitch(audio_dir, out_path) + print(json.dumps(info, indent=1)) + if info.get("silent_sections"): + print("WARNING: silent sections detected; check the RMS checks above") + sys.exit(1) + + +if __name__ == "__main__": + main(sys.argv[1], sys.argv[2]) diff --git a/pdf2audiobook/synth.py b/pdf2audiobook/synth.py new file mode 100644 index 0000000..3e8cc0a --- /dev/null +++ b/pdf2audiobook/synth.py @@ -0,0 +1,79 @@ +"""Resumable edge-tts batch synthesis. + +- One MP3 per chunk, in /audio/ +- Skips chunks that already have a non-trivial MP3 (safe to re-run after a + crash or to pick up stragglers) +- 2-way concurrency + retries with backoff keeps the free service happy + (a 546-page book ~= 251 chunks finished in ~30-35 min wall time) +- --limit N synthesizes only the first N missing chunks (for quick checks) +""" +import asyncio +import glob +import os +import sys + +import edge_tts + +MIN_BYTES = 1000 +RETRIES = 5 + + +async def synth_one(text: str, voice: str, out: str) -> bool: + for attempt in range(RETRIES): + try: + await edge_tts.Communicate(text, voice).save(out) + if os.path.exists(out) and os.path.getsize(out) > MIN_BYTES: + return True + except Exception: + pass + if os.path.exists(out) and os.path.getsize(out) < MIN_BYTES: + os.remove(out) + await asyncio.sleep(2 * (attempt + 1)) + return False + + +async def run(chunks_dir: str, outdir: str, voice: str, limit: int = None) -> None: + os.makedirs(outdir, exist_ok=True) + files = sorted(glob.glob(os.path.join(chunks_dir, "*.txt"))) + todo = [] + for f in files: + name = os.path.basename(f)[:-4] + if os.path.exists(os.path.join(outdir, f"{name}.mp3")): + continue + todo.append((name, open(f, encoding="utf-8").read())) + if limit: + todo = todo[:limit] + + print(f"voice={voice} total_chunks={len(files)} todo={len(todo)}", flush=True) + if not todo: + print("nothing to do") + return + + sem = asyncio.Semaphore(2) + done = 0 + + async def worker(name, text): + nonlocal done + async with sem: + ok = await synth_one(text, voice, os.path.join(outdir, f"{name}.mp3")) + done += 1 + if not ok: + print(f"FAILED {name}", flush=True) + elif done % 10 == 0: + print(f"progress {done}/{len(todo)}", flush=True) + + await asyncio.gather(*(worker(n, t) for n, t in todo)) + + # final consistency report + missing = [ + os.path.basename(f)[:-4] for f in files + if not os.path.exists(os.path.join(outdir, os.path.basename(f)[:-4] + ".mp3")) + ] + print(f"done: {done}/{len(todo)}; missing chunks overall: {len(missing)}", flush=True) + if missing: + print("re-run synth to pick up the missing ones", flush=True) + sys.exit(1) + + +def main(chunks_dir, outdir, voice, limit=None): + asyncio.run(run(chunks_dir, outdir, voice, limit)) diff --git a/pdf2audiobook/voices.py b/pdf2audiobook/voices.py new file mode 100644 index 0000000..49012e1 --- /dev/null +++ b/pdf2audiobook/voices.py @@ -0,0 +1,105 @@ +"""Voice sampling for edge-tts. + +`edge-tts --list-voices` returns the full catalogue (hundreds of voices in +many languages), so this module offers: + - filter voices by language / gender / gender-ish names + - render one identical sample text in N voices (resumable: existing + samples are skipped) + - optionally concatenate all samples into one comparison MP3 with a + 1.2 s silence gap between voices, so a single playthrough compares them +""" +import asyncio +import os +import subprocess + +import edge_tts + + +def list_voices(lang_prefix: str = None, gender: str = None) -> list: + """List available edge-tts voices, optionally filtered. + + lang_prefix: e.g. 'en' or 'en-GB'; gender: 'Male'/'Female'. + Returns list of dicts (name, shortName, gender, locale). + """ + r = subprocess.run( + ["python", "-m", "edge_tts", "--list-voices"], + capture_output=True, text=True, check=True, + ) + # the CLI prints 'Name Short Gender Age ...' lines after a header + out = [] + for line in r.stdout.splitlines(): + parts = line.split() + if len(parts) >= 4 and parts[0].endswith("Neural"): + v = {"name": parts[0], "short": parts[1], "gender": parts[2]} + if lang_prefix and not v["name"].lower().startswith(lang_prefix.lower()): + continue + if gender and v["gender"] != gender: + continue + out.append(v) + return out + + +async def _render_sample(text: str, voice: str, out: str) -> bool: + if os.path.exists(out) and os.path.getsize(out) > 1000: + return True + for attempt in range(4): + try: + await edge_tts.Communicate(text, voice).save(out) + if os.path.exists(out) and os.path.getsize(out) > 1000: + return True + except Exception: + pass + await asyncio.sleep(1.5 * (attempt + 1)) + return False + + +async def sample(text: str, voices: list, outdir: str, compare: bool = True) -> list: + """Render the same text in each voice. Returns [(voice, file, ok)].""" + os.makedirs(outdir, exist_ok=True) + sem = asyncio.Semaphore(4) + + async def one(voice): + name = voice.replace("/", "_") + out = os.path.join(outdir, f"{name}.mp3") + async with sem: + ok = await _render_sample(text, voice, out) + return (voice, out, ok) + + results = list(await asyncio.gather(*(one(v) for v in voices))) + + if compare: + files = [f for _, f, ok in results if ok] + if len(files) >= 2: + silence = os.path.join(outdir, "_silence12.mp3") + if not os.path.exists(silence): + subprocess.run( + ["ffmpeg", "-y", "-f", "lavfi", "-i", "anullsrc=r=24000:cl=mono", + "-t", "1.2", "-ar", "24000", "-b:a", "48k", silence], + capture_output=True, check=True, + ) + listfile = os.path.join(outdir, "_compare_list.txt") + with open(listfile, "w") as f: + # single quotes only: the concat demuxer does not parse + # JSON-style double quotes + f.write("file '_silence12.mp3'\n") + for v, fp, ok in results: + if ok: + f.write(f"file '{os.path.basename(fp)}'\n") + else: + f.write("file '_silence12.mp3'\n") + comp = os.path.join(outdir, "compare_all_voices.mp3") + subprocess.run( + ["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", listfile, + "-c", "copy", comp], + capture_output=True, check=True, + ) + print(f"comparison file: {comp}") + ok = 0 + for voice, fp, good in results: + if good: + ok += 1 + print(f"OK {voice:<28} {fp}") + else: + print(f"FAIL {voice:<28} (voice 404 or network error)") + print(f"{ok}/{len(voices)} samples rendered") + return results diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c62f2a5 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +edge-tts>=7.0 +mutagen>=1.40