To convert a PDF to audiobook audio with Python, extract its text, correct the reading order, divide the reviewed script into chapters, then synthesize and save manageable audio segments. The workflow below uses pypdf 7.0.0 and Piper 1.8.0 for local WAV output. Scanned pages need OCR first. These examples follow the documented APIs but have not been executed as a PDF-to-audio conversion.
Choose extraction and speech components
Use pypdf for the PDF's existing text layer and Open Home Foundation's Piper for speech. Their responsibilities are separate:
PdfReader(path).pagesandpage.extract_text()read text; pypdf does not recognize words in page images. Its package metadata declares Python 3.10 or newer and the BSD-3-Clause code license. See the extraction guide and package metadata.- The package named
piper-ttssuppliesPiperVoice.load()andvoice.synthesize_wav(text, wav_file). The inspected 1.8.0 package declares Python 3.9 or newer and GPL-3.0-or-later. Its package metadata and Python API are the basis for the calls below.
Use an isolated Python 3.12 environment for this example; the script also uses standard-library functionality introduced in Python 3.11. Package installation and platform-specific wheels have not been tested here. With that environment activated, the versioned setup commands are:
python -m pip install "pypdf==7.0.0" "piper-tts==1.8.0"
Choose a voice and review its MODEL_CARD before downloading it. Piper requires a model/configuration pair, such as en_US-lessac-medium.onnx and en_US-lessac-medium.onnx.json. The code license does not determine every voice's terms, or your permission to distribute narration of a particular document. The voice documentation identifies the paired files and per-voice license check.
The upstream example downloads that voice with:
python -m piper.download_voices en_US-lessac-medium
This is an initial network download, not speech generation. The synthesis script below expects both files in its working directory and uses the default CPU path. It does not call a hosted speech API. No package or voice model was installed for this guide.
Inspect reading order before synthesis
Save this first example as extract.py in a new working directory beside input.pdf. It retains one text file per PDF page and reports pages with no extracted words instead of silently skipping them.
from pathlib import Path
from pypdf import PdfReader
reader = PdfReader("input.pdf")
output = Path("extracted-pages")
output.mkdir(exist_ok=False)
empty_pages = []
character_count = 0
for page_number, page in enumerate(reader.pages, start=1):
text = page.extract_text() or ""
(output / f"page-{page_number:04d}.txt").write_text(text, encoding="utf-8")
character_count += len(text)
if not text.strip():
empty_pages.append(page_number)
print("PDF pages:", len(reader.pages))
print("Extracted Unicode code points:", character_count)
print("Pages to inspect for missing text:", empty_pages)
Run it with python extract.py. The output directory must be new so a later extraction cannot leave old page files mixed with a shorter document. If the run fails partway through, inspect the error, keep the source, and use a new output directory for the corrected run. An empty extract can mean an intentional blank, a scan or a parsing problem; inspect the page before deciding. Follow the OCR workflow for image-only content.
Next, compare the text with the original PDF. Page-number order is not a guarantee of correct order inside a page. Check columns, headings, captions, footnotes, page-edge sentences and line-ending hyphens. page.extract_text(extraction_mode="layout") can provide a second view of placement; it does not create a reliable chapter plan. pypdf's API implementation explicitly warns about output order. Its layout mode also ignores visitor callbacks, so use the separate header and footnote cleanup method when position filtering is needed.
Create a chapters directory containing only the corrected narration text, with zero-padded names such as 001-opening.txt and 002-next-chapter.txt. Join paragraphs across page breaks, retain meaningful chapter headings, and put a blank line between paragraphs. Keep the page files as your review trail. The chapter filenames determine sequence; they are not spoken automatically, so include a heading in the text if you want to hear it.
The printed count uses Python len(text): Unicode code points, including spaces and line breaks. It is neither a word count nor a universal TTS billing count. Count the final corrected script again before estimating a run; cleanup can change it substantially.
Generate and checkpoint audio chunks
This second example, synthesize.py, groups complete paragraphs into chunks of at most 1,600 characters. That is a conservative workflow choice for smaller retry units, not a published Piper limit. If a single paragraph exceeds it, the script stops before synthesis so you can split that paragraph at a sensible sentence boundary. It does not cut a sentence or discard excess text automatically.
The script records a plan identified by the text, voice files, runtime versions and its chunking recipe. It writes each new WAV to a temporary path, checks the file, then renames it. Repeating the same plan reuses completed segments; a changed plan gets a different directory. Run only one copy at a time.
import hashlib
import json
import re
import wave
from importlib.metadata import version
from pathlib import Path
from piper import PiperVoice
LIMIT = 1600
MODEL = Path("en_US-lessac-medium.onnx")
CONFIG = Path(str(MODEL) + ".json")
def file_hash(path):
with path.open("rb") as handle:
return hashlib.file_digest(handle, "sha256").hexdigest()
def chunks(text):
current = ""
for paragraph in re.split(r"\n\s*\n", text.strip()):
paragraph = paragraph.strip()
if len(paragraph) > LIMIT:
raise ValueError("Split a long paragraph before synthesis.")
candidate = "\n\n".join(filter(None, (current, paragraph)))
if len(candidate) > LIMIT:
yield current
current = paragraph
else:
current = candidate
if current:
yield current
def wav_seconds(path):
with wave.open(str(path), "rb") as audio:
frames = audio.getnframes()
expected = frames * audio.getnchannels() * audio.getsampwidth()
if not frames or len(audio.readframes(frames)) != expected:
raise ValueError(f"Incomplete WAV: {path}")
return frames / audio.getframerate()
plan = []
for source in sorted(Path("chapters").glob("*.txt")):
text = source.read_text(encoding="utf-8").strip()
if not text:
raise ValueError(f"Empty chapter: {source}")
for part, text_chunk in enumerate(chunks(text), start=1):
plan.append({"chapter": source.stem, "part": part, "text": text_chunk})
if not plan:
raise ValueError("Add reviewed chapter text files first.")
settings = {
"recipe": "paragraphs-v1-cpu-defaults",
"limit": LIMIT,
"piper": version("piper-tts"),
"onnxruntime": version("onnxruntime"),
"model_sha256": file_hash(MODEL),
"config_sha256": file_hash(CONFIG),
}
plan_json = json.dumps({"settings": settings, "chunks": plan}, sort_keys=True)
run_id = hashlib.sha256(plan_json.encode("utf-8")).hexdigest()
output = Path("audio-runs") / run_id
output.mkdir(parents=True, exist_ok=True)
(output / "plan.json").write_text(plan_json, encoding="utf-8")
voice = PiperVoice.load(str(MODEL))
manifest = []
for number, chunk in enumerate(plan, start=1):
target = output / f"{number:04d}.wav"
if not target.exists():
temporary = target.with_suffix(".partial.wav")
with wave.open(str(temporary), "wb") as audio:
voice.synthesize_wav(chunk["text"], audio)
wav_seconds(temporary)
temporary.replace(target)
duration = wav_seconds(target)
manifest.append({
"file": target.name,
"chapter": chunk["chapter"],
"part": chunk["part"],
"characters": len(chunk["text"]),
"seconds": duration,
})
print(target.name, chunk["chapter"], round(duration, 2))
temporary_manifest = output / "manifest.tmp"
temporary_manifest.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
temporary_manifest.replace(output / "manifest.json")
print("Completed run:", output)
Run python synthesize.py from the same working directory. The PiperVoice.load and synthesize_wav calls follow the official Python example. Chunk planning, hashing and retry handling are this guide's illustrative orchestration, not built-in Piper features. The example has not been run with a voice model.
If synthesis stops, correct the reported cause and rerun with unchanged text and settings. Completed numbered WAVs are checked and reused; an unfinished .partial.wav is regenerated. An unreadable or structurally incomplete completed WAV stops the run: move that specific file aside and rerun to regenerate it. Keep the plan and remaining files together. Do not combine concurrent runs in one directory.
Changes to the text or chapter names recorded in the plan, model/configuration file contents, recorded runtime versions, chunk limit or recipe value produce a different plan identifier. The script file itself is not hashed: when changing the splitting or synthesis logic, manually update recipe. If you add synthesis settings such as speed or a speaker selection, include them in settings too. The hashes identify reuse conditions; they do not promise identical audio bytes across machines or prove that every word was spoken.
Export chapters and validate the result
A successful run produces numbered WAV segments, plan.json and manifest.json. The manifest preserves chapter names and segment order. Several segments can belong to one chapter; the program does not automatically merge them or embed M4B chapter markers. Keep the manifest with the audio, and use its sequence when importing segments into an audio editor or packaging tool. For chaptered containers and player considerations, see MP3 versus M4B.
Validate the result before treating it as a finished audiobook:
- Compare the manifest's chapter sequence with the corrected chapter files. Account for all intended chapters and sections.
- Listen across segment boundaries while following the script. Check the first and last words of each segment, repeated or missing sentences, names, numbers and pauses.
- Investigate unusually short or silent-looking outputs. The script's WAV checks detect empty or structurally truncated files; they cannot detect a well-formed recording that stops speaking early or pronounces a passage incorrectly.
- If you join or transcode the audio, verify the new file in the intended player. Confirm its ending and chapter navigation separately from the original WAV checks.
For a rough runtime estimate, time a representative corrected section with the selected voice and settings. Multiply its synthesis seconds per character by the remaining character count, keeping model-load time and final export time separate. Treat that as a planning estimate: different text and system load can change it. This guide provides no measured throughput or audio-quality result.
This local script has no hosted per-character charge. Its costs are the machine, electricity, download/storage space and your review time. Switching to a cloud TTS API introduces that provider's billing unit, limits and retry behavior, which this code does not implement. Neither the repository license nor a completed WAV establishes publication rights for the text or voice.