Files
VoiceStudio/backend/api/routers/engines.py
T
Palash Debnathand4shil 1edd35cfd0 Per-segment audio effects DSP preset selector (closes #67, rebased from #68) (#109)
* Add per-segment audio effects DSP preset selector to dub pipeline

* Add shape assertions to podcast, warm, and bright preset tests

* Fix raw preset semantics, add preset validation, update docs, remove duplicate sys.path

* Narrow OOM catch to model.generate only in dub_generate

* Preserve original OOM exception context in dub_generate

* Bind effect_preset to _gen via explicit parameter to avoid loop capture

* Catch RuntimeError instead of torch.mps.MPSError for MPS OOM

---------

Co-authored-by: 4shil <166588383+4shil@users.noreply.github.com>
2026-05-20 13:40:16 +05:30

290 lines
11 KiB
Python

"""
Engines router — Phase 3 wiring.
Exposes the three adapter registries (TTS, ASR, LLM) so the Settings UI can
render an engine picker + availability reasons.
GET /engines → { tts, asr, llm }
GET /engines/{family} → list of backends
POST /engines/select → persist a backend choice in prefs.json
GET /engines/{engine_id}/health → spawn-or-ping for SubprocessBackend
subclasses; ``is_available()`` for
in-process backends (Plan 02-04)
Environment variables (`OMNIVOICE_TTS_BACKEND`, `OMNIVOICE_ASR_BACKEND`,
`OMNIVOICE_LLM_BACKEND`) still win over the UI choice so power-users can pin
a backend without Settings silently undoing it.
"""
from time import perf_counter
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from api.dependencies import require_loopback
from core import prefs
from services import tts_backend, asr_backend, llm_backend, translation_engines
from services.audio_dsp import list_effect_presets
from api.schemas import EffectPresetsResponse
router = APIRouter()
_FAMILIES = {
"tts": (tts_backend, "tts_backend"),
"asr": (asr_backend, "asr_backend"),
"llm": (llm_backend, "llm_backend"),
}
@router.get("/engines")
def list_all_engines():
return {
"tts": {
"active": tts_backend.active_backend_id(),
"backends": tts_backend.list_backends(),
},
"asr": {
"active": asr_backend.active_backend_id(),
"backends": asr_backend.list_backends(),
},
"llm": {
"active": llm_backend.active_backend_id(),
"backends": llm_backend.list_backends(),
},
}
@router.get("/engines/tts")
def list_tts_backends():
return {"active": tts_backend.active_backend_id(), "backends": tts_backend.list_backends()}
@router.get("/engines/asr")
def list_asr_backends():
return {"active": asr_backend.active_backend_id(), "backends": asr_backend.list_backends()}
@router.get("/engines/llm")
def list_llm_backends():
return {"active": llm_backend.active_backend_id(), "backends": llm_backend.list_backends()}
@router.get("/engines/effects/presets", response_model=EffectPresetsResponse)
def list_effects_presets():
"""Return available DSP effect presets for the dub pipeline.
Each preset is a named chain of audio effects (EQ, compressor, reverb, etc.)
that can be applied to generated TTS audio on a per-segment basis.
"""
return {"presets": list_effect_presets()}
@router.get("/engines/translation")
def list_translation_engines():
"""Translation engines with per-engine pip-package availability.
Separate from the tts/asr/llm "family" endpoints because these are
pip-installable on demand rather than select-from-what's-available.
The UI uses this to show a one-click Install chip when the user picks
an engine whose Python dependency isn't importable yet.
"""
return {
"engines": translation_engines.list_engines(),
"sandboxed": translation_engines.is_frozen(),
}
@router.post("/engines/translation/{engine_id}/install")
async def install_translation_engine(engine_id: str):
entry = translation_engines.get_engine(engine_id)
if not entry:
raise HTTPException(status_code=404, detail=f"Unknown translation engine: {engine_id!r}")
if translation_engines.is_frozen():
raise HTTPException(
status_code=400,
detail=(
"Engine install is disabled in the packaged build — the "
"bundled Python environment is read-only and signed. Run the "
"source/dev install (`uv sync`) if you need to add an engine."
),
)
pkg = entry.get("pip_package")
if not pkg:
return {"status": "already_installed", "engine": engine_id, "reason": "no pip package required"}
if translation_engines.is_installed(engine_id):
return {"status": "already_installed", "engine": engine_id}
rc, out = await translation_engines.run_pip(["install", pkg])
if rc != 0:
raise HTTPException(status_code=500, detail=f"pip install {pkg} failed ({rc}): {out[-1000:]}")
# Probe again so the response reflects post-install reality; site-packages
# is visible immediately but importlib may have cached a failure.
import importlib
importlib.invalidate_caches()
ok = translation_engines.is_installed(engine_id)
return {
"status": "installed" if ok else "installed_but_probe_failed",
"engine": engine_id,
"package": pkg,
"log_tail": out[-800:],
"restart_required": not ok,
}
@router.delete("/engines/translation/{engine_id}")
async def uninstall_translation_engine(engine_id: str):
entry = translation_engines.get_engine(engine_id)
if not entry:
raise HTTPException(status_code=404, detail=f"Unknown translation engine: {engine_id!r}")
if entry.get("builtin"):
raise HTTPException(
status_code=400,
detail=(
f"{entry['display_name']} is built-in and cannot be uninstalled. "
"It shares its Python dependency with core features."
),
)
if translation_engines.is_frozen():
raise HTTPException(status_code=400, detail="Engine uninstall is disabled in packaged builds.")
pkg = entry.get("pip_package")
if not pkg:
return {"status": "no_op", "engine": engine_id}
rc, out = await translation_engines.run_pip(["uninstall", "-y", pkg])
if rc != 0:
raise HTTPException(status_code=500, detail=f"pip uninstall {pkg} failed ({rc}): {out[-1000:]}")
return {"status": "uninstalled", "engine": engine_id, "package": pkg, "log_tail": out[-800:]}
# ── Engine health-check (Plan 02-04 / ENGINE-06) ───────────────────────────
#
# The Compat Matrix UI's "Test engine" button calls into this endpoint so
# that users can verify a SubprocessBackend engine is alive without
# kicking off a full synthesize. For an in-process backend the check is a
# cheap ``is_available()`` round-trip; for a SubprocessBackend subclass
# the call spawns the sidecar (if not already up) and round-trips a ping
# frame. Result includes wall-clock latency so the UI can render
# "1234 ms — pong" inline next to the button.
#
# Loopback-gated (T-02-13): only the local desktop frontend may trigger
# a sidecar spawn through this endpoint.
# Engine instances cached for the lifetime of the FastAPI process so that
# repeated health checks don't spawn a new SubprocessBackend (each spawn
# allocates a sidecar venv probe + atexit hook). The cache is keyed by
# class to survive registry-sandbox tests that rebind ids transiently.
_ENGINE_INSTANCES: dict[type, object] = {}
def _get_engine_instance(cls):
"""Return a cached singleton instance of ``cls``.
SubprocessBackend's ``__init__`` registers an atexit shutdown hook,
so re-instantiating per request would leak handler entries (and on
real engines, additional sidecar processes the first time the lock
is acquired). One instance per process is the right move.
"""
inst = _ENGINE_INSTANCES.get(cls)
if inst is None:
inst = cls()
_ENGINE_INSTANCES[cls] = inst
return inst
def _resolve_engine_class(engine_id: str):
"""Look up ``engine_id`` across the tts/asr/llm registries.
Returns the class or ``None`` if no family knows the id. Order is
tts → asr → llm so the most-common case (TTS engine matrix) wins
early. No collision risk today — all current ids are family-unique.
"""
for registry in (
tts_backend._REGISTRY,
asr_backend._REGISTRY,
llm_backend._REGISTRY,
):
if engine_id in registry:
return registry[engine_id]
return None
@router.get(
"/engines/{engine_id}/health",
dependencies=[Depends(require_loopback)],
)
def engine_health(engine_id: str):
"""Spawn-and-ping a SubprocessBackend; ``is_available()`` for the rest.
Returns:
{ id, ok, message, latency_ms }
Never raises through to a 500: if the backend's check throws, the
exception is captured into the response body as ``ok=False`` /
``message="ExcType: ..."`` so the UI can render a per-row failure
without crashing the panel. Unknown engine ids return 404.
"""
cls = _resolve_engine_class(engine_id)
if cls is None:
raise HTTPException(
status_code=404,
detail=f"unknown engine id: {engine_id!r}",
)
t0 = perf_counter()
if hasattr(cls, "health_check"):
# SubprocessBackend path — spawn sidecar (if not running) and ping.
# ``health_check`` already swallows its own exceptions per Plan
# 02-01's contract; we still wrap in a defensive try so a custom
# subclass that violates the contract can't 500 the endpoint.
try:
instance = _get_engine_instance(cls)
ok, msg = instance.health_check()
except Exception as exc:
ok, msg = False, f"{type(exc).__name__}: {exc}"
else:
# In-process backend — `is_available()` is the classmethod-level
# liveness check. Cheap and side-effect-free for every shipping
# backend (it imports the engine package, no model load).
try:
ok, msg = cls.is_available()
except Exception as exc:
ok, msg = False, f"{type(exc).__name__}: {exc}"
# Mask any HF token the engine accidentally leaked into the message
# so the response body matches the same redaction guarantee as
# ``list_backends()``.
from services.tts_backend import _mask_hf_tokens
latency_ms = (perf_counter() - t0) * 1000.0
return {
"id": engine_id,
"ok": bool(ok),
"message": _mask_hf_tokens(msg) if isinstance(msg, str) else str(msg),
"latency_ms": latency_ms,
}
class SelectEngineRequest(BaseModel):
family: str # "tts" | "asr" | "llm"
backend_id: str
@router.post("/engines/select")
def select_engine(req: SelectEngineRequest):
"""Persist a family's engine pick to prefs.json. Refuses unknown backends
+ refuses backends whose deps aren't installed (so the UI can't silently
brick a pipeline by picking an unavailable engine)."""
family = _FAMILIES.get(req.family)
if not family:
raise HTTPException(400, f"Unknown family: {req.family}. Expected one of tts/asr/llm.")
module, pref_key = family
available = {b["id"]: b for b in module.list_backends()}
if req.backend_id not in available:
raise HTTPException(400, f"Unknown {req.family} backend: {req.backend_id!r}")
if not available[req.backend_id]["available"]:
reason = available[req.backend_id].get("reason") or "unavailable"
raise HTTPException(400, f"Backend {req.backend_id} not ready: {reason}")
prefs.set_(pref_key, req.backend_id)
return {
"family": req.family,
"active": module.active_backend_id(),
"env_override": bool(__import__("os").environ.get(f"OMNIVOICE_{req.family.upper()}_BACKEND")),
}