Kazeia-central/kazeia_central/voice/bridge.py

102 lines
3.9 KiB
Python

"""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