#!/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()