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).
106 lines
3.9 KiB
Python
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
|