"""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))