"""Gradio demo for Tiny Hinglish Turn Detection. The UI intentionally labels the heuristic fallback. A missing model must never look like a trained result in a hiring submission. """ from __future__ import annotations import os import sys from functools import lru_cache from pathlib import Path from typing import Any try: import spaces except ModuleNotFoundError as exc: if exc.name != "spaces": raise class _LocalSpaces: """No-op compatibility layer for local installs without ZeroGPU.""" @staticmethod def GPU(*, duration: int = 60) -> Any: # noqa: N802 - mirrors spaces.GPU del duration def decorate(function: Any) -> Any: return function return decorate spaces = _LocalSpaces() PROJECT_ROOT = Path(__file__).resolve().parent SRC_ROOT = PROJECT_ROOT / "src" if str(SRC_ROOT) not in sys.path: sys.path.insert(0, str(SRC_ROOT)) from turn_detection.runtime import ControllerConfig, TurnController, TurnState # noqa: E402 from turn_detection.runtime.predictor import load_predictor # noqa: E402 DEFAULT_MODEL_CANDIDATES = ( PROJECT_ROOT / "artifacts" / "model.onnx", # Hugging Face Space layout PROJECT_ROOT / "model.onnx", # Hugging Face model-repository layout # Curated GitHub/source-checkout layout. PROJECT_ROOT / "artifacts" / "partial-shard-warmstart-lr3e4-5ep" / "model.onnx", ) def _default_model_path() -> Path: """Resolve packaged model layouts without hiding a genuinely missing model.""" for candidate in DEFAULT_MODEL_CANDIDATES: if candidate.is_file(): return candidate return DEFAULT_MODEL_CANDIDATES[0] @lru_cache(maxsize=1) def get_predictor() -> Any: configured = os.environ.get("TURN_MODEL_PATH") return load_predictor(configured or _default_model_path()) def _predictor_metadata(predictor: Any) -> Any | None: """Return optional exported metadata without coupling the UI to ONNX.""" return getattr(predictor, "metadata", None) def _controller_for_ui( metadata: Any | None, threshold: float, max_silence_ms: float ) -> ControllerConfig: stored = getattr(metadata, "controller", None) if not isinstance(stored, ControllerConfig): stored = ControllerConfig( endpoint_threshold=threshold, long_pause_threshold=max(0.0, threshold - 0.18), ) relaxation_delta = stored.endpoint_threshold - stored.long_pause_threshold return ControllerConfig( endpoint_threshold=threshold, long_pause_threshold=max(0.0, threshold - relaxation_delta), min_silence_ms=stored.min_silence_ms, relax_after_ms=min(stored.relax_after_ms, max_silence_ms), max_silence_ms=max_silence_ms, required_confirmations=stored.required_confirmations, ) def _timeline_html(probability: float, threshold: float, state: TurnState) -> str: probability_width = round(probability * 100, 1) threshold_left = round(threshold * 100, 1) color = "#16a34a" if state is TurnState.END else "#f59e0b" return f"""
HOLD · 0threshold {threshold:.2f}1 · END
""" @spaces.GPU(duration=10) def analyze_turn( audio: tuple[int, Any] | None, threshold: float, silence_ms: float, max_silence_ms: float, ) -> tuple[str, dict[str, float], dict[str, Any], str]: if audio is None: raise ValueError("Record or upload an utterance first") sample_rate, samples = audio predictor = get_predictor() prediction = predictor.predict(samples, int(sample_rate)) metadata = _predictor_metadata(predictor) controller = TurnController( _controller_for_ui(metadata, float(threshold), float(max_silence_ms)) ) decision = controller.evaluate_pause(prediction, float(silence_ms)) is_fallback = prediction.model_name == "heuristic-development-only" is_development = bool(getattr(metadata, "development_only", False)) if is_fallback: warning = ( "\n\n⚠️ **Development fallback active:** exported weights are not present; " "this score is not a trained-model result." ) elif is_development: scope = getattr(metadata, "data_scope", None) or "limited development data" warning = ( "\n\n⚠️ **Development model:** this score comes from an unqualified preview " f"trained on {scope}. It is not evidence of real-world Hinglish accuracy." ) else: warning = "" status = ( f"## {decision.state.value}\n\n" f"Reason: `{decision.reason}` · p(END): **{prediction.endpoint_probability:.3f}**" f"{warning}" ) label = { "END": prediction.endpoint_probability, "HOLD": 1.0 - prediction.endpoint_probability, } diagnostics = { "state": decision.state.value, "emit_response": decision.emit_response, "reason": decision.reason, "model": prediction.model_name, "development_only": is_fallback or is_development, "training_status": getattr(metadata, "training_status", "fallback"), "data_scope": getattr(metadata, "data_scope", None), "data_revision": getattr(metadata, "data_revision", None), "parameter_count": getattr(metadata, "parameter_count", None), "p_end": round(prediction.endpoint_probability, 6), "threshold": round(decision.threshold or threshold, 6), "assumed_silence_ms": silence_ms, "model_inference_ms": round(prediction.inference_ms, 3), "sample_rate_hz": int(sample_rate), "samples": int(len(samples)), } return ( status, label, diagnostics, _timeline_html( prediction.endpoint_probability, decision.threshold or threshold, decision.state, ), ) def build_demo() -> Any: try: import gradio as gr except ImportError as exc: # pragma: no cover - optional dependency raise RuntimeError("Install the demo dependencies: uv sync --extra demo") from exc predictor = get_predictor() metadata = _predictor_metadata(predictor) default_threshold = float(getattr(metadata, "threshold", 0.60)) parameter_count = getattr(metadata, "parameter_count", None) frontend = getattr(metadata, "frontend", None) window_seconds = getattr(frontend, "max_seconds", None) model_summary = ( f"Loaded `{getattr(metadata, 'model_name', 'unknown')}` · " f"{int(parameter_count):,} parameters" + (f" · {float(window_seconds):g} s suffix window" if window_seconds else "") if parameter_count is not None else "No exported model metadata is loaded." ) if metadata is not None and bool(getattr(metadata, "development_only", False)): prediction_notice = getattr(metadata, "data_scope", None) or "limited development data" evidence_notice = ( "> ⚠️ **Development preview.** Data scope: " f"{prediction_notice}. No official-test or collected-Hinglish claim is made." ) elif predictor.__class__.__name__ == "HeuristicDevelopmentPredictor": evidence_notice = ( "> ⚠️ **Heuristic fallback.** Trained weights are absent; outputs are UI-only." ) else: evidence_notice = "" with gr.Blocks(title="Tiny Hinglish Turn Detector") as demo: gr.Markdown( "# Tiny Hinglish Turn Detector\n" "Audio-native **HOLD vs END** decisions at VAD pause checkpoints. " "Try incomplete phrases, fillers, corrections, and complete Shiprocket-style requests.\n\n" f"{evidence_notice}\n\n{model_summary}" ) gr.Markdown( "### What to record\n\n" "Use natural pacing and leave a short pause at the end of each clip. These are prompts, " "not included evaluation examples.\n\n" "| Expected | Example prompt | Why |\n" "|---|---|---|\n" "| HOLD | `mera order number hai... umm...` | filler before missing detail |\n" "| END | `mera order cancel kar do` | complete request |\n" "| HOLD | `haan matlab... kal wala parcel...` | self-repair / continuation |\n" "| END | `haan, kal wala parcel reschedule kar do` | complete after filler |\n" "| HOLD | `address change karna hai, flat number...` | slot still missing |\n" "| END | `address change karke Flat 12B kar do` | slot supplied |" ) with gr.Row(): with gr.Column(scale=3): audio = gr.Audio( sources=["microphone", "upload"], type="numpy", label="Current user turn", ) analyze = gr.Button("Analyze pause checkpoint", variant="primary") with gr.Column(scale=2): threshold = gr.Slider( 0.0, 1.0, value=default_threshold, step=0.01, label="END threshold", ) silence_ms = gr.Slider( 200, 1800, value=300, step=50, label="Silence at checkpoint (ms)", ) max_silence_ms = gr.Slider( 800, 3000, value=1800, step=100, label="Maximum response timeout (ms)", ) status = gr.Markdown("## Waiting for audio") timeline = gr.HTML() with gr.Row(): scores = gr.Label(num_top_classes=2, label="Decision probabilities") diagnostics = gr.JSON(label="Runtime diagnostics") gr.Markdown( "**Interpretation:** HOLD means the agent should keep listening. END means it may respond. " "The production controller also imposes a maximum timeout so uncertain predictions cannot wait forever." ) analyze.click( fn=analyze_turn, inputs=[audio, threshold, silence_ms, max_silence_ms], outputs=[status, scores, diagnostics, timeline], ) return demo if __name__ == "__main__": build_demo().queue(default_concurrency_limit=2).launch()