bitscuit a1796e82aa 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).
2026-08-25 14:15:50 +02:00

80 lines
2.5 KiB
Python

"""Resumable edge-tts batch synthesis.
- One MP3 per chunk, in <workdir>/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))