"""Pont API ↔ worker d'enrôlement (côté Python 3.14, SANS torch). L'encodeur vit dans cv_venv (torch), l'API en Python 3.14 → on ne peut pas l'importer en-process. `VoiceBridge` lance et pilote le worker `cv_venv` (`voice/worker.py`) en subprocess, lui parle en JSON-lines, et sérialise les jobs (un verrou : torch n'est de toute façon pas thread-safe en inférence concurrente). Démarrage paresseux (le worker n'est lancé qu'au 1ᵉʳ job → l'API ne paie le coût que si on enrôle). """ from __future__ import annotations import json import os import subprocess import threading from pathlib import Path # Interpréteur de l'env encodeur (torch + cosyvoice + whisper + gguf). CV_PYTHON = os.environ.get("CV_PYTHON", "/opt/Kazeia/cv_venv/bin/python") _REPO = Path(__file__).resolve().parents[2] # /opt/Kazeia-central class VoiceWorkerError(RuntimeError): pass class VoiceBridge: """Gère un worker cv_venv chaud. Thread-safe (jobs sérialisés).""" def __init__(self, cv_python: str = CV_PYTHON) -> None: self.cv_python = cv_python self._proc: subprocess.Popen | None = None self._lock = threading.Lock() self._id = 0 def available(self) -> bool: return os.path.exists(self.cv_python) def _ensure(self) -> None: if self._proc and self._proc.poll() is None: return if not os.path.exists(self.cv_python): raise VoiceWorkerError( f"interpréteur encodeur introuvable: {self.cv_python} " f"(provisionner cv_venv, ou définir CV_PYTHON)") env = dict(os.environ, PYTHONPATH=str(_REPO), PYTHONUNBUFFERED="1") self._proc = subprocess.Popen( [self.cv_python, "-m", "kazeia_central.voice.worker"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=None, # stderr → console text=True, env=env, bufsize=1, ) # Attendre l'event "ready" (la 1ʳᵉ ligne du protocole). while True: line = self._proc.stdout.readline() if not line: raise VoiceWorkerError("le worker s'est arrêté avant d'être prêt") try: msg = json.loads(line) except Exception: continue if msg.get("event") == "ready": return def _call(self, method: str, params: dict | None = None) -> dict: with self._lock: self._ensure() self._id += 1 rid = self._id assert self._proc and self._proc.stdin and self._proc.stdout self._proc.stdin.write( json.dumps({"id": rid, "method": method, "params": params or {}}) + "\n") self._proc.stdin.flush() line = self._proc.stdout.readline() if not line: self._proc = None raise VoiceWorkerError(f"worker terminé sans réponse (method={method})") resp = json.loads(line) if not resp.get("ok"): raise VoiceWorkerError(resp.get("error", "échec worker")) return resp["result"] # ---- API publique ----------------------------------------------------- def ping(self) -> dict: return self._call("ping") def transcribe(self, wav_path: str, model: str | None = None) -> dict: return self._call("transcribe", {"wav": wav_path, "model": model}) def enroll(self, wav_path: str, text: str, out_path: str) -> dict: return self._call("enroll", {"wav": wav_path, "text": text, "out": out_path}) def shutdown(self) -> None: with self._lock: if self._proc and self._proc.poll() is None: try: self._proc.stdin.write(json.dumps({"id": 0, "method": "shutdown"}) + "\n") self._proc.stdin.flush() self._proc.wait(timeout=10) except Exception: self._proc.kill() self._proc = None