Files
reyna-cli/backup/macmini-automation-baseline-2026-08-03/hermes-scripts/kokoro_codex_tts.py
T
2026-08-03 11:47:09 -04:00

120 lines
5.0 KiB
Python

#!/usr/bin/env python3
"""
Codex-grade TTS provider for Hermes — calls M4 Mac mini Kokoro 82M 8-bit warm daemon at :7331
"""
import argparse, base64, json, os, sys
from pathlib import Path
def load_token():
for k in ("MACMINI_MCP_TOKEN","MACMINI_TOKEN"):
v=os.getenv(k)
if v: return v.strip()
try:
import yaml
cfg_path = Path.home()/".hermes"/"config.yaml"
if cfg_path.exists():
data=yaml.safe_load(cfg_path.read_text())
mac=data.get('mcp_servers',{}).get('macmini',{})
auth=mac.get('headers',{}).get('Authorization','')
if auth:
if auth.startswith('Bearer '):
return auth.split('Bearer ',1)[1].strip()
return auth.strip()
except Exception as e:
print(f"token from config.yaml failed: {e}", file=sys.stderr)
return "85d0b06d95b2b891de0c9bea3a0b89e50c67eebf99ebc1b063bae596e7868a95"
def synthesize_via_macmini(text, voice, speed, host, port, token):
import urllib.request, json, base64
url = f"http://{host}:{port}/mcp"
payload = {
"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"speech_kokoro_synthesize_base64",
"arguments":{"text": text[:8000],"voice": voice,"speed": float(speed),"langCode":"a"}}
}
data = json.dumps(payload).encode()
req = urllib.request.Request(url, data=data,
headers={"Content-Type":"application/json","Accept":"application/json, text/event-stream","Authorization": f"Bearer {token}" if token else ""})
with urllib.request.urlopen(req, timeout=25) as resp:
raw = resp.read().decode()
# MCP returns SSE: event: message\ndata: {...}\n — extract data line
body = raw
if raw.startswith("event:"):
for line in raw.splitlines():
if line.startswith("data:"):
body = line[len("data:"):].strip()
break
j = json.loads(body)
result = j.get("result",{})
content_text=""
if isinstance(result, dict) and "content" in result:
for c in result["content"]:
if "text" in c: content_text+=c["text"]
else:
content_text=json.dumps(result)
if "isError" in j or (isinstance(result,dict) and result.get("isError")):
raise RuntimeError(f"MCP error: {content_text[:1000]}")
try:
inner=json.loads(content_text)
except:
raise RuntimeError(f"no json inner: {content_text[:500]}")
b64=inner.get("wavBase64") or inner.get("audio_base64") or inner.get("base64")
if not b64:
raise RuntimeError(f"no b64 in inner: {list(inner.keys())} preview {content_text[:500]}")
return base64.b64decode(b64)
def main():
ap=argparse.ArgumentParser()
ap.add_argument("--input", required=True)
ap.add_argument("--output", required=True)
ap.add_argument("--voice", default=None)
ap.add_argument("--speed", default=None)
args=ap.parse_args()
text=Path(args.input).read_text(encoding="utf-8").strip()
if not text:
print("empty input", file=sys.stderr); sys.exit(1)
voice=args.voice or os.getenv("CODEX_KOKORO_VOICE") or os.getenv("KSAY_VOICE") or "af_heart"
speed=args.speed or os.getenv("CODEX_KOKORO_SPEED") or "1.0"
host=os.getenv("MACMINI_MCP_HOST") or "192.168.68.102"
port=int(os.getenv("MACMINI_MCP_PORT") or "7331")
token=load_token()
presets={"codex-warm":"af_heart","cove":"af_heart","warm":"af_heart",
"codex-soft":"af_bella","juniper":"af_bella","soft":"af_bella",
"codex-calm":"af_sarah","calm":"af_sarah",
"codex-male":"am_adam","male":"am_adam",
"codex-british":"bf_emma","british":"bf_emma"}
vl=voice.lower()
if vl in presets: voice=presets[vl]
try:
audio_bytes=synthesize_via_macmini(text, voice, speed, host, port, token)
except Exception as e:
print(f"Kokoro M4 failed: {e}", file=sys.stderr)
sys.exit(2)
out=Path(args.output)
out.parent.mkdir(parents=True, exist_ok=True)
if out.suffix.lower()==".mp3":
tmp=out.with_suffix(".tmp.wav")
tmp.write_bytes(audio_bytes)
import shutil, subprocess
if shutil.which("ffmpeg"):
try:
subprocess.run(["ffmpeg","-y","-i",str(tmp),"-codec:a","libmp3lame","-qscale:a","2",str(out)],
check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=15)
tmp.unlink(missing_ok=True)
except Exception as ce:
print(f"ffmpeg failed {ce}, using wav", file=sys.stderr)
tmp.rename(out.with_suffix(".wav"))
out.write_bytes(audio_bytes)
else:
tmp.rename(out.with_suffix(".wav"))
out.write_bytes(audio_bytes)
else:
out.write_bytes(audio_bytes)
print(f"OK {len(audio_bytes)} bytes voice={voice} speed={speed} -> {out}")
if __name__=="__main__":
main()