"""Voice-tuning clips: one paragraph, many voice variants, so a human can pick by ear. Standard library only. Usage: python3 tune_clips.py --text-file --paragraph 3 --out [--chatterbox URL] [--kokoro URL] Writes /NN-name.wav (and .mp3 if ffmpeg exists) plus /clips.md describing each clip.""" import argparse import json import os import shutil import subprocess import time import urllib.request SEED = 7 # same seed everywhere so differences come from the settings, not luck # Chatterbox: stock male voices, then setting changes on one voice. exaggeration = expressiveness (lower = calmer), # cfg_weight = pacing (lower = slower, more deliberate), temperature = variation (lower = steadier). CHATTERBOX = [ ("henry-default", "Henry", dict(exaggeration=0.5, cfg_weight=0.5, temperature=0.8), "Henry, server defaults."), ("henry-calm", "Henry", dict(exaggeration=0.3, cfg_weight=0.3, temperature=0.6), "Henry, calmer and slower: low expressiveness, low pace weight, steadier."), ("gabriel-calm", "Gabriel", dict(exaggeration=0.3, cfg_weight=0.3, temperature=0.6), "Gabriel, same calm settings as Henry calm."), ("adrian-calm", "Adrian", dict(exaggeration=0.3, cfg_weight=0.3, temperature=0.6), "Adrian, same calm settings."), ("everett-calm", "Everett", dict(exaggeration=0.3, cfg_weight=0.3, temperature=0.6), "Everett, same calm settings."), ("henry-warmer", "Henry", dict(exaggeration=0.4, cfg_weight=0.4, temperature=0.7), "Henry, between default and calm: a bit more life."), ] # Kokoro: male voices. speed < 1 is slower. KOKORO = [ ("kokoro-adam", "am_adam", 0.92, "Kokoro am_adam at 0.92 speed."), ("kokoro-michael", "am_michael", 0.92, "Kokoro am_michael at 0.92 speed."), ("kokoro-george", "bm_george", 0.92, "Kokoro bm_george (British) at 0.92 speed."), ] def post(url, body, timeout=600): req = urllib.request.Request(url, json.dumps(body).encode(), {"Content-Type": "application/json"}) with urllib.request.urlopen(req, timeout=timeout) as r: return r.read() def get_json(url): with urllib.request.urlopen(url, timeout=30) as r: return json.loads(r.read()) def chatterbox_clip(base, text, voice, p): body = dict(text=text, voice_mode="predefined", predefined_voice_id=voice + ".wav" if not voice.endswith(".wav") else voice, output_format="wav", split_text=True, chunk_size=240, seed=SEED, **p) return post(base.rstrip("/") + "/tts", body) def kokoro_clip(base, text, voice, speed): return post(base.rstrip("/") + "/v1/audio/speech", dict(model="kokoro", input=text, voice=voice, speed=speed, response_format="wav")) def main(): ap = argparse.ArgumentParser() ap.add_argument("--text-file", required=True) ap.add_argument("--paragraph", type=int, default=3, help="1-based paragraph number") ap.add_argument("--out", required=True) ap.add_argument("--chatterbox", default="http://127.0.0.1:8090") ap.add_argument("--kokoro", default="http://127.0.0.1:8091") a = ap.parse_args() paras = [p.strip() for p in open(a.text_file, encoding="utf-8").read().split("\n\n") if p.strip()] text = paras[a.paragraph - 1] os.makedirs(a.out, exist_ok=True) rows = ["# Voice-tuning clips", "", "Text: Clark, Chapter I, paragraph %d (%d words). Same text in every clip." % (a.paragraph, len(text.split())), "", "> " + text, ""] ff = shutil.which("ffmpeg") n = 0 jobs = [(nm, "Chatterbox", lambda v=v, p=p: chatterbox_clip(a.chatterbox, text, v, p), d) for nm, v, p, d in CHATTERBOX] try: have = {x if isinstance(x, str) else x.get("id", x.get("name")) for x in get_json(a.kokoro.rstrip("/") + "/v1/audio/voices").get("voices", [])} except Exception as e: print("kokoro voice list failed:", e) have = None for nm, v, sp, d in KOKORO: if have is not None and v not in have: print("skip", v, "(not on server)") continue jobs.append((nm, "Kokoro", lambda v=v, sp=sp: kokoro_clip(a.kokoro, text, v, sp), d)) for nm, eng, fn, d in jobs: n += 1 base = os.path.join(a.out, "%02d-%s" % (n, nm)) t0 = time.time() try: data = fn() except Exception as e: print("FAILED", nm, e) rows.append("- %02d-%s: FAILED (%s)" % (n, nm, e)) continue with open(base + ".wav", "wb") as f: f.write(data) final = base + ".wav" if ff: subprocess.run([ff, "-y", "-loglevel", "error", "-i", base + ".wav", "-ac", "1", "-ar", "44100", "-b:a", "192k", base + ".mp3"], check=True) final = base + ".mp3" print("ok %s %.1fs render, %s" % (nm, time.time() - t0, final)) rows.append("- **%02d-%s** (%s): %s" % (n, nm, eng, d)) with open(os.path.join(a.out, "clips.md"), "w", encoding="utf-8") as f: f.write("\n".join(rows) + "\n") if __name__ == "__main__": main()