120 lines
5.0 KiB
Python
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()
|