diff --git a/_pipeline/settings.yaml b/_pipeline/settings.yaml index 7ea7439..7dff85c 100644 --- a/_pipeline/settings.yaml +++ b/_pipeline/settings.yaml @@ -24,12 +24,21 @@ llm: # OpenAI-compatible speech endpoint. voice: - endpoint: http://127.0.0.1:8000/v1/audio/speech + # LOCKED by Ty 2026-10-17 for MVP ("clip B"): Chatterbox, stock voice Michael, native settings only. + # Never set speed_factor: the server applies it as a time-stretch after generation and ruins the sound. + api: chatterbox_tts # chatterbox_tts = native /tts route; anything else = OpenAI-style /v1/audio/speech + endpoint: http://voice:8004/tts model: chatterbox - voice: default - extra: {} # extra JSON fields sent with every request (for example speed) + voice: Michael.wav + extra: # extra JSON fields sent with every request + exaggeration: 0.25 + cfg_weight: 0.2 + temperature: 0.6 + seed: 7 + split_text: true + chunk_size: 240 # the server splits long text at sentence ends into chunks of about this size timeout_s: 600 - max_chunk_chars: 300 # long paragraphs are cut at sentence ends into pieces this size or smaller + max_chunk_chars: 100000 # one request per paragraph; the server does the sentence splitting (chunk_size above) gap_sentence_s: 0.25 # pause between pieces inside a paragraph gap_paragraph_s: 0.7 # pause between paragraphs and after cues and labels diff --git a/_pipeline/tests/test_pipeline.py b/_pipeline/tests/test_pipeline.py index a27d23c..7ae7913 100644 --- a/_pipeline/tests/test_pipeline.py +++ b/_pipeline/tests/test_pipeline.py @@ -67,7 +67,7 @@ class StubHandler(http.server.BaseHTTPRequestHandler): def do_POST(self): body = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) StubHandler.seen.append((self.path, body)) - if self.path.endswith("/audio/speech"): + if self.path.endswith("/audio/speech") or self.path == "/tts": with tempfile.TemporaryDirectory() as d: p = os.path.join(d, "x.wav") test_piece(p, 130, 1, 0.1) @@ -100,6 +100,8 @@ class ClientTests(unittest.TestCase): def cfg(self): c = json.loads(json.dumps(CFG)) c["voice"]["endpoint"] = self.url + "/v1/audio/speech" + c["voice"]["api"] = "openai" + c["voice"]["extra"] = {} c["llm"].update(endpoint=self.url + "/v1/chat/completions", model="m") return c @@ -112,6 +114,18 @@ class ClientTests(unittest.TestCase): self.assertEqual(body["response_format"], "wav") self.assertEqual(data[:4], b"RIFF") + def test_locked_voice_uses_native_route_with_locked_settings(self): + StubHandler.seen.clear() + c = json.loads(json.dumps(CFG)) + c["voice"]["endpoint"] = self.url + "/tts" + voice.speak(c, "Hello there.") + path, body = StubHandler.seen[0] + self.assertEqual(path, "/tts") + self.assertEqual(body["text"], "Hello there.") + self.assertEqual(body["predefined_voice_id"], "Michael.wav") + self.assertEqual((body["exaggeration"], body["cfg_weight"], body["temperature"], body["seed"], body["chunk_size"]), (0.25, 0.2, 0.6, 7, 240)) + self.assertNotIn("speed_factor", body) + def test_sentence_split_keeps_abbreviations(self): s = voice.sentences('One day Dr. Alexis Carrel sent word. "The world," he said. E. Stanley Jones came.') self.assertEqual(len(s), 3) diff --git a/_pipeline/tools/tune_clips.py b/_pipeline/tools/tune_clips.py new file mode 100644 index 0000000..d951985 --- /dev/null +++ b/_pipeline/tools/tune_clips.py @@ -0,0 +1,103 @@ +"""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() diff --git a/_pipeline/voice.py b/_pipeline/voice.py index fb61f37..bcbae90 100644 --- a/_pipeline/voice.py +++ b/_pipeline/voice.py @@ -55,7 +55,11 @@ def plan(cfg, segs): def speak(cfg, text): v = cfg["voice"] - body = {"model": v["model"], "input": text, "voice": v["voice"], "response_format": "wav"} + if v.get("api") == "chatterbox_tts": + # Native Chatterbox server route: the only one that accepts exaggeration, cfg_weight and temperature per request. + body = {"text": text, "voice_mode": "predefined", "predefined_voice_id": v["voice"], "output_format": "wav"} + else: + body = {"model": v["model"], "input": text, "voice": v["voice"], "response_format": "wav"} body.update(v.get("extra") or {}) req = urllib.request.Request(v["endpoint"], json.dumps(body).encode(), {"Content-Type": "application/json"}) with urllib.request.urlopen(req, timeout=v["timeout_s"]) as r: