From 0f982f69db22043b4b3cc2bea3b6187b7adbccb2 Mon Sep 17 00:00:00 2001 From: ashum9 Date: Wed, 8 Jul 2026 12:27:18 +0530 Subject: [PATCH 1/3] kokoro local TTS bridge and hybrid production config --- .gitignore | 1 + kokoro_tts_server.py | 139 +++++++++++++++++++++++++++++++++++++++++++ mofa_hybrid.toml | 25 ++++++++ 3 files changed, 165 insertions(+) create mode 100644 kokoro_tts_server.py create mode 100644 mofa_hybrid.toml diff --git a/.gitignore b/.gitignore index 7ee12e8..8002557 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ __pycache__/ node_modules/ e2e/test-results/ e2e/playwright-report/ +.kokoro-models/ diff --git a/kokoro_tts_server.py b/kokoro_tts_server.py new file mode 100644 index 0000000..35f58e9 --- /dev/null +++ b/kokoro_tts_server.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Kokoro TTS Bridge — exposes local Kokoro as an OpenAI-compatible TTS endpoint. + +Usage: + source .kokoro-venv/bin/activate + python3 kokoro_tts_server.py +""" + +import json, io, os, tempfile +from http.server import HTTPServer, BaseHTTPRequestHandler + +_pipeline = None + +from pathlib import Path + +def get_pipeline(): + global _pipeline + if _pipeline is None: + print("[kokoro] Loading ONNX pipeline...") + + from kokoro_onnx import Kokoro + + model_dir = Path(__file__).parent / ".kokoro-models" + + _pipeline = Kokoro( + str(model_dir / "kokoro-v1.0.fp16.onnx"), + str(model_dir / "voices-v1.0.bin"), + ) + + print("[kokoro] Pipeline ready!") + + return _pipeline + + +class KokoroHandler(BaseHTTPRequestHandler): + def do_GET(self): + if self.path == "/v1/models": + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps({ + "data": [{"id": "kokoro", "object": "model"}] + }).encode()) + else: + self.send_response(200) + self.end_headers() + self.wfile.write(b'{"status":"ok"}') + + def do_POST(self): + if self.path != "/v1/audio/speech": + self.send_response(404) + self.end_headers() + return + + content_length = int(self.headers.get("Content-Length", 0)) + body = json.loads(self.rfile.read(content_length)) if content_length else {} + text = body.get("input", "Hello from Kokoro") + raw_voice = body.get("voice", "heart") + # Map OpenAI voice names to Kokoro format (af_ prefix) + voice = raw_voice if raw_voice.startswith("af_") else f"af_{raw_voice}" + + print(f"[kokoro] Generating: {text[:80]}... ({len(text)} chars)") + try: + import soundfile as sf + import numpy as np + import re + pipeline = get_pipeline() + + # Kokoro has a 510 phoneme limit. Chinese chars expand to ~15 phonemes + # each, so max ~30 chars. Use 25 for safety margin. + MAX_CHARS = 25 + # Split on ANY punctuation (Chinese + English) + parts = re.split(r'(?<=[。!?.!?\n,,、;;::))」』])', text) + chunks = [] + current = "" + for p in parts: + if not p: + continue + if len(current) + len(p) > MAX_CHARS and current: + chunks.append(current) + # If single part is still too long, force-split it + while len(p) > MAX_CHARS: + chunks.append(p[:MAX_CHARS]) + p = p[MAX_CHARS:] + current = p + else: + current += p + if current: + chunks.append(current) + if not chunks: + chunks = [text[:MAX_CHARS]] + + print(f"[kokoro] Processing {len(chunks)} chunk(s)...") + all_samples = [] + sample_rate = 24000 + for i, chunk in enumerate(chunks): + chunk = chunk.strip() + if not chunk: + continue + samples, sr = pipeline.create(chunk, voice=voice, speed=1.0) + all_samples.append(samples) + sample_rate = sr + print(f"[kokoro] chunk {i+1}/{len(chunks)}: '{chunk[:20]}' -> {len(samples)} samples") + + if not all_samples: + raise RuntimeError("Kokoro returned no audio") + + full_audio = np.concatenate(all_samples) + + buf = io.BytesIO() + sf.write(buf, full_audio, sample_rate, format="WAV") + audio_bytes = buf.getvalue() + + tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False, dir="/tmp") + tmp.write(audio_bytes) + tmp.close() + print(f"[kokoro] Done: {tmp.name} ({len(audio_bytes):,} bytes)") + + self.send_response(200) + self.send_header("Content-Type", "audio/wav") + self.send_header("X-Audio-File", tmp.name) + self.send_header("Content-Length", str(len(audio_bytes))) + self.end_headers() + self.wfile.write(audio_bytes) + except Exception as e: + print(f"[kokoro] ERROR: {e}") + import traceback; traceback.print_exc() + self.send_response(500) + self.end_headers() + self.wfile.write(json.dumps({"error": str(e)}).encode()) + + def log_message(self, *a): pass + +if __name__ == "__main__": + port = int(os.environ.get("KOKORO_PORT", "8421")) + print(f"[kokoro] Kokoro TTS bridge on http://127.0.0.1:{port}") + print(f"[kokoro] POST /v1/audio/speech") + get_pipeline() # pre-warm + HTTPServer(("127.0.0.1", port), KokoroHandler).serve_forever() diff --git a/mofa_hybrid.toml b/mofa_hybrid.toml new file mode 100644 index 0000000..d2bdf47 --- /dev/null +++ b/mofa_hybrid.toml @@ -0,0 +1,25 @@ +[listen] +host = "127.0.0.1" +port = 8420 + +[memory] +budget_mb = 8192 +idle_timeout_secs = 300 + +[observability] +enabled = true + +[[providers]] +name = "ollama" +kind = "ollama" +base_url = "http://127.0.0.1:11434" + +[[providers]] +name = "kokoro" +kind = "openai_compatible" +base_url = "http://127.0.0.1:8421/v1" +api_key = "local" + +[[providers.models]] +name = "kokoro" +capability = "tts" From cf59fbd4834295f51cda546bb4aa5b4fd64da516 Mon Sep 17 00:00:00 2001 From: ashum9 Date: Fri, 17 Jul 2026 00:59:00 +0530 Subject: [PATCH 2/3] add misaki chinese phonemizer and voice mapping --- kokoro_tts_server.py | 209 ++++++++++++++++++++++++++++--------------- 1 file changed, 138 insertions(+), 71 deletions(-) diff --git a/kokoro_tts_server.py b/kokoro_tts_server.py index 35f58e9..77266b9 100644 --- a/kokoro_tts_server.py +++ b/kokoro_tts_server.py @@ -1,50 +1,156 @@ #!/usr/bin/env python3 """Kokoro TTS Bridge — exposes local Kokoro as an OpenAI-compatible TTS endpoint. +Uses kokoro_onnx for synthesis and misaki for Chinese phonemization. + Usage: source .kokoro-venv/bin/activate python3 kokoro_tts_server.py """ -import json, io, os, tempfile +import json, io, os, tempfile, re from http.server import HTTPServer, BaseHTTPRequestHandler +from pathlib import Path -_pipeline = None +_kokoro = None +_zh_g2p = None -from pathlib import Path +# Voice name mapping: friendly names → Kokoro voice IDs +VOICE_MAP = { + # Chinese voices + "xiaoxiao": "zf_xiaoxiao", "yunxi": "zm_yunxi", "xiaoni": "zf_xiaoni", + "xiaoyi": "zf_xiaoyi", "xiaobei": "zf_xiaobei", + "yunjian": "zm_yunjian", "yunyang": "zm_yunyang", "yunxia": "zm_yunxia", + # English voices + "alloy": "af_alloy", "nova": "af_nova", "echo": "am_echo", + "fable": "bm_fable", "onyx": "am_onyx", "shimmer": "af_bella", + "heart": "af_heart", +} -def get_pipeline(): - global _pipeline - if _pipeline is None: - print("[kokoro] Loading ONNX pipeline...") +# Chinese voice prefixes +CHINESE_PREFIXES = ("zf_", "zm_") - from kokoro_onnx import Kokoro +def get_kokoro(): + global _kokoro + if _kokoro is None: + print("[kokoro] Loading ONNX pipeline...") + from kokoro_onnx import Kokoro model_dir = Path(__file__).parent / ".kokoro-models" - - _pipeline = Kokoro( + _kokoro = Kokoro( str(model_dir / "kokoro-v1.0.fp16.onnx"), str(model_dir / "voices-v1.0.bin"), ) + print("[kokoro] ONNX pipeline ready!") + return _kokoro + + +def get_zh_g2p(): + global _zh_g2p + if _zh_g2p is None: + print("[kokoro] Loading Chinese phonemizer (misaki)...") + from misaki.zh import ZHG2P + _zh_g2p = ZHG2P() + print("[kokoro] Chinese phonemizer ready!") + return _zh_g2p + + +def resolve_voice(raw_voice: str) -> str: + """Resolve a voice name to a Kokoro voice ID.""" + raw = raw_voice.lower().strip() + if raw in VOICE_MAP: + return VOICE_MAP[raw] + if any(raw.startswith(p) for p in ("af_", "am_", "bf_", "bm_", "ef_", "em_", "zf_", "zm_")): + return raw + return f"af_{raw}" + + +def is_chinese_voice(voice_id: str) -> bool: + return voice_id.startswith(CHINESE_PREFIXES) + + +def synthesize(text: str, voice: str) -> bytes: + """Synthesize text to WAV bytes.""" + import soundfile as sf + import numpy as np + + kokoro = get_kokoro() + use_chinese = is_chinese_voice(voice) + + if use_chinese: + # Use misaki to convert Chinese text → phonemes + g2p = get_zh_g2p() + + # Split text into chunks for processing + MAX_CHARS = 80 if use_chinese else 200 + parts = re.split(r'(?<=[。!?.!?\n,,;;])', text) + chunks = [] + current = "" + for p in parts: + if not p: + continue + if len(current) + len(p) > MAX_CHARS and current: + chunks.append(current) + while len(p) > MAX_CHARS: + chunks.append(p[:MAX_CHARS]) + p = p[MAX_CHARS:] + current = p + else: + current += p + if current: + chunks.append(current) + if not chunks: + chunks = [text[:MAX_CHARS]] + + print(f"[kokoro] Processing {len(chunks)} chunk(s), chinese={use_chinese}...") + all_samples = [] + sample_rate = 24000 + + for i, chunk in enumerate(chunks): + chunk = chunk.strip() + if not chunk: + continue + + if use_chinese: + # Convert Chinese text to phonemes, then synthesize + phonemes = g2p(chunk) + print(f"[kokoro] chunk {i+1}/{len(chunks)}: '{chunk[:30]}' -> phonemes: '{phonemes[:40]}'") + samples, sr = kokoro.create(phonemes, voice=voice, speed=1.0, is_phonemes=True) + else: + print(f"[kokoro] chunk {i+1}/{len(chunks)}: '{chunk[:30]}'") + samples, sr = kokoro.create(chunk, voice=voice, speed=1.0) + + all_samples.append(samples) + sample_rate = sr + + if not all_samples: + raise RuntimeError("Kokoro returned no audio") - print("[kokoro] Pipeline ready!") + full_audio = np.concatenate(all_samples) - return _pipeline + buf = io.BytesIO() + sf.write(buf, full_audio, sample_rate, format="WAV") + return buf.getvalue() class KokoroHandler(BaseHTTPRequestHandler): def do_GET(self): if self.path == "/v1/models": + body = json.dumps({"data": [{"id": "kokoro", "object": "model"}]}).encode() self.send_response(200) self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.send_header("Connection", "close") self.end_headers() - self.wfile.write(json.dumps({ - "data": [{"id": "kokoro", "object": "model"}] - }).encode()) + self.wfile.write(body) else: + body = b'{"status":"ok"}' self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.send_header("Connection", "close") self.end_headers() - self.wfile.write(b'{"status":"ok"}') + self.wfile.write(body) def do_POST(self): if self.path != "/v1/audio/speech": @@ -55,61 +161,13 @@ def do_POST(self): content_length = int(self.headers.get("Content-Length", 0)) body = json.loads(self.rfile.read(content_length)) if content_length else {} text = body.get("input", "Hello from Kokoro") - raw_voice = body.get("voice", "heart") - # Map OpenAI voice names to Kokoro format (af_ prefix) - voice = raw_voice if raw_voice.startswith("af_") else f"af_{raw_voice}" + raw_voice = body.get("voice", "xiaoxiao") + voice = resolve_voice(raw_voice) + + print(f"[kokoro] Request: voice={voice} text='{text[:60]}...' ({len(text)} chars)") - print(f"[kokoro] Generating: {text[:80]}... ({len(text)} chars)") try: - import soundfile as sf - import numpy as np - import re - pipeline = get_pipeline() - - # Kokoro has a 510 phoneme limit. Chinese chars expand to ~15 phonemes - # each, so max ~30 chars. Use 25 for safety margin. - MAX_CHARS = 25 - # Split on ANY punctuation (Chinese + English) - parts = re.split(r'(?<=[。!?.!?\n,,、;;::))」』])', text) - chunks = [] - current = "" - for p in parts: - if not p: - continue - if len(current) + len(p) > MAX_CHARS and current: - chunks.append(current) - # If single part is still too long, force-split it - while len(p) > MAX_CHARS: - chunks.append(p[:MAX_CHARS]) - p = p[MAX_CHARS:] - current = p - else: - current += p - if current: - chunks.append(current) - if not chunks: - chunks = [text[:MAX_CHARS]] - - print(f"[kokoro] Processing {len(chunks)} chunk(s)...") - all_samples = [] - sample_rate = 24000 - for i, chunk in enumerate(chunks): - chunk = chunk.strip() - if not chunk: - continue - samples, sr = pipeline.create(chunk, voice=voice, speed=1.0) - all_samples.append(samples) - sample_rate = sr - print(f"[kokoro] chunk {i+1}/{len(chunks)}: '{chunk[:20]}' -> {len(samples)} samples") - - if not all_samples: - raise RuntimeError("Kokoro returned no audio") - - full_audio = np.concatenate(all_samples) - - buf = io.BytesIO() - sf.write(buf, full_audio, sample_rate, format="WAV") - audio_bytes = buf.getvalue() + audio_bytes = synthesize(text, voice) tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False, dir="/tmp") tmp.write(audio_bytes) @@ -125,15 +183,24 @@ def do_POST(self): except Exception as e: print(f"[kokoro] ERROR: {e}") import traceback; traceback.print_exc() + body = json.dumps({"error": str(e)}).encode() self.send_response(500) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.send_header("Connection", "close") self.end_headers() - self.wfile.write(json.dumps({"error": str(e)}).encode()) + self.wfile.write(body) def log_message(self, *a): pass + if __name__ == "__main__": port = int(os.environ.get("KOKORO_PORT", "8421")) print(f"[kokoro] Kokoro TTS bridge on http://127.0.0.1:{port}") print(f"[kokoro] POST /v1/audio/speech") - get_pipeline() # pre-warm + print(f"[kokoro] Voices: {', '.join(sorted(VOICE_MAP.keys()))}") + # Pre-warm both the ONNX model and the Chinese phonemizer + get_kokoro() + get_zh_g2p() + print(f"[kokoro] Ready! Listening...") HTTPServer(("127.0.0.1", port), KokoroHandler).serve_forever() From 4cff2dc8394b2547a44b94120cb62c33c80019fc Mon Sep 17 00:00:00 2001 From: ashum9 Date: Fri, 17 Jul 2026 11:20:27 +0530 Subject: [PATCH 3/3] handle invalid JSON with proper 400 response --- kokoro_tts_server.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/kokoro_tts_server.py b/kokoro_tts_server.py index 77266b9..b05e71f 100644 --- a/kokoro_tts_server.py +++ b/kokoro_tts_server.py @@ -159,7 +159,17 @@ def do_POST(self): return content_length = int(self.headers.get("Content-Length", 0)) - body = json.loads(self.rfile.read(content_length)) if content_length else {} + raw = self.rfile.read(content_length) if content_length else b"{}" + try: + body = json.loads(raw) + except json.JSONDecodeError: + self.send_response(400) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(b'{"error":"invalid_json"}') + return + if not isinstance(body, dict): + body = {} text = body.get("input", "Hello from Kokoro") raw_voice = body.get("voice", "xiaoxiao") voice = resolve_voice(raw_voice)