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

106 lines
3.9 KiB
Python

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