""" MeloTTS worker ============== A tiny, dependency-light HTTP service that keeps a MeloTTS voice warm and synthesises speech on demand. It runs in its OWN Python venv (``/opt/melo`` in the container) so the heavy MeloTTS/torch/transformers stack stays isolated from the slim brain-bridge venv (which pins ``numpy<2`` for faster-whisper). The bridge's ``synthesize()`` POSTs ``{"text": "..."}`` here and gets back a 16-bit PCM WAV. The MeloTTS model is loaded once at startup and reused, so each request only pays inference cost, not model-load cost. Config (env): MELO_WORKER_HOST bind host (default 127.0.0.1) MELO_WORKER_PORT bind port (default 8770) MELO_LANGUAGE MeloTTS language (default KR) MELO_SPEED speaking rate (default 1.5 -> the approved "150") MELO_DEVICE torch device (default cpu) Run: /opt/melo/bin/python -m bridge.melo_worker """ from __future__ import annotations import io import json import os import sys import tempfile import threading import wave from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer HOST = os.environ.get("MELO_WORKER_HOST", "127.0.0.1") PORT = int(os.environ.get("MELO_WORKER_PORT", "8770")) LANGUAGE = os.environ.get("MELO_LANGUAGE", "KR") SPEED = float(os.environ.get("MELO_SPEED", "1.5")) DEVICE = os.environ.get("MELO_DEVICE", "cpu") # Model + speaker id are loaded once, guarded by a lock because MeloTTS # inference is not guaranteed thread-safe. _model = None _speaker_id = None _model_lock = threading.Lock() _load_error: str | None = None def _ensure_model() -> None: global _model, _speaker_id, _load_error if _model is not None or _load_error is not None: return with _model_lock: if _model is not None or _load_error is not None: return try: from melo.api import TTS # type: ignore model = TTS(language=LANGUAGE, device=DEVICE) # spk2id is a melo HParams object (dict-like, supports __getitem__, # __contains__, keys) but NOT .get(). The KR model exposes a single # 'KR' speaker; fall back to the first id for other languages. spk_map = model.hps.data.spk2id keys = list(spk_map.keys()) speaker_id = spk_map[LANGUAGE] if LANGUAGE in spk_map else spk_map[keys[0]] _model = model _speaker_id = speaker_id # Warm the GPU once at load: the first CUDA synth pays a one-off # kernel-init cost (~5s) that would otherwise land on the user's # first reply. A throwaway synth here moves it to startup. No-op # cost on CPU. try: with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as _wt: _wp = _wt.name model.tts_to_file("워밍업", speaker_id, _wp, speed=SPEED) try: os.unlink(_wp) except OSError: pass except Exception as _we: # pragma: no cover print(f"[melo-worker] warmup synth skipped: {_we}", flush=True) print( f"[melo-worker] ready (lang={LANGUAGE} speed={SPEED} " f"device={DEVICE} speakers={list(spk_map.keys())})", flush=True, ) except Exception as e: # pragma: no cover - depends on local model files _load_error = f"{type(e).__name__}: {e}" print(f"[melo-worker] model load FAILED: {_load_error}", flush=True) def _synthesize(text: str) -> bytes: """Synthesise ``text`` to a 16-bit PCM WAV (bytes).""" _ensure_model() if _model is None: raise RuntimeError(_load_error or "melo model unavailable") # MeloTTS writes to a file via soundfile; render to a container-disk temp # file (NOT tmpfs), read it back, then drop it. with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp: tmp_path = tmp.name try: with _model_lock: _model.tts_to_file(text, _speaker_id, tmp_path, speed=SPEED) with open(tmp_path, "rb") as f: raw = f.read() finally: try: os.unlink(tmp_path) except OSError: pass return _ensure_pcm16_wav(raw) def _ensure_pcm16_wav(raw: bytes) -> bytes: """Guarantee a 16-bit PCM WAV. MeloTTS/soundfile usually emit float WAVs; the Discord playback path (ffmpeg) tolerates both, but we normalise to PCM16 so the contract matches the previous Piper output.""" try: with wave.open(io.BytesIO(raw), "rb") as wf: if wf.getsampwidth() == 2: return raw # already PCM16 except wave.Error: pass # Non-PCM16 (e.g. float) — convert with soundfile if available. try: import numpy as np import soundfile as sf data, sr = sf.read(io.BytesIO(raw), dtype="float32") if data.ndim > 1: data = data.mean(axis=1) # mono pcm = np.clip(data, -1.0, 1.0) pcm = (pcm * 32767.0).astype(" None: body = json.dumps(payload).encode("utf-8") self.send_response(code) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def do_GET(self): # noqa: N802 if self.path == "/health": _ensure_model() ok = _model is not None self._json(200 if ok else 503, {"ok": ok, "error": _load_error}) else: self._json(404, {"error": "not found"}) def do_POST(self): # noqa: N802 if self.path != "/synth": self._json(404, {"error": "not found"}) return try: length = int(self.headers.get("Content-Length", "0")) data = json.loads(self.rfile.read(length) or b"{}") text = (data.get("text") or "").strip() except Exception as e: self._json(400, {"error": f"bad request: {e}"}) return if not text: self._json(400, {"error": "missing 'text'"}) return try: wav = _synthesize(text) except Exception as e: self._json(503, {"error": f"{type(e).__name__}: {e}"}) return self.send_response(200) self.send_header("Content-Type", "audio/wav") self.send_header("Content-Length", str(len(wav))) self.end_headers() self.wfile.write(wav) def log_message(self, *args): # silence default request logging return def main() -> int: # Warm the model at startup so the first Discord turn isn't slow. _ensure_model() server = ThreadingHTTPServer((HOST, PORT), _Handler) print(f"[melo-worker] listening on http://{HOST}:{PORT}", flush=True) try: server.serve_forever() except KeyboardInterrupt: pass return 0 if __name__ == "__main__": sys.exit(main())