audio: STT backends, diarization, speaker embeddings/ID, word timestamps

Transcription (/v1/audio/transcriptions):
- Route multipart uploads by peeked model (front _peek_model) so they land on
  the model's assigned engine instead of scattering by capability.
- New pluggable STT backends (codai/api/stt_backends.py): wav2vec2 (HF), vosk
  (CPU dirs), NVIDIA NeMo-Canary (isolated venv worker) + faster-whisper/whisper.
- Per-model language selection + validation; Canary source->target translation.
- OpenAI timestamp_granularities[]=word: top-level words[] in verbose_json
  (vosk/wav2vec2/faster-whisper/canary).
- VRAM-eviction tracking (acquire_stt_backend) + keep_resident co-residency for
  the small set; /v1/models surfaces languages + backends.

Diarization (/v1/audio/diarization + diarize=true):
- pyannote.audio isolated venv worker (codai/api/pyannote_worker.py,
  tools/pyannote_service.py) on the 3090; speaker-labeled segments; graceful
  degradation. Pinned torch/pyannote/hf_hub/matplotlib in requirements-pyannote.txt.

Speaker embeddings + recognition:
- /v1/audio/speaker-embeddings (ecapa via speechbrain, pyannote/wespeaker).
- Enroll/list/delete + identify/verify (codai/api/speaker_registry.py); persistent
  voiceprint store; diarization identify=true relabels turns with enrolled names.

New isolated-venv requirements: requirements-nemo.txt, requirements-pyannote.txt
(+ vosk in requirements.txt).
Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R6iugoNeEgt9PkajKrFyq4
parent f54428cf
......@@ -2701,7 +2701,9 @@ async def api_model_configure(request: Request, username: str = Depends(require_
"turboquant", "engine", "engine_fallback",
"quant_backend", "kv_cache_budget_mb", "kv_cache_slots", "mmproj",
"auto_compact", "auto_compact_pct", "auto_compact_strategy",
"auto_compact_model", "suppress_reasoning"):
"auto_compact_model", "suppress_reasoning",
# Speech-to-text (wav2vec2 / vosk / NeMo-Canary) options.
"languages", "supports_translation", "keep_resident"):
if key in data:
entry[key] = data[key]
......
......@@ -545,7 +545,7 @@ window.__DEFAULT_WHISPER_SERVER_PATH__ = {{ default_whisper_server_path|tojson }
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.75rem">
<div class="form-row" style="margin:0">
<label class="form-label">Compute backend</label>
<select id="cfg-backend" class="form-input">
<select id="cfg-backend" class="form-input" onchange="_updateSttVisibility()">
<option value="auto">Auto-detect</option>
<option value="nvidia">NVIDIA (CUDA)</option>
<option value="vulkan">Vulkan</option>
......@@ -556,6 +556,9 @@ window.__DEFAULT_WHISPER_SERVER_PATH__ = {{ default_whisper_server_path|tojson }
<option value="k3">k3 (Kimi-K3 / kimi-k3-in-c)</option>
<option value="kt">ktransformers (SGLang, CPU+GPU)</option>
<option value="vllm">vLLM (high-concurrency, CUDA)</option>
<option value="wav2vec2">wav2vec2 / HF-ASR (speech-to-text)</option>
<option value="vosk">Vosk (offline STT, CPU, per-language dir)</option>
<option value="nemo">NVIDIA NeMo — Canary / Parakeet (isolated venv)</option>
</select>
</div>
<div class="form-row" style="margin:0">
......@@ -566,6 +569,24 @@ window.__DEFAULT_WHISPER_SERVER_PATH__ = {{ default_whisper_server_path|tojson }
</select>
</div>
</div>
<!-- Speech-to-text language options (wav2vec2 / vosk / NeMo-Canary) -->
<div id="cfg-stt-row" style="margin-top:.75rem;display:none">
<div class="form-row" style="margin:0">
<label class="form-label">Supported languages
<span class="muted">(comma-separated ISO codes, blank = accept any)</span></label>
<input id="cfg-stt-languages" class="form-input"
placeholder="en, it, de, fr, es">
</div>
<label class="form-label" style="display:flex;align-items:center;gap:.5rem;margin-top:.5rem">
<input type="checkbox" id="cfg-stt-translation">
Supports translation (NeMo/Canary: enables the request's <code>target_language</code>)
</label>
<label class="form-label" style="display:flex;align-items:center;gap:.5rem;margin-top:.5rem">
<input type="checkbox" id="cfg-stt-keep-resident">
Keep resident (co-reside with the other small STT models — skip routine
eviction; still yields to a larger model as a last resort)
</label>
</div>
<div class="form-row" id="cfg-engine-row" style="margin-top:.75rem;display:none">
<label class="form-label">Engine / card</label>
<select id="cfg-engine" class="form-input" onchange="_updateGpuSplitVisibility()">
......@@ -3364,6 +3385,10 @@ function openCfgModal(idx, cfgIdx){
(configs.length > 1 || (isNewConfig && m.in_config)) ? '' : 'none';
document.getElementById('cfg-backend').value = s.backend || 'auto';
document.getElementById('cfg-load-mode').value = s.load_mode || 'on-request';
document.getElementById('cfg-stt-languages').value = (s.languages || []).join(', ');
document.getElementById('cfg-stt-translation').checked = !!s.supports_translation;
document.getElementById('cfg-stt-keep-resident').checked = !!s.keep_resident;
_updateSttVisibility();
// Used VRAM
const usedVram = s.used_vram_gb != null ? s.used_vram_gb : null;
document.getElementById('cfg-used-vram').value = usedVram != null ? usedVram : '';
......@@ -3821,6 +3846,13 @@ function _collectColibri(){
return o;
}
// Show the speech-to-text language options only for STT backends.
function _updateSttVisibility(){
const be = (document.getElementById('cfg-backend').value || '');
const row = document.getElementById('cfg-stt-row');
if (row) row.style.display = (be === 'wav2vec2' || be === 'vosk' || be === 'nemo') ? '' : 'none';
}
async function saveModelConfig(){
const path = document.getElementById('cfg-path').value;
const maxGpu = parseFloat(document.getElementById('cfg-max-gpu').value);
......@@ -3840,6 +3872,10 @@ async function saveModelConfig(){
config_name: document.getElementById('cfg-config-name').value.trim() || null,
backend: document.getElementById('cfg-backend').value,
load_mode: document.getElementById('cfg-load-mode').value,
languages: (function(){ const v=document.getElementById('cfg-stt-languages').value.trim();
return v ? v.split(',').map(s=>s.trim()).filter(Boolean) : []; })(),
supports_translation: document.getElementById('cfg-stt-translation').checked,
keep_resident: document.getElementById('cfg-stt-keep-resident').checked,
used_vram_gb: isNaN(usedVram) ? null : usedVram,
force_vram_update: document.getElementById('cfg-force-vram-update').checked,
max_instances: parseInt(document.getElementById('cfg-max-instances').value) || 1,
......
# CoderAI - OpenAI-compatible API server
# Copyright (C) 2026 Stefy Lanza <stefy@nexlab.net>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
"""Fully-managed NVIDIA NeMo worker (Canary / Parakeet ASR).
NeMo pins a stack (transformers, huggingface-hub, numba, etc.) that conflicts with
the coderai server's transformers 5.x, so — like parler-tts — it can't share this
venv. coderai owns the lifecycle: it uses a NeMo venv (baked into the image at
``/opt/coderai/nemo_venv`` when present, else built on demand from
``requirements-nemo.txt``), launches ``tools/canary_service.py`` in it as a local
HTTP service, health-checks it, and hands back the URL. The
``_RemoteNemoBackend.cleanup()`` calls :func:`stop_service` so normal model
eviction tears the process down.
"""
import os
import socket
import subprocess
import sys
import threading
import time
from pathlib import Path
_REPO_ROOT = Path(__file__).resolve().parents[2]
_SERVICE_SCRIPT = _REPO_ROOT / "tools" / "canary_service.py"
_REQUIREMENTS = _REPO_ROOT / "requirements-nemo.txt"
# Baked-into-image venv (built by build.sh) is preferred; fall back to a runtime
# build under the user's cache. Mirrors codai.api.vllm_worker.resolve_venv_dir.
_BAKED_VENV = Path("/opt/coderai/nemo_venv")
_lock = threading.RLock()
_services: dict = {} # model_name -> {"proc","port","url"}
_bootstrapped = False
def resolve_venv_dir() -> Path:
explicit = os.environ.get("CODERAI_NEMO_VENV")
if explicit:
return Path(explicit)
if (_BAKED_VENV / ("Scripts" if os.name == "nt" else "bin")).exists():
return _BAKED_VENV
return Path(os.path.expanduser("~/.coderai/nemo_venv"))
def _venv_python(venv: Path) -> Path:
return venv / ("Scripts" if os.name == "nt" else "bin") / (
"python.exe" if os.name == "nt" else "python")
def _pip_ok(py: Path) -> bool:
try:
return subprocess.run(
[str(py), "-c", "import nemo.collections.asr"],
capture_output=True).returncode == 0
except Exception:
return False
def _bootstrap_venv() -> Path:
"""Return the NeMo venv's python, building it from requirements-nemo.txt if a
baked/prebuilt one isn't already present. Idempotent."""
global _bootstrapped
venv = resolve_venv_dir()
py = _venv_python(venv)
if _bootstrapped and py.exists():
return py
if py.exists() and _pip_ok(py):
_bootstrapped = True
return py
if not py.exists():
print(f"[canary] creating isolated NeMo venv at {venv} …", flush=True)
venv.parent.mkdir(parents=True, exist_ok=True)
subprocess.run([sys.executable, "-m", "venv", str(venv)], check=True)
py = _venv_python(venv)
if not _pip_ok(py):
if not _REQUIREMENTS.exists():
raise RuntimeError(f"NeMo requirements file missing: {_REQUIREMENTS}")
print("[canary] installing NeMo (nemo_toolkit[asr]) into the isolated venv "
"(first run downloads several GB, this can take a while) …", flush=True)
subprocess.run([str(py), "-m", "pip", "install", "-U", "pip"], check=True)
subprocess.run([str(py), "-m", "pip", "install", "-r", str(_REQUIREMENTS)],
check=True)
if not _pip_ok(py):
raise RuntimeError("NeMo install did not yield an importable nemo.collections.asr")
_bootstrapped = True
return py
def _free_port() -> int:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
s.close()
return port
def _pump_logs(proc: subprocess.Popen, tail):
for line in proc.stdout:
line = line.rstrip()
if line:
tail.append(line)
print(f"[canary] {line}", flush=True)
def _health_ok(url: str) -> bool:
import requests
try:
r = requests.get(url + "/health", timeout=3)
return r.ok and bool(r.json().get("ok"))
except Exception:
return False
def ensure_service(model_name: str, config: dict = None,
ready_timeout: float = 1800.0) -> str:
"""Start (or reuse) the NeMo worker for ``model_name`` and return its base URL.
First call may build the venv and download the model, so the timeout is
generous. Raises RuntimeError if the service never comes up."""
config = config or {}
with _lock:
svc = _services.get(model_name)
if svc and svc["proc"].poll() is None and _health_ok(svc["url"]):
return svc["url"]
if svc and svc["proc"].poll() is not None:
_services.pop(model_name, None)
py = _bootstrap_venv()
port = _free_port()
url = f"http://127.0.0.1:{port}"
env = dict(os.environ)
# Share coderai's HF cache. NeMo pulls .nemo checkpoints from HF hub; we
# allow the fetch (coderai's downloader doesn't handle .nemo) but keep it
# in the shared cache so it's pulled once.
try:
from codai.models.cache import get_hf_hub_cache_dir
hub = get_hf_hub_cache_dir()
env["HF_HUB_CACHE"] = hub
env["HUGGINGFACE_HUB_CACHE"] = hub
except Exception:
pass
# Pin the worker to its configured GPU (the 3090 by default).
gpu = config.get("gpu_device")
if gpu is not None:
env["CUDA_VISIBLE_DEVICES"] = str(gpu)
cmd = [str(py), str(_SERVICE_SCRIPT), "--model", model_name,
"--host", "127.0.0.1", "--port", str(port)]
mp = config.get("model_path")
if mp:
cmd += ["--model-path", str(mp)]
proc = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
bufsize=1, env=env, cwd=str(_REPO_ROOT))
import collections
tail = collections.deque(maxlen=15)
threading.Thread(target=_pump_logs, args=(proc, tail), daemon=True).start()
_services[model_name] = {"proc": proc, "port": port, "url": url}
def _tail_msg():
joined = " | ".join(list(tail)[-5:]).strip()
return f". Last output: {joined}" if joined else ""
deadline = time.time() + ready_timeout
while time.time() < deadline:
if proc.poll() is not None:
raise RuntimeError(
f"NeMo worker exited (code {proc.returncode}) before becoming ready"
+ _tail_msg())
if _health_ok(url):
print(f"[canary] service ready for {model_name} at {url}", flush=True)
return url
time.sleep(2)
stop_service(model_name)
raise RuntimeError(f"NeMo worker for {model_name} did not become ready in time"
+ _tail_msg())
def is_running(model_name: str) -> bool:
with _lock:
svc = _services.get(model_name)
return bool(svc and svc["proc"].poll() is None)
def stop_service(model_name: str) -> None:
with _lock:
svc = _services.pop(model_name, None)
if not svc:
return
proc = svc["proc"]
if proc.poll() is None:
try:
proc.terminate()
proc.wait(timeout=10)
except Exception:
pass
if proc.poll() is None:
try:
proc.kill()
except Exception:
pass
print(f"[canary] service for {model_name} stopped", flush=True)
def stop_all() -> None:
for name in list(_services.keys()):
stop_service(name)
import atexit as _atexit
_atexit.register(stop_all)
# CoderAI - OpenAI-compatible API server
# Copyright (C) 2026 Stefy Lanza <stefy@nexlab.net>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""Speaker diarization via the isolated pyannote.audio worker.
Provides the plumbing shared by the standalone ``/v1/audio/diarization`` endpoint
and the ``diarize=true`` option on ``/v1/audio/transcriptions``:
* :func:`diarize_audio` — start/reuse the (VRAM-eviction-tracked) pyannote worker
and return speaker turns.
* :func:`merge_speakers` — attach a ``speaker`` label to each ASR segment by
maximal temporal overlap with the diarization turns.
"""
from typing import List, Optional
from codai.models.manager import multi_model_manager
class _DiarizerHandle:
"""Evictable handle for a running pyannote worker. Its ``cleanup`` stops the
subprocess, so the manager's LRU eviction frees the worker's VRAM like any
other loaded model."""
def __init__(self, model: str, url: str):
self.model = model
self.url = url
def cleanup(self):
try:
from codai.api import pyannote_worker
pyannote_worker.stop_service(self.model)
except Exception:
pass
def _get_diarizer(model: Optional[str], config: Optional[dict]) -> _DiarizerHandle:
"""Ensure the pyannote worker is running and registered as an evictable model."""
from codai.api import pyannote_worker
resolved = model or pyannote_worker._default_model()
key = f"diarization:{resolved}"
cfg = config or {}
# ensure_service is idempotent: it reuses a healthy worker and re-spawns a dead
# one (possibly on a new port). Always call it so a worker that died since the
# handle was cached self-heals — otherwise acquire_stt_backend would hand back a
# stale handle pointing at the dead worker's port (connection refused).
url = pyannote_worker.ensure_service(resolved, cfg)
def _loader():
return _DiarizerHandle(resolved, url)
needed = float(cfg.get("used_vram_gb") or 2.0)
# pyannote is small (~2GB) — default it into the co-resident set so it stays
# warm alongside the STT models (still last-resort-evictable for a big LLM).
keep_resident = bool(cfg.get("keep_resident", True))
handle = multi_model_manager.acquire_stt_backend(
key, needed, True, _loader, keep_resident=keep_resident)
# Refresh the URL in case a cached handle points at a re-spawned worker.
handle.url = url
return handle
def diarize_audio(audio_path: str, num_speakers=None, min_speakers=None,
max_speakers=None, model: Optional[str] = None,
config: Optional[dict] = None, identify: bool = False,
identify_backend: str = "ecapa", identify_threshold: float = 0.25) -> dict:
"""Return ``{"segments":[{start,end,speaker}], "num_speakers":N}`` for the audio.
When ``identify`` is set, the worker also embeds one slice per diarized speaker;
each is matched against the enrolled speaker registry and the diarization
labels (SPEAKER_00…) are replaced with the recognised names (falling back to
the pyannote label when unknown). Also returns ``speakers`` = {label: name}."""
import requests
handle = _get_diarizer(model, config)
params = {}
if num_speakers:
params["num_speakers"] = int(num_speakers)
if min_speakers:
params["min_speakers"] = int(min_speakers)
if max_speakers:
params["max_speakers"] = int(max_speakers)
if identify:
params["embed"] = identify_backend or "ecapa"
with open(audio_path, "rb") as f:
resp = requests.post(f"{handle.url}/diarize", params=params,
data=f.read(), timeout=1800,
headers={"Content-Type": "application/octet-stream"})
if resp.status_code != 200:
raise RuntimeError(f"pyannote worker error {resp.status_code}: {resp.text[:300]}")
j = resp.json()
if j.get("error"):
raise RuntimeError(j["error"])
segments = j.get("segments") or []
out = {"segments": segments, "num_speakers": j.get("num_speakers")}
if identify and j.get("speaker_embeddings"):
from codai.api import speaker_registry
label_to_name = {}
for label, emb in j["speaker_embeddings"].items():
res = speaker_registry.identify(emb, identify_backend or "ecapa",
threshold=identify_threshold)
label_to_name[label] = {"name": res["name"], "score": res.get("score")}
# Relabel segments with recognised names (keep pyannote label when unknown).
for s in segments:
m = label_to_name.get(s.get("speaker"))
if m and m["name"] != "unknown":
s["speaker_label"] = s["speaker"]
s["speaker"] = m["name"]
out["speakers"] = label_to_name
return out
def _overlap(a_start, a_end, b_start, b_end) -> float:
return max(0.0, min(a_end, b_end) - max(a_start, b_start))
def merge_speakers(asr_segments: List[dict], diar_segments: List[dict]) -> List[dict]:
"""Attach a ``speaker`` to each ASR segment: the diarization turn it overlaps
most. ASR segments with no overlap are left unlabeled. Returns a new list."""
out = []
for seg in asr_segments or []:
s0 = float(seg.get("start", 0.0) or 0.0)
s1 = float(seg.get("end", 0.0) or 0.0)
best_spk, best_ov = None, 0.0
for d in diar_segments or []:
ov = _overlap(s0, s1, float(d.get("start", 0.0)), float(d.get("end", 0.0)))
if ov > best_ov:
best_ov, best_spk = ov, d.get("speaker")
merged = dict(seg)
if best_spk is not None:
merged["speaker"] = best_spk
out.append(merged)
return out
# CoderAI - OpenAI-compatible API server
# Copyright (C) 2026 Stefy Lanza <stefy@nexlab.net>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
"""Fully-managed pyannote.audio worker (speaker diarization).
pyannote.audio pins torch + pytorch-lightning + speechbrain, which conflict with
the coderai server's transformers 5.x stack — so, like NeMo/Canary, it runs in an
isolated venv. coderai owns the lifecycle: it uses a pyannote venv (baked into the
image at ``/opt/coderai/pyannote_venv`` when present, else built on demand from
``requirements-pyannote.txt``), launches ``tools/pyannote_service.py`` in it as a
local HTTP service, health-checks it, and hands back the URL.
The gated ``pyannote/speaker-diarization-3.1`` needs an HF token (accept its
conditions + ``pyannote/segmentation-3.0`` first). The worker passes ``HF_TOKEN``
/ ``HUGGINGFACE_TOKEN`` from the environment through to the service; the default
model can be pointed at an ungated mirror via ``CODERAI_DIARIZATION_MODEL`` so no
token is needed.
"""
import os
import socket
import subprocess
import sys
import threading
import time
from pathlib import Path
_REPO_ROOT = Path(__file__).resolve().parents[2]
_SERVICE_SCRIPT = _REPO_ROOT / "tools" / "pyannote_service.py"
_REQUIREMENTS = _REPO_ROOT / "requirements-pyannote.txt"
_BAKED_VENV = Path("/opt/coderai/pyannote_venv")
_lock = threading.RLock()
_services: dict = {} # model_name -> {"proc","port","url"}
_bootstrapped = False
def resolve_venv_dir() -> Path:
explicit = os.environ.get("CODERAI_PYANNOTE_VENV")
if explicit:
return Path(explicit)
if (_BAKED_VENV / ("Scripts" if os.name == "nt" else "bin")).exists():
return _BAKED_VENV
return Path(os.path.expanduser("~/.coderai/pyannote_venv"))
def _venv_python(venv: Path) -> Path:
return venv / ("Scripts" if os.name == "nt" else "bin") / (
"python.exe" if os.name == "nt" else "python")
def _pip_ok(py: Path) -> bool:
try:
return subprocess.run(
[str(py), "-c", "import pyannote.audio"],
capture_output=True).returncode == 0
except Exception:
return False
def _bootstrap_venv() -> Path:
global _bootstrapped
venv = resolve_venv_dir()
py = _venv_python(venv)
if _bootstrapped and py.exists():
return py
if py.exists() and _pip_ok(py):
_bootstrapped = True
return py
if not py.exists():
print(f"[pyannote] creating isolated venv at {venv} …", flush=True)
venv.parent.mkdir(parents=True, exist_ok=True)
subprocess.run([sys.executable, "-m", "venv", str(venv)], check=True)
py = _venv_python(venv)
if not _pip_ok(py):
if not _REQUIREMENTS.exists():
raise RuntimeError(f"pyannote requirements file missing: {_REQUIREMENTS}")
print("[pyannote] installing pyannote.audio into the isolated venv "
"(first run downloads several GB, this can take a while) …", flush=True)
subprocess.run([str(py), "-m", "pip", "install", "-U", "pip"], check=True)
subprocess.run([str(py), "-m", "pip", "install", "-r", str(_REQUIREMENTS)],
check=True)
if not _pip_ok(py):
raise RuntimeError("pyannote install did not yield an importable pyannote.audio")
_bootstrapped = True
return py
def _free_port() -> int:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
s.close()
return port
def _pump_logs(proc: subprocess.Popen, tail):
for line in proc.stdout:
line = line.rstrip()
if line:
tail.append(line)
print(f"[pyannote] {line}", flush=True)
def _health_ok(url: str) -> bool:
import requests
try:
r = requests.get(url + "/health", timeout=3)
return r.ok and bool(r.json().get("ok"))
except Exception:
return False
def _default_model() -> str:
# Official pyannote pipeline (canonical, best-supported). It's gated: needs an
# HF token (see _resolve_hf_token) + accepting the pyannote/speaker-diarization-3.1
# and pyannote/segmentation-3.0 conditions. Override with CODERAI_DIARIZATION_MODEL.
return os.environ.get("CODERAI_DIARIZATION_MODEL") or "pyannote/speaker-diarization-3.1"
def _resolve_hf_token(config: dict) -> str:
"""Find an HF token: request/config, then env, then a protected file in the
coderai config dir (~/.coderai/hf_token or /config/coderai/hf_token). Lets the
token be configured once without editing the container's launch command."""
tok = (config or {}).get("hf_token")
if tok:
return tok
for var in ("HF_TOKEN", "HUGGINGFACE_TOKEN", "HUGGING_FACE_HUB_TOKEN"):
if os.environ.get(var):
return os.environ[var]
for path in (os.environ.get("CODERAI_HF_TOKEN_FILE"),
os.path.expanduser("~/.coderai/hf_token"),
"/config/coderai/hf_token"):
if path and os.path.isfile(path):
try:
t = open(path).read().strip()
if t:
return t
except OSError:
pass
return ""
def ensure_service(model_name: str = None, config: dict = None,
ready_timeout: float = 1800.0) -> str:
"""Start (or reuse) the pyannote worker and return its base URL."""
config = config or {}
model_name = model_name or config.get("model_path") or _default_model()
with _lock:
svc = _services.get(model_name)
if svc and svc["proc"].poll() is None and _health_ok(svc["url"]):
return svc["url"]
if svc and svc["proc"].poll() is not None:
_services.pop(model_name, None)
py = _bootstrap_venv()
port = _free_port()
url = f"http://127.0.0.1:{port}"
env = dict(os.environ)
try:
from codai.models.cache import get_hf_hub_cache_dir
hub = get_hf_hub_cache_dir()
env["HF_HUB_CACHE"] = hub
env["HUGGINGFACE_HUB_CACHE"] = hub
except Exception:
pass
# Pass an HF token through for the gated model (harmless for ungated).
tok = _resolve_hf_token(config)
if tok:
env["HF_TOKEN"] = tok
gpu = config.get("gpu_device")
if gpu is not None:
env["CUDA_VISIBLE_DEVICES"] = str(gpu)
proc = subprocess.Popen(
[str(py), str(_SERVICE_SCRIPT), "--model", model_name,
"--host", "127.0.0.1", "--port", str(port)],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
bufsize=1, env=env, cwd=str(_REPO_ROOT))
import collections
tail = collections.deque(maxlen=15)
threading.Thread(target=_pump_logs, args=(proc, tail), daemon=True).start()
_services[model_name] = {"proc": proc, "port": port, "url": url}
def _tail_msg():
joined = " | ".join(list(tail)[-5:]).strip()
return f". Last output: {joined}" if joined else ""
deadline = time.time() + ready_timeout
while time.time() < deadline:
if proc.poll() is not None:
raise RuntimeError(
f"pyannote worker exited (code {proc.returncode}) before becoming ready"
+ _tail_msg())
if _health_ok(url):
print(f"[pyannote] service ready for {model_name} at {url}", flush=True)
return url
time.sleep(2)
stop_service(model_name)
raise RuntimeError(f"pyannote worker for {model_name} did not become ready in time"
+ _tail_msg())
def is_running(model_name: str = None) -> bool:
model_name = model_name or _default_model()
with _lock:
svc = _services.get(model_name)
return bool(svc and svc["proc"].poll() is None)
def stop_service(model_name: str = None) -> None:
model_name = model_name or _default_model()
with _lock:
svc = _services.pop(model_name, None)
if not svc:
return
proc = svc["proc"]
if proc.poll() is None:
try:
proc.terminate()
proc.wait(timeout=10)
except Exception:
pass
if proc.poll() is None:
try:
proc.kill()
except Exception:
pass
print(f"[pyannote] service for {model_name} stopped", flush=True)
def stop_all() -> None:
for name in list(_services.keys()):
stop_service(name)
import atexit as _atexit
_atexit.register(stop_all)
# CoderAI - OpenAI-compatible API server
# Copyright (C) 2026 Stefy Lanza <stefy@nexlab.net>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""Speaker-embedding extraction (voiceprints) via the isolated pyannote venv.
Backends (served by tools/pyannote_service.py's /embed):
* ``ecapa`` — speechbrain ECAPA-TDNN (ungated, 192-d). Default.
* ``pyannote`` — pyannote.audio embedding model (may be gated, uses HF_TOKEN).
* ``wespeaker``— pyannote wespeaker resnet embedding.
Reuses the same worker (and VRAM-eviction registration) as diarization.
"""
from typing import Optional
def get_speaker_embedding(audio_path: str, backend: str = "ecapa",
model: Optional[str] = None,
config: Optional[dict] = None) -> dict:
"""Return ``{"embedding":[...], "dim":N, "backend":..., "model":...}``."""
import requests
from codai.api.diarization import _get_diarizer
# _get_diarizer ensures the (VRAM-tracked, self-healing) pyannote worker is up
# and returns a handle with its URL; the worker serves /embed as well.
handle = _get_diarizer(None, config or {})
params = {"backend": backend or "ecapa"}
if model:
params["model"] = model
with open(audio_path, "rb") as f:
resp = requests.post(f"{handle.url}/embed", params=params, data=f.read(),
timeout=600, headers={"Content-Type": "application/octet-stream"})
if resp.status_code != 200:
raise RuntimeError(f"pyannote worker error {resp.status_code}: {resp.text[:300]}")
j = resp.json()
if j.get("error"):
raise RuntimeError(j["error"])
return j
# CoderAI - OpenAI-compatible API server
# Copyright (C) 2026 Stefy Lanza <stefy@nexlab.net>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""Speaker identification registry — enrolled voiceprints + cosine matching.
A named speaker is one or more embeddings (averaged) produced by a specific
embedding backend (ecapa / wespeaker / …). Identification/verification compares a
query embedding against enrolled speakers of the SAME backend by cosine
similarity — embeddings from different backends are NOT comparable.
Persisted as JSON in the coderai config dir so enrollments survive restarts.
"""
import json
import os
import threading
import time
from typing import Optional
_lock = threading.RLock()
def _store_path() -> str:
p = os.environ.get("CODERAI_SPEAKER_STORE")
if p:
return p
for d in ("/config/coderai", os.path.expanduser("~/.coderai")):
if os.path.isdir(d):
return os.path.join(d, "speakers.json")
return os.path.join(os.path.expanduser("~/.coderai"), "speakers.json")
def _load() -> dict:
try:
with open(_store_path()) as f:
return json.load(f)
except Exception:
return {"speakers": {}}
def _save(data: dict) -> None:
path = _store_path()
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp = path + ".tmp"
with open(tmp, "w") as f:
json.dump(data, f)
os.replace(tmp, path)
def _norm(v):
import numpy as np
a = np.asarray(v, dtype=np.float32)
n = np.linalg.norm(a)
return a / n if n > 0 else a
def _cosine(a, b) -> float:
import numpy as np
return float(np.dot(_norm(a), _norm(b)))
def enroll(name: str, embedding: list, backend: str, model: str = None) -> dict:
"""Add a sample for ``name``. Re-enrolling averages with existing samples
(running mean), improving the voiceprint. One voiceprint per (name, backend)."""
import numpy as np
name = (name or "").strip()
if not name:
raise ValueError("speaker name is required")
with _lock:
data = _load()
spk = data.setdefault("speakers", {})
key = f"{name}::{backend}"
rec = spk.get(key)
if rec is None:
rec = {"name": name, "backend": backend, "model": model,
"dim": len(embedding), "samples": 0,
"embedding": [0.0] * len(embedding)}
n = rec["samples"]
# running mean of the raw embeddings
prev = np.asarray(rec["embedding"], dtype=np.float32)
cur = np.asarray(embedding, dtype=np.float32)
if prev.shape != cur.shape:
prev = np.zeros_like(cur); n = 0
newmean = (prev * n + cur) / (n + 1)
rec["embedding"] = newmean.astype(float).tolist()
rec["samples"] = n + 1
rec["dim"] = len(rec["embedding"])
rec["model"] = model or rec.get("model")
rec["updated"] = None # timestamp stamped by caller if wanted
spk[key] = rec
_save(data)
return {"name": name, "backend": backend, "samples": rec["samples"],
"dim": rec["dim"]}
def list_speakers() -> list:
with _lock:
data = _load()
return [{"name": r["name"], "backend": r["backend"], "model": r.get("model"),
"samples": r.get("samples", 1), "dim": r.get("dim")}
for r in data.get("speakers", {}).values()]
def delete(name: str, backend: str = None) -> int:
name = (name or "").strip()
removed = 0
with _lock:
data = _load()
spk = data.get("speakers", {})
for key in [k for k, r in spk.items()
if r.get("name") == name and (backend is None or r.get("backend") == backend)]:
spk.pop(key, None)
removed += 1
if removed:
_save(data)
return removed
def get(name: str, backend: str) -> Optional[dict]:
with _lock:
for r in _load().get("speakers", {}).values():
if r.get("name") == name and r.get("backend") == backend:
return r
return None
def _enrolled_for_backend(backend: str) -> list:
data = _load()
return [r for r in data.get("speakers", {}).values() if r.get("backend") == backend]
def identify(embedding: list, backend: str, threshold: float = 0.25,
top_k: int = 3) -> dict:
"""Return the best-matching enrolled speaker for ``embedding`` (same backend).
``score`` is cosine similarity in [-1,1]; below ``threshold`` → name "unknown".
Also returns the top_k ranked candidates."""
with _lock:
enrolled = _enrolled_for_backend(backend)
ranked = sorted(
({"name": r["name"], "score": _cosine(embedding, r["embedding"])}
for r in enrolled),
key=lambda x: x["score"], reverse=True)
best = ranked[0] if ranked else None
name = best["name"] if (best and best["score"] >= threshold) else "unknown"
return {"name": name, "score": (best["score"] if best else None),
"threshold": threshold, "candidates": ranked[:top_k],
"enrolled": len(enrolled), "backend": backend}
def verify(embedding_a: list, embedding_b: list, threshold: float = 0.25) -> dict:
score = _cosine(embedding_a, embedding_b)
return {"same_speaker": score >= threshold, "score": score, "threshold": threshold}
This diff is collapsed.
This diff is collapsed.
......@@ -950,10 +950,47 @@ class FrontProxy:
@staticmethod
def _peek_model(body: bytes, content_type: str) -> Optional[str]:
if not body or "application/json" not in (content_type or "").lower():
ct = (content_type or "").lower()
if not body:
return None
if "application/json" in ct:
try:
return (json.loads(body) or {}).get("model")
except Exception:
return None
# multipart/form-data (audio transcriptions/translations, image edits):
# the `model` is a small text field. Extract it WITHOUT fully parsing the
# body — it carries the (up to 100MB) uploaded file. Routing needs the
# model to honour its engine pin (e.g. a whisper-server pinned to radeon);
# without it the request routes by capability alone and can land on an
# engine that has no whisper-server registered → "Invalid model size".
if "multipart/form-data" in ct:
return FrontProxy._peek_model_multipart(body)
return None
@staticmethod
def _peek_model_multipart(body: bytes) -> Optional[str]:
"""Pull the value of the `model` form field out of a multipart body.
Anchors on the part header ``Content-Disposition: … name="model"`` and
reads the value up to the next CRLF. A ``bytes.find`` for a short needle
over the whole body is C-fast even at 100MB, and the file part's own
``name="file"`` never matches this anchor."""
try:
return (json.loads(body) or {}).get("model")
marker = body.find(b'name="model"\r\n')
if marker == -1:
marker = body.find(b'name="model"')
if marker == -1:
return None
sep = body.find(b"\r\n\r\n", marker)
if sep == -1:
return None
start = sep + 4
end = body.find(b"\r\n", start)
if end == -1:
return None
val = body[start:end].decode("utf-8", "replace").strip()
return val or None
except Exception:
return None
......
......@@ -34,6 +34,11 @@ _INFERENCE_PATHS = {
"/v1/images/edits",
"/v1/audio/speech",
"/v1/audio/transcriptions",
"/v1/audio/diarization",
"/v1/audio/speaker-embeddings",
"/v1/audio/speakers",
"/v1/audio/speaker-identify",
"/v1/audio/speaker-verify",
"/v1/video/generations",
}
......@@ -100,7 +105,10 @@ def required_capability(model: Optional[str], path: Optional[str] = None,
alias, then an unambiguous name marker (a Kimi name claimed by BOTH colibri and k3
falls through — the model must pin a backend)."""
p = (path or "").split("?", 1)[0].rstrip("/")
if p == "/v1/audio/transcriptions" or (backend or "") == "whisper-server":
if p in ("/v1/audio/transcriptions", "/v1/audio/diarization",
"/v1/audio/speaker-embeddings", "/v1/audio/speakers",
"/v1/audio/speaker-identify", "/v1/audio/speaker-verify") \
or (backend or "") == "whisper-server":
return "whisper"
# 1. Explicit engine-backend pin wins (authoritative), like the manager resolver.
b = (backend or "").lower()
......@@ -158,6 +166,18 @@ def pick_engine(registry: EngineRegistry, path: str, method: str,
if method.upper() == "POST" and is_inference_path(path):
cap = required_cap
# Speaker diarization (pyannote) must run on a CUDA engine to use the GPU.
# When the request carries no model to pin it, prefer the primary (nvidia)
# so it doesn't land on a Vulkan/CPU engine and run diarization on CPU. An
# explicit model pin (below) still wins.
if not pinned and not model and (path or "").split("?", 1)[0].rstrip("/") \
in ("/v1/audio/diarization", "/v1/audio/speaker-embeddings",
"/v1/audio/speakers", "/v1/audio/speaker-identify",
"/v1/audio/speaker-verify"):
prim = registry.primary()
if prim is not None and prim.can_serve(cap) and prim.is_alive():
return prim
# 0. Per-model pin (models.json "engine") is a HARD constraint: the model
# runs on that engine or not at all. Route there if it can serve and its
# process is alive — even when it's busy (mid-generation → failing health
......
......@@ -984,6 +984,13 @@ def main():
)
else:
multi_model_manager.set_audio_model(mid, config=_model_cfg(m, "audio"))
# Register the alias→path mapping (like image/vision models) so a
# request for e.g. "vosk-en" / "wav2vec2-it" / "canary" resolves to the
# model's real path and carries its config — otherwise the STT backend
# receives the bare alias as its model reference. (whisper-server
# entries take the branch above and manage their own round-robin alias.)
if isinstance(m, dict) and m.get("alias"):
multi_model_manager.set_model_alias(m["alias"], mid)
# Image models
image_models = models_config.get("image_models", [])
......
......@@ -58,6 +58,7 @@ class ModelCapabilities:
speech_to_text: bool = False # Whisper transcription
text_to_speech: bool = False # Kokoro, Bark, XTTS
subtitle_generation: bool = False # WhisperX / forced alignment subtitles
speaker_diarization: bool = False # pyannote "who spoke when"
# Audio: generation & manipulation
audio_generation: bool = False # MusicGen, AudioLDM2, StableAudio
......@@ -170,8 +171,13 @@ def detect_model_capabilities(model_name: str) -> ModelCapabilities:
caps.audio_to_audio = True
return caps
if any(x in n for x in ['pyannote', 'diariz', 'speaker-diarization']):
caps.speaker_diarization = True
return caps
if any(x in n for x in ['whisper', 'faster-whisper', 'distil-whisper',
'wav2vec', 'hubert', 'seamless']):
'wav2vec', 'hubert', 'seamless', 'wavlm', 'mms-',
'canary', 'parakeet', 'nemo', 'vosk']):
caps.speech_to_text = True
caps.subtitle_generation = True
return caps
......
This diff is collapsed.
......@@ -150,6 +150,11 @@ class ModelInfo(BaseModel):
port: Optional[int] = None
gpu_device: Optional[int] = None
load_mode: Optional[str] = None
# Speech-to-text: languages this model supports (empty/None = accepts any) and
# whether it can translate (source→target_language, e.g. NVIDIA Canary).
languages: Optional[List[str]] = None
supports_translation: Optional[bool] = None
speaker_diarization: Optional[bool] = None
class ModelList(BaseModel):
......
# Isolated venv for NVIDIA NeMo ASR (Canary / Parakeet) speech-to-text.
#
# NeMo pins a transformers/huggingface-hub/numba stack that conflicts with the
# coderai server's transformers 5.x, so it must NOT be installed into the shared
# venv. codai/api/canary_worker.py builds this venv (baked at /opt/coderai/nemo_venv
# by build.sh, or on demand under ~/.coderai/nemo_venv) and runs
# tools/canary_service.py inside it. Do NOT merge these into requirements.txt.
#
# CUDA-only (needs an NVIDIA GPU). Install order matters: torch first, then nemo.
torch
torchaudio
nemo_toolkit[asr]>=2.0.0
cuda-python
# NeMo transcribe I/O + audio decode
soundfile>=0.12.0
librosa>=0.10.0
# HTTP client used by nothing here (service is stdlib http.server); requests is
# pulled by nemo transitively but pin a floor to be safe.
requests>=2.31.0
# Isolated venv for pyannote.audio speaker diarization.
#
# pyannote.audio pulls torch + pytorch-lightning + speechbrain + asteroid, which
# conflict with the coderai server's transformers 5.x stack, so it must NOT be
# installed into the shared venv. codai/api/pyannote_worker.py builds this venv
# (baked at /opt/coderai/pyannote_venv by build.sh, or on demand under
# ~/.coderai/pyannote_venv) and runs tools/pyannote_service.py inside it. Do NOT
# merge these into requirements.txt.
#
# TORCH IS PINNED: the bleeding-edge torch (2.13/cu130) that `torch` resolves to
# has a device-placement bug with pyannote's SincNet ("Expected all tensors to be
# on the same device" — weights on cuda, input on cpu), which forces CPU. Pin to a
# pyannote-compatible CUDA build so diarization runs on the 3090. cu124 wheels run
# fine on this box's newer driver (backward compatible).
--extra-index-url https://download.pytorch.org/whl/cu124
torch==2.6.0+cu124
torchaudio==2.6.0+cu124
# Pin pyannote.audio too: leaving it open let pip pick a version mismatched with
# the pinned torch AND the modern huggingface_hub (older pyannote calls
# hf_hub_download(use_auth_token=...), which new hf_hub removed → TypeError). 3.3.2
# is stable, GPU-proven, and uses the modern token= API.
pyannote.audio==3.3.2
# pyannote 3.3.2 calls hf_hub_download(use_auth_token=...), which huggingface_hub
# removed in 1.0 → "unexpected keyword argument 'use_auth_token'". Pin below 1.0
# where it still works (deprecated but accepted).
huggingface_hub<1.0
soundfile>=0.12.0
# pyannote 3.3.2 imports matplotlib (segmentation mixins) but doesn't always pull
# it under the pinned resolution.
matplotlib>=3.7
# Speaker-embedding backends served from this venv: ECAPA-TDNN via speechbrain
# (ungated), alongside pyannote's own embedding model.
speechbrain>=1.0.0
......@@ -52,6 +52,7 @@ setproctitle>=1.1 # Process naming (replaces procname, Python 3.13 compatible)
# Optional: Audio transcription dependencies
faster-whisper>=0.10.0 # For NVIDIA/CUDA whisper transcription
vosk>=0.3.45 # Offline Kaldi-based STT backend (CPU, per-language model dirs)
whispercpp>=0.0.17 # Alternative whisper library (works without PyTorch)
# LiteLLM for standardized API responses
......
#!/usr/bin/env python3
"""Standalone NVIDIA NeMo ASR HTTP microservice — run in its OWN venv.
NeMo (Canary / Parakeet) pins a stack that conflicts with coderai's transformers
5.x, so it runs here behind a tiny stdlib HTTP shim and coderai talks to it as a
remote STT backend (``codai.api.stt_backends._RemoteNemoBackend``), managed by
``codai.api.canary_worker``.
Endpoints:
GET /health -> {"ok": true, "model": ..., "multitask": bool}
POST /transcribe -> {"text", "segments":[{start,end,text}], "language"}
body: raw audio bytes;
query: ?language=<src>&target_language=<tgt>
The uploaded audio is transcoded to 16 kHz mono WAV via ffmpeg before inference.
"""
import argparse
import json
import os
import subprocess
import tempfile
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse, parse_qs
def _to_wav_16k_mono(raw: bytes) -> str:
fd, src = tempfile.mkstemp(suffix=".input")
os.write(fd, raw)
os.close(fd)
fd, out = tempfile.mkstemp(suffix=".wav")
os.close(fd)
try:
p = subprocess.run(
["ffmpeg", "-y", "-i", src, "-ar", "16000", "-ac", "1",
"-c:a", "pcm_s16le", "-f", "wav", out],
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
if p.returncode != 0:
raise RuntimeError("ffmpeg conversion failed: " +
p.stderr.decode("utf-8", "replace")[-300:])
return out
finally:
try:
os.unlink(src)
except OSError:
pass
class _Engine:
"""Loads a NeMo ASR model once and transcribes audio files.
Handles both Canary-style multitask models (source/target language +
translation, ``EncDecMultiTaskModel``) and plain ASR models (Parakeet, etc.)."""
def __init__(self, model_name: str, model_path: str = None):
from nemo.collections.asr.models import ASRModel
self.model_name = model_name
if model_path and os.path.isfile(model_path):
self._model = ASRModel.restore_from(model_path)
else:
self._model = ASRModel.from_pretrained(model_name)
try:
import torch
if torch.cuda.is_available():
self._model = self._model.cuda()
except Exception:
pass
self._model.eval()
# Multitask (Canary) models accept source_lang/target_lang.
self.multitask = self._model.__class__.__name__ == "EncDecMultiTaskModel"
def transcribe(self, wav_path: str, language: str = None,
target_language: str = None) -> dict:
kwargs = {"timestamps": True}
if self.multitask:
src = language or "en"
kwargs["source_lang"] = src
kwargs["target_lang"] = target_language or src
kwargs["pnc"] = "yes"
try:
out = self._model.transcribe([wav_path], **kwargs)
except TypeError:
# Older NeMo without timestamps kwarg.
kwargs.pop("timestamps", None)
out = self._model.transcribe([wav_path], **kwargs)
hyp = out[0] if isinstance(out, (list, tuple)) and out else out
text = getattr(hyp, "text", None)
if text is None:
text = hyp if isinstance(hyp, str) else str(hyp)
segments, words = [], []
ts = getattr(hyp, "timestamp", None)
if isinstance(ts, dict):
for seg in (ts.get("segment") or []):
segments.append({
"start": float(seg.get("start", 0.0)),
"end": float(seg.get("end", 0.0)),
"text": (seg.get("segment") or seg.get("text") or "").strip(),
})
for w in (ts.get("word") or []):
words.append({
"word": (w.get("word") or w.get("text") or "").strip(),
"start": float(w.get("start", 0.0)),
"end": float(w.get("end", 0.0)),
})
return {"text": (text or "").strip(), "segments": segments, "words": words,
"language": target_language or language}
ENGINE = None # set in main()
class Handler(BaseHTTPRequestHandler):
def _send(self, code, body=b"", ctype="application/json"):
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
if body:
self.wfile.write(body)
def log_message(self, fmt, *args):
pass
def do_GET(self):
if urlparse(self.path).path == "/health":
self._send(200, json.dumps(
{"ok": True, "model": ENGINE.model_name,
"multitask": ENGINE.multitask}).encode())
else:
self._send(404, b'{"error":"not found"}')
def do_POST(self):
parsed = urlparse(self.path)
if parsed.path != "/transcribe":
self._send(404, b'{"error":"not found"}')
return
wav = None
try:
n = int(self.headers.get("Content-Length", 0))
raw = self.rfile.read(n) if n else b""
if not raw:
self._send(400, b'{"error":"empty audio"}')
return
q = parse_qs(parsed.query)
language = (q.get("language") or [None])[0]
target = (q.get("target_language") or [None])[0]
wav = _to_wav_16k_mono(raw)
result = ENGINE.transcribe(wav, language=language, target_language=target)
self._send(200, json.dumps(result).encode())
except Exception as e:
import traceback
traceback.print_exc()
self._send(500, json.dumps({"error": str(e)}).encode())
finally:
if wav:
try:
os.unlink(wav)
except OSError:
pass
def main(argv=None):
ap = argparse.ArgumentParser(description="Standalone NVIDIA NeMo ASR HTTP service")
ap.add_argument("--model", default="nvidia/canary-1b-flash")
ap.add_argument("--model-path", default=None,
help="Local .nemo checkpoint (overrides --model)")
ap.add_argument("--host", default="127.0.0.1")
ap.add_argument("--port", type=int, default=8124)
args = ap.parse_args(argv)
global ENGINE
print(f"Loading NeMo model {args.model_path or args.model} …")
ENGINE = _Engine(args.model, args.model_path)
print(f"Ready: {args.model} (multitask={ENGINE.multitask}) — "
f"serving on http://{args.host}:{args.port}")
ThreadingHTTPServer((args.host, args.port), Handler).serve_forever()
if __name__ == "__main__":
main()
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment