Lock MVP voice (Michael, native settings); native /tts route
This commit is contained in:
+13
-4
@@ -24,12 +24,21 @@ llm:
|
|||||||
|
|
||||||
# OpenAI-compatible speech endpoint.
|
# OpenAI-compatible speech endpoint.
|
||||||
voice:
|
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
|
model: chatterbox
|
||||||
voice: default
|
voice: Michael.wav
|
||||||
extra: {} # extra JSON fields sent with every request (for example speed)
|
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
|
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_sentence_s: 0.25 # pause between pieces inside a paragraph
|
||||||
gap_paragraph_s: 0.7 # pause between paragraphs and after cues and labels
|
gap_paragraph_s: 0.7 # pause between paragraphs and after cues and labels
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ class StubHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
def do_POST(self):
|
def do_POST(self):
|
||||||
body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
|
body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
|
||||||
StubHandler.seen.append((self.path, body))
|
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:
|
with tempfile.TemporaryDirectory() as d:
|
||||||
p = os.path.join(d, "x.wav")
|
p = os.path.join(d, "x.wav")
|
||||||
test_piece(p, 130, 1, 0.1)
|
test_piece(p, 130, 1, 0.1)
|
||||||
@@ -100,6 +100,8 @@ class ClientTests(unittest.TestCase):
|
|||||||
def cfg(self):
|
def cfg(self):
|
||||||
c = json.loads(json.dumps(CFG))
|
c = json.loads(json.dumps(CFG))
|
||||||
c["voice"]["endpoint"] = self.url + "/v1/audio/speech"
|
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")
|
c["llm"].update(endpoint=self.url + "/v1/chat/completions", model="m")
|
||||||
return c
|
return c
|
||||||
|
|
||||||
@@ -112,6 +114,18 @@ class ClientTests(unittest.TestCase):
|
|||||||
self.assertEqual(body["response_format"], "wav")
|
self.assertEqual(body["response_format"], "wav")
|
||||||
self.assertEqual(data[:4], b"RIFF")
|
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):
|
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.')
|
s = voice.sentences('One day Dr. Alexis Carrel sent word. "The world," he said. E. Stanley Jones came.')
|
||||||
self.assertEqual(len(s), 3)
|
self.assertEqual(len(s), 3)
|
||||||
|
|||||||
@@ -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 <chapter-01.txt> --paragraph 3 --out <dir> [--chatterbox URL] [--kokoro URL]
|
||||||
|
Writes <out>/NN-name.wav (and .mp3 if ffmpeg exists) plus <out>/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()
|
||||||
@@ -55,6 +55,10 @@ def plan(cfg, segs):
|
|||||||
|
|
||||||
def speak(cfg, text):
|
def speak(cfg, text):
|
||||||
v = cfg["voice"]
|
v = cfg["voice"]
|
||||||
|
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 = {"model": v["model"], "input": text, "voice": v["voice"], "response_format": "wav"}
|
||||||
body.update(v.get("extra") or {})
|
body.update(v.get("extra") or {})
|
||||||
req = urllib.request.Request(v["endpoint"], json.dumps(body).encode(), {"Content-Type": "application/json"})
|
req = urllib.request.Request(v["endpoint"], json.dumps(body).encode(), {"Content-Type": "application/json"})
|
||||||
|
|||||||
Reference in New Issue
Block a user