"""EYEWITNESS — you saw the thief for 3 seconds. Your memory is the only witness. Game loop: case intro -> timed glimpse -> testimony -> sketch reveal -> lineup (built from YOUR errors) -> verdict. Escalating ranks shorten the glimpse and grow the lineup; from INSPECTOR the culprit changes one feature before the lineup ("he's been to a barber since"). UI architecture: a single state-driven @gr.render stage. Column-visibility toggling desyncs in Gradio 6 once demo.load touches it (see FIELD_NOTES.md). """ from __future__ import annotations import base64 import os import random import gradio as gr from pathlib import Path from game.casegen import make_case, Case, RANKS from game.face import render_face_svg, FaceSpec from game.lineup import build_lineup from game.parser import parse_testimony from game.poster import make_wanted_poster from game.scoring import grade_testimony, detective_rating ASSETS = Path(__file__).resolve().parent / "assets" def verdict_voice(correct: bool) -> tuple[int, "np.ndarray"] | None: """Pre-rendered VoxCPM2 line (Modal voice bank) if present — zero live GPU. Returned in-memory as (sample_rate, samples): gr.Audio file paths outside Gradio's allowed dirs raise InvalidPathError and kill the whole render.""" import random as _r import wave import numpy as np kind = "caught" if correct else "escaped" files = sorted(ASSETS.glob(f"voice_{kind}_*.wav")) if ASSETS.exists() else [] if not files: return None with wave.open(str(_r.choice(files))) as w: frames = w.readframes(w.getnframes()) return w.getframerate(), np.frombuffer(frames, dtype=np.int16) try: # Tier B (deployed): MiniCPM5-1B slot-filler. Falls back to Tier A locally. from game.model import parse_testimony_model, model_enabled, culprit_taunt HAS_MODEL = model_enabled() except Exception: HAS_MODEL = False try: # live VoxCPM2 verdict voice (anchored per suspect); bank is the fallback from game.voice import speak as live_speak except Exception: def live_speak(line, seed, culprit=None): return None try: # spoken testimony (Cohere Transcribe 2B, sponsor model) from game.asr import transcribe as asr_transcribe HAS_ASR = True except Exception: HAS_ASR = False def transcribe_testimony(audio, current_text: str, lang_label: str): lang = "es" if (lang_label or "").lower().startswith(("es", "span")) else "en" text = asr_transcribe(audio, lang) merged = (current_text.strip() + " " + text).strip() if (current_text or "").strip() else text out = merged if text else (current_text or "") print(f"[ui] transcribe -> {len(text or '')} chars into textbox", flush=True) return out # ------------------------------------------------------------------ helpers def svg_uri(svg: str) -> str: return "data:image/svg+xml;base64," + base64.b64encode(svg.encode()).decode() from game.render import face_image as face_png # gr.Gallery needs PIL, not data-URIs def glimpse_html(case: Case) -> str: """Timed reveal in pure CSS (gr.HTML strips