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).
80 lines
2.5 KiB
Python
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))
|