CoolFace
Apppublic

cstr/CrispASR

sourceHugging Faceupdated 2mo agoView on Hugging Face
0likes
app.py743 linesDownload Raw Back to root
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}` &nbsp;·&nbsp; Server: `{SERVER_URL}` &nbsp;·&nbsp; 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