cstr/CrispASR
0
1"""Gradio UI wrapper for the CrispASR HTTP server.2 3Surfaces multiple capability areas of the C++ engine inside one Space:4 * Transcribe — 9 ASR backends, hot-swapped through POST /load.5 * Speak — Kokoro TTS through POST /v1/audio/speech.6 * Detect — text language identification via the crispasr-lid binary.7 * Backends — capability snapshot from /backends + /health.8"""9 10from __future__ import annotations11 12import json13import os14import shutil15import subprocess16import time17from pathlib import Path18from typing import Iterable19 20import gradio as gr21import httpx22import requests23import uvicorn24from fastapi import FastAPI, Request, Response25 26 27SERVER_URL = os.environ.get("CRISPASR_SERVER_URL", "http://127.0.0.1:8080").rstrip("/")28SPACE_TITLE = os.environ.get("CRISPASR_SPACE_TITLE", "CrispASR")29DEFAULT_LANGUAGE = os.environ.get("CRISPASR_LANGUAGE", "auto")30API_KEY = next(31 (k.strip() for k in os.environ.get("CRISPASR_API_KEYS", "").split(",") if k.strip()),32 "",33)34CACHE_DIR = Path(os.environ.get("CRISPASR_CACHE_DIR", "/cache"))35SAMPLES_DIR = Path(os.environ.get("CRISPASR_SAMPLES_DIR", "/space/samples"))36CRISPASR_LID_BIN = shutil.which("crispasr-lid") or "crispasr-lid"37 38# (display, backend, model_arg, default_language, approx_size, blurb)39ASR_MODELS = [40 ("Whisper base — multilingual, balanced", "whisper", "auto", "auto", "~147 MB",41 "OpenAI Whisper. 99 langs. Native timestamps + speech translation."),42 ("Moonshine tiny — fastest CPU, EN", "moonshine", "auto", "en", "~37 MB",43 "Smallest model. ~16× realtime on CPU. English only."),44 ("Moonshine base DE — German fine-tune (CC-BY-NC-SA)", "moonshine-de", "auto", "de", "~150 MB",45 "fidoriel German fine-tune of moonshine-base. 6.9% WER on CV22."),46 ("Parakeet TDT v3 — 25 EU langs, word timestamps", "parakeet", "auto", "auto", "~467 MB",47 "NVIDIA Parakeet. Multilingual, native word-level timestamps."),48 ("Wav2vec2 XLSR — English CTC", "wav2vec2", "auto", "en", "~212 MB",49 "Lightweight CTC. No punctuation/casing — pair with --punc-model locally."),50 ("Wav2vec2 XLSR — German CTC", "wav2vec2",51 "wav2vec2-large-xlsr-53-german-q4_k.gguf", "de", "~250 MB",52 "German fine-tune of wav2vec2-XLSR-53. CTC head."),53 ("Fast-conformer CTC 0.6B — English, 10× realtime", "fastconformer-ctc",54 "parakeet-ctc-0.6b-q4_k.gguf", "en", "~250 MB",55 "NeMo FastConformer + CTC. Fastest reasonable EN backend."),56 ("Cohere Transcribe — 13 langs, lowest EN WER", "cohere", "auto", "auto", "~550 MB",57 "Cohere Labs. Punctuation + casing. Slowest of the small set."),58 ("Qwen3 ASR 0.6B — 30 langs + 22 Chinese dialects", "qwen3", "auto", "auto", "~500 MB",59 "Speech-LLM (Whisper enc + Qwen3 0.6B). Native language ID."),60 ("Canary — multilingual + translation", "canary", "auto", "auto", "~800 MB",61 "NVIDIA Canary 1B. Native speech translation. 25+ langs."),62 ("HuBERT CTC — English", "hubert", "auto", "en", "~380 MB",63 "Self-supervised CTC. Lightweight encoder, no punctuation."),64 ("Data2Vec CTC — English", "data2vec", "auto", "en", "~380 MB",65 "Meta Data2Vec CTC. Similar to HuBERT."),66]67 68TTS_MODELS = [69 ("Kokoro 82M — multilingual StyleTTS2", "kokoro", "auto", "en", "~85 MB",70 "9 langs (EN/ES/FR/HI/IT/JA/PT/ZH/DE). Apache-2.0. Only TTS realistic on free-tier CPU."),71 ("VibeVoice 0.5B — EN/DE/ZH StyleTTS", "vibevoice", "auto", "en", "~200 MB",72 "VibeVoice encoder-decoder TTS. EN/DE/ZH. Apache-2.0."),73 ("Orpheus 0.5B — English expressive TTS", "orpheus", "auto", "en", "~400 MB",74 "SNAC codec + LLM decoder. Expressive speech with emotion tags."),75 ("Chatterbox — English voice cloning", "chatterbox", "auto", "en", "~450 MB",76 "Resemble AI. Zero-shot voice cloning from a reference clip. Apache-2.0."),77 ("Chatterbox Turbo — faster voice cloning", "chatterbox-turbo", "auto", "en", "~350 MB",78 "Faster Chatterbox variant. Same voice cloning, 2× speed."),79]80 81# (display, model arg passed to `-m`, blurb)82LID_MODELS = [83 ("CLD3 — 109 ISO-639-1 (default)", "auto", "Google CLD3 in GGUF. ~440 KB, instant."),84 ("GlotLID-V3 — 2102 ISO-639-3 + script", "auto:glotlid", "cis-lmu fastText. Max language coverage."),85 ("LID-176 — 176 ISO-639-1 (CC-BY-SA)", "auto:lid-fasttext176", "Facebook fastText. Output GGUF inherits CC-BY-SA-3.0."),86]87 88# (display, backend, model_arg, approx_size, blurb)89NMT_MODELS = [90 ("M2M-100 418M — 100 langs, any→any", "m2m100", "auto", "~800 MB",91 "Facebook M2M-100. 100 language pairs. Best general-purpose NMT."),92 ("WMT21 Dense — en↔X, 14 high-resource", "m2m100-wmt21", "auto", "~800 MB",93 "WMT21 competition model. Higher quality for en↔de, en↔zh, etc."),94 ("MADLAD-400 — 419 langs (CC-BY-SA)", "madlad", "auto", "~500 MB",95 "Google T5-based. Widest language coverage. Output inherits CC-BY-SA."),96]97 98CRISPASR_CLI_BIN = shutil.which("crispasr-cli") or shutil.which("crispasr") or "crispasr-cli"99 100 101def log(msg: str) -> None:102 print(103 f"[{time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}] hf-space-app: {msg}",104 flush=True,105 )106 107 108def _request(method: str, path: str, **kwargs):109 if API_KEY:110 h = dict(kwargs.pop("headers", {}) or {})111 h.setdefault("Authorization", f"Bearer {API_KEY}")112 kwargs["headers"] = h113 return requests.request(method, f"{SERVER_URL}{path}", timeout=900, **kwargs)114 115 116def _spec_by_label(table: Iterable[tuple], label: str):117 for entry in table:118 if entry[0] == label:119 return entry120 return None121 122 123def fetch_status() -> tuple[str, str, str, str]:124 try:125 h = _request("GET", "/health")126 m = _request("GET", "/v1/models")127 b = _request("GET", "/backends")128 except Exception as exc:129 return "starting", f"{type(exc).__name__}: {exc}", "", ""130 if h.status_code == 503:131 return "loading", "model loading", "", ""132 if not h.ok:133 return "error", f"/health -> {h.status_code}", "", ""134 h_json = h.json() if h.ok else {}135 m_json = m.json() if m.ok else {}136 b_json = b.json() if b.ok else {}137 backend = h_json.get("backend", "")138 model_ids = ", ".join(d.get("id", "") for d in m_json.get("data", []))139 backends = ", ".join(b_json.get("backends", []))140 info = f"backend: {backend or '(none)'}\nmodel: {model_ids or '(none)'}"141 return "ready", info, model_ids, backends142 143 144def wait_for_server() -> tuple[str, str]:145 log("wait_for_server: start")146 for i in range(600):147 st, info, _, backends = fetch_status()148 if st == "ready":149 log(f"wait_for_server: ready after {i + 1} probe(s)")150 return f"{st}\n{info}", backends151 time.sleep(1)152 log("wait_for_server: timed out")153 return "timeout — server did not become ready", ""154 155 156def _load_via_endpoint(backend: str, model: str, language: str) -> None:157 log(f"load: backend={backend} model={model} language={language}")158 r = _request(159 "POST",160 "/load",161 files={162 "backend": (None, backend),163 "model": (None, model),164 "language": (None, language),165 },166 )167 if r.status_code >= 400:168 log(f"load: error status={r.status_code} body={r.text[:300]}")169 raise gr.Error(f"/load returned {r.status_code}: {r.text[:300]}")170 171 172def load_asr(choice: str):173 spec = _spec_by_label(ASR_MODELS, choice)174 if spec is None:175 raise gr.Error(f"Unknown ASR choice: {choice}")176 _, backend, model, lang, _, _ = spec177 _load_via_endpoint(backend, model, lang)178 _, info, _, backends = fetch_status()179 return f"ready\n{info}", backends, lang180 181 182def load_tts(choice: str):183 spec = _spec_by_label(TTS_MODELS, choice)184 if spec is None:185 raise gr.Error(f"Unknown TTS choice: {choice}")186 _, backend, model, lang, _, _ = spec187 _load_via_endpoint(backend, model, lang)188 _, info, _, backends = fetch_status()189 return f"ready\n{info}", backends190 191 192def transcribe(audio_path, language, prompt, temperature, response_format):193 if not audio_path:194 raise gr.Error("Upload, record, or pick a sample first.")195 fp = Path(audio_path)196 if not fp.exists():197 raise gr.Error("Audio file is no longer available.")198 data = {199 "model": "loaded-model",200 "response_format": response_format,201 "temperature": f"{float(temperature):.2f}",202 }203 if language and language != "auto":204 data["language"] = language205 if prompt:206 data["prompt"] = prompt207 log(208 f"transcribe: file={fp.name} language={language or 'default'} "209 f"format={response_format} temp={float(temperature):.2f}"210 )211 with fp.open("rb") as f:212 r = _request(213 "POST",214 "/v1/audio/transcriptions",215 files={"file": (fp.name, f, "application/octet-stream")},216 data=data,217 )218 if r.status_code >= 400:219 raise gr.Error(f"{r.status_code}: {r.text[:400]}")220 ct = r.headers.get("content-type", "")221 if response_format == "verbose_json" or "application/json" in ct:222 payload = r.json()223 text = payload.get("text", "") if isinstance(payload, dict) else ""224 return text, json.dumps(payload, indent=2, ensure_ascii=False)225 body = r.text.strip()226 return body, body227 228 229def synthesize(text, voice, speed):230 text = (text or "").strip()231 if not text:232 raise gr.Error("Type some text first.")233 payload = {234 "input": text,235 "speed": float(speed),236 "response_format": "wav",237 }238 voice = (voice or "").strip()239 if voice:240 payload["voice"] = voice241 log(f"synthesize: chars={len(text)} voice={voice or '(default)'} speed={speed:.2f}")242 r = _request(243 "POST",244 "/v1/audio/speech",245 headers={"Content-Type": "application/json"},246 data=json.dumps(payload),247 )248 if r.status_code >= 400:249 try:250 err = r.json().get("error", {})251 msg = err.get("message") or r.text[:400]252 except Exception:253 msg = r.text[:400]254 if r.status_code == 400 and "CAP_TTS" in (r.text or ""):255 msg += " — Load a TTS backend first (Kokoro) on the Speak tab."256 raise gr.Error(f"{r.status_code}: {msg}")257 CACHE_DIR.mkdir(parents=True, exist_ok=True)258 out = CACHE_DIR / f"tts_{int(time.time() * 1000)}.wav"259 out.write_bytes(r.content)260 return str(out)261 262 263def list_voices() -> str:264 try:265 r = _request("GET", "/v1/voices")266 except Exception as exc:267 return f"({type(exc).__name__}: {exc})"268 if not r.ok:269 return f"(server returned {r.status_code})"270 try:271 voices = r.json().get("voices", [])272 except Exception:273 return f"(invalid JSON: {r.text[:200]})"274 if not voices:275 return ("(no extra voice files in --voice-dir; built-in voices still work — "276 "Kokoro default is `af_heart`)")277 lines = []278 for v in voices:279 if isinstance(v, dict):280 lines.append(f"{v.get('name', '?')}\t{v.get('format', '')}")281 else:282 lines.append(str(v))283 return "\n".join(lines)284 285 286def detect_text_language(text, model_choice, top_k):287 text = (text or "").strip()288 if not text:289 raise gr.Error("Paste some text first.")290 spec = _spec_by_label(LID_MODELS, model_choice) or LID_MODELS[0]291 _, model_arg, _ = spec292 cmd = [CRISPASR_LID_BIN, "-m", model_arg, "--text", text, "-k", str(int(top_k)), "--quiet"]293 log(f"lid: cmd={cmd[:5]} k={top_k}")294 env = {**os.environ, "CRISPASR_CACHE_DIR": str(CACHE_DIR)}295 try:296 proc = subprocess.run(297 cmd,298 capture_output=True,299 text=True,300 timeout=600,301 env=env,302 )303 except FileNotFoundError:304 raise gr.Error(f"crispasr-lid not found at '{CRISPASR_LID_BIN}'.")305 if proc.returncode != 0:306 raise gr.Error(307 f"crispasr-lid exited {proc.returncode}: {(proc.stderr or '').strip()[:400]}"308 )309 rows = []310 for line in proc.stdout.splitlines():311 line = line.strip()312 if not line or line.startswith("#"):313 continue314 parts = line.split()315 if len(parts) < 2:316 continue317 label = parts[0]318 try:319 score = float(parts[1])320 except ValueError:321 continue322 rows.append([label, round(score, 6)])323 if not rows:324 rows = [["(no prediction)", 0.0]]325 return rows, (proc.stdout or "") + ("\n--- stderr ---\n" + proc.stderr if proc.stderr else "")326 327 328def translate_text(text, model_choice, src_lang, tgt_lang):329 text = (text or "").strip()330 if not text:331 raise gr.Error("Enter some text to translate.")332 spec = _spec_by_label(NMT_MODELS, model_choice) or NMT_MODELS[0]333 _, backend, model_arg, _, _ = spec334 src = (src_lang or "en").strip()335 tgt = (tgt_lang or "de").strip()336 cmd = [337 CRISPASR_CLI_BIN,338 "--backend", backend,339 "-m", model_arg,340 "--auto-download",341 "--cache-dir", str(CACHE_DIR),342 "--text", text,343 "-sl", src,344 "-tl", tgt,345 ]346 log(f"translate: backend={backend} {src}→{tgt} chars={len(text)}")347 env = {**os.environ, "CRISPASR_CACHE_DIR": str(CACHE_DIR)}348 try:349 proc = subprocess.run(350 cmd,351 capture_output=True,352 text=True,353 timeout=300,354 env=env,355 )356 except FileNotFoundError:357 raise gr.Error(f"crispasr not found at '{CRISPASR_CLI_BIN}'.")358 if proc.returncode != 0:359 raise gr.Error(360 f"Translation failed (exit {proc.returncode}): {(proc.stderr or '').strip()[:400]}"361 )362 result = proc.stdout.strip()363 if not result:364 raise gr.Error("Translation returned empty output.")365 return result366 367 368def select_nmt(choice):369 spec = _spec_by_label(NMT_MODELS, choice) or NMT_MODELS[0]370 return f"{spec[4]}\n\nApprox. download: {spec[3]}"371 372 373def list_sample_files() -> list[str]:374 if not SAMPLES_DIR.exists():375 return []376 out = []377 for p in sorted(SAMPLES_DIR.iterdir()):378 if p.suffix.lower() in {".wav", ".mp3", ".flac", ".ogg", ".m4a", ".aac", ".opus", ".webm", ".wma"}:379 out.append(str(p))380 return out381 382 383def select_asr(choice):384 spec = _spec_by_label(ASR_MODELS, choice) or ASR_MODELS[0]385 return f"{spec[5]}\n\nApprox. download: {spec[4]}", spec[3]386 387 388def select_tts(choice):389 spec = _spec_by_label(TTS_MODELS, choice) or TTS_MODELS[0]390 return f"{spec[5]}\n\nApprox. download: {spec[4]}"391 392 393def select_lid(choice):394 spec = _spec_by_label(LID_MODELS, choice) or LID_MODELS[0]395 return spec[2]396 397 398def refresh_status():399 st, info, _, backends = fetch_status()400 return f"{st}\n{info}", backends401 402 403def use_sample(path):404 return path405 406 407CAPABILITY_TABLE_MD = """### Free-tier ASR backends in this Space408 409| Backend | Native ts | LID | Speech translation | Best for |410|---|:-:|:-:|:-:|---|411| `whisper` | ✔ | ✔ | ✔ | All-rounder, multilingual translation |412| `parakeet` | ✔ | ✔ | | 25 EU langs + free word timestamps |413| `moonshine` | | | | Smallest English model (~37 MB) |414| `moonshine-de` | | | | German fine-tune (CC-BY-NC-SA) |415| `wav2vec2` | | | | Lightweight CTC (no punctuation) |416| `parakeet-ctc-0.6b` | | | | Fast English CTC |417| `cohere` | ✔ | LID | | Lowest English WER |418| `qwen3` | | ✔ | ✔ | 30 langs + 22 Chinese dialects |419| `canary` | ✔ | ✔ | ✔ | Multilingual + translation |420| `hubert` | | | | Self-supervised English CTC |421| `data2vec` | | | | Meta English CTC |422 423### TTS424* `kokoro` — 82M StyleTTS2, multilingual (9 langs). Apache-2.0. Only TTS realistic on free-tier CPU.425* `vibevoice` — 0.5B encoder-decoder. EN/DE/ZH. Apache-2.0. ~200 MB.426* `orpheus` — 0.5B SNAC + LLM. Expressive EN with emotion tags. ~400 MB.427* `chatterbox` / `chatterbox-turbo` — Zero-shot voice cloning from a reference clip. Apache-2.0.428 429### Why not the big speech-LLMs?430Voxtral (2.5 GB), MiMo-V2.5-ASR (4.5 GB), Granite-4.1 (3 GB), Qwen3-TTS (1.5 GB),431IndexTTS (2 GB), CosyVoice3 (1.5 GB), and VoxCPM2-TTS (2 GB) all run in CrispASR432but exceed the free-tier 16 GB ceiling. Run them locally:433 434```bash435docker build -f hf-space/Dockerfile -t crispasr-hf-space .436docker run --rm -p 7860:7860 -p 8080:8080 \\437 -e CRISPASR_BACKEND=voxtral -e CRISPASR_AUTO_DOWNLOAD=1 \\438 crispasr-hf-space439```440 441The full backend list, GPU options, and language bindings are documented in the [CrispASR README](https://github.com/CrispStrobe/CrispASR) and the live feature matrix at [`docs/feature-matrix.md`](https://github.com/CrispStrobe/CrispASR/blob/main/docs/feature-matrix.md).442"""443 444 445with gr.Blocks(title=SPACE_TITLE, theme=gr.themes.Soft()) as demo:446 gr.Markdown(447 f"""# {SPACE_TITLE}448 449CPU-only demo of [CrispASR](https://github.com/CrispStrobe/CrispASR) — one C++ binary, 24+ ASR backends and 8 TTS engines, no Python at inference time.450 451Each tab loads its own backend through the server's `/load` endpoint; the server holds **one** model in memory, so switching tabs may trigger a model hot-swap (download + load on first use, instant thereafter).452 453* Cache: `{CACHE_DIR}` · Server: `{SERVER_URL}` · Samples: `{SAMPLES_DIR}`454"""455 )456 457 with gr.Row():458 status_box = gr.Textbox(label="Server status / loaded model", interactive=False, lines=3, scale=3)459 backends_box = gr.Textbox(label="Available backends", interactive=False, lines=3, scale=2)460 refresh_btn = gr.Button("Refresh status", size="sm")461 462 with gr.Tabs():463 # --- Transcribe ------------------------------------------------464 with gr.Tab("Transcribe (ASR)"):465 gr.Markdown(466 "Speech → text. Pick a model, click **Load**, then upload, record, or pick a sample. "467 "The OpenAI-compatible `/v1/audio/transcriptions` endpoint is what carries the request."468 )469 with gr.Row():470 asr_choice = gr.Dropdown(471 [e[0] for e in ASR_MODELS], value=ASR_MODELS[0][0], label="ASR backend / model", scale=3472 )473 asr_load_btn = gr.Button("Load model", variant="primary", scale=1)474 asr_info = gr.Textbox(475 value=f"{ASR_MODELS[0][5]}\n\nApprox. download: {ASR_MODELS[0][4]}",476 label="Notes",477 interactive=False,478 lines=2,479 )480 481 with gr.Row():482 with gr.Column():483 audio = gr.Audio(label="Audio", type="filepath", sources=["upload", "microphone"])484 sample_picker = gr.Dropdown(485 choices=list_sample_files(),486 label="…or load a bundled sample",487 value=None,488 )489 with gr.Column():490 language = gr.Textbox(491 value=DEFAULT_LANGUAGE,492 label="Language",493 placeholder="auto / en / de / fr / es / zh …",494 )495 response_format = gr.Dropdown(496 ["verbose_json", "text", "srt", "vtt"],497 value="verbose_json",498 label="Response format",499 )500 temperature = gr.Slider(0.0, 1.0, value=0.0, step=0.1, label="Temperature")501 prompt = gr.Textbox(label="Prompt", placeholder="Optional context / initial prompt")502 503 asr_submit = gr.Button("Transcribe", variant="primary")504 transcript = gr.Textbox(label="Transcript", lines=8)505 asr_raw = gr.Code(label="Raw server response", language="json")506 507 # --- TTS -------------------------------------------------------508 with gr.Tab("Speak (TTS)"):509 gr.Markdown(510 "Text → speech via the OpenAI-compatible `/v1/audio/speech` endpoint. "511 "Load Kokoro before synthesizing — Kokoro is the only TTS engine that fits comfortably on free-tier CPU."512 )513 # EU AI Act Art. 50 disclosure. Art. 50(2) marking is discharged by the514 # binary (every /v1/audio/speech response is watermarked and carries a515 # C2PA manifest), but as the DEPLOYER of this Space we owe the516 # human-readable disclosure — Art. 50(4) is explicit that a517 # machine-readable mark alone does not satisfy it. Stated here rather518 # than only in the README because it has to be visible at first519 # exposure, on the surface that produces the audio.520 gr.Markdown(521 "> ⚠️ **Audio produced here is AI-generated.** Every clip is watermarked and carries "522 "[C2PA Content Credentials](https://c2pa.org/) identifying it as synthetic — the marking survives "523 "download. If you republish it, say that it is AI-generated "524 "([EU AI Act Art. 50](https://github.com/CrispStrobe/CrispASR/blob/main/docs/eu-ai-act.md)). "525 "Voice cloning is not enabled in this Space."526 )527 with gr.Row():528 tts_choice = gr.Dropdown(529 [e[0] for e in TTS_MODELS], value=TTS_MODELS[0][0], label="TTS backend / model", scale=3530 )531 tts_load_btn = gr.Button("Load model", variant="primary", scale=1)532 tts_info = gr.Textbox(533 value=f"{TTS_MODELS[0][5]}\n\nApprox. download: {TTS_MODELS[0][4]}",534 label="Notes",535 interactive=False,536 lines=2,537 )538 539 with gr.Row():540 with gr.Column():541 tts_text = gr.Textbox(542 value=(543 "Hello world. CrispASR is one binary, twenty-four ASR backends, "544 "and eight TTS engines — running offline on this Space."545 ),546 label="Text to synthesize",547 lines=4,548 )549 tts_voice = gr.Textbox(550 label="Voice",551 value="af_heart",552 placeholder="Kokoro voices: af_heart, af_bella, am_michael, df_victoria (DE) …",553 )554 with gr.Column():555 tts_speed = gr.Slider(0.5, 2.0, value=1.0, step=0.05, label="Speed")556 tts_voices_btn = gr.Button("List server-side voices (GET /v1/voices)")557 tts_voices_list = gr.Textbox(label="/v1/voices", interactive=False, lines=4)558 559 tts_submit = gr.Button("Synthesize", variant="primary")560 tts_audio = gr.Audio(label="Output audio", interactive=False, type="filepath")561 562 # --- Text LID --------------------------------------------------563 with gr.Tab("Detect language (text)"):564 gr.Markdown(565 "Identify the language of pasted text via the standalone `crispasr-lid` binary "566 "(routes between CLD3, GlotLID-V3, and LID-176 by GGUF architecture)."567 )568 with gr.Row():569 lid_choice = gr.Dropdown(570 [e[0] for e in LID_MODELS], value=LID_MODELS[0][0], label="LID model"571 )572 lid_topk = gr.Slider(1, 10, value=3, step=1, label="Top-K")573 lid_info = gr.Textbox(value=LID_MODELS[0][2], label="Notes", interactive=False, lines=1)574 lid_text = gr.Textbox(575 label="Text",576 value="Bonjour le monde, comment ça va aujourd'hui ?",577 lines=4,578 )579 lid_run = gr.Button("Detect", variant="primary")580 lid_table = gr.Dataframe(581 headers=["language", "confidence"],582 label="Top-K predictions",583 interactive=False,584 )585 lid_raw = gr.Code(label="Raw output")586 587 # --- Text translation ------------------------------------------588 with gr.Tab("Translate text (NMT)"):589 gr.Markdown(590 "Text → text translation via M2M-100, WMT21, or MADLAD-400 NMT backends. "591 "Uses the `crispasr` CLI's `--text` mode — the model downloads on first use."592 )593 with gr.Row():594 nmt_choice = gr.Dropdown(595 [e[0] for e in NMT_MODELS], value=NMT_MODELS[0][0], label="NMT model", scale=3596 )597 nmt_info = gr.Textbox(598 value=f"{NMT_MODELS[0][4]}\n\nApprox. download: {NMT_MODELS[0][3]}",599 label="Notes",600 interactive=False,601 lines=2,602 )603 with gr.Row():604 nmt_src_lang = gr.Textbox(value="en", label="Source language", placeholder="en / de / fr / zh …")605 nmt_tgt_lang = gr.Textbox(value="de", label="Target language", placeholder="de / en / fr / zh …")606 nmt_input = gr.Textbox(607 label="Source text",608 value="The quick brown fox jumps over the lazy dog.",609 lines=4,610 )611 nmt_submit = gr.Button("Translate", variant="primary")612 nmt_output = gr.Textbox(label="Translation", lines=4)613 614 # --- Backends info --------------------------------------------615 with gr.Tab("About & backends"):616 gr.Markdown(CAPABILITY_TABLE_MD)617 618 # --- Wiring -------------------------------------------------------619 refresh_btn.click(refresh_status, outputs=[status_box, backends_box])620 621 asr_choice.change(select_asr, inputs=[asr_choice], outputs=[asr_info, language])622 asr_load_btn.click(load_asr, inputs=[asr_choice], outputs=[status_box, backends_box, language])623 624 tts_choice.change(select_tts, inputs=[tts_choice], outputs=[tts_info])625 tts_load_btn.click(load_tts, inputs=[tts_choice], outputs=[status_box, backends_box])626 nmt_choice.change(select_nmt, inputs=[nmt_choice], outputs=[nmt_info])627 tts_voices_btn.click(list_voices, outputs=[tts_voices_list])628 629 lid_choice.change(select_lid, inputs=[lid_choice], outputs=[lid_info])630 631 sample_picker.change(use_sample, inputs=[sample_picker], outputs=[audio])632 633 asr_submit.click(634 transcribe,635 inputs=[audio, language, prompt, temperature, response_format],636 outputs=[transcript, asr_raw],637 )638 tts_submit.click(synthesize, inputs=[tts_text, tts_voice, tts_speed], outputs=[tts_audio])639 lid_run.click(detect_text_language, inputs=[lid_text, lid_choice, lid_topk], outputs=[lid_table, lid_raw])640 nmt_submit.click(641 translate_text,642 inputs=[nmt_input, nmt_choice, nmt_src_lang, nmt_tgt_lang],643 outputs=[nmt_output],644 )645 646 demo.load(wait_for_server, outputs=[status_box, backends_box])647 648 649# ── OpenAI-compatible REST proxy in front of Gradio ──────────────────650# HF Spaces only route the public port (7860) to the Gradio app, so the651# CrispASR HTTP server's OpenAI-compatible API on :8080 was unreachable652# from outside the container — `/v1/*`, `/health`, `/backends`, `/load`653# all 404'd publicly. Mount a thin reverse proxy so those endpoints are654# served on the public URL (with CORS), for HTTP API consumers like the655# CrisperWeaver web/PWA app. The Gradio UI stays mounted at "/".656 657_PROXY_TIMEOUT = httpx.Timeout(900.0, connect=15.0)658_HOP_BY_HOP = {659 "connection", "keep-alive", "proxy-authenticate", "proxy-authorization",660 "te", "trailers", "transfer-encoding", "upgrade", "content-length",661 "host", "content-encoding",662}663 664 665def _cors(headers: dict) -> dict:666 headers["Access-Control-Allow-Origin"] = "*"667 headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS"668 headers["Access-Control-Allow-Headers"] = "*"669 headers["Access-Control-Max-Age"] = "86400"670 return headers671 672 673def _build_app():674 """FastAPI reverse proxy for the CrispASR server, with Gradio at '/'."""675 api = FastAPI(title="CrispASR API proxy", docs_url=None, redoc_url=None)676 client = httpx.AsyncClient(timeout=_PROXY_TIMEOUT)677 678 async def _forward(request: Request, path: str) -> Response:679 # Preflight: answer here so the browser never reaches the backend.680 if request.method == "OPTIONS":681 return Response(status_code=204, headers=_cors({}))682 body = await request.body()683 headers = {684 k: v for k, v in request.headers.items()685 if k.lower() not in _HOP_BY_HOP and k.lower() != "origin"686 }687 if API_KEY and "authorization" not in {k.lower() for k in headers}:688 headers["Authorization"] = f"Bearer {API_KEY}"689 try:690 upstream = await client.request(691 request.method, f"{SERVER_URL}{path}", content=body,692 headers=headers, params=request.query_params,693 )694 except httpx.HTTPError as exc:695 return Response(696 content=json.dumps({"error": f"upstream unavailable: {exc}"}),697 status_code=502, media_type="application/json",698 headers=_cors({}),699 )700 out = _cors({701 k: v for k, v in upstream.headers.items()702 if k.lower() not in _HOP_BY_HOP703 })704 return Response(705 content=upstream.content, status_code=upstream.status_code,706 headers=out, media_type=upstream.headers.get("content-type"),707 )708 709 @api.api_route("/v1/{path:path}",710 methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])711 async def _proxy_v1(path: str, request: Request):712 return await _forward(request, f"/v1/{path}")713 714 @api.api_route("/health", methods=["GET", "OPTIONS"])715 async def _proxy_health(request: Request):716 return await _forward(request, "/health")717 718 @api.api_route("/backends", methods=["GET", "OPTIONS"])719 async def _proxy_backends(request: Request):720 return await _forward(request, "/backends")721 722 @api.api_route("/load", methods=["POST", "OPTIONS"])723 async def _proxy_load(request: Request):724 return await _forward(request, "/load")725 726 # Gradio UI at the root; the explicit API routes above take precedence.727 return gr.mount_gradio_app(api, demo, path="/")728 729 730app = _build_app()731 732 733if __name__ == "__main__":734 log(735 f"launch: server_url={SERVER_URL} samples={SAMPLES_DIR} "736 f"lid_bin={CRISPASR_LID_BIN} cache={CACHE_DIR}"737 )738 uvicorn.run(739 app,740 host=os.environ.get("GRADIO_SERVER_NAME", "0.0.0.0"),741 port=int(os.environ.get("GRADIO_SERVER_PORT", "7860")),742 )743 