102 lines
4.1 KiB
Python
102 lines
4.1 KiB
Python
"""Pont API ↔ worker d'enrôlement (côté Python 3.14, SANS torch).
|
|
|
|
L'encodeur OmniVoice vit dans ov_venv (torch + omnivoice + transformers), l'API en
|
|
Python 3.14 → on ne peut pas l'importer en-process. `VoiceBridge` lance et pilote le
|
|
worker `ov_venv` (`voice/worker.py`) en subprocess, lui parle en JSON-lines, et
|
|
sérialise les jobs (torch n'est 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 + omnivoice + transformers + librosa/soundfile).
|
|
OV_PYTHON = os.environ.get("OV_PYTHON", "/opt/Kazeia/ov_venv/bin/python")
|
|
_REPO = Path(__file__).resolve().parents[2] # /opt/Kazeia-central
|
|
|
|
|
|
class VoiceWorkerError(RuntimeError):
|
|
http_status = 503 # lu par core.context.Ctx.guard (encodeur indispo) sans coupler core↔voices
|
|
|
|
|
|
class VoiceBridge:
|
|
"""Gère un worker ov_venv chaud. Thread-safe (jobs sérialisés)."""
|
|
|
|
def __init__(self, ov_python: str = OV_PYTHON) -> None:
|
|
self.ov_python = ov_python
|
|
self._proc: subprocess.Popen | None = None
|
|
self._lock = threading.Lock()
|
|
self._id = 0
|
|
|
|
def available(self) -> bool:
|
|
return os.path.exists(self.ov_python)
|
|
|
|
def _ensure(self) -> None:
|
|
if self._proc and self._proc.poll() is None:
|
|
return
|
|
if not os.path.exists(self.ov_python):
|
|
raise VoiceWorkerError(
|
|
f"interpréteur encodeur introuvable: {self.ov_python} "
|
|
f"(provisionner ov_venv, ou définir OV_PYTHON)")
|
|
env = dict(os.environ, PYTHONPATH=str(_REPO), PYTHONUNBUFFERED="1")
|
|
self._proc = subprocess.Popen(
|
|
[self.ov_python, "-m", "kazeia_central.features.technical.voices.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
|