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