Spaces:
Sleeping
Sleeping
Download app.py from samxkevin/SceneSolver: direct link, hf CLI and curl.
- Browser
- Download file 213 kB
-
https://huggingface.co/spaces/samxkevin/SceneSolver/resolve/main/app.py
- Command line
-
hf download hf://spaces/samxkevin/SceneSolver/app.py
-
curl -L -o app.py https://huggingface.co/spaces/samxkevin/SceneSolver/resolve/main/app.py
213 kB
| # -*- coding: utf-8 -*- | |
| """ | |
| app.py β CrimeSceneSolver (Scene Solver v3) β Hugging Face Space | |
| ================================================================ | |
| Faithful port of the notebook's `demo_v2` application: the four-tab | |
| frontend (Home / Analyse / Sherl0ck Agent / Contact), the nine-stage | |
| SceneSolver pipeline, the Sherl0ck agentic escalation layer (Cohere | |
| reasoning + LFM2.5-Audio voice), evidence dispatch, incoming-call | |
| behaviour and the forensic PDF/JSON outputs. | |
| Deployment notes: | |
| * `import spaces` must run before scenesolver_core (ZeroGPU CUDA model). | |
| * GPU-heavy entry points are decorated with @spaces.GPU (dynamic duration). | |
| * All secrets (COHERE_API_KEY, TELEGRAM_*, DISCORD_WEBHOOK_URL, SMTP | |
| credentials) come from environment variables / Space secrets only. | |
| """ | |
| import spaces # noqa: F401 β must precede torch device selection (ZeroGPU) | |
| from scenesolver_core import ( # noqa: E402 | |
| AUDIOPATHS, | |
| Config, | |
| SceneSolverPipeline, | |
| frame_to_timestamp, | |
| get_pipeline, | |
| log, | |
| ) | |
| import gradio as gr | |
| import numpy as np | |
| import json, os, traceback, smtplib, ssl, re, base64 | |
| from email.mime.text import MIMEText | |
| from email.mime.multipart import MIMEMultipart | |
| from PIL import Image | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # SHARED CONSTANTS (used by both the web app and the agent layer) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| ADMIN_EMAIL = os.environ.get("ADMIN_EMAIL", "anumulasamarth008@gmail.com") | |
| SMTP_SERVER = "smtp.gmail.com" | |
| SMTP_PORT = 465 | |
| SENDER_EMAIL = os.environ.get("SENDER_EMAIL", "") | |
| SENDER_PASS = os.environ.get("SENDER_PASS", "") | |
| # Design tokens β pitch black / white / orange-saffron | |
| C_BG = "#000000" | |
| C_ORANGE = "#FF8C00" | |
| C_RED = "#FF4500" | |
| C_GREEN = "#4CAF50" | |
| # Shared HTML blocks | |
| NAV_HTML = """ | |
| <div style="background:#000;border-bottom:1px solid #1a1a1a; | |
| padding:16px 36px;display:flex;justify-content:space-between;align-items:center;"> | |
| <span style="font-family:'Roboto Mono',monospace;font-size:.92rem; | |
| letter-spacing:.15em;color:#fff;text-transform:uppercase; | |
| font-weight:700;-webkit-font-smoothing:antialiased;"> | |
| CrimeSceneSolver | |
| </span> | |
| <span style="font-family:'Roboto Mono',monospace;font-size:.6rem; | |
| letter-spacing:.1em;color:#555;text-transform:uppercase;"> | |
| Scene Solver v3 | |
| </span> | |
| </div> | |
| """ | |
| FOOTER_HTML = """ | |
| <div style="border-top:1px solid #0d0d0d;margin-top:16px;"> | |
| <div style="background:#0d0d0d;border-bottom:1px solid #1a1a1a;padding:14px 32px;"> | |
| <div style="max-width:860px;margin:0 auto;display:flex;align-items:flex-start;gap:12px;"> | |
| <svg style="flex-shrink:0;margin-top:2px;" width="14" height="14" viewBox="0 0 24 24" | |
| fill="none" stroke="#FF8C00" stroke-width="2"> | |
| <circle cx="12" cy="12" r="10"/> | |
| <line x1="12" y1="8" x2="12" y2="12"/> | |
| <line x1="12" y1="16" x2="12.01" y2="16"/> | |
| </svg> | |
| <p style="font-family:'Roboto Mono',monospace;font-size:.62rem; | |
| letter-spacing:.04em;color:#888;line-height:1.7;margin:0;font-weight:500;"> | |
| <span style="color:#FF8C00;font-weight:700;">DISCLAIMER — </span> | |
| This report is produced by machine learning models and should be treated as an | |
| investigative aid, not a definitive conclusion. Human expert review is strongly | |
| recommended before any operational action is taken. | |
| </p> | |
| </div> | |
| </div> | |
| <div style="text-align:center;padding:18px;font-family:'Roboto Mono',monospace; | |
| font-size:.6rem;letter-spacing:.1em;color:#444;text-transform:uppercase;"> | |
| CrimeSceneSolver · Scene Solver v3 · | |
| Confidential · ML outputs require expert review | |
| </div> | |
| </div> | |
| """ | |
| def sec_label(text: str) -> str: | |
| """Shared section-label helper used across the web app and agent layer.""" | |
| return (f"<div style='font-family:\"Roboto Mono\",monospace;font-size:.62rem;" | |
| f"letter-spacing:.14em;text-transform:uppercase;color:#ddd;" | |
| f"margin-bottom:10px;font-weight:800;-webkit-font-smoothing:antialiased;" | |
| f"border-left:3px solid #FF8C00;padding-left:10px;'>{text}</div>") | |
| def risk_color(r: float) -> str: | |
| if r >= 0.70: return C_RED | |
| if r >= 0.40: return C_ORANGE | |
| return C_GREEN | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def send_contact_email(name: str, email: str, phone: str, message: str) -> bool: | |
| if not SENDER_EMAIL or not SENDER_PASS: | |
| with open("/tmp/css_contact.log", "a") as f: | |
| f.write(f"[CONTACT] {name} | {email} | {phone}\n") | |
| return False | |
| try: | |
| msg = MIMEMultipart("alternative") | |
| msg["Subject"] = f"[CrimeSceneSolver] Contact: {name}" | |
| msg["From"] = SENDER_EMAIL | |
| msg["To"] = ADMIN_EMAIL | |
| msg.attach(MIMEText(f"Name:{name}\nEmail:{email}\nPhone:{phone}\nMessage:\n{message}\n", "plain")) | |
| ctx = ssl.create_default_context() | |
| with smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, context=ctx) as srv: | |
| srv.login(SENDER_EMAIL, SENDER_PASS) | |
| srv.sendmail(SENDER_EMAIL, ADMIN_EMAIL, msg.as_string()) | |
| return True | |
| except Exception: | |
| return False | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # PIPELINE WRAPPER | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def run_pipeline(video_path: str, enable_llava: bool = True) -> dict: | |
| pipeline = get_pipeline() | |
| try: | |
| results = pipeline.analyze( | |
| video_path=video_path, base_output_dir="/tmp/css_webapp", | |
| enable_llava=bool(enable_llava), enable_rl=True, enable_tracking=True, | |
| ) | |
| finally: | |
| if enable_llava: | |
| pipeline.release_stage8() | |
| s0 = results.get("stage0") or {} | |
| s1 = results.get("stage1") or {} | |
| s2 = results.get("stage2") or {} | |
| s3 = results.get("stage3") or {} | |
| s4 = results.get("stage4") or {} | |
| s5 = results.get("stage5") or {} | |
| s6 = results.get("stage6") or {} | |
| s7 = results.get("stage7") or {} | |
| s8 = results.get("stage8") or {} | |
| fps = float(s0.get("fps", 25.0)) | |
| def ts(fi): return frame_to_timestamp(int(fi), fps) | |
| risk_score = float(s7.get("final_risk", s6.get("fused_risk", 0.0))) | |
| rl_status = s7.get("status", "") | |
| _rl_map = {"CRITICAL":"CRITICAL","HIGH_RISK":"HIGH","MODERATE":"MODERATE","LOW_RISK":"LOW"} | |
| risk_label = (_rl_map.get(rl_status, "") or | |
| results.get("risk_label_final") or | |
| s6.get("risk_level", "LOW")) | |
| status = results.get("status", "UNKNOWN") | |
| narrative = (s8 or {}).get("unified_narrative") or (s8 or {}).get("description") or "" | |
| report = { | |
| "overall_status": status, | |
| "final_risk_score": f"{risk_score:.1%}", | |
| "risk_level": risk_label, | |
| "dynamic_threshold": f"{results.get('threshold',0):.6f}", | |
| "ae_max_error": f"{s0.get('max_error',0):.6f}", | |
| "ae_avg_error": f"{s0.get('avg_error',0):.6f}", | |
| "peak_timestamp": ts(s0.get("peak_frame_idx",0)), | |
| "anomaly_window": f"{ts(s0.get('anomaly_start_frame',0))} \u2192 {ts(s0.get('anomaly_end_frame',0))}", | |
| "stage1_tf_binary": f"{s1.get('classification','?')} ({s1.get('confidence',0):.1%})", | |
| "stage2_anomaly_type": f"{s2.get('class','N/A')} ({s2.get('confidence',0):.1%})", | |
| "stage2_top3": s2.get("top3", []), | |
| "stage2_all_probs": s2.get("all_probabilities", {}), | |
| "stage3_weapon": ("YES β " + str(s3.get("dominant_class","?")) | |
| if s3.get("weapon_flag") else "None detected"), | |
| "stage4_audio": f"{s4.get('status','N/A')} (score: {s4.get('audio_score',0):.4f})", | |
| "stage5_motion": f"{s5.get('motion_intensity',0):.3f} | {', '.join(s5.get('behaviors',[]) or ['STATIC'])}", | |
| "stage6_fusion_risk": f"{s6.get('fused_risk',0):.1%} [{s6.get('risk_level','?')}]", | |
| "stage6_reasoning": s6.get("reasoning_trace", []), | |
| "stage7_rl_final": f"{s7.get('final_risk',0):.1%} [{rl_status}]", | |
| "stage7_interpretation": s7.get("interpretation",""), | |
| "llava_narrative": narrative, | |
| # raw values for charts | |
| "_stage1_conf": float(s1.get("confidence", 0)), | |
| "_stage2_conf": float(s2.get("confidence", 0)), | |
| "_stage2_top3_raw": s2.get("top3", []), | |
| "_stage2_all_probs": s2.get("all_probabilities", {}), | |
| "_risk_score": risk_score, | |
| "_fused_risk": float(s6.get("fused_risk", 0)), | |
| "_audio_score": float(s4.get("audio_score", 0)), | |
| "_motion": float(s5.get("motion_intensity", 0)), | |
| } | |
| frames_l = s0.get("frames", []) | |
| scores_l = s0.get("scores", []) | |
| samp_idx = s0.get("sampled_indices", []) | |
| top_frame = None | |
| if frames_l and scores_l: | |
| psi = int(s0.get("peak_score_idx", 0)) | |
| pfi = min(samp_idx[psi] if psi < len(samp_idx) else 0, len(frames_l) - 1) | |
| top_frame = frames_l[pfi] | |
| def _serial(o): | |
| if isinstance(o, np.ndarray): return o.tolist() | |
| if isinstance(o, np.integer): return int(o) | |
| if isinstance(o, np.floating): return float(o) | |
| return str(o) | |
| jd = { | |
| "status": status, "risk_score": round(risk_score, 4), "risk_level": risk_label, | |
| "threshold": round(float(results.get("threshold", 0)), 6), | |
| "stage0": {"max_error": round(float(s0.get("max_error",0)),6), | |
| "avg_error": round(float(s0.get("avg_error",0)),6), | |
| "peak_timestamp": ts(s0.get("peak_frame_idx",0)), | |
| "anomaly_start": ts(s0.get("anomaly_start_frame",0)), | |
| "anomaly_end": ts(s0.get("anomaly_end_frame",0))}, | |
| "stage1": {"classification": s1.get("classification","?"), | |
| "confidence": round(float(s1.get("confidence",0)),4)}, | |
| "stage2": {"class": s2.get("class","N/A"), | |
| "confidence": round(float(s2.get("confidence",0)),4), | |
| "top3": s2.get("top3",[])}, | |
| "stage3": {"weapon_flag": bool(s3.get("weapon_flag",False)), | |
| "dominant_class": s3.get("dominant_class","None"), | |
| "weapon_count": int(s3.get("weapon_count",0))}, | |
| "stage4": {"status": s4.get("status","N/A"), | |
| "audio_score": round(float(s4.get("audio_score",0)),4)}, | |
| "stage5": {"motion_intensity": round(float(s5.get("motion_intensity",0)),4), | |
| "behaviors": s5.get("behaviors",[])}, | |
| "stage6": {"fused_risk": round(float(s6.get("fused_risk",0)),4), | |
| "risk_level": s6.get("risk_level","?"), | |
| "reasoning": s6.get("reasoning_trace",[])}, | |
| "stage7": {"final_risk": round(risk_score,4), "status": rl_status, | |
| "interpretation": s7.get("interpretation","")}, | |
| "llava_narrative": narrative, | |
| } | |
| pdf = (results.get("pdf_path_local") or results.get("pdf_path") | |
| or "/tmp/forensic_report_v3.pdf") | |
| if not os.path.exists(pdf): pdf = None | |
| graph_path = results.get("graph_path", "/tmp/deviation_graph_v3.png") | |
| if not os.path.exists(graph_path): graph_path = None | |
| return {"risk_score": risk_score, "risk_label": risk_label, "status": status, | |
| "report": report, "top_frame": top_frame, "json_data": jd, | |
| "pdf_path": pdf, "graph_path": graph_path} | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # HTML HELPERS | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def img_to_data_uri(path: str) -> str: | |
| """Encode an image file as a base64 data-URI for inline HTML embedding.""" | |
| if not path or not os.path.exists(path): | |
| return "" | |
| ext = os.path.splitext(path)[1].lower().lstrip(".") | |
| mime = {"png":"image/png","jpg":"image/jpeg","jpeg":"image/jpeg"}.get(ext,"image/png") | |
| with open(path, "rb") as f: | |
| encoded = base64.b64encode(f.read()).decode("utf-8") | |
| return f"data:{mime};base64,{encoded}" | |
| def spinner_html() -> str: | |
| return """ | |
| <div style="text-align:center;padding:18px 0;"> | |
| <div style="display:inline-block;width:40px;height:40px; | |
| border:3px solid rgba(255,255,255,0.07);border-top-color:#FF8C00; | |
| border-right-color:rgba(255,140,0,0.28);border-radius:50%; | |
| animation:arc-spin .75s cubic-bezier(0.6,0.2,0.4,0.8) infinite;"></div> | |
| <div style="font-family:'Roboto Mono',monospace;font-size:.64rem; | |
| letter-spacing:.12em;color:#FF8C00;margin-top:11px;text-transform:uppercase;font-weight:600;"> | |
| Processing… this may take several minutes | |
| </div> | |
| </div>""" | |
| def no_result_html() -> str: | |
| return ("<div style='text-align:center;padding:60px 20px;color:#333;" | |
| "font-family:Charter,serif;font-size:1rem;letter-spacing:.04em;'>" | |
| "Upload footage and press ANALYSE to begin.</div>") | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # REPORT HTML BUILDER | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def build_report_html(report: dict, risk_score: float, risk_label: str, | |
| status: str, graph_path: str = None) -> str: | |
| col = risk_color(risk_score) | |
| def numfmt(v: str) -> str: | |
| return re.sub(r'([\d]+\.?[\d]*%?)', | |
| r"<span style='font-family:\"Times New Roman\",Times,serif;'>\1</span>", v) | |
| def row(label: str, value: str, mono: bool = False) -> str: | |
| vd = numfmt(str(value)) | |
| vfont = "font-family:'Roboto Mono',monospace;font-size:.8rem;" if mono else "font-size:.82rem;" | |
| return (f"<tr>" | |
| f"<td style='padding:9px 14px;color:#aaa;font-size:.78rem;" | |
| f"border-bottom:1px solid #222;white-space:nowrap;width:38%;'>{label}</td>" | |
| f"<td style='padding:9px 14px;color:#eee;{vfont}border-bottom:1px solid #222;'>{vd}</td>" | |
| f"</tr>") | |
| def section_hdr(title: str) -> str: | |
| return (f"<div style='font-size:.65rem;letter-spacing:.16em;text-transform:uppercase;" | |
| f"color:#FF8C00;padding:14px 0 6px 2px;border-bottom:2px solid #FF8C00;" | |
| f"margin-bottom:0;font-family:\"Roboto Mono\",monospace;font-weight:800;'>{title}</div>") | |
| def section(title: str, rows_html: str, extra: str = "") -> str: | |
| return (f"<div style='margin-bottom:26px;'>" | |
| + section_hdr(title) | |
| + f"<table style='width:100%;border-collapse:collapse;'>{rows_html}</table>" | |
| + extra + "</div>") | |
| # Risk badge | |
| is_crit = (status == "CRITICAL") | |
| badge_text = f"{'ANOMALY DETECTED' if is_crit else 'NO ANOMALY DETECTED'} ({status})" | |
| badge = (f"<div style='text-align:center;padding:28px 20px;margin-bottom:28px;" | |
| f"background:{col};border-radius:4px;'>" | |
| f"<div style='font-size:3.8rem;font-weight:700;color:#fff;" | |
| f"font-family:\"Times New Roman\",Times,serif;line-height:1;'>" | |
| f"{risk_score:.1%}</div>" | |
| f"<div style='color:rgba(255,255,255,.85);font-size:.75rem;margin-top:10px;" | |
| f"letter-spacing:.14em;text-transform:uppercase;font-family:\"Roboto Mono\",monospace;" | |
| f"font-weight:600;'>{badge_text} | {risk_label} RISK</div>" | |
| f"</div>") | |
| # Raw chart values | |
| stage1_conf = float(report.get("_stage1_conf", 0)) | |
| stage2_conf = float(report.get("_stage2_conf", 0)) | |
| top3_raw = report.get("_stage2_top3_raw", []) or [] | |
| all_probs = report.get("_stage2_all_probs", {}) or {} | |
| fused_risk = float(report.get("_fused_risk", risk_score)) | |
| audio_score = float(report.get("_audio_score", 0)) | |
| motion = float(report.get("_motion", 0)) | |
| PIE_COLORS = ["#c0392b","#2471a3","#d35400","#6c3483","#117a65","#1a5276","#7d6608"] | |
| # ββ Donut pie (SVG) ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def donut_pie_svg(probs: dict) -> str: | |
| if not probs: return "" | |
| import math | |
| items = sorted(probs.items(), key=lambda x: x[1], reverse=True) | |
| labels = [i[0] for i in items] | |
| sizes = [max(float(i[1]), 1e-6) for i in items] | |
| total = sum(sizes) | |
| cx = cy = R = 90; R = 70; r = 36 | |
| wedges = "" | |
| start = -90 | |
| for idx, (label, val) in enumerate(zip(labels, sizes)): | |
| sweep = (val / total) * 360 | |
| end = start + sweep | |
| x1o, y1o = cx + R * math.cos(math.radians(start)), cy + R * math.sin(math.radians(start)) | |
| x2o, y2o = cx + R * math.cos(math.radians(end)), cy + R * math.sin(math.radians(end)) | |
| x1i, y1i = cx + r * math.cos(math.radians(end)), cy + r * math.sin(math.radians(end)) | |
| x2i, y2i = cx + r * math.cos(math.radians(start)), cy + r * math.sin(math.radians(start)) | |
| lg = 1 if sweep > 180 else 0 | |
| fill = PIE_COLORS[idx % len(PIE_COLORS)] | |
| mid_a = start + sweep / 2 | |
| lx = cx + (R + r) / 2 * math.cos(math.radians(mid_a)) | |
| ly = cy + (R + r) / 2 * math.sin(math.radians(mid_a)) | |
| num_lbl = (f"<text x='{lx:.1f}' y='{ly:.1f}' text-anchor='middle' " | |
| f"dominant-baseline='middle' fill='#fff' font-size='9' font-weight='bold'>{idx+1}</text>" | |
| if sweep > 15 else "") | |
| wedges += (f"<path d='M {x1o:.2f} {y1o:.2f} A {R} {R} 0 {lg} 1 {x2o:.2f} {y2o:.2f} " | |
| f"L {x1i:.2f} {y1i:.2f} A {r} {r} 0 {lg} 0 {x2i:.2f} {y2i:.2f} Z' " | |
| f"fill='{fill}' stroke='#000' stroke-width='1'/>{num_lbl}") | |
| start = end | |
| legend = "".join( | |
| f"<div style='display:flex;align-items:center;gap:7px;margin-bottom:5px;'>" | |
| f"<div style='width:11px;height:11px;min-width:11px;background:{PIE_COLORS[i%len(PIE_COLORS)]};border-radius:2px;'></div>" | |
| f"<span style='font-family:\"Roboto Mono\",monospace;font-size:.62rem;color:#ccc;'>" | |
| f"{i+1}. {cls} <span style='font-family:\"Times New Roman\",Times,serif;color:#fff;font-weight:700;'>" | |
| f"({v/total*100:.1f}%)</span></span></div>" | |
| for i, (cls, v) in enumerate(zip(labels, sizes)) | |
| ) | |
| svg = (f"<svg width='180' height='180' viewBox='0 0 180 180'>{wedges}" | |
| f"<circle cx='{cx}' cy='{cy}' r='{r-2}' fill='#050505'/>" | |
| f"<text x='{cx}' y='{cy}' text-anchor='middle' dominant-baseline='middle' " | |
| f"fill='#666' font-size='8'>ANOMALY</text>" | |
| f"<text x='{cx}' y='{cy+11}' text-anchor='middle' dominant-baseline='middle' " | |
| f"fill='#666' font-size='8'>CLASSES</text></svg>") | |
| return (f"<div style='display:flex;gap:16px;align-items:flex-start;padding:16px;" | |
| f"background:#050505;border:1px solid #1e1e1e;border-radius:3px;margin-top:6px;'>" | |
| f"<div style='flex-shrink:0;'>{svg}</div><div style='flex:1;'>{legend}</div></div>") | |
| # ββ Horizontal bar chart ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def conf_bar_chart(top3: list) -> str: | |
| if not top3: return "" | |
| BAR_COLORS = ["#c0392b","#d35400","#5d6d7e"] | |
| rows = "".join( | |
| f"<div style='margin-bottom:10px;'>" | |
| f"<div style='display:flex;justify-content:space-between;align-items:center;margin-bottom:4px;'>" | |
| f"<span style='font-family:\"Roboto Mono\",monospace;font-size:.68rem;color:#ccc;'>{cls}</span>" | |
| f"<span style='font-family:\"Times New Roman\",Times,serif;color:#fff;font-weight:700;font-size:.82rem;margin-left:8px;'>{prob*100:.1f}%</span>" | |
| f"</div><div style='background:#1a1a1a;border-radius:2px;height:10px;overflow:hidden;'>" | |
| f"<div style='background:{BAR_COLORS[idx]};height:10px;width:{prob*100:.1f}%;border-radius:2px;'></div>" | |
| f"</div></div>" | |
| for idx, (cls, prob) in enumerate(top3[:3]) | |
| ) | |
| return (f"<div style='padding:14px 16px;background:#050505;border:1px solid #1e1e1e;" | |
| f"border-radius:3px;margin-top:6px;'>" | |
| f"<div style='font-family:\"Roboto Mono\",monospace;font-size:.6rem;" | |
| f"letter-spacing:.14em;color:#FF8C00;text-transform:uppercase;margin-bottom:12px;'>" | |
| f"Top-3 Anomaly Predictions</div>{rows}</div>") | |
| # ββ Signal gauge bars βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def risk_gauge(val: float, label: str, color: str) -> str: | |
| pct = min(100, int(val * 100)) | |
| return (f"<div style='margin-bottom:10px;'>" | |
| f"<div style='display:flex;justify-content:space-between;align-items:center;margin-bottom:3px;'>" | |
| f"<span style='font-family:\"Roboto Mono\",monospace;font-size:.65rem;color:#aaa;'>{label}</span>" | |
| f"<span style='font-family:\"Times New Roman\",Times,serif;color:#fff;font-weight:700;font-size:.8rem;'>{val:.1%}</span>" | |
| f"</div><div style='background:#1a1a1a;border-radius:2px;height:8px;overflow:hidden;'>" | |
| f"<div style='background:{color};height:8px;width:{pct}%;border-radius:2px;'></div>" | |
| f"</div></div>") | |
| # ββ Small donut pies for Stage 1 / Stage 2 βββββββββββββββββββββββββββββββ | |
| def small_donut(pct: float, color: str, label: str, size: int = 84) -> str: | |
| import math | |
| cx = cy = size // 2 | |
| R = cx - 8 | |
| r = R - 12 | |
| sweep = pct * 360 | |
| end_a = -90 + sweep | |
| x1, y1 = cx + R*math.cos(math.radians(-90)), cy + R*math.sin(math.radians(-90)) | |
| x2, y2 = cx + R*math.cos(math.radians(end_a)), cy + R*math.sin(math.radians(end_a)) | |
| xi1, yi1 = cx + r*math.cos(math.radians(end_a)), cy + r*math.sin(math.radians(end_a)) | |
| xi2, yi2 = cx + r*math.cos(math.radians(-90)), cy + r*math.sin(math.radians(-90)) | |
| lg = 1 if sweep > 180 else 0 | |
| if pct >= 0.999: | |
| arc = (f"<circle cx='{cx}' cy='{cy}' r='{R}' fill='none' stroke='{color}' stroke-width='12'/>" | |
| f"<circle cx='{cx}' cy='{cy}' r='{r}' fill='#050505'/>") | |
| else: | |
| arc = (f"<circle cx='{cx}' cy='{cy}' r='{R}' fill='none' stroke='#1a1a1a' stroke-width='12'/>" | |
| f"<path d='M {x1:.2f} {y1:.2f} A {R} {R} 0 {lg} 1 {x2:.2f} {y2:.2f} " | |
| f"L {xi1:.2f} {yi1:.2f} A {r} {r} 0 {lg} 0 {xi2:.2f} {yi2:.2f} Z' fill='{color}'/>" | |
| f"<circle cx='{cx}' cy='{cy}' r='{r}' fill='#050505'/>") | |
| return (f"<div style='text-align:center;'>" | |
| f"<svg width='{size}' height='{size}' viewBox='0 0 {size} {size}'>" | |
| f"{arc}" | |
| f"<text x='{cx}' y='{cy}' text-anchor='middle' dominant-baseline='middle' " | |
| f"fill='#fff' font-family='Times New Roman,Times,serif' font-size='11' font-weight='700'>" | |
| f"{pct:.0%}</text></svg>" | |
| f"<div style='font-family:\"Roboto Mono\",monospace;font-size:.55rem;color:#666;margin-top:2px;'>" | |
| f"{label}</div></div>") | |
| charts_cls = ( | |
| f"<div style='display:flex;gap:20px;padding:14px 16px;background:#050505;" | |
| f"border:1px solid #1e1e1e;border-radius:3px;margin-top:6px;align-items:flex-start;flex-wrap:wrap;'>" | |
| + small_donut(stage1_conf, '#FF8C00', 'TF BINARY Β· Stage 1') | |
| + small_donut(stage2_conf, '#c0392b', 'ANOMALY Β· Stage 2') | |
| + f"<div style='flex:1;min-width:200px;'>{conf_bar_chart(top3_raw)}</div>" | |
| + (f"<div style='width:100%;margin-top:10px;'>{donut_pie_svg(all_probs)}</div>" if all_probs else "") | |
| + "</div>" | |
| ) | |
| charts_sensing = ( | |
| f"<div style='padding:14px 16px;background:#050505;border:1px solid #1e1e1e;" | |
| f"border-radius:3px;margin-top:6px;'>" | |
| f"<div style='font-family:\"Roboto Mono\",monospace;font-size:.6rem;letter-spacing:.14em;" | |
| f"color:#FF8C00;text-transform:uppercase;margin-bottom:12px;'>Signal Intensities</div>" | |
| + risk_gauge(fused_risk, "Fusion Risk (Stage 6)", risk_color(fused_risk)) | |
| + risk_gauge(audio_score, "Audio Score (Stage 4)", "#2471a3") | |
| + risk_gauge(min(motion, 1.0), "Motion Intensity (Stage 5)", "#117a65") | |
| + "</div>" | |
| ) | |
| # ββ Table rows ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| ae_rows = (row("Overall Status", report.get("overall_status","")) | |
| + row("Dynamic Threshold", report.get("dynamic_threshold",""), mono=True) | |
| + row("AE Max Error", report.get("ae_max_error",""), mono=True) | |
| + row("AE Avg Error", report.get("ae_avg_error",""), mono=True) | |
| + row("Peak Timestamp", report.get("peak_timestamp",""), mono=True) | |
| + row("Anomaly Window", report.get("anomaly_window",""), mono=True)) | |
| cl_rows = (row("TF Binary (Stage 1)", report.get("stage1_tf_binary","")) | |
| + row("Anomaly Type (Stage 2)", report.get("stage2_anomaly_type",""))) | |
| for i, (cls, prob) in enumerate(report.get("stage2_top3",[]), 1): | |
| cl_rows += row(f" Top-{i}", f"{cls} ({prob:.2%})", mono=True) | |
| det_rows = (row("Weapon Detection (Stage 3)", report.get("stage3_weapon","")) | |
| + row("Audio Status (Stage 4)", report.get("stage4_audio","")) | |
| + row("Motion (Stage 5)", report.get("stage5_motion",""))) | |
| fus_rows = row("Fusion Risk (Stage 6)", report.get("stage6_fusion_risk","")) | |
| for i, line in enumerate(report.get("stage6_reasoning",[]), 1): | |
| fus_rows += row(f" Step {i}", line) | |
| rl_rows = (row("RL3033 Final Risk (Stage 7)", report.get("stage7_rl_final",""), mono=True) | |
| + row("Interpretation", report.get("stage7_interpretation",""))) | |
| narr = report.get("llava_narrative","") | |
| narr_html = ( | |
| f"<div style='margin-bottom:26px;'>" | |
| + section_hdr("AI Visual Narrative (LLaVA β Stage 8)") | |
| + f"<div style='color:#ccc;font-size:.85rem;line-height:1.78;padding:16px 18px;" | |
| f"background:#050505;border:1px solid #1e1e1e;border-radius:3px;margin-top:6px;'>{narr}</div>" | |
| + "</div>" | |
| ) if narr else "" | |
| ae_graph_html = "" | |
| if graph_path: | |
| data_uri = img_to_data_uri(graph_path) | |
| if data_uri: | |
| ae_graph_html = ( | |
| f"<div style='margin-bottom:26px;'>" | |
| + section_hdr("AE Deviation Graph β Temporal Reconstruction Error") | |
| + f"<div style='padding:14px;background:#050505;border:1px solid #1e1e1e;" | |
| f"border-radius:3px;margin-top:6px;'>" | |
| f"<img src='{data_uri}' alt='AE Deviation Graph' " | |
| f"style='width:100%;border-radius:2px;display:block;'/>" | |
| f"<div style='font-family:\"Roboto Mono\",monospace;font-size:.58rem;color:#555;" | |
| f"margin-top:8px;text-align:center;'>" | |
| f"Red = reconstruction error · Green dashed = dynamic threshold " | |
| f" · Star = peak anomaly frame" | |
| f"</div></div></div>" | |
| ) | |
| return ( | |
| f"<div style='background:#0a0a0a;padding:28px 26px;border:1px solid #1e1e1e;border-radius:4px;'>" | |
| + badge + ae_graph_html | |
| + section("Autoencoder β Stage 0", ae_rows) | |
| + section("Classification β Stages 1 & 2", cl_rows, charts_cls) | |
| + section("Detection & Sensing β Stages 3-5", det_rows, charts_sensing) | |
| + section("Fusion Layer β Stage 6", fus_rows) | |
| + section("RL3033 Temporal Reasoning β Stage 7", rl_rows) | |
| + narr_html | |
| + "</div>" | |
| ) | |
| def process_video(video): | |
| if video is None: | |
| return (no_result_html(), None, None, None) | |
| try: | |
| out = run_pipeline(video) | |
| r = out["risk_score"] | |
| report_html = build_report_html(out["report"], r, out["risk_label"], | |
| out["status"], graph_path=out.get("graph_path")) | |
| fi = Image.fromarray(out["top_frame"]) if out["top_frame"] is not None else None | |
| return (report_html, fi, json.dumps(out["json_data"], indent=2, default=str), out["pdf_path"]) | |
| except Exception: | |
| tb = traceback.format_exc() | |
| return (f"<div style='color:#FF4500;font-family:\"Roboto Mono\",monospace;" | |
| f"font-size:.8rem;padding:20px;background:#050505;'>Analysis failed:\n\n{tb}</div>", | |
| None, None, None) | |
| def handle_contact(name: str, email: str, phone: str, msg: str) -> str: | |
| """Shared contact form handler β used in both the base app and agent layer tabs.""" | |
| if not name.strip() or not email.strip() or not msg.strip(): | |
| return ("<div style='font-family:\"Roboto Mono\",monospace;font-size:.72rem;" | |
| "color:#FF4500;padding:10px 0;font-weight:600;'>" | |
| "Name, email, and message are required.</div>") | |
| if not re.match(r"^[^@]+@[^@]+\.[^@]+$", email.strip()): | |
| return ("<div style='font-family:\"Roboto Mono\",monospace;font-size:.72rem;" | |
| "color:#FF4500;padding:10px 0;font-weight:600;'>" | |
| "Please enter a valid email address.</div>") | |
| send_contact_email(name.strip(), email.strip(), phone.strip(), msg.strip()) | |
| return ("<div style='font-family:\"Roboto Mono\",monospace;font-size:.72rem;" | |
| "color:#4CAF50;padding:10px 0;letter-spacing:.06em;font-weight:600;'>" | |
| "✓ Message received. We will respond at the provided email.</div>") | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # CSS β pitch black / white / orange palette | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| APP_CSS = """ | |
| @import url('https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@300;400;500;600;700&display=swap'); | |
| :root { | |
| --bg: #000000; | |
| --surface:#080808; | |
| --border: #1e1e1e; | |
| --white: #FFFFFF; | |
| --dim: #888888; | |
| --orange: #FF8C00; | |
| --red: #FF4500; | |
| --mono: 'Roboto Mono', monospace; | |
| --serif: Charter, 'Bitstream Charter', Georgia, serif; | |
| } | |
| *, *::before, *::after { box-sizing:border-box; margin:0; padding:0; } | |
| html, body, .gradio-container, .gradio-container > * { | |
| background:var(--bg) !important; color:var(--white) !important; | |
| font-family:var(--serif) !important; | |
| } | |
| .gradio-container { | |
| zoom: 1.3 !important; | |
| } | |
| * { -webkit-font-smoothing:antialiased; } | |
| ::-webkit-scrollbar { width:4px; height:4px; } | |
| ::-webkit-scrollbar-track { background:var(--bg); } | |
| ::-webkit-scrollbar-thumb { background:#333; border-radius:2px; } | |
| @keyframes arc-spin { | |
| from { transform:rotate(0deg); } to { transform:rotate(360deg); } | |
| } | |
| /* TABS */ | |
| .tab-nav { | |
| background:var(--bg) !important; border-bottom:1px solid var(--border) !important; | |
| padding:0 24px !important; display:flex !important; gap:0 !important; | |
| align-items:center !important; justify-content:flex-start !important; | |
| } | |
| .tab-nav button { | |
| font-family:var(--mono) !important; font-size:.72rem !important; | |
| letter-spacing:.13em !important; color:#999 !important; | |
| background:transparent !important; border:none !important; | |
| border-bottom:2px solid transparent !important; padding:14px 0 !important; | |
| min-width:160px !important; width:160px !important; text-align:center !important; | |
| text-transform:uppercase !important; transition:color .18s, border-color .18s !important; | |
| font-weight:600 !important; white-space:nowrap !important; flex:0 0 160px !important; | |
| } | |
| .tab-nav button:hover { color:#ddd !important; } | |
| .tab-nav button.selected { | |
| color:#FFFFFF !important; border-bottom-color:var(--orange) !important; font-weight:700 !important; | |
| } | |
| /* GRADIO INTERNALS */ | |
| .gradio-container .block, .gradio-container .panel, | |
| .gradio-container .form, .gradio-container fieldset, | |
| .gradio-container .wrap, .gradio-container .container, | |
| .gradio-container > div { background:var(--bg) !important; border:none !important; } | |
| /* INPUTS */ | |
| input, textarea, select { | |
| background:#0d0d0d !important; border:1px solid #2a2a2a !important; | |
| border-radius:3px !important; color:#f0f0f0 !important; | |
| font-family:var(--serif) !important; font-size:.88rem !important; | |
| padding:12px 14px !important; transition:border-color .18s !important; width:100% !important; | |
| } | |
| input:focus, textarea:focus { | |
| border-color:var(--orange) !important; outline:none !important; | |
| box-shadow:0 0 0 1px var(--orange) !important; | |
| } | |
| label, .label-wrap span { | |
| font-family:var(--mono) !important; font-size:.65rem !important; | |
| letter-spacing:.1em !important; text-transform:uppercase !important; | |
| color:#ccc !important; font-weight:600 !important; | |
| } | |
| /* BUTTONS */ | |
| .gr-button { | |
| background:var(--white) !important; color:var(--bg) !important; | |
| border-radius:3px !important; font-family:var(--mono) !important; | |
| font-size:.72rem !important; letter-spacing:.12em !important; font-weight:700 !important; | |
| } | |
| .gr-button:hover { background:var(--orange) !important; } | |
| #ana-btn, #ana-btn button { | |
| width:auto !important; max-width:260px !important; | |
| min-height:44px !important; height:44px !important; padding:0 30px !important; | |
| } | |
| /* VIDEO UPLOAD */ | |
| #vid-up { min-height:150px !important; max-height:200px !important; } | |
| #vid-up .upload-container, #vid-up .wrap { | |
| background:#060606 !important; border:1px dashed #2a2a2a !important; | |
| border-radius:3px !important; min-height:150px !important; max-height:200px !important; | |
| } | |
| #vid-up .wrap:hover { border-color:var(--orange) !important; } | |
| /* OUTPUT AREAS */ | |
| #report-html { background:var(--bg) !important; } | |
| #kf-img, #kf-img img { border-radius:3px !important; border:1px solid var(--border) !important; width:100% !important; } | |
| #json-out, #json-out textarea { | |
| background:#060606 !important; color:#bbb !important; | |
| font-family:var(--mono) !important; font-size:.75rem !important; | |
| line-height:1.7 !important; border:1px solid var(--border) !important; border-radius:3px !important; | |
| } | |
| #pdf-out { border:1px solid var(--border) !important; border-radius:3px !important; } | |
| #pdf-out .file-preview { background:#060606 !important; } | |
| .rule { border:none; border-top:1px solid #111; margin:28px 0; } | |
| .contact-card { | |
| background:#080808; border:1px solid #1e1e1e; border-radius:4px; padding:28px 28px 20px; | |
| } | |
| .contact-info-block { | |
| background:#060606; border:1px solid #222; border-radius:3px; padding:18px 20px; margin-top:16px; | |
| } | |
| footer { display:none !important; } | |
| """ | |
| HOME_HTML = """ | |
| <div style="max-width:900px;margin:0 auto;padding:0 28px 72px;"> | |
| <!-- HERO --> | |
| <div style="padding:72px 0 56px;border-bottom:1px solid #111;"> | |
| <div style="font-family:'Roboto Mono',monospace;font-size:.62rem;letter-spacing:.18em; | |
| color:#FF8C00;text-transform:uppercase;margin-bottom:16px;font-weight:700;"> | |
| Forensic AI Platform · Scene Solver v3 | |
| </div> | |
| <h1 style="font-family:Charter,Georgia,serif;font-size:3.6rem;font-weight:400; | |
| color:#fff;line-height:1.08;margin-bottom:22px;letter-spacing:-.01em;"> | |
| Uncover the Fact. | |
| </h1> | |
| <p style="font-family:Charter,Georgia,serif;font-size:1rem;color:#666; | |
| max-width:560px;line-height:1.78;margin-bottom:40px;"> | |
| CrimeSceneSolver is an end-to-end forensic intelligence platform. | |
| Upload CCTV footage and a nine-stage AI pipeline analyses it, then | |
| Sherlπ¬ck, the agentic escalation layer, briefs you by voice or text, | |
| dispatches evidence to all channels, and generates a full PDF dossier. | |
| </p> | |
| <button onclick="(function(){ | |
| var b=document.querySelectorAll('.tab-nav button,[role=tab]'); | |
| for(var i=0;i<b.length;i++){var t=(b[i].textContent||'').trim().toLowerCase(); | |
| if(t.indexOf('analyse')!==-1){b[i].click();return;}} | |
| if(b.length>1)b[1].click(); | |
| })()" | |
| style="background:#fff;color:#000;font-family:'Roboto Mono',monospace;font-size:.74rem; | |
| letter-spacing:.14em;text-transform:uppercase;padding:14px 36px;border-radius:3px; | |
| border:none;cursor:pointer;font-weight:700;transition:background .2s;" | |
| onmouseover="this.style.background='#FF8C00'" onmouseout="this.style.background='#fff'"> | |
| Analyse Footage → | |
| </button> | |
| </div> | |
| <!-- NINE-STAGE PIPELINE --> | |
| <div style="padding:56px 0 48px;border-bottom:1px solid #111;"> | |
| <div style="font-family:'Roboto Mono',monospace;font-size:.62rem;letter-spacing:.16em; | |
| color:#FF8C00;text-transform:uppercase;margin-bottom:28px;font-weight:700;"> | |
| Nine-Stage Forensic Pipeline | |
| </div> | |
| <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:10px;"> | |
| <div style="padding:20px 18px;background:#080808;border:1px solid #1e1e1e;border-radius:3px;"> | |
| <div style="font-family:'Roboto Mono',monospace;font-size:.55rem;color:#FF8C00;margin-bottom:6px;font-weight:700;">STAGE 0</div> | |
| <div style="font-family:Charter,Georgia,serif;font-size:.9rem;color:#fff;margin-bottom:6px;">Autoencoder (AE)</div> | |
| <div style="font-family:Charter,Georgia,serif;font-size:.76rem;color:#555;line-height:1.6;">Primary anomaly signal. Reconstruction error over a dynamic threshold triggers the pipeline.</div> | |
| </div> | |
| <div style="padding:20px 18px;background:#080808;border:1px solid #1e1e1e;border-radius:3px;"> | |
| <div style="font-family:'Roboto Mono',monospace;font-size:.55rem;color:#FF8C00;margin-bottom:6px;font-weight:700;">STAGE 1</div> | |
| <div style="font-family:Charter,Georgia,serif;font-size:.9rem;color:#fff;margin-bottom:6px;">TimeSformer Binary</div> | |
| <div style="font-family:Charter,Georgia,serif;font-size:.76rem;color:#555;line-height:1.6;">Semantic gate: classifies the clip as Normal or Suspicious. Normal overrides the AE signal to SAFE.</div> | |
| </div> | |
| <div style="padding:20px 18px;background:#080808;border:1px solid #1e1e1e;border-radius:3px;"> | |
| <div style="font-family:'Roboto Mono',monospace;font-size:.55rem;color:#FF8C00;margin-bottom:6px;font-weight:700;">STAGE 2</div> | |
| <div style="font-family:Charter,Georgia,serif;font-size:.9rem;color:#fff;margin-bottom:6px;">TimeSformer Multi-class</div> | |
| <div style="font-family:Charter,Georgia,serif;font-size:.76rem;color:#555;line-height:1.6;">Fine-grained anomaly type across 7 categories using AE-weighted temporal sampling.</div> | |
| </div> | |
| <div style="padding:20px 18px;background:#080808;border:1px solid #1e1e1e;border-radius:3px;"> | |
| <div style="font-family:'Roboto Mono',monospace;font-size:.55rem;color:#FF8C00;margin-bottom:6px;font-weight:700;">STAGE 3</div> | |
| <div style="font-family:Charter,Georgia,serif;font-size:.9rem;color:#fff;margin-bottom:6px;">YOLO Weapon Detection</div> | |
| <div style="font-family:Charter,Georgia,serif;font-size:.76rem;color:#555;line-height:1.6;">Hard safety trigger. Melee and firearm detection within the anomaly window. Forces 0.90 risk floor.</div> | |
| </div> | |
| <div style="padding:20px 18px;background:#080808;border:1px solid #1e1e1e;border-radius:3px;"> | |
| <div style="font-family:'Roboto Mono',monospace;font-size:.55rem;color:#FF8C00;margin-bottom:6px;font-weight:700;">STAGE 4</div> | |
| <div style="font-family:Charter,Georgia,serif;font-size:.9rem;color:#fff;margin-bottom:6px;">Audio Feature Extraction</div> | |
| <div style="font-family:Charter,Georgia,serif;font-size:.76rem;color:#555;line-height:1.6;">ZCR, MFCC variance, spectral flux, RMS, and centroid scored against global thresholds.</div> | |
| </div> | |
| <div style="padding:20px 18px;background:#080808;border:1px solid #1e1e1e;border-radius:3px;"> | |
| <div style="font-family:'Roboto Mono',monospace;font-size:.55rem;color:#FF8C00;margin-bottom:6px;font-weight:700;">STAGE 5</div> | |
| <div style="font-family:Charter,Georgia,serif;font-size:.9rem;color:#fff;margin-bottom:6px;">Tracking & Motion</div> | |
| <div style="font-family:Charter,Georgia,serif;font-size:.76rem;color:#555;line-height:1.6;">YOLO tracking with Farneback optical flow fallback. Detects FAST_MOVEMENT, CLUSTERING, STATIC.</div> | |
| </div> | |
| <div style="padding:20px 18px;background:#080808;border:1px solid #1e1e1e;border-radius:3px;"> | |
| <div style="font-family:'Roboto Mono',monospace;font-size:.55rem;color:#FF8C00;margin-bottom:6px;font-weight:700;">STAGE 6</div> | |
| <div style="font-family:Charter,Georgia,serif;font-size:.9rem;color:#fff;margin-bottom:6px;">Fusion Layer</div> | |
| <div style="font-family:Charter,Georgia,serif;font-size:.76rem;color:#555;line-height:1.6;">Explainable rule-based reasoning combining all stage outputs into a single risk score with a full trace.</div> | |
| </div> | |
| <div style="padding:20px 18px;background:#080808;border:1px solid #1e1e1e;border-radius:3px;"> | |
| <div style="font-family:'Roboto Mono',monospace;font-size:.55rem;color:#FF8C00;margin-bottom:6px;font-weight:700;">STAGE 7</div> | |
| <div style="font-family:Charter,Georgia,serif;font-size:.9rem;color:#fff;margin-bottom:6px;">RL3033 Agent</div> | |
| <div style="font-family:Charter,Georgia,serif;font-size:.76rem;color:#555;line-height:1.6;">PPO reinforcement learning agent. Final risk blends TFB, TFM, Fusion, and RL scores <span style="font-family:'Times New Roman',Times,serif;">(70/20/5/5%)</span>.</div> | |
| </div> | |
| <div style="padding:20px 18px;background:#080808;border:1px solid #1e1e1e;border-radius:3px;"> | |
| <div style="font-family:'Roboto Mono',monospace;font-size:.55rem;color:#FF8C00;margin-bottom:6px;font-weight:700;">STAGE 8</div> | |
| <div style="font-family:Charter,Georgia,serif;font-size:.9rem;color:#fff;margin-bottom:6px;">LLaVA Narrative</div> | |
| <div style="font-family:Charter,Georgia,serif;font-size:.76rem;color:#555;line-height:1.6;">Four keyframes described in natural language and synthesised into a coherent scene narrative.</div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- SHERL0CK AGENT --> | |
| <div style="padding:56px 0 48px;border-bottom:1px solid #111;"> | |
| <div style="font-family:'Roboto Mono',monospace;font-size:.62rem;letter-spacing:.16em; | |
| color:#FF8C00;text-transform:uppercase;margin-bottom:28px;font-weight:700;"> | |
| Sherlπ¬ck β Agentic Escalation Layer | |
| </div> | |
| <div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;"> | |
| <div style="padding:26px 22px;background:#080808;border:1px solid #1e1e1e;border-radius:3px;"> | |
| <div style="font-family:Charter,Georgia,serif;font-size:.95rem;color:#fff;margin-bottom:8px;">Voice Call Interface</div> | |
| <div style="font-family:Charter,Georgia,serif;font-size:.8rem;color:#555;line-height:1.7;"> | |
| When a CRITICAL or HIGH risk is detected, Sherlπ¬ck initiates an incoming call. | |
| Accept it to speak directly with the agent, Whisper transcribes your speech, | |
| Cohere generates an analytical response, and XTTS v2 delivers it as a voice reply. | |
| Server-side VAD handles turn detection automatically. | |
| </div> | |
| </div> | |
| <div style="padding:26px 22px;background:#080808;border:1px solid #1e1e1e;border-radius:3px;"> | |
| <div style="font-family:Charter,Georgia,serif;font-size:.95rem;color:#fff;margin-bottom:8px;">Text Chat Interface</div> | |
| <div style="font-family:Charter,Georgia,serif;font-size:.8rem;color:#555;line-height:1.7;"> | |
| A persistent text chat powered by Cohere command-a-05-2026 with the full pipeline | |
| JSON pre-loaded as context. Ask about any stage output, request reasoning traces, | |
| or say <em style="color:#FF8C00;font-style:normal;">"send report"</em> to dispatch evidence. | |
| </div> | |
| </div> | |
| <div style="padding:26px 22px;background:#080808;border:1px solid #1e1e1e;border-radius:3px;"> | |
| <div style="font-family:Charter,Georgia,serif;font-size:.95rem;color:#fff;margin-bottom:8px;">Evidence Dispatch</div> | |
| <div style="font-family:Charter,Georgia,serif;font-size:.8rem;color:#555;line-height:1.7;"> | |
| One click dispatches the full forensic dossier simultaneously to Telegram, | |
| Discord, and Email, including the peak anomaly frame and the 11-page PDF report. | |
| CRITICAL alerts auto-dispatch without user action. | |
| </div> | |
| </div> | |
| <div style="padding:26px 22px;background:#080808;border:1px solid #1e1e1e;border-radius:3px;"> | |
| <div style="font-family:Charter,Georgia,serif;font-size:.95rem;color:#fff;margin-bottom:8px;">11-Page PDF Dossier</div> | |
| <div style="font-family:Charter,Georgia,serif;font-size:.8rem;color:#555;line-height:1.7;"> | |
| Auto-generated PDF covering every stage: AE temporal graph, classification charts, | |
| weapon detections with bounding boxes, audio feature breakdown, motion tracking, | |
| fusion reasoning trace, RL3033 observation space, anomalous frames, and LLaVA narrative. | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- WORKFLOW --> | |
| <div style="padding:56px 0 48px;border-bottom:1px solid #111;"> | |
| <div style="font-family:'Roboto Mono',monospace;font-size:.62rem;letter-spacing:.16em; | |
| color:#FF8C00;text-transform:uppercase;margin-bottom:28px;font-weight:700;">End-to-End Workflow</div> | |
| <div style="display:grid;grid-template-columns:repeat(4,1fr);gap:24px;"> | |
| <div> | |
| <div style="font-family:'Roboto Mono',monospace;font-size:.6rem;color:#333;margin-bottom:8px;font-weight:700;">01</div> | |
| <div style="font-family:Charter,Georgia,serif;font-size:.92rem;color:#fff;margin-bottom:6px;">Upload Footage</div> | |
| <div style="font-family:Charter,Georgia,serif;font-size:.78rem;color:#555;line-height:1.65;">Drop CCTV footage on the Analyse tab and press Analyse Footage.</div> | |
| </div> | |
| <div> | |
| <div style="font-family:'Roboto Mono',monospace;font-size:.6rem;color:#333;margin-bottom:8px;font-weight:700;">02</div> | |
| <div style="font-family:Charter,Georgia,serif;font-size:.92rem;color:#fff;margin-bottom:6px;">Pipeline Runs</div> | |
| <div style="font-family:Charter,Georgia,serif;font-size:.78rem;color:#555;line-height:1.65;">All nine stages execute sequentially. Report, frame, JSON, and PDF are produced.</div> | |
| </div> | |
| <div> | |
| <div style="font-family:'Roboto Mono',monospace;font-size:.6rem;color:#333;margin-bottom:8px;font-weight:700;">03</div> | |
| <div style="font-family:Charter,Georgia,serif;font-size:.92rem;color:#fff;margin-bottom:6px;">Sherl0ck Calls</div> | |
| <div style="font-family:Charter,Georgia,serif;font-size:.78rem;color:#555;line-height:1.65;">On HIGH or CRITICAL risk, an incoming call overlay appears. Accept to begin the briefing.</div> | |
| </div> | |
| <div> | |
| <div style="font-family:'Roboto Mono',monospace;font-size:.6rem;color:#333;margin-bottom:8px;font-weight:700;">04</div> | |
| <div style="font-family:Charter,Georgia,serif;font-size:.92rem;color:#fff;margin-bottom:6px;">Dispatch & Close</div> | |
| <div style="font-family:Charter,Georgia,serif;font-size:.78rem;color:#555;line-height:1.65;">Dispatch evidence to all channels, download the PDF dossier, and end the session.</div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- PRICING --> | |
| <div style="padding:56px 0 48px;border-bottom:1px solid #111;"> | |
| <div style="font-family:'Roboto Mono',monospace;font-size:.62rem;letter-spacing:.16em; | |
| color:#FF8C00;text-transform:uppercase;margin-bottom:28px;font-weight:700;">Pricing</div> | |
| <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px;"> | |
| <div style="padding:26px 22px;background:#080808;border:1px solid #1e1e1e;border-radius:3px;"> | |
| <div style="font-family:'Roboto Mono',monospace;font-size:.62rem;color:#555;margin-bottom:12px;font-weight:700;">BASIC</div> | |
| <div style="font-size:2rem;color:#fff;margin-bottom:14px;font-family:'Times New Roman',Times,serif;">Free</div> | |
| <div style="font-family:Charter,Georgia,serif;font-size:.8rem;color:#555;line-height:2.1;"> | |
| 9-stage pipeline<br>5 analyses / month<br>PDF dossier<br>JSON output | |
| </div> | |
| </div> | |
| <div style="padding:26px 22px;background:#080808;border:1px solid #FF8C00;border-radius:3px;"> | |
| <div style="font-family:'Roboto Mono',monospace;font-size:.62rem;color:#FF8C00;margin-bottom:12px;font-weight:700;">PRO</div> | |
| <div style="font-size:2rem;color:#fff;margin-bottom:14px;font-family:'Times New Roman',Times,serif;">₹49/mo</div> | |
| <div style="font-family:Charter,Georgia,serif;font-size:.8rem;color:#888;line-height:2.1;"> | |
| Unlimited analyses<br>Sherl0ck voice & text agent<br>Auto-dispatch (Telegram, Discord, Email)<br>LLaVA narratives<br>Priority support | |
| </div> | |
| </div> | |
| <div style="padding:26px 22px;background:#080808;border:1px solid #1e1e1e;border-radius:3px;"> | |
| <div style="font-family:'Roboto Mono',monospace;font-size:.62rem;color:#555;margin-bottom:12px;font-weight:700;">ENTERPRISE</div> | |
| <div style="font-size:2rem;color:#fff;margin-bottom:14px;font-family:'Times New Roman',Times,serif;">Custom</div> | |
| <div style="font-family:Charter,Georgia,serif;font-size:.8rem;color:#555;line-height:2.1;"> | |
| Custom model fine-tuning<br>On-premise deployment<br>Dedicated SLA<br>API access | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- TESTIMONIAL --> | |
| <div style="padding:56px 0 24px;"> | |
| <div style="font-family:'Roboto Mono',monospace;font-size:.62rem;letter-spacing:.16em; | |
| color:#FF8C00;text-transform:uppercase;margin-bottom:26px;font-weight:700;">From the Field</div> | |
| <blockquote style="font-family:Charter,Georgia,serif;font-size:1.08rem;color:#aaa;line-height:1.76; | |
| border-left:2px solid #FF8C00;padding-left:22px;font-style:italic;margin:0 0 14px;"> | |
| “We closed a complex six-month investigation in under two weeks after deploying | |
| SceneSolver. The nine-stage pipeline, Sherl0ck voice briefings, and automated | |
| evidence dispatch changed how our forensics team operates entirely.” | |
| </blockquote> | |
| <div style="font-family:'Roboto Mono',monospace;font-size:.66rem;color:#555;font-weight:500;"> | |
| — Detective Lara Monroe, Forensics Division | |
| </div> | |
| </div> | |
| </div> | |
| """ | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # AGENTIC ESCALATION LAYER : Sherl0ck Agent | |
| # Run AFTER Cell 2 (pipeline), Cell 5 (web app), and Cell 6 (dependencies). | |
| # Reuses shared constants/helpers from Cell 5: NAV_HTML, FOOTER_HTML, APP_CSS, | |
| # sec_label, risk_color, img_to_data_uri, spinner_html, no_result_html, | |
| # run_pipeline, build_report_html, process_video, handle_contact, HOME_HTML. | |
| # | |
| # VOICE SUBSYSTEM (this revision): | |
| # Continuous hands-free voice after Accept Call. | |
| # Browser MediaStream + Web-Audio VAD + MediaRecorder | |
| # β same-origin POST /ssvoice/turn (binary WebM bytes) | |
| # β FFmpeg β mono 24 kHz WAV | |
| # β LFM2.5-Audio-1.5B ASR | |
| # β Cohere / Sherl0ck reasoning | |
| # β LFM2.5-Audio-1.5B US male TTS | |
| # β browser playback + live waveforms + barge-in | |
| # β automatic next turn until End Call. | |
| # | |
| # PRIMARY BUG FIXED: | |
| # Previous Gradio textbox bridge delivered size=0 to Python while the browser | |
| # held a real nonzero Blob. Transport is now raw HTTP binary on the same | |
| # Gradio FastAPI app β BROWSER BYTES == SERVER BYTES. | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| import os, re, json, smtplib, ssl, base64, traceback, threading, subprocess, sys, io, wave | |
| import struct, math, time, uuid, tempfile, shutil | |
| from pathlib import Path as _Path | |
| from datetime import datetime, timezone | |
| from email.mime.multipart import MIMEMultipart | |
| from email.mime.text import MIMEText | |
| from email.mime.base import MIMEBase | |
| from email import encoders | |
| from typing import Optional, Dict, Any, List, Tuple | |
| import requests | |
| import numpy as np | |
| import gradio as gr | |
| try: | |
| import liquid_audio # noqa: F401 β ensure dependency cell installed it | |
| except Exception: | |
| pass | |
| # Deployment note: liquid-audio is installed from requirements.txt (no runtime pip). | |
| # ββ Suppress non-critical log noise βββββββββββββββββββββββββββββββββββββββββββ | |
| import logging as _logging, warnings as _warnings | |
| _warnings.filterwarnings("ignore", category=DeprecationWarning) | |
| _warnings.filterwarnings("ignore", category=UserWarning, module="gradio") | |
| _warnings.filterwarnings("ignore", category=UserWarning, module="transformers") | |
| for _noisy in ("gradio.routes", "gradio.queueing", "starlette", "uvicorn.error", | |
| "httpx", "httpcore", "urllib3", "transformers.modeling_utils", | |
| "transformers.generation.configuration_utils"): | |
| _logging.getLogger(_noisy).setLevel(_logging.ERROR) | |
| os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error") | |
| # ββ Config ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "") | |
| TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "") | |
| DISCORD_WEBHOOK_URL = os.environ.get("DISCORD_WEBHOOK_URL", "") | |
| COHERE_API_KEY = os.environ.get("COHERE_API_KEY", "") | |
| ALERT_EMAIL_FROM = os.environ.get("ALERT_EMAIL_FROM", "") | |
| ALERT_EMAIL_PASS = os.environ.get("ALERT_EMAIL_PASS", "") | |
| ALERT_EMAIL_TO = os.environ.get("ALERT_EMAIL_TO", "") | |
| ALERT_SMTP_SERVER = "smtp.gmail.com" | |
| ALERT_SMTP_PORT = 465 | |
| CALLER_TUNE_PATH = os.environ.get("CALLER_TUNE_PATH", AUDIOPATHS.get("CALLER_TUNE", "") if isinstance(AUDIOPATHS, dict) else "") | |
| THRESH_CRITICAL = 0.90 | |
| THRESH_HIGH = 0.70 | |
| COHERE_MODEL = "command-a-03-2025" | |
| # ββ Optional deps βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| try: | |
| import cohere as _cohere | |
| COHERE_AVAILABLE = True | |
| print("[AgentLayer] Cohere OK") | |
| except ImportError: | |
| COHERE_AVAILABLE = False | |
| print("[AgentLayer] Cohere unavailable") | |
| # Cohere is only usable when the (env-provided) API key is present β the public | |
| # repository contains no credentials; Sherl0ck falls back to the notebook's | |
| # offline replies when the key is absent. | |
| COHERE_READY = COHERE_AVAILABLE and bool(COHERE_API_KEY.strip()) | |
| if COHERE_AVAILABLE and not COHERE_READY: | |
| print("[AgentLayer] COHERE_API_KEY not set - Sherl0ck will use offline fallback replies.") | |
| try: | |
| import torch | |
| except ImportError: | |
| torch = None | |
| print("[AgentLayer] torch unavailable") | |
| try: | |
| import torchaudio | |
| except ImportError: | |
| torchaudio = None | |
| print("[AgentLayer] torchaudio unavailable") | |
| HF_LFM_AUDIO_REPO = "LiquidAI/LFM2.5-Audio-1.5B" | |
| LFM_AUDIO_AVAILABLE = False | |
| _lfm_processor = None | |
| _lfm_model = None | |
| _lfm_lock = threading.Lock() | |
| try: | |
| from liquid_audio import LFM2AudioModel, LFM2AudioProcessor, ChatState, LFMModality | |
| LFM_AUDIO_AVAILABLE = True | |
| print("[AgentLayer] liquid-audio import OK") | |
| except ImportError as e: | |
| LFM_AUDIO_AVAILABLE = False | |
| print(f"[AgentLayer] liquid-audio unavailable: {e}") | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # SVG ICONS | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _icon(body: str, size: int = 18, col: str = "currentColor") -> str: | |
| return (f'<svg width="{size}" height="{size}" viewBox="0 0 24 24" fill="none" ' | |
| f'stroke="{col}" stroke-width="1.8" stroke-linecap="round" ' | |
| f'stroke-linejoin="round" style="display:inline-block;vertical-align:' | |
| f'middle;flex-shrink:0;">{body}</svg>') | |
| ICON_SHIELD = _icon('<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>', 18) | |
| ICON_DISPATCH = _icon('<polyline points="22 2 11 13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>', 16) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # PIPELINE DATA HELPERS | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _has_pipeline_data(jd: dict) -> bool: | |
| return bool(jd) and isinstance(jd, dict) and len(jd) > 0 | |
| def _build_plain_context(jd: dict, risk_pct: int) -> str: | |
| lines = ["=== FORENSIC PIPELINE SUMMARY ==="] | |
| lines.append(f"Overall status : {jd.get('status', 'UNKNOWN')}") | |
| lines.append(f"Risk level label : {jd.get('risk_level', 'UNKNOWN')}") | |
| lines.append(f"Final risk score : {risk_pct}%") | |
| s1 = jd.get("stage1") or {} | |
| if s1: | |
| lines.append(f"Stage 1 TF-Binary: {s1.get('classification','?')} @ " | |
| f"{round(float(s1.get('confidence',0))*100)}% confidence") | |
| s2 = jd.get("stage2") or {} | |
| cls2 = s2.get("class", "") | |
| if s2 and cls2 and cls2 != "Skipped": | |
| lines.append(f"Stage 2 Anomaly : {cls2} @ {round(float(s2.get('confidence',0))*100)}% confidence") | |
| else: | |
| lines.append("Stage 2 Anomaly : Skipped / Normal scene") | |
| s3 = jd.get("stage3") or {} | |
| if s3: | |
| lines.append(f"Stage 3 Weapon : detected={'YES' if s3.get('weapon_flag') else 'NO'}, " | |
| f"class={s3.get('dominant_class','?')}, count={s3.get('weapon_count',0)}") | |
| s4 = jd.get("stage4") or {} | |
| if s4: | |
| lines.append(f"Stage 4 Audio : {s4.get('status','?')}, " | |
| f"score={round(float(s4.get('audio_score',0))*100)}%") | |
| s5 = jd.get("stage5") or {} | |
| if s5: | |
| bhv = ", ".join(s5.get("behaviors") or ["STATIC"]) | |
| lines.append(f"Stage 5 Motion : intensity={round(float(s5.get('motion_intensity',0))*100)}%, behaviors=[{bhv}]") | |
| s6 = jd.get("stage6") or {} | |
| if s6: | |
| lines.append(f"Stage 6 Fusion : fused_risk={round(float(s6.get('fused_risk',0))*100)}%, level={s6.get('risk_level','?')}") | |
| if s6.get("reasoning"): | |
| lines.append(f" Fusion reasoning: {s6['reasoning']}") | |
| s7 = jd.get("stage7") or {} | |
| if s7: | |
| lines.append(f"Stage 7 RL3033 : final_risk={round(float(s7.get('final_risk',0))*100)}%, status={s7.get('status','?')}") | |
| if s7.get("interpretation"): | |
| lines.append(f" Interpretation: {s7['interpretation']}") | |
| s0 = jd.get("stage0") or {} | |
| if s0: | |
| lines.append(f"Anomaly window : {s0.get('anomaly_start','?')} -> {s0.get('anomaly_end','?')}, peak={s0.get('peak_timestamp','?')}") | |
| narr = jd.get("llava_narrative", "") | |
| if narr: | |
| lines.append(f"LLaVA narrative : {str(narr)[:600]}") | |
| lines.append("=== END SUMMARY ===") | |
| return "\n".join(lines) | |
| def _build_priming_messages(payload: dict, level: str) -> list: | |
| risk_pct = round(float(payload.get("risk", 0.0)) * 100) | |
| jd = payload.get("json_data") or {} | |
| if not _has_pipeline_data(jd): | |
| return [ | |
| {"role": "user", "content": "Confirm your data status."}, | |
| {"role": "assistant", "content": "No pipeline data is currently loaded. I cannot answer analysis questions yet."}, | |
| ] | |
| plain = _build_plain_context(jd, risk_pct) | |
| return [ | |
| {"role": "user", "content": ( | |
| f"Loading the forensic pipeline context for this session. " | |
| f"Alert level: {level}. Risk score: {risk_pct}%. " | |
| f"Here is the full summary β confirm you have received it:\n\n{plain}" | |
| )}, | |
| {"role": "assistant", "content": ( | |
| f"Confirmed. I have received and processed the forensic pipeline context. " | |
| f"This session is locked to alert level {level} with a final risk score of {risk_pct}%. " | |
| f"I am ready to answer your questions about the findings." | |
| )}, | |
| ] | |
| def _build_text_system_prompt(payload: dict, level: str) -> str: | |
| risk_pct = round(float(payload.get("risk", 0.0)) * 100) | |
| jd = payload.get("json_data") or {} | |
| has_data = _has_pipeline_data(jd) | |
| try: | |
| full_json = json.dumps(jd, indent=2, default=str) | |
| except Exception: | |
| full_json = str(jd) | |
| plain_ctx = _build_plain_context(jd, risk_pct) if has_data else "(no pipeline data)" | |
| no_data_block = "" | |
| if not has_data: | |
| no_data_block = ( | |
| "\n\nDATA STATUS: No pipeline data is loaded yet.\n" | |
| "Tell the analyst: 'No forensic analysis has been loaded yet. " | |
| "Please run the pipeline on the Analyse tab first.'\nDo NOT fabricate findings.\n" | |
| ) | |
| return ( | |
| f"You are Sherl0ck, a senior forensic surveillance analyst AI integrated into the " | |
| f"SceneSolver platform. Communicate with precision, depth, and professional clarity.\n\n" | |
| f"SESSION CONSTANTS β NEVER CHANGE:\n" | |
| f" Alert Level : {level}\n" | |
| f" Risk Score : {risk_pct}% (ALWAYS use {risk_pct}%, never any other value)\n" | |
| f"{no_data_block}\n" | |
| f"COMMUNICATION RULES:\n" | |
| f"1. You are Sherl0ck. You may confirm this identity if asked.\n" | |
| f"2. Respond with full analytical depth. Be thorough when warranted, concise otherwise.\n" | |
| f"3. No markdown headers. No leading dashes or bullets. Use numbered lists for sequences, " | |
| f" plain prose for explanations. Bold key terms with **term** only when essential.\n" | |
| f"4. All risk scores as whole-number percentages (e.g. {risk_pct}%).\n" | |
| f"5. Risk score and alert level are FROZEN. Never alter them.\n" | |
| f"6. If the analyst says 'send report', 'dispatch', 'forward evidence', etc. β confirm " | |
| f" report dispatched to all channels with delivery status.\n" | |
| f"7. Answer ONLY from the pipeline data below. Do not fabricate.\n" | |
| f"8. The investigative-aid disclaimer was stated at session open. Do not repeat it.\n\n" | |
| f"PRE-PARSED CONTEXT (primary reference):\n{plain_ctx}\n\n" | |
| f"FULL PIPELINE JSON:\n{full_json}\n" | |
| ) | |
| def _build_voice_system_prompt(payload: dict, level: str) -> str: | |
| risk_pct = round(float(payload.get("risk", 0.0)) * 100) | |
| jd = payload.get("json_data") or {} | |
| plain_ctx = _build_plain_context(jd, risk_pct) if _has_pipeline_data(jd) else "(no data)" | |
| try: | |
| full_json = json.dumps(jd, indent=2, default=str) | |
| except Exception: | |
| full_json = str(jd) | |
| return ( | |
| f"You are Sherl0ck, a forensic surveillance AI agent speaking aloud to an analyst.\n\n" | |
| f"PERSONA: male, mature, calm, confident, warm, steady, reassuring, precise, experienced. " | |
| f"If the analyst sounds frightened, acknowledge briefly, reduce fear, establish control, " | |
| f"explain what can be determined. Do not amplify fear, become theatrical, or sound patronizing.\n\n" | |
| f"RULES:\n" | |
| f"1. You are Sherl0ck. You may confirm this if asked.\n" | |
| f"2. Plain words only. No punctuation except percent signs. No markdown, no bullets.\n" | |
| f"3. Short sentences. 2-4 sentences max per response.\n" | |
| f"4. All scores as whole-number percentages e.g. {risk_pct}%.\n" | |
| f"5. Risk score FIXED at {risk_pct}%. Alert level FIXED at {level}.\n" | |
| f"6. No disclaimer repetition.\n" | |
| f"7. If user says send report β confirm dispatch to all channels.\n" | |
| f"8. Answer ONLY from pipeline data.\n\n" | |
| f"CONTEXT SUMMARY:\n{plain_ctx}\n\nFULL JSON:\n{full_json}\n" | |
| ) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # CONVERSATION STATE | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| _text_conversation_history: list = [] | |
| _text_active_payload: dict = {} | |
| _voice_conversation_history: list = [] | |
| _voice_active_payload: dict = {} | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # TEXT AGENT | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def text_agent_init(payload: dict, level: str) -> str: | |
| global _text_conversation_history, _text_active_payload | |
| _text_conversation_history = [] | |
| _text_active_payload = payload | |
| risk_pct = round(float(payload.get("risk", 0.0)) * 100) | |
| jd = payload.get("json_data") or {} | |
| has_data = _has_pipeline_data(jd) | |
| print(f"[AgentLayer][Text] init: has_data={has_data} jd_keys={list(jd.keys())[:8]}") | |
| if not has_data: | |
| reply = ( | |
| "No forensic analysis has been loaded yet. Please run the pipeline on the " | |
| "**Analyse** tab first β upload your footage and press Analyse. " | |
| "Once complete, click **Start Chat** to begin your briefing." | |
| ) | |
| _text_conversation_history.append({"role": "assistant", "content": reply}) | |
| return reply | |
| fallback = ( | |
| f"**Alert Level: {level} | Risk Score: {risk_pct}%**\n\n" | |
| "Forensic pipeline analysis complete. I am ready to answer your questions.\n\n" | |
| "_This is an investigative aid β not a definitive conclusion. " | |
| "Human expert review is required before any operational action._" | |
| ) | |
| if not COHERE_READY: | |
| _text_conversation_history.append({"role": "assistant", "content": fallback}) | |
| return fallback | |
| try: | |
| co = _cohere.ClientV2(COHERE_API_KEY) | |
| msgs = [{"role": "system", "content": _build_text_system_prompt(payload, level)}] | |
| msgs.extend(_build_priming_messages(payload, level)) | |
| msgs.append({"role": "user", "content": ( | |
| f"The analysis is complete. Alert level is {level}, risk score is {risk_pct}%. " | |
| f"Provide a structured opening briefing: headline finding, key contributing factors " | |
| f"with actual values, high-confidence detections above 70%, temporal context, and " | |
| f"the LLaVA narrative in your own words. Plain prose, no markdown headers. " | |
| f"End with the standard investigative-aid disclaimer." | |
| )}) | |
| resp = co.chat(model=COHERE_MODEL, messages=msgs) | |
| reply = resp.message.content[0].text.strip() | |
| except Exception as e: | |
| print(f"[AgentLayer][Text] Cohere init FAIL: {e}") | |
| reply = fallback | |
| _text_conversation_history.append({"role": "assistant", "content": reply}) | |
| return reply | |
| def _build_dispatch_reply(payload: dict, level: str) -> str: | |
| results = dispatch_all_channels(payload, level) | |
| ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") | |
| risk_pct = round(float(payload.get("risk", 0.0)) * 100) | |
| lines = [ | |
| f"**Forensic dossier dispatched.** Timestamp: {ts}", | |
| f"Alert Level: **{level}** | Risk Score: **{risk_pct}%**", "", | |
| ] | |
| all_ok = True | |
| for ch, ok in results.items(): | |
| lines.append(f"{'OK' if ok else 'FAIL'} **{ch.capitalize()}**: {'Delivered' if ok else 'Failed'}") | |
| if not ok: all_ok = False | |
| lines += ["", "**Dossier contents transmitted:**", str(payload.get("summary", ""))] | |
| if payload.get("image"): lines.append("Peak anomaly frame: attached (JPEG)") | |
| if payload.get("pdf"): lines.append("Forensic PDF report: attached") | |
| lines.append("") | |
| lines.append("All channels confirmed delivery." if all_ok else "One or more channels failed β verify manually.") | |
| return "\n".join(lines) | |
| _DISPATCH_HOOKS = ( | |
| "send report", "send pdf", "share evidence", "dispatch report", | |
| "forward report", "share report", "forward evidence", "send dossier", | |
| "transmit report", "transmit dossier", "send findings", "escalate", "send alert", | |
| ) | |
| def text_agent_respond(user_text: str) -> str: | |
| global _text_conversation_history, _text_active_payload | |
| if not user_text or not user_text.strip(): | |
| return "Please enter a message." | |
| _text_conversation_history.append({"role": "user", "content": user_text.strip()}) | |
| if any(h in user_text.lower() for h in _DISPATCH_HOOKS): | |
| level = determine_alert_level(float(_text_active_payload.get("risk", 0.0))) | |
| reply = _build_dispatch_reply(_text_active_payload, level) | |
| _text_conversation_history.append({"role": "assistant", "content": reply}) | |
| return reply | |
| if not COHERE_READY: | |
| reply = f"AI offline. You said: {user_text.strip()}" | |
| _text_conversation_history.append({"role": "assistant", "content": reply}) | |
| return reply | |
| try: | |
| co = _cohere.ClientV2(COHERE_API_KEY) | |
| level = determine_alert_level(float(_text_active_payload.get("risk", 0.0))) | |
| msgs = [{"role": "system", "content": _build_text_system_prompt(_text_active_payload, level)}] | |
| msgs.extend(_build_priming_messages(_text_active_payload, level)) | |
| real_history = _text_conversation_history[:-1] | |
| for m in real_history[-14:]: | |
| msgs.append({"role": m["role"], "content": m["content"]}) | |
| msgs.append({"role": "user", "content": user_text.strip()}) | |
| resp = co.chat(model=COHERE_MODEL, messages=msgs) | |
| reply = resp.message.content[0].text.strip() | |
| except Exception as e: | |
| print(f"[AgentLayer][Text] Cohere respond FAIL: {e}") | |
| reply = "I encountered a processing error. Please repeat your question." | |
| _text_conversation_history.append({"role": "assistant", "content": reply}) | |
| return reply | |
| def handle_text_input(user_text: str): | |
| if not user_text or not user_text.strip(): | |
| return gr.update(), "" | |
| text_agent_respond(user_text) | |
| return _render_chat_html(_text_conversation_history), "" | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # VOICE AGENT (Cohere reasoning; LFM is speech bridge) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _sanitise_for_tts(text: str) -> str: | |
| text = re.sub(r'\bSherl[0o]ck\b', 'Sherlock', text, flags=re.IGNORECASE) | |
| text = re.sub(r'[^\w\s%]', ' ', text) | |
| return re.sub(r' {2,}', ' ', text).strip() | |
| def voice_agent_init(payload: dict, level: str) -> str: | |
| global _voice_conversation_history, _voice_active_payload | |
| _voice_conversation_history = [] | |
| _voice_active_payload = payload | |
| risk_pct = round(float(payload.get("risk", 0.0)) * 100) | |
| suffix = ( | |
| " This is an investigative AI assessment not a definitive conclusion" | |
| " Speak your question and pause briefly I will respond automatically" | |
| " Say send report to dispatch evidence to all channels" | |
| ) | |
| fallback = f"Alert level {level} risk score {risk_pct}%" + suffix | |
| if not COHERE_READY: | |
| _voice_conversation_history.append({"role": "CHATBOT", "content": fallback}) | |
| return fallback | |
| try: | |
| co = _cohere.ClientV2(COHERE_API_KEY) | |
| msgs = [{"role": "system", "content": _build_voice_system_prompt(payload, level)}] | |
| msgs.extend(_build_priming_messages(payload, level)) | |
| msgs.append({"role": "user", "content": ( | |
| f"State alert level is {level} and risk score is {risk_pct}% in one short sentence. " | |
| "No punctuation except percent. Under 80 characters." | |
| )}) | |
| resp = co.chat(model=COHERE_MODEL, messages=msgs) | |
| reply = _sanitise_for_tts(resp.message.content[0].text.strip()) + suffix | |
| except Exception as e: | |
| print(f"[AgentLayer][Voice] Cohere init FAIL: {e}") | |
| reply = fallback | |
| reply = _sanitise_for_tts(reply) | |
| _voice_conversation_history.append({"role": "CHATBOT", "content": reply}) | |
| return reply | |
| def voice_agent_respond(user_text: str): | |
| global _voice_conversation_history, _voice_active_payload | |
| if not user_text or not user_text.strip(): | |
| return "Please say that again", None | |
| _voice_conversation_history.append({"role": "USER", "content": user_text.strip()}) | |
| if any(h in user_text.lower() for h in | |
| ("send report","send pdf","share evidence","dispatch report","forward report")): | |
| level = determine_alert_level(float(_voice_active_payload.get("risk", 0.0))) | |
| results = dispatch_all_channels(_voice_active_payload, level) | |
| ok = all(results.values()) | |
| reply = _sanitise_for_tts( | |
| f"Report dispatched to all channels " | |
| f"{'Delivery confirmed' if ok else 'Some channels may have failed'}" | |
| ) | |
| _voice_conversation_history.append({"role": "CHATBOT", "content": reply}) | |
| return reply, "report_sent" | |
| if not COHERE_READY: | |
| reply = _sanitise_for_tts(f"AI offline You said {user_text.strip()[:60]}") | |
| _voice_conversation_history.append({"role": "CHATBOT", "content": reply}) | |
| return reply, None | |
| try: | |
| co = _cohere.ClientV2(COHERE_API_KEY) | |
| level = determine_alert_level(float(_voice_active_payload.get("risk", 0.0))) | |
| msgs = [{"role": "system", "content": _build_voice_system_prompt(_voice_active_payload, level)}] | |
| msgs.extend(_build_priming_messages(_voice_active_payload, level)) | |
| tail = _voice_conversation_history[-12:] | |
| start_idx = 0 | |
| for i, m in enumerate(tail): | |
| if m.get("role", "USER").upper() in ("USER", "user"): | |
| start_idx = i | |
| break | |
| for m in tail[start_idx:]: | |
| role = m.get("role", "USER").upper() | |
| msgs.append({ | |
| "role": "user" if role == "USER" else "assistant", | |
| "content": m.get("content", "") | |
| }) | |
| if not msgs or msgs[-1]["role"] != "user": | |
| msgs.append({"role": "user", "content": user_text.strip()}) | |
| resp = co.chat(model=COHERE_MODEL, messages=msgs) | |
| reply = _sanitise_for_tts(resp.message.content[0].text.strip()) | |
| except Exception as e: | |
| print(f"[AgentLayer][Voice] Cohere respond FAIL: {e}") | |
| reply = "Processing error Please repeat your question" | |
| _voice_conversation_history.append({"role": "CHATBOT", "content": reply}) | |
| return reply, None | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # NOTIFICATION / DISPATCH | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _fmt_msg(payload: dict, level: str) -> str: | |
| ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") | |
| risk = float(payload.get("risk", 0.0)) | |
| jd = payload.get("json_data") or {} | |
| summ = str(payload.get("summary", "No summary available.")) | |
| sep, line = "=" * 56, "-" * 56 | |
| header = (f"{sep}\n SCENESOLVER | FORENSIC ALERT DOSSIER\n" | |
| f" Level : {level}\n Risk Score : {round(risk*100)}%\n" | |
| f" Timestamp : {ts}\n{sep}") | |
| body = ["", "DETECTION SUMMARY", line] | |
| body += [f" {r.strip()}" for r in summ.split("\n") if r.strip()] | |
| if _has_pipeline_data(jd): | |
| body += ["", "STAGE-BY-STAGE BREAKDOWN", line] | |
| body += [f" {r}" for r in _build_plain_context(jd, round(risk*100)).split("\n")] | |
| footer = ( | |
| f"\n{line}\n ACTION REQUIRED\n Automated escalation triggered.\n" | |
| f"{line}\n DISCLAIMER: ML investigative aid. Human review required.\n{sep}" | |
| if level == "CRITICAL" else | |
| f"\n{line}\n DISCLAIMER: Investigative aid only. Human review required.\n{sep}" | |
| ) | |
| return "\n".join([header] + body + [footer]) | |
| def _fmt_discord(payload: dict, level: str) -> str: | |
| ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") | |
| risk = float(payload.get("risk", 0.0)) | |
| jd = payload.get("json_data") or {} | |
| summ = str(payload.get("summary", "No summary available.")) | |
| col = ":red_circle:" if level == "CRITICAL" else ":orange_circle:" | |
| lines = [ | |
| f"**SCENESOLVER FORENSIC ALERT DOSSIER** {col}", | |
| f"**Level:** `{level}` | **Risk:** `{round(risk*100)}%` | **Time:** `{ts}`", "```", | |
| ] | |
| lines += [f" {r.strip()}" for r in summ.split("\n") if r.strip()] | |
| if _has_pipeline_data(jd): | |
| lines.append("") | |
| lines += [f" {r}" for r in _build_plain_context(jd, round(risk*100)).split("\n")] | |
| lines += ["```"] | |
| if level == "CRITICAL": | |
| lines.append("**ACTION REQUIRED** β automated escalation triggered.") | |
| lines.append("*Investigative aid only β human review required before any action.*") | |
| return "\n".join(lines) | |
| def build_alert_payload(pipeline_output: dict) -> dict: | |
| risk = float(pipeline_output.get("risk_score", 0.0)) | |
| jd = pipeline_output.get("json_data") or {} | |
| parts = [ | |
| f"Status : {jd.get('status','UNKNOWN')}", | |
| f"Risk Level : {jd.get('risk_level','?')}", | |
| f"Risk Score : {round(risk*100)}%", | |
| ] | |
| for key, label in [("stage1","TF Binary"),("stage2","Anomaly Type"),("stage3","Weapon"), | |
| ("stage4","Audio"),("stage5","Motion"),("stage6","Fusion"),("stage7","RL3033")]: | |
| s = jd.get(key) or {} | |
| if not s: continue | |
| if key == "stage1": | |
| parts.append(f"{label:<12s}: {s.get('classification','?')} ({round(float(s.get('confidence',0))*100)}%)") | |
| elif key == "stage2" and s.get("class") not in ("Skipped", None): | |
| parts.append(f"{label:<12s}: {s.get('class','?')} ({round(float(s.get('confidence',0))*100)}%)") | |
| elif key == "stage3" and s.get("weapon_flag"): | |
| parts.append(f"{label:<12s}: {s.get('dominant_class','?')} (count={s.get('weapon_count',0)})") | |
| elif key == "stage4": | |
| parts.append(f"{label:<12s}: {s.get('status','?')} score={round(float(s.get('audio_score',0))*100)}%") | |
| elif key == "stage5": | |
| bhv = ", ".join(s.get("behaviors") or ["STATIC"]) | |
| parts.append(f"{label:<12s}: intensity={round(float(s.get('motion_intensity',0))*100)}% [{bhv}]") | |
| elif key == "stage6": | |
| parts.append(f"{label:<12s}: {round(float(s.get('fused_risk',0))*100)}% [{s.get('risk_level','?')}]") | |
| elif key == "stage7": | |
| parts.append(f"{label:<12s}: {round(float(s.get('final_risk',0))*100)}% [{s.get('status','?')}]") | |
| narr = jd.get("llava_narrative","") | |
| if narr: parts.append(f"Narrative : {str(narr)[:300]}") | |
| top_frame_path = None | |
| frame = pipeline_output.get("top_frame") | |
| if frame is not None: | |
| try: | |
| from PIL import Image as _I | |
| top_frame_path = "/tmp/agent_top_frame.jpg" | |
| _I.fromarray(frame).save(top_frame_path) | |
| except Exception: | |
| pass | |
| pdf_path = pipeline_output.get("pdf_path") | |
| if pdf_path and not os.path.exists(str(pdf_path)): | |
| pdf_path = None | |
| print(f"[AgentLayer] Payload risk={risk:.3f} jd_keys={list(jd.keys())[:8]}") | |
| return {"risk": risk, "summary": "\n".join(parts), "image": top_frame_path, | |
| "pdf": pdf_path, "json_data": jd} | |
| def determine_alert_level(risk: float) -> str: | |
| if risk >= THRESH_CRITICAL: return "CRITICAL" | |
| if risk >= THRESH_HIGH: return "HIGH" | |
| return "LOW" | |
| def send_telegram_text(text: str) -> bool: | |
| if not TELEGRAM_BOT_TOKEN or not TELEGRAM_CHAT_ID: return False | |
| url, ok = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage", True | |
| for chunk in [text[i:i+4000] for i in range(0, len(text), 4000)]: | |
| try: | |
| r = requests.post(url, data={"chat_id": TELEGRAM_CHAT_ID, "text": chunk, "parse_mode": ""}, timeout=15) | |
| if r.status_code != 200: ok = False | |
| except Exception: ok = False | |
| return ok | |
| def send_telegram_image(path: str, caption: str = "") -> bool: | |
| if not TELEGRAM_BOT_TOKEN or not TELEGRAM_CHAT_ID: return False | |
| if not path or not os.path.exists(path): return False | |
| try: | |
| with open(path, "rb") as f: | |
| r = requests.post(f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendPhoto", | |
| data={"chat_id": TELEGRAM_CHAT_ID, "caption": caption[:1000]}, files={"photo": f}, timeout=30) | |
| return r.status_code == 200 | |
| except Exception: return False | |
| def send_telegram_file(path: str, caption: str = "") -> bool: | |
| if not TELEGRAM_BOT_TOKEN or not TELEGRAM_CHAT_ID: return False | |
| if not path or not os.path.exists(path): return False | |
| try: | |
| with open(path, "rb") as f: | |
| r = requests.post(f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendDocument", | |
| data={"chat_id": TELEGRAM_CHAT_ID, "caption": caption[:1000]}, files={"document": f}, timeout=60) | |
| return r.status_code == 200 | |
| except Exception: return False | |
| def send_telegram_alert(payload: dict, level: str) -> bool: | |
| ok = send_telegram_text(_fmt_msg(payload, level)) | |
| if payload.get("image"): send_telegram_image(payload["image"], f"Peak anomaly frame [{level}]") | |
| if payload.get("pdf"): send_telegram_file(payload["pdf"], f"Forensic report [{level}]") | |
| return ok | |
| def send_discord_text(content: str) -> bool: | |
| if not DISCORD_WEBHOOK_URL or DISCORD_WEBHOOK_URL.startswith("YOUR_"): return False | |
| ok = True | |
| for chunk in [content[i:i+1900] for i in range(0, len(content), 1900)]: | |
| try: | |
| r = requests.post(DISCORD_WEBHOOK_URL, json={"content": chunk}, timeout=15) | |
| if r.status_code not in (200, 204): ok = False | |
| except Exception: ok = False | |
| return ok | |
| def send_discord_file(path: str, fname: str = None) -> bool: | |
| if not path or not os.path.exists(path): return False | |
| if not DISCORD_WEBHOOK_URL or DISCORD_WEBHOOK_URL.startswith("YOUR_"): return False | |
| try: | |
| with open(path, "rb") as f: | |
| r = requests.post(DISCORD_WEBHOOK_URL, files={"file": (fname or os.path.basename(path), f)}, timeout=60) | |
| return r.status_code in (200, 204) | |
| except Exception: return False | |
| def send_discord_alert(payload: dict, level: str) -> bool: | |
| ok = send_discord_text(_fmt_discord(payload, level)) | |
| if payload.get("image"): send_discord_file(payload["image"], "peak_frame.jpg") | |
| if payload.get("pdf"): send_discord_file(payload["pdf"], "forensic_report.pdf") | |
| return ok | |
| def send_email_alert(payload: dict, level: str) -> bool: | |
| if not ALERT_EMAIL_FROM or not ALERT_EMAIL_PASS: return False | |
| try: | |
| msg = MIMEMultipart() | |
| risk = float(payload.get("risk", 0.0)) | |
| msg["Subject"] = f"SceneSolver Forensic Alert [{level}] Risk {round(risk*100)}%" | |
| msg["From"], msg["To"] = ALERT_EMAIL_FROM, ALERT_EMAIL_TO | |
| msg.attach(MIMEText(_fmt_msg(payload, level), "plain", "utf-8")) | |
| for fp, fn in [(payload.get("image"), "peak_frame.jpg"), (payload.get("pdf"), "forensic_report.pdf")]: | |
| if fp and os.path.exists(str(fp)): | |
| with open(fp, "rb") as f: | |
| p = MIMEBase("application", "octet-stream"); p.set_payload(f.read()) | |
| encoders.encode_base64(p) | |
| p.add_header("Content-Disposition", f"attachment; filename={fn}") | |
| msg.attach(p) | |
| ctx = ssl.create_default_context() | |
| with smtplib.SMTP_SSL(ALERT_SMTP_SERVER, ALERT_SMTP_PORT, context=ctx) as srv: | |
| srv.login(ALERT_EMAIL_FROM, ALERT_EMAIL_PASS) | |
| srv.sendmail(ALERT_EMAIL_FROM, ALERT_EMAIL_TO, msg.as_string()) | |
| return True | |
| except Exception as e: | |
| print(f"[Email] FAIL: {e}"); return False | |
| def dispatch_all_channels(payload: dict, level: str) -> dict: | |
| print(f"[AgentLayer] Dispatching [{level}]...") | |
| return { | |
| "telegram": send_telegram_alert(payload, level), | |
| "discord": send_discord_alert(payload, level), | |
| "email": send_email_alert(payload, level), | |
| } | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # LFM2.5-Audio-1.5B β ASR + TTS | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| _SPECIAL_TOKEN_RE = re.compile(r'<\|[a-z_]+\|>') | |
| def _strip_special_tokens(text: str) -> str: | |
| return _SPECIAL_TOKEN_RE.sub('', text or '').strip() | |
| def _looks_like_english(text: str) -> bool: | |
| if not text or not text.strip(): | |
| return False | |
| stripped = text.strip() | |
| if re.search(r'[\u3040-\u30ff\u4e00-\u9fff\uac00-\ud7af\u0e00-\u0e7f\u0400-\u04FF]', stripped): | |
| return False | |
| non_ascii = sum(1 for ch in stripped if ord(ch) > 127) | |
| if non_ascii / max(len(stripped), 1) > 0.15: | |
| return False | |
| return True | |
| def _load_lfm_audio(): | |
| global _lfm_processor, _lfm_model | |
| if not LFM_AUDIO_AVAILABLE: | |
| return None, None | |
| if _lfm_model is not None and _lfm_processor is not None: | |
| return _lfm_processor, _lfm_model | |
| with _lfm_lock: | |
| if _lfm_model is None: | |
| print("[AgentLayer] Loading LFM2.5-Audio-1.5B (first use, ~3.65GB)...") | |
| _lfm_processor = LFM2AudioProcessor.from_pretrained(HF_LFM_AUDIO_REPO).eval() | |
| _lfm_model = LFM2AudioModel.from_pretrained(HF_LFM_AUDIO_REPO).eval() | |
| if torch is not None and torch.cuda.is_available(): | |
| try: | |
| _lfm_model = _lfm_model.to("cuda") | |
| print("[AgentLayer] LFM2.5-Audio moved to CUDA") | |
| except Exception as e: | |
| print(f"[AgentLayer] CUDA move skipped: {e}") | |
| print("[AgentLayer] LFM2.5-Audio-1.5B loaded") | |
| return _lfm_processor, _lfm_model | |
| def _lfm_transcribe(audio_path: str) -> str: | |
| if not audio_path or not os.path.exists(str(audio_path)): | |
| return "" | |
| processor, model = _load_lfm_audio() | |
| if model is None or torchaudio is None or torch is None: | |
| print("[LFM-ASR] model or torch/torchaudio unavailable") | |
| return "" | |
| try: | |
| wav, sr = torchaudio.load(audio_path) | |
| with _lfm_lock, torch.inference_mode(): | |
| chat = ChatState(processor) | |
| chat.new_turn("system") | |
| chat.add_text("Perform ASR.") | |
| chat.end_turn() | |
| chat.new_turn("user") | |
| chat.add_audio(wav, sr) | |
| chat.end_turn() | |
| chat.new_turn("assistant") | |
| chunks = [] | |
| for t in model.generate_sequential(**chat, max_new_tokens=256): | |
| if t.numel() == 1: | |
| chunks.append(processor.text.decode(t)) | |
| text = "".join(chunks) | |
| text = _strip_special_tokens(text) | |
| if not _looks_like_english(text): | |
| print(f"[LFM-ASR] Rejected non-English/garbled output: '{text}'") | |
| return "" | |
| print(f"[LFM-ASR] {text}") | |
| return text | |
| except Exception as e: | |
| print(f"[LFM-ASR] FAIL: {e}") | |
| return "" | |
| _LFM_VOICE_SYSTEM_PROMPT = ( | |
| "Perform TTS. Use the US male voice. " | |
| "Speak calmly, confidently, warmly, and reassuringly. " | |
| "Use a steady, mature male delivery." | |
| ) | |
| def lfm_text_to_speech(text: str, out_path: str = "/tmp/sherl0ck_reply.wav"): | |
| if not text or not text.strip(): | |
| return None | |
| if torchaudio is None or torch is None: | |
| print("[LFM-TTS] torch/torchaudio unavailable") | |
| return None | |
| text = _sanitise_for_tts(text) | |
| processor, model = _load_lfm_audio() | |
| if model is None: | |
| print("[LFM-TTS] model unavailable") | |
| return None | |
| try: | |
| with _lfm_lock, torch.inference_mode(): | |
| chat = ChatState(processor) | |
| chat.new_turn("system") | |
| chat.add_text(_LFM_VOICE_SYSTEM_PROMPT) | |
| chat.end_turn() | |
| chat.new_turn("user") | |
| chat.add_text(text) | |
| chat.end_turn() | |
| chat.new_turn("assistant") | |
| audio_out = [] | |
| for token in model.generate_sequential( | |
| **chat, max_new_tokens=512, audio_temperature=0.8, audio_top_k=64, | |
| ): | |
| if token.numel() > 1: | |
| audio_out.append(token) | |
| if not audio_out: | |
| print("[LFM-TTS] no audio tokens generated") | |
| return None | |
| codes_list = audio_out[:-1] if len(audio_out) > 1 else audio_out | |
| if not codes_list: | |
| print("[LFM-TTS] no audio codes after trimming EOA") | |
| return None | |
| audio_codes = torch.stack(codes_list, 1).unsqueeze(0) | |
| waveform = processor.decode(audio_codes) | |
| wf = waveform.detach().float().cpu() | |
| if wf.numel() < 240: | |
| print(f"[LFM-TTS] waveform too short numel={wf.numel()}") | |
| return None | |
| if not torch.isfinite(wf).all(): | |
| print("[LFM-TTS] waveform has non-finite values") | |
| return None | |
| if wf.dim() == 1: | |
| wf = wf.unsqueeze(0) | |
| torchaudio.save(out_path, wf, 24000) | |
| try: | |
| if not os.path.exists(out_path) or os.path.getsize(out_path) < 44: | |
| print("[LFM-TTS] saved file missing/too small") | |
| return None | |
| with open(out_path, "rb") as _bf: | |
| _hdr = _bf.read(12) | |
| if _hdr[0:4] != b"RIFF" or _hdr[8:12] != b"WAVE": | |
| print("[LFM-TTS] saved file not RIFF/WAVE") | |
| return None | |
| print(f"[LFM-TTS] saved ok bytes={os.path.getsize(out_path)}") | |
| except Exception as _ve: | |
| print(f"[LFM-TTS] saved file check fail: {_ve}") | |
| return None | |
| return out_path | |
| except Exception as e: | |
| print(f"[LFM-TTS] FAIL: {e}") | |
| return None | |
| def _caller_tune_uri() -> str: | |
| path = (CALLER_TUNE_PATH or "").strip() | |
| if not path or not os.path.exists(path): return "" | |
| try: | |
| ext = os.path.splitext(path)[1].lower() | |
| mime = "audio/mpeg" if ext == ".mp3" else "audio/wav" | |
| with open(path, "rb") as f: | |
| data = base64.b64encode(f.read()).decode() | |
| return f"data:{mime};base64,{data}" | |
| except Exception as e: | |
| print(f"[CallerTune] FAIL: {e}"); return "" | |
| def _make_synth_ring_bytes() -> bytes: | |
| sr = 22050 | |
| duration = 2.4 | |
| freq = 480 | |
| n = int(sr * duration) | |
| buf = io.BytesIO() | |
| with wave.open(buf, 'wb') as wf: | |
| wf.setnchannels(1) | |
| wf.setsampwidth(2) | |
| wf.setframerate(sr) | |
| for i in range(n): | |
| t = i / sr | |
| in_burst = (t < 0.40) or (0.45 <= t < 0.85) | |
| amp = int(28000 * math.sin(2 * math.pi * freq * t)) if in_burst else 0 | |
| wf.writeframes(struct.pack('<h', amp)) | |
| return buf.getvalue() | |
| def _stream_ringtone(): | |
| CHUNK = 16384 | |
| MAX_SEC = 60 | |
| start = time.time() | |
| path = (CALLER_TUNE_PATH or "").strip() | |
| if path and os.path.exists(path): | |
| print(f"[Ringtone] Streaming file: {path}") | |
| try: | |
| with open(path, "rb") as f: | |
| raw = f.read() | |
| except Exception as e: | |
| print(f"[Ringtone] File read failed: {e}") | |
| raw = _make_synth_ring_bytes() | |
| else: | |
| print("[Ringtone] File not found, using synth ring") | |
| raw = _make_synth_ring_bytes() | |
| header_yielded = False | |
| while (time.time() - start) < MAX_SEC: | |
| if _AGENT_STATE.get("ring_stopped", False): | |
| print("[Ringtone] Ring stopped, stopping stream") | |
| break | |
| if not header_yielded: | |
| yield raw | |
| header_yielded = True | |
| else: | |
| yield raw | |
| time.sleep(2.5) | |
| def _stop_ringtone(): | |
| return b"" | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # AGENT STATE + TTS EXECUTOR | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| _AGENT_STATE = { | |
| "payload": {}, | |
| "level": "LOW", | |
| "voice_live": False, | |
| "text_live": False, | |
| "ring_stopped": False, | |
| "voice_session_id": "", | |
| "voice_turn": 0, | |
| } | |
| import concurrent.futures as _cf | |
| _tts_executor = _cf.ThreadPoolExecutor(max_workers=2, thread_name_prefix="tts") | |
| def _tts_blocking(text: str, out_path: str = "/tmp/sherl0ck_reply.wav"): | |
| try: | |
| future = _tts_executor.submit(lfm_text_to_speech, text, out_path) | |
| return future.result(timeout=90) | |
| except _cf.TimeoutError: | |
| print("[TTS] Timeout after 90s") | |
| return None | |
| except Exception as e: | |
| print(f"[TTS] Thread error: {e}") | |
| return None | |
| def _ffmpeg_to_wav(src_path: str, dst_path: str, sr: int = 24000) -> bool: | |
| try: | |
| cmd = [ | |
| "ffmpeg", "-y", "-loglevel", "error", | |
| "-i", src_path, | |
| "-ac", "1", "-ar", str(sr), | |
| "-c:a", "pcm_s16le", | |
| dst_path, | |
| ] | |
| r = subprocess.run(cmd, capture_output=True, text=True, timeout=60) | |
| if r.returncode != 0: | |
| print(f"[VoiceTransport] ffmpeg FAIL rc={r.returncode} stderr={(r.stderr or '')[:400]}") | |
| return False | |
| if not os.path.exists(dst_path) or os.path.getsize(dst_path) < 44: | |
| print("[VoiceTransport] ffmpeg produced empty/missing wav") | |
| return False | |
| return True | |
| except Exception as e: | |
| print(f"[VoiceTransport] ffmpeg exception: {e}") | |
| return False | |
| def _wav_duration_sec(path: str) -> float: | |
| try: | |
| with wave.open(path, "rb") as wf: | |
| return wf.getnframes() / float(wf.getframerate() or 1) | |
| except Exception: | |
| return 0.0 | |
| def _validate_wav_file(path: str, expect_sr: int = 24000) -> tuple: | |
| """Return (ok, meta_or_error). Rejects non-RIFF / empty / absurd files.""" | |
| try: | |
| if not path or not os.path.exists(path): | |
| return False, "missing" | |
| sz = os.path.getsize(path) | |
| if sz < 44: | |
| return False, "too_small" | |
| with open(path, "rb") as f: | |
| hdr = f.read(12) | |
| if len(hdr) < 12 or hdr[0:4] != b"RIFF" or hdr[8:12] != b"WAVE": | |
| return False, "not_riff_wave" | |
| with wave.open(path, "rb") as wf: | |
| ch = wf.getnchannels() | |
| sw = wf.getsampwidth() | |
| sr = wf.getframerate() | |
| nframes = wf.getnframes() | |
| dur = nframes / float(sr or 1) | |
| if ch < 1 or sw < 1 or sr < 8000 or nframes < 1: | |
| return False, f"bad_params ch={ch} sw={sw} sr={sr} n={nframes}" | |
| # Allow nearby sample rates; ffmpeg should have forced 24k | |
| if abs(int(sr) - int(expect_sr)) > 100 and int(sr) not in (16000, 22050, 24000, 44100, 48000): | |
| return False, f"unexpected_sr={sr}" | |
| return True, {"duration": float(dur), "sr": int(sr), "channels": int(ch), | |
| "sampwidth": int(sw), "nframes": int(nframes), "bytes": int(sz)} | |
| except Exception as e: | |
| return False, f"exc:{e}" | |
| # Serialize voice turns so opening / barge-in / double-submit cannot overlap LFM+Cohere | |
| _voice_turn_lock = threading.Lock() | |
| _VOICE_MAX_BODY_BYTES = 3 * 1024 * 1024 # 3 MB raw WebM upper bound | |
| _processed_turns: set = set() | |
| _processed_turns_lock = threading.Lock() | |
| def process_voice_bytes(raw: bytes, session_id: str = "", turn_id: str = "", | |
| is_opening: bool = False) -> dict: | |
| t0 = time.time() | |
| result = { | |
| "ok": False, | |
| "status": "error", | |
| "transcript": "", | |
| "reply": "", | |
| "audio_b64": "", | |
| "audio_mime": "audio/wav", | |
| "turn_id": turn_id, | |
| "session_id": session_id, | |
| "action": None, | |
| "error": "", | |
| "timings_ms": {}, | |
| } | |
| nbytes = len(raw) if raw else 0 | |
| print(f"[VoiceTransport] RECEIVED bytes={nbytes} " | |
| f"session={session_id[:8] if session_id else '-'} turn={turn_id}") | |
| # Reject oversized bodies | |
| if nbytes > _VOICE_MAX_BODY_BYTES: | |
| result["error"] = "body_too_large" | |
| result["status"] = "body_too_large" | |
| print(f"[VoiceTransport] REJECT body_too_large bytes={nbytes}") | |
| return result | |
| # Duplicate turn guard (same session+turn) | |
| turn_key = f"{session_id}:{turn_id}:{int(bool(is_opening))}" | |
| with _processed_turns_lock: | |
| if turn_id and turn_key in _processed_turns and not is_opening: | |
| result["error"] = "duplicate_turn" | |
| result["status"] = "duplicate_turn" | |
| print(f"[VoiceTransport] REJECT duplicate_turn {turn_key}") | |
| return result | |
| if turn_id: | |
| _processed_turns.add(turn_key) | |
| # bound memory | |
| if len(_processed_turns) > 500: | |
| _processed_turns.clear() | |
| # Must hold voice_live for non-opening (session active) | |
| if not is_opening and not _AGENT_STATE.get("voice_live"): | |
| result["error"] = "call_not_active" | |
| result["status"] = "call_not_active" | |
| print("[VoiceTransport] REJECT call_not_active") | |
| return result | |
| # Serialize entire turn (LFM lock alone is not enough across ASR+Cohere+TTS) | |
| if not _voice_turn_lock.acquire(blocking=True, timeout=120): | |
| result["error"] = "turn_busy" | |
| result["status"] = "turn_busy" | |
| print("[VoiceTransport] REJECT turn_busy") | |
| return result | |
| try: | |
| return _process_voice_bytes_locked(raw, session_id, turn_id, is_opening, result, t0) | |
| finally: | |
| _voice_turn_lock.release() | |
| def _process_voice_bytes_locked(raw, session_id, turn_id, is_opening, result, t0): | |
| if is_opening: | |
| try: | |
| payload = _AGENT_STATE.get("payload", {}) or _voice_active_payload | |
| level = _AGENT_STATE.get("level", "LOW") | |
| print("[Voice] OPENING START") | |
| opening = voice_agent_init(payload, level) | |
| print(f"[Voice] OPENING TEXT chars={len(opening)}") | |
| out_path = f"/tmp/sherl0ck_opening_{int(time.time()*1000)}.wav" | |
| print("[Voice] TTS_START (opening)") | |
| tts_path = _tts_blocking(opening, out_path) | |
| print(f"[Voice] TTS_DONE path={tts_path}") | |
| if tts_path and os.path.exists(tts_path): | |
| vok, vmeta = _validate_wav_file(tts_path, expect_sr=24000) | |
| if not vok: | |
| print(f"[Voice] OPENING TTS_INVALID_WAV {vmeta}") | |
| result.update({"ok": True, "status": "ok_no_audio", "reply": opening, | |
| "error": f"tts_invalid_wav:{vmeta}"}) | |
| else: | |
| with open(tts_path, "rb") as f: | |
| ab = f.read() | |
| result.update({ | |
| "ok": True, | |
| "status": "ok", | |
| "transcript": "", | |
| "reply": opening, | |
| "audio_b64": base64.b64encode(ab).decode("ascii"), | |
| "audio_mime": "audio/wav", | |
| }) | |
| print(f"[Voice] AUDIO_RESPONSE bytes={len(ab)} (opening)") | |
| else: | |
| print("[Voice] TTS_FAILURE (opening)") | |
| result.update({"ok": True, "status": "ok_no_audio", "reply": opening, | |
| "error": "tts_failed"}) | |
| result["timings_ms"]["total"] = int((time.time() - t0) * 1000) | |
| return result | |
| except Exception as e: | |
| print(f"[Voice] OPENING FAIL: {e}") | |
| traceback.print_exc() | |
| result["error"] = str(e) | |
| return result | |
| if not raw or len(raw) < 64: | |
| print("[VoiceTransport] Empty or tiny packet β rejecting") | |
| result["error"] = "empty_packet" | |
| result["status"] = "empty" | |
| return result | |
| stamp = int(time.time() * 1000) | |
| webm_path = f"/tmp/ssvoice_in_{stamp}.webm" | |
| wav_path = f"/tmp/ssvoice_in_{stamp}.wav" | |
| try: | |
| with open(webm_path, "wb") as f: | |
| f.write(raw) | |
| print(f"[VoiceTransport] WEBM bytes={os.path.getsize(webm_path)} path={webm_path}") | |
| except Exception as e: | |
| result["error"] = f"write_webm_failed:{e}" | |
| return result | |
| print("[Voice] FFMPEG_START") | |
| ok_conv = _ffmpeg_to_wav(webm_path, wav_path, sr=24000) | |
| if not ok_conv: | |
| # HARD FAILURE β never rename/copy WebM to .wav | |
| result["error"] = "ffmpeg_failed" | |
| result["status"] = "convert_error" | |
| print("[Voice] FFMPEG_FAILURE") | |
| try: | |
| if os.path.exists(webm_path): | |
| os.remove(webm_path) | |
| except Exception: | |
| pass | |
| return result | |
| print("[Voice] FFMPEG_SUCCESS") | |
| wav_sz = os.path.getsize(wav_path) | |
| # Validate real WAV structure before LFM | |
| wav_ok, wav_meta = _validate_wav_file(wav_path, expect_sr=24000) | |
| if not wav_ok: | |
| result["error"] = f"invalid_wav:{wav_meta}" | |
| result["status"] = "invalid_wav" | |
| print(f"[Voice] WAV_INVALID {wav_meta}") | |
| for p in (webm_path, wav_path): | |
| try: | |
| if os.path.exists(p): | |
| os.remove(p) | |
| except Exception: | |
| pass | |
| return result | |
| dur = float(wav_meta.get("duration", 0.0)) | |
| print(f"[VoiceTransport] WAV bytes={wav_sz} duration={dur:.3f}s sr={wav_meta.get('sr')} ch={wav_meta.get('channels')}") | |
| result["timings_ms"]["convert"] = int((time.time() - t0) * 1000) | |
| if dur < 0.15 or wav_sz < 1000: | |
| print("[VoiceTransport] utterance too short β skip ASR") | |
| result["status"] = "too_short" | |
| result["error"] = "utterance_too_short" | |
| result["ok"] = True | |
| for p in (webm_path, wav_path): | |
| try: | |
| if os.path.exists(p): | |
| os.remove(p) | |
| except Exception: | |
| pass | |
| return result | |
| print("[Voice] ASR_START") | |
| t_asr = time.time() | |
| transcript = _lfm_transcribe(wav_path) | |
| result["timings_ms"]["asr"] = int((time.time() - t_asr) * 1000) | |
| print(f"[Voice] ASR_DONE transcript={transcript!r}") | |
| result["transcript"] = transcript or "" | |
| if not transcript or len(transcript.strip()) < 2: | |
| result["status"] = "no_speech" | |
| result["error"] = "no_speech" | |
| result["ok"] = True | |
| print("[Voice] No usable speech β returning to LISTENING") | |
| return result | |
| print("[Voice] COHERE_START") | |
| t_co = time.time() | |
| reply, action = voice_agent_respond(transcript) | |
| result["timings_ms"]["cohere"] = int((time.time() - t_co) * 1000) | |
| print(f"[Voice] COHERE_DONE reply={reply[:120]!r} action={action}") | |
| result["reply"] = reply | |
| result["action"] = action | |
| print("[Voice] TTS_START") | |
| t_tts = time.time() | |
| out_path = f"/tmp/sherl0ck_reply_{stamp}.wav" | |
| tts_path = _tts_blocking(reply, out_path) | |
| result["timings_ms"]["tts"] = int((time.time() - t_tts) * 1000) | |
| print(f"[Voice] TTS_DONE path={tts_path}") | |
| if tts_path and os.path.exists(tts_path): | |
| vok, vmeta = _validate_wav_file(tts_path, expect_sr=24000) | |
| if not vok: | |
| print(f"[Voice] TTS_INVALID_WAV {vmeta}") | |
| result["ok"] = True | |
| result["status"] = "ok_no_audio" | |
| result["error"] = f"tts_invalid_wav:{vmeta}" | |
| else: | |
| with open(tts_path, "rb") as f: | |
| ab = f.read() | |
| result["audio_b64"] = base64.b64encode(ab).decode("ascii") | |
| result["audio_mime"] = "audio/wav" | |
| print(f"[Voice] AUDIO_RESPONSE bytes={len(ab)} turn={turn_id}") | |
| result["ok"] = True | |
| result["status"] = "ok" | |
| else: | |
| result["ok"] = True | |
| result["status"] = "ok_no_audio" | |
| result["error"] = "tts_failed" | |
| print("[Voice] TTS_FAILURE") | |
| result["timings_ms"]["total"] = int((time.time() - t0) * 1000) | |
| for p in (webm_path, wav_path): | |
| try: | |
| if os.path.exists(p): | |
| os.remove(p) | |
| except Exception: | |
| pass | |
| return result | |
| _SSVOICE_ROUTES_REGISTERED = False | |
| def _register_ssvoice_routes(fastapi_app): | |
| global _SSVOICE_ROUTES_REGISTERED | |
| if _SSVOICE_ROUTES_REGISTERED: | |
| return | |
| try: | |
| from fastapi import Request | |
| from fastapi.responses import JSONResponse | |
| from fastapi.middleware.cors import CORSMiddleware | |
| except ImportError: | |
| print("[VoiceTransport] fastapi import failed β routes not registered") | |
| return | |
| try: | |
| fastapi_app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| allow_credentials=True, | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| except Exception as e: | |
| print(f"[VoiceTransport] CORS middleware note: {e}") | |
| async def ssvoice_turn(request: Request): | |
| import anyio | |
| try: | |
| session_id = request.headers.get("x-ss-session", "") or "" | |
| turn_id = request.headers.get("x-ss-turn", "") or "" | |
| is_opening = request.headers.get("x-ss-opening", "0") == "1" | |
| # Reject absurd Content-Length early when present | |
| cl = request.headers.get("content-length") | |
| if cl is not None: | |
| try: | |
| if int(cl) > _VOICE_MAX_BODY_BYTES: | |
| return JSONResponse({"ok": False, "status": "body_too_large", | |
| "error": "body_too_large"}, status_code=413) | |
| except Exception: | |
| pass | |
| raw = await request.body() | |
| print(f"[VoiceTransport] REQUEST_ENTERED " | |
| f"session_id={session_id} body_bytes={len(raw)} " | |
| f"opening={is_opening} turn={turn_id}") | |
| if len(raw) > _VOICE_MAX_BODY_BYTES: | |
| return JSONResponse({"ok": False, "status": "body_too_large", | |
| "error": "body_too_large"}, status_code=413) | |
| active = _AGENT_STATE.get("voice_session_id", "") | |
| if active and session_id and session_id != active and not is_opening: | |
| print(f"[VoiceTransport] stale session {session_id[:8]} != {active[:8]}") | |
| return JSONResponse({"ok": False, "status": "stale_session", | |
| "error": "stale_session", "turn_id": turn_id}, status_code=409) | |
| # Run blocking ASR/LLM/TTS off the event loop | |
| out = await anyio.to_thread.run_sync( | |
| process_voice_bytes, raw, session_id, turn_id, is_opening | |
| ) | |
| return JSONResponse(out) | |
| except Exception as e: | |
| print(f"[VoiceTransport] /ssvoice/turn EXCEPTION: {e}") | |
| traceback.print_exc() | |
| return JSONResponse({"ok": False, "status": "error", "error": str(e)}, status_code=500) | |
| async def ssvoice_health(): | |
| return JSONResponse({ | |
| "ok": True, | |
| "lfm": LFM_AUDIO_AVAILABLE, | |
| "cohere": COHERE_AVAILABLE, | |
| "voice_live": bool(_AGENT_STATE.get("voice_live")), | |
| "session": (_AGENT_STATE.get("voice_session_id") or "")[:8], | |
| }) | |
| async def ssvoice_end(request: Request): | |
| sid = request.headers.get("x-ss-session", "") | |
| print(f"[VoiceTransport] END session={sid[:8] if sid else '-'}") | |
| _AGENT_STATE["voice_live"] = False | |
| if sid and sid == _AGENT_STATE.get("voice_session_id"): | |
| _AGENT_STATE["voice_session_id"] = "" | |
| return JSONResponse({"ok": True}) | |
| async def ssvoice_accept(request: Request): | |
| """Mint/return the authoritative voice session. Browser calls this from the Accept gesture.""" | |
| global _voice_active_payload | |
| try: | |
| payload = _AGENT_STATE.get("payload", {}) or {} | |
| level = _AGENT_STATE.get("level", "LOW") | |
| # Reuse existing live session if already minted by Gradio accept_call | |
| sid = _AGENT_STATE.get("voice_session_id") or "" | |
| if not sid or not _AGENT_STATE.get("voice_live"): | |
| sid = uuid.uuid4().hex | |
| _AGENT_STATE["voice_session_id"] = sid | |
| _AGENT_STATE["voice_turn"] = 0 | |
| _AGENT_STATE["voice_live"] = True | |
| _AGENT_STATE["ring_stopped"] = True | |
| _voice_active_payload = payload | |
| print(f"[VoiceTransport] ACCEPT session_id={sid} level={level} " | |
| f"has_payload={bool(payload)}") | |
| return JSONResponse({ | |
| "ok": True, | |
| "session_id": sid, | |
| "level": level, | |
| "risk": float(payload.get("risk", 0.0) or 0.0), | |
| }) | |
| except Exception as e: | |
| print(f"[VoiceTransport] ACCEPT FAIL: {e}") | |
| traceback.print_exc() | |
| return JSONResponse({"ok": False, "error": str(e)}, status_code=500) | |
| _SSVOICE_ROUTES_REGISTERED = True | |
| print("[VoiceTransport] Routes registered: POST /ssvoice/turn GET /ssvoice/health " | |
| "POST /ssvoice/end POST /ssvoice/accept") | |
| AGENT_CSS = APP_CSS + """ | |
| @keyframes phone-shake { | |
| 0%,100% { transform:rotate(0deg) scale(1); } | |
| 10%,30%,50%,70% { transform:rotate(-14deg) scale(1.08); } | |
| 20%,40%,60%,80% { transform:rotate(14deg) scale(1.08); } | |
| 90% { transform:rotate(-6deg) scale(1); } | |
| } | |
| @keyframes ring-pulse { | |
| 0%,100% { box-shadow:0 0 0 0 rgba(255,69,0,0); } | |
| 50% { box-shadow:0 0 0 18px rgba(255,69,0,0); } | |
| } | |
| @keyframes fade-in-down { | |
| from { opacity:0; transform:translateY(-18px) scale(.97); } | |
| to { opacity:1; transform:translateY(0) scale(1); } | |
| } | |
| @keyframes pulse-dot { | |
| 0%,100% { opacity:1; transform:scale(1); } 50% { opacity:.25; transform:scale(.65); } | |
| } | |
| @keyframes scan-line { | |
| 0% { transform:translateY(-100%); } 100% { transform:translateY(100vh); } | |
| } | |
| @keyframes progress-bar { | |
| 0% { transform:scaleX(0); } 100% { transform:scaleX(1); } | |
| } | |
| @keyframes glow-border { | |
| 0%,100% { border-color:rgba(255,69,0,.5); } | |
| 50% { border-color:rgba(255,69,0,1); box-shadow:0 0 20px rgba(255,69,0,.18); } | |
| } | |
| @keyframes status-flash { | |
| 0%,100% { opacity:1; } 50% { opacity:.55; } | |
| } | |
| #ss-overlay-root { | |
| position:fixed !important; top:0 !important; left:0 !important; | |
| width:0 !important; height:0 !important; overflow:visible !important; | |
| pointer-events:none !important; z-index:2147483647 !important; | |
| } | |
| #ss-call-card { | |
| position:fixed !important; top:20px !important; right:20px !important; | |
| width:360px !important; pointer-events:all !important; | |
| background:rgba(6,3,1,.97) !important; backdrop-filter:blur(16px) !important; | |
| border:1px solid rgba(255,69,0,.7) !important; border-radius:4px !important; | |
| overflow:hidden !important; | |
| animation:fade-in-down .3s cubic-bezier(.2,.8,.3,1), ring-pulse 2.4s ease-in-out infinite !important; | |
| box-shadow:0 24px 64px rgba(0,0,0,.95) !important; | |
| } | |
| #ss-call-card .cc-scan { | |
| position:absolute !important; top:0 !important; left:0 !important; | |
| width:100% !important; height:2px !important; | |
| background:linear-gradient(90deg,transparent,rgba(255,69,0,.5),transparent) !important; | |
| animation:scan-line 3s linear infinite !important; pointer-events:none !important; | |
| } | |
| #ss-call-card .cc-header { | |
| display:flex !important; align-items:center !important; justify-content:space-between !important; | |
| padding:6px 14px !important; background:rgba(255,69,0,.07) !important; | |
| border-bottom:1px solid rgba(255,69,0,.18) !important; | |
| } | |
| #ss-call-card .cc-header-label { | |
| font-family:var(--mono) !important; font-size:.5rem !important; letter-spacing:.2em !important; | |
| text-transform:uppercase !important; color:rgba(255,69,0,.85) !important; font-weight:700 !important; | |
| } | |
| #ss-call-card .cc-header-dot { | |
| width:6px !important; height:6px !important; border-radius:50% !important; | |
| background:#FF4500 !important; animation:pulse-dot 1s ease-in-out infinite !important; | |
| } | |
| #ss-call-card .cc-body { | |
| padding:18px 18px 14px !important; display:flex !important; align-items:center !important; gap:16px !important; | |
| } | |
| #ss-call-card .cc-phone-icon { | |
| width:52px !important; height:52px !important; border-radius:50% !important; | |
| background:rgba(255,69,0,.1) !important; border:1px solid rgba(255,69,0,.3) !important; | |
| display:flex !important; align-items:center !important; justify-content:center !important; | |
| animation:phone-shake 1.2s ease-in-out infinite !important; flex-shrink:0 !important; | |
| } | |
| #ss-call-card .cc-phone-icon.stopped { animation:none !important; } | |
| #ss-call-card .cc-info-title { | |
| font-family:var(--serif) !important; font-size:.95rem !important; font-weight:700 !important; | |
| color:#fff !important; line-height:1.3 !important; | |
| } | |
| #ss-call-card .cc-info-sub { | |
| font-family:var(--mono) !important; font-size:.6rem !important; | |
| color:rgba(255,255,255,.45) !important; margin-top:4px !important; | |
| } | |
| #ss-call-card .cc-risk-badge { | |
| font-family:var(--mono) !important; font-size:.65rem !important; font-weight:700 !important; | |
| color:#FF4500 !important; background:rgba(255,69,0,.09) !important; | |
| border:1px solid rgba(255,69,0,.3) !important; border-radius:3px !important; | |
| padding:2px 8px !important; margin-top:5px !important; display:inline-block !important; | |
| animation:status-flash 2s ease-in-out infinite !important; | |
| } | |
| #ss-call-card .cc-actions { display:flex !important; gap:8px !important; padding:0 18px 18px !important; } | |
| .cc-btn-accept { | |
| flex:1 !important; background:linear-gradient(135deg,#2e7d32,#388e3c) !important; | |
| color:#fff !important; font-family:var(--mono) !important; font-size:.6rem !important; | |
| letter-spacing:.1em !important; font-weight:700 !important; text-transform:uppercase !important; | |
| border:none !important; border-radius:3px !important; padding:11px 0 !important; cursor:pointer !important; | |
| } | |
| .cc-btn-accept:hover { background:linear-gradient(135deg,#388e3c,#43a047) !important; } | |
| .cc-btn-dismiss { | |
| background:rgba(255,255,255,.05) !important; color:rgba(255,255,255,.4) !important; | |
| font-family:var(--mono) !important; font-size:.6rem !important; letter-spacing:.1em !important; | |
| font-weight:600 !important; text-transform:uppercase !important; | |
| border:1px solid rgba(255,255,255,.1) !important; border-radius:3px !important; | |
| padding:11px 14px !important; cursor:pointer !important; | |
| } | |
| .cc-btn-dismiss:hover { background:rgba(255,255,255,.09) !important; } | |
| #ss-call-card .cc-progress { height:2px !important; background:rgba(255,69,0,.1) !important; } | |
| #ss-call-card .cc-progress-fill { | |
| height:100% !important; width:100% !important; | |
| background:linear-gradient(90deg,#FF4500,#FF8C00) !important; | |
| transform-origin:left !important; animation:progress-bar 4s linear infinite !important; | |
| } | |
| .agent-page { max-width:860px; margin:0 auto; padding:0 28px 80px; font-family:var(--serif); } | |
| .agent-sec-label { | |
| font-family:var(--mono); font-size:.62rem; letter-spacing:.14em; text-transform:uppercase; | |
| color:#ddd; margin-bottom:10px; font-weight:800; border-left:3px solid var(--orange); padding-left:10px; | |
| } | |
| .agent-status-card { | |
| background:var(--surface); border:1px solid var(--border); border-radius:3px; padding:20px 22px; | |
| display:flex; align-items:flex-start; justify-content:space-between; gap:16px; | |
| position:relative; overflow:hidden; | |
| } | |
| .agent-status-card.critical { border-color:rgba(255,69,0,.4); background:rgba(255,69,0,.04); animation:glow-border 2s ease-in-out infinite; } | |
| .agent-status-card.high { border-color:rgba(255,140,0,.35); background:rgba(255,140,0,.03); } | |
| .agent-dep-chips { display:flex; flex-wrap:wrap; gap:5px; margin-top:10px; } | |
| .agent-dep-chip { | |
| font-family:var(--mono); font-size:.5rem; letter-spacing:.08em; | |
| background:rgba(255,255,255,.05); border:1px solid rgba(255,255,255,.1); | |
| border-radius:3px; padding:3px 9px; display:flex; align-items:center; gap:5px; | |
| } | |
| .agent-call-strip { | |
| border-radius:3px; padding:14px 18px; display:flex; align-items:center; gap:14px; | |
| background:rgba(255,69,0,.06); border:1px solid rgba(255,69,0,.28); | |
| margin:12px 0 0; cursor:pointer; user-select:none; | |
| } | |
| .strip-phone { animation:phone-shake 1.2s ease-in-out infinite; flex-shrink:0; } | |
| .agent-dual-panel { display:grid; grid-template-columns:1fr 1fr; gap:20px; margin-top:8px; align-items:start; } | |
| @media (max-width:700px) { .agent-dual-panel { grid-template-columns:1fr; } } | |
| .agent-panel { background:var(--surface); border:1px solid var(--border); border-radius:3px; padding:18px 18px 16px; } | |
| .agent-panel.voice-panel { border-color:rgba(76,175,80,.15); background:rgba(76,175,80,.02); } | |
| .agent-panel.text-panel { border-color:rgba(100,149,237,.18); background:rgba(100,149,237,.02); } | |
| .panel-header { | |
| font-family:var(--mono); font-size:.52rem; letter-spacing:.16em; text-transform:uppercase; | |
| font-weight:700; margin-bottom:10px; display:flex; align-items:center; gap:8px; | |
| } | |
| .panel-header.voice-header { color:rgba(76,175,80,.85); } | |
| .panel-header.text-header { color:rgba(100,149,237,.85); } | |
| .experimental-badge { | |
| font-family:var(--mono); font-size:.42rem; letter-spacing:.1em; text-transform:uppercase; | |
| font-weight:700; color:rgba(255,193,7,.9); background:rgba(255,193,7,.08); | |
| border:1px solid rgba(255,193,7,.25); border-radius:3px; padding:2px 7px; margin-left:4px; | |
| } | |
| .panel-desc { | |
| font-family:var(--mono); font-size:.52rem; letter-spacing:.05em; color:rgba(255,255,255,.25); | |
| margin-bottom:12px; line-height:1.85; | |
| } | |
| .chat-display { | |
| background:#050505; border:1px solid var(--border); border-radius:3px; padding:16px 18px; | |
| min-height:280px; max-height:440px; overflow-y:auto; display:flex; flex-direction:column; gap:16px; | |
| } | |
| .chat-display .msg-agent { display:flex; flex-direction:column; gap:5px; align-self:flex-start; max-width:96%; } | |
| .chat-display .msg-agent .msg-lbl { | |
| font-family:var(--mono); font-size:.55rem; letter-spacing:.1em; text-transform:uppercase; | |
| color:var(--orange); font-weight:600; display:flex; align-items:center; gap:6px; | |
| } | |
| .chat-display .msg-agent .msg-lbl .lbl-dot { | |
| width:6px; height:6px; border-radius:50%; background:var(--orange); flex-shrink:0; display:inline-block; | |
| } | |
| .chat-display .msg-agent .msg-lbl .brand-name { display:inline-flex; align-items:baseline; } | |
| .chat-display .msg-agent .msg-lbl .brand-zero { font-family:'Times New Roman',Times,serif; font-weight:900; font-size:1.1em; } | |
| .chat-display .msg-agent .msg-bubble { | |
| background:#080808; border:1px solid var(--border); border-left:3px solid rgba(255,140,0,.5); | |
| border-radius:0 3px 3px 3px; padding:14px 16px; font-family:var(--serif); font-size:.84rem; | |
| line-height:1.8; color:#e0ddd8; | |
| } | |
| .chat-display .msg-you { display:flex; flex-direction:column; gap:5px; align-self:flex-end; max-width:78%; } | |
| .chat-display .msg-you .msg-lbl { | |
| font-family:var(--mono); font-size:.55rem; letter-spacing:.1em; text-transform:uppercase; | |
| color:rgba(100,149,237,.7); font-weight:600; text-align:right; | |
| display:flex; align-items:center; justify-content:flex-end; gap:6px; | |
| } | |
| .chat-display .msg-you .msg-lbl .lbl-dot { | |
| width:6px; height:6px; border-radius:50%; background:rgba(100,149,237,.7); flex-shrink:0; display:inline-block; | |
| } | |
| .chat-display .msg-you .msg-bubble { | |
| background:#0d0d0d; border:1px solid #2a2a2a; border-right:3px solid rgba(100,149,237,.4); | |
| border-radius:3px 0 3px 3px; padding:12px 16px; font-family:var(--serif); font-size:.84rem; | |
| line-height:1.75; color:rgba(220,230,255,.85); | |
| } | |
| .chat-display .msg-system { | |
| align-self:center; font-family:var(--mono); font-size:.6rem; font-weight:600; letter-spacing:.06em; | |
| color:rgba(76,175,80,.6); background:rgba(76,175,80,.05); border:1px solid rgba(76,175,80,.15); | |
| border-radius:3px; padding:6px 12px; text-align:center; | |
| } | |
| .chat-display .msg-empty { | |
| flex:1; display:flex; flex-direction:column; align-items:center; justify-content:center; | |
| font-family:var(--serif); font-size:.88rem; color:#333; text-align:center; padding:48px 24px; | |
| } | |
| #text-chat-input textarea, #text-chat-input input { | |
| background:#0d0d0d !important; color:#e0ddd8 !important; font-family:var(--serif) !important; | |
| font-size:.86rem !important; border:1px solid #2a2a2a !important; border-radius:3px !important; | |
| padding:12px 14px !important; min-height:52px !important; height:52px !important; | |
| resize:none !important; overflow:hidden !important; | |
| } | |
| #voice-conv-box textarea { | |
| background:#060606 !important; color:rgba(200,200,200,.7) !important; font-family:var(--mono) !important; | |
| font-size:.65rem !important; line-height:1.9 !important; border:1px solid rgba(76,175,80,.12) !important; | |
| border-radius:3px !important; | |
| } | |
| #dispatch-status textarea { | |
| background:#060606 !important; color:rgba(255,255,255,.35) !important; font-family:var(--mono) !important; | |
| font-size:.63rem !important; border:1px solid var(--border) !important; border-radius:3px !important; | |
| } | |
| .agent-btn, .agent-btn button { | |
| height:40px !important; min-height:40px !important; padding:0 20px !important; | |
| font-family:var(--mono) !important; font-size:.65rem !important; letter-spacing:.12em !important; | |
| font-weight:700 !important; text-transform:uppercase !important; border-radius:3px !important; | |
| border:none !important; cursor:pointer !important; background:#ffffff !important; color:#000000 !important; | |
| } | |
| .agent-btn:hover, .agent-btn button:hover { background:var(--orange) !important; } | |
| .btn-accept, .btn-accept button { background:linear-gradient(135deg,#1b5e20,#2e7d32) !important; color:#fff !important; } | |
| .btn-end, .btn-end button { background:linear-gradient(135deg,#7f0000,#b71c1c) !important; color:#fff !important; } | |
| .btn-dispatch, .btn-dispatch button { background:#ffffff !important; color:#000 !important; width:100% !important; height:44px !important; } | |
| .btn-send, .btn-send button { background:#ffffff !important; color:#000 !important; min-height:44px !important; width:100% !important; } | |
| .btn-start, .btn-start button { background:#ffffff !important; color:#000 !important; width:100% !important; height:40px !important; } | |
| .live-indicator { | |
| display:inline-flex; align-items:center; gap:8px; background:rgba(76,175,80,.08); | |
| border:1px solid rgba(76,175,80,.25); border-radius:3px; padding:5px 12px; margin:10px 0 6px; | |
| } | |
| .live-dot { display:inline-block; width:6px; height:6px; border-radius:50%; background:#4CAF50; animation:pulse-dot 1.2s ease-in-out infinite; } | |
| .live-label { font-family:var(--mono); font-size:.55rem; letter-spacing:.1em; color:rgba(76,175,80,.9); font-weight:700; text-transform:uppercase; } | |
| #ss-voice-ui { | |
| margin-top: 8px; border: 1px solid rgba(76,175,80,.18); border-radius: 3px; | |
| background: #050505; padding: 12px; | |
| } | |
| #ss-wave-canvas { | |
| width: 100%; height: 72px; display: block; background: #0a0a0a; | |
| border-radius: 2px; border: 1px solid #1a1a1a; | |
| } | |
| #ss-voice-status-line { | |
| font-family: var(--mono); font-size: .56rem; letter-spacing: .1em; text-transform: uppercase; | |
| color: rgba(76,175,80,.85); margin-top: 8px; min-height: 16px; | |
| } | |
| #ss-voice-status-line.mode-listening { color: rgba(76,175,80,.9); } | |
| #ss-voice-status-line.mode-recording { color: #FF4500; } | |
| #ss-voice-status-line.mode-processing { color: #FF8C00; } | |
| #ss-voice-status-line.mode-speaking { color: #64B5F6; } | |
| #ss-voice-status-line.mode-error { color: #FF4500; } | |
| #ss-playback-audio { | |
| position: fixed !important; left: -9999px !important; width: 1px !important; height: 1px !important; | |
| opacity: 0 !important; pointer-events: none !important; | |
| } | |
| #ss-ringtone-out { | |
| position:fixed !important; bottom:-200px !important; left:-200px !important; | |
| width:1px !important; height:1px !important; overflow:hidden !important; | |
| pointer-events:none !important; opacity:0 !important; | |
| } | |
| """ | |
| SSVOICE_CONTROLLER_HTML = r""" | |
| <div id="ss-voice-boot" style="display:none;"></div> | |
| <audio id="ss-playback-audio" preload="auto"></audio> | |
| <script> | |
| (function(){ | |
| console.log('[SSVoice] CONTROLLER_BOOT'); | |
| if (window.__SSVoiceV2) { | |
| console.log('[SSVoice] CONTROLLER_ALREADY_INSTALLED'); | |
| return; | |
| } | |
| window.__SSVoiceV2 = true; | |
| console.log('[SSVoice] VERSION=2.1.0'); | |
| console.log('[SSVoice] CONTROLLER_READY'); | |
| var CFG = { | |
| noiseCalibMs: 600, | |
| speechStartMul: 3.2, | |
| speechStopMul: 1.8, | |
| minSpeechMs: 280, | |
| silenceHangMs: 720, | |
| maxUtterMs: 12000, | |
| minPacketBytes: 800, | |
| turnCooldownMs: 350, | |
| bargeInMul: 4.0, | |
| bargeInMs: 220 | |
| }; | |
| var S = { | |
| mode: 'IDLE', | |
| sessionId: '', | |
| turn: 0, | |
| active: false, | |
| stream: null, | |
| audioCtx: null, | |
| micSource: null, | |
| micAnalyser: null, | |
| playAnalyser: null, | |
| playSource: null, | |
| mediaRecorder: null, | |
| recChunks: [], | |
| recording: false, | |
| speechOn: false, | |
| speechStartAt: 0, | |
| silenceStartAt: 0, | |
| noiseFloor: 0.008, | |
| raf: 0, | |
| submitting: false, | |
| playbackTurn: 0, | |
| bargeMs: 0, | |
| lastSubmitAt: 0, | |
| mime: '', | |
| vadTimer: 0, | |
| openingDone: false, | |
| ending: false | |
| }; | |
| /* ββ Idempotent browser ringtone controller ββ */ | |
| var Ring = { | |
| el: null, | |
| timer: null, | |
| playing: false, | |
| ensure: function(){ | |
| if (this.el) return this.el; | |
| var a = document.getElementById('ss-browser-ringtone'); | |
| if (!a) { | |
| a = document.createElement('audio'); | |
| a.id = 'ss-browser-ringtone'; | |
| a.loop = true; | |
| a.preload = 'auto'; | |
| a.style.cssText = 'position:fixed;left:-9999px;width:1px;height:1px;opacity:0;pointer-events:none;'; | |
| document.body.appendChild(a); | |
| } | |
| this.el = a; | |
| return a; | |
| }, | |
| start: function(dataUri){ | |
| try { | |
| var a = this.ensure(); | |
| if (dataUri) a.src = dataUri; | |
| // If no src yet, leave silent β Gradio stream may still play | |
| if (!a.src) return; | |
| this.playing = true; | |
| var self = this; | |
| a.play().then(function(){ console.log('[SSVoice] RINGTONE_START'); }).catch(function(e){ | |
| console.log('[SSVoice] RINGTONE_PLAY_BLOCKED', e); | |
| }); | |
| } catch(e){ console.log('[SSVoice] RINGTONE_START_FAIL', e); } | |
| }, | |
| stop: function(){ | |
| try { | |
| this.playing = false; | |
| if (this.timer) { clearInterval(this.timer); this.timer = null; } | |
| var a = this.el || document.getElementById('ss-browser-ringtone'); | |
| if (a) { | |
| try { a.pause(); } catch(e){} | |
| try { a.currentTime = 0; } catch(e){} | |
| try { a.removeAttribute('src'); a.load(); } catch(e){} | |
| } | |
| // Also mute any Gradio streaming ringtone audio elements | |
| try { | |
| var nodes = document.querySelectorAll('#ss-ringtone-out audio, audio'); | |
| for (var i=0;i<nodes.length;i++){ | |
| var n = nodes[i]; | |
| if (n.id === 'ss-playback-audio') continue; | |
| // only stop elements that look like ringtone / streaming | |
| try { | |
| if (n.id === 'ss-browser-ringtone' || (n.closest && n.closest('#ss-ringtone-out'))) { | |
| n.pause(); n.currentTime = 0; | |
| } | |
| } catch(e){} | |
| } | |
| // Force-stop Gradio ringtone component audio inside #ss-ringtone-out | |
| var root = document.getElementById('ss-ringtone-out'); | |
| if (root) { | |
| root.querySelectorAll('audio').forEach(function(au){ | |
| try { au.pause(); au.currentTime=0; au.removeAttribute('src'); au.load(); } catch(e){} | |
| }); | |
| } | |
| } catch(e){} | |
| console.log('[SSVoice] RINGTONE_STOP'); | |
| } catch(e){ console.log('[SSVoice] RINGTONE_STOP_FAIL', e); } | |
| } | |
| }; | |
| // Canonical global used by overlay Accept/Dismiss/End | |
| window._ssRingStop = function(){ Ring.stop(); }; | |
| window._ssRingStart = function(uri){ Ring.start(uri || ''); }; | |
| function log(){ | |
| try { | |
| var a = ['[SSVoice]'].concat([].slice.call(arguments)); | |
| console.log.apply(console, a); | |
| } catch(e){} | |
| } | |
| function setStatus(mode, msg){ | |
| S.mode = mode; | |
| var el = document.getElementById('ss-voice-status-line'); | |
| if (!el) return; | |
| el.className = 'mode-' + String(mode||'').toLowerCase(); | |
| el.textContent = msg || mode || ''; | |
| log('STATE', mode, msg || ''); | |
| } | |
| function ensureCanvas(){ | |
| var host = document.getElementById('ss-voice-ui'); | |
| if (!host) return null; | |
| var c = document.getElementById('ss-wave-canvas'); | |
| if (!c) { | |
| c = document.createElement('canvas'); | |
| c.id = 'ss-wave-canvas'; | |
| c.width = 640; c.height = 72; | |
| host.insertBefore(c, host.firstChild); | |
| } | |
| var st = document.getElementById('ss-voice-status-line'); | |
| if (!st) { | |
| st = document.createElement('div'); | |
| st.id = 'ss-voice-status-line'; | |
| host.appendChild(st); | |
| } | |
| return c; | |
| } | |
| function rmsFromAnalyser(an){ | |
| if (!an) return 0; | |
| var buf = new Uint8Array(an.fftSize); | |
| an.getByteTimeDomainData(buf); | |
| var sum = 0; | |
| for (var i=0;i<buf.length;i++){ | |
| var v = (buf[i]-128)/128; | |
| sum += v*v; | |
| } | |
| return Math.sqrt(sum / buf.length); | |
| } | |
| function drawWave(){ | |
| var c = ensureCanvas(); | |
| if (!c) { S.raf = requestAnimationFrame(drawWave); return; } | |
| var ctx = c.getContext('2d'); | |
| var w = c.width, h = c.height; | |
| ctx.fillStyle = '#0a0a0a'; | |
| ctx.fillRect(0,0,w,h); | |
| ctx.strokeStyle = '#1a1a1a'; | |
| ctx.beginPath(); | |
| ctx.moveTo(0, h/2); ctx.lineTo(w, h/2); ctx.stroke(); | |
| var an = null; | |
| var col = '#4CAF50'; | |
| if (S.mode === 'SPEAKING' && S.playAnalyser) { an = S.playAnalyser; col = '#64B5F6'; } | |
| else if (S.micAnalyser) { an = S.micAnalyser; col = (S.mode==='RECORDING') ? '#FF4500' : (S.mode==='PROCESSING' ? '#FF8C00' : '#4CAF50'); } | |
| ctx.strokeStyle = col; | |
| ctx.lineWidth = 2; | |
| ctx.beginPath(); | |
| if (an) { | |
| var data = new Uint8Array(an.fftSize); | |
| an.getByteTimeDomainData(data); | |
| var step = Math.max(1, Math.floor(data.length / w)); | |
| for (var x=0; x<w; x++){ | |
| var v = data[x*step] || 128; | |
| var y = (v/255) * h; | |
| if (x===0) ctx.moveTo(x,y); else ctx.lineTo(x,y); | |
| } | |
| } else { | |
| // baseline ambient | |
| for (var x2=0; x2<w; x2++){ | |
| var y2 = h/2 + Math.sin(x2/18 + Date.now()/400)*2; | |
| if (x2===0) ctx.moveTo(x2,y2); else ctx.lineTo(x2,y2); | |
| } | |
| } | |
| ctx.stroke(); | |
| if (S.active) S.raf = requestAnimationFrame(drawWave); | |
| } | |
| function pickMime(){ | |
| var cands = [ | |
| 'audio/webm;codecs=opus', | |
| 'audio/webm', | |
| 'audio/ogg;codecs=opus', | |
| 'audio/mp4' | |
| ]; | |
| for (var i=0;i<cands.length;i++){ | |
| try { | |
| if (window.MediaRecorder && MediaRecorder.isTypeSupported && MediaRecorder.isTypeSupported(cands[i])) { | |
| return cands[i]; | |
| } | |
| } catch(e){} | |
| } | |
| return ''; | |
| } | |
| async function unlockAudio(){ | |
| try { | |
| var AC = window.AudioContext || window.webkitAudioContext; | |
| if (!S.audioCtx) S.audioCtx = new AC(); | |
| if (S.audioCtx.state === 'suspended') await S.audioCtx.resume(); | |
| // unlock element play | |
| var a = document.getElementById('ss-playback-audio'); | |
| if (a) { | |
| a.muted = true; | |
| try { await a.play(); a.pause(); a.currentTime=0; } catch(e){} | |
| a.muted = false; | |
| } | |
| log('AudioContext unlocked', S.audioCtx.state); | |
| } catch(e){ log('unlock fail', e); } | |
| } | |
| async function startMic(){ | |
| log('mediaDevices:', !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia)); | |
| if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { | |
| log('MIC_API_UNAVAILABLE'); | |
| setStatus('ERROR', 'Microphone API unavailable'); | |
| throw new Error('no mediaDevices'); | |
| } | |
| // Check permission state if available | |
| try { | |
| if (navigator.permissions && navigator.permissions.query) { | |
| var perm = await navigator.permissions.query({name: 'microphone'}); | |
| log('MIC_PERMISSION_STATE=' + perm.state); | |
| } | |
| } catch(pe) { log('MIC_PERMISSION_STATE=unknown'); } | |
| log('MIC_REQUEST_START'); | |
| S.stream = await navigator.mediaDevices.getUserMedia({ | |
| audio: { | |
| channelCount: 1, | |
| echoCancellation: true, | |
| noiseSuppression: true, | |
| autoGainControl: true | |
| }, | |
| video: false | |
| }); | |
| log('MIC_REQUEST_RESOLVED'); | |
| var tracks = S.stream.getTracks(); | |
| log('MIC_STREAM_OK tracks=' + tracks.length); | |
| if (tracks.length > 0) { | |
| log('MIC_TRACK_KIND=' + tracks[0].kind); | |
| log('MIC_TRACK_STATE=' + tracks[0].state); | |
| } | |
| var AC = window.AudioContext || window.webkitAudioContext; | |
| if (!S.audioCtx) S.audioCtx = new AC(); | |
| if (S.audioCtx.state === 'suspended') await S.audioCtx.resume(); | |
| log('AUDIO_CONTEXT_RUNNING state=' + S.audioCtx.state); | |
| S.micSource = S.audioCtx.createMediaStreamSource(S.stream); | |
| S.micAnalyser = S.audioCtx.createAnalyser(); | |
| S.micAnalyser.fftSize = 2048; | |
| S.micAnalyser.smoothingTimeConstant = 0.8; | |
| // NEVER connect mic to destination (no feedback) | |
| S.micSource.connect(S.micAnalyser); | |
| log('MIC_ANALYSER_READY'); | |
| log('WAVEFORM_READY'); | |
| log('MIC_READY'); | |
| // calibrate noise floor | |
| var t0 = performance.now(); | |
| var samples = []; | |
| while (performance.now() - t0 < CFG.noiseCalibMs) { | |
| samples.push(rmsFromAnalyser(S.micAnalyser)); | |
| await new Promise(function(r){ setTimeout(r, 30); }); | |
| } | |
| samples.sort(function(a,b){return a-b;}); | |
| var mid = samples[Math.floor(samples.length*0.5)] || 0.008; | |
| S.noiseFloor = Math.max(0.004, mid); | |
| log('VAD noiseFloor', S.noiseFloor.toFixed(5)); | |
| S.mime = pickMime(); | |
| log('MediaRecorder mime', S.mime || '(default)'); | |
| } | |
| function stopMicTracks(){ | |
| try { | |
| if (S.stream) S.stream.getTracks().forEach(function(t){ try{t.stop();}catch(e){} }); | |
| } catch(e){} | |
| S.stream = null; | |
| try { if (S.micSource) S.micSource.disconnect(); } catch(e){} | |
| S.micSource = null; | |
| S.micAnalyser = null; | |
| } | |
| function startRecorder(){ | |
| if (S.recording || !S.stream) return; | |
| S.recChunks = []; | |
| try { | |
| var opts = S.mime ? { mimeType: S.mime, audioBitsPerSecond: 64000 } : { audioBitsPerSecond: 64000 }; | |
| S.mediaRecorder = new MediaRecorder(S.stream, opts); | |
| } catch(e) { | |
| try { S.mediaRecorder = new MediaRecorder(S.stream); } catch(e2) { | |
| log('MediaRecorder failed', e2); | |
| return; | |
| } | |
| } | |
| S.mediaRecorder.ondataavailable = function(ev){ | |
| if (ev.data && ev.data.size > 0) S.recChunks.push(ev.data); | |
| }; | |
| S.mediaRecorder.onstop = function(){ | |
| S.recording = false; | |
| var blob = new Blob(S.recChunks, { type: (S.mime || 'audio/webm') }); | |
| S.recChunks = []; | |
| log('RECORDING_STOPPED', 'blob', blob.size); | |
| if (blob.size >= CFG.minPacketBytes) { | |
| submitBlob(blob); | |
| } else { | |
| log('blob too small, back to LISTENING', blob.size); | |
| if (S.active) { setStatus('LISTENING', 'Listeningβ¦'); } | |
| } | |
| }; | |
| S.recording = true; | |
| S.mediaRecorder.start(100); | |
| setStatus('RECORDING', 'Recordingβ¦'); | |
| log('RECORDING_STARTED'); | |
| } | |
| function stopRecorder(){ | |
| if (!S.recording || !S.mediaRecorder) return; | |
| try { | |
| if (S.mediaRecorder.state === 'recording') S.mediaRecorder.stop(); | |
| } catch(e){ log('stopRecorder', e); } | |
| } | |
| function _schedVad(){ | |
| if (S.vadTimer) { try { clearTimeout(S.vadTimer); } catch(e){} } | |
| S.vadTimer = setTimeout(vadTick, 80); | |
| } | |
| function vadTick(){ | |
| if (!S.active || S.ending) return; | |
| if (S.submitting) { _schedVad(); return; } | |
| if (!S.openingDone) { _schedVad(); return; } | |
| if (S.mode === 'PROCESSING') { _schedVad(); return; } | |
| var lvl = rmsFromAnalyser(S.micAnalyser); | |
| var startTh = S.noiseFloor * CFG.speechStartMul; | |
| var stopTh = S.noiseFloor * CFG.speechStopMul; | |
| var bargeTh = S.noiseFloor * CFG.bargeInMul; | |
| var now = performance.now(); | |
| // barge-in while speaking | |
| if (S.mode === 'SPEAKING') { | |
| if (lvl >= bargeTh) { | |
| S.bargeMs += 80; | |
| if (S.bargeMs >= CFG.bargeInMs) { | |
| log('BARGE-IN'); | |
| interruptPlayback(); | |
| S.bargeMs = 0; | |
| S.speechOn = true; | |
| S.speechStartAt = now; | |
| S.silenceStartAt = 0; | |
| startRecorder(); | |
| } | |
| } else { | |
| S.bargeMs = 0; | |
| } | |
| _schedVad(); | |
| return; | |
| } | |
| if (S.mode !== 'LISTENING' && S.mode !== 'RECORDING') { | |
| _schedVad(); | |
| return; | |
| } | |
| if (!S.speechOn) { | |
| if (lvl >= startTh) { | |
| if (!S.speechStartAt) S.speechStartAt = now; | |
| if (now - S.speechStartAt >= 90) { | |
| S.speechOn = true; | |
| S.silenceStartAt = 0; | |
| log('SPEECH_START', 'rms', lvl.toFixed(4)); | |
| startRecorder(); | |
| } | |
| } else { | |
| S.speechStartAt = 0; | |
| } | |
| } else { | |
| // speaking / recording | |
| if (lvl < stopTh) { | |
| if (!S.silenceStartAt) S.silenceStartAt = now; | |
| var spoken = now - (S.speechStartAt || now); | |
| var silent = now - S.silenceStartAt; | |
| if (spoken >= CFG.minSpeechMs && silent >= CFG.silenceHangMs) { | |
| log('SPEECH_END', 'spokenMs', Math.round(spoken)); | |
| S.speechOn = false; | |
| S.speechStartAt = 0; | |
| S.silenceStartAt = 0; | |
| stopRecorder(); | |
| } | |
| } else { | |
| S.silenceStartAt = 0; | |
| } | |
| // max utterance | |
| if (S.recording && S.speechStartAt && (now - S.speechStartAt) > CFG.maxUtterMs) { | |
| log('MAX_UTTERANCE'); | |
| S.speechOn = false; | |
| S.speechStartAt = 0; | |
| S.silenceStartAt = 0; | |
| stopRecorder(); | |
| } | |
| } | |
| _schedVad(); | |
| } | |
| function b64ToBlob(b64, mime){ | |
| var bin = atob(b64); | |
| var len = bin.length; | |
| var arr = new Uint8Array(len); | |
| for (var i=0;i<len;i++) arr[i] = bin.charCodeAt(i); | |
| return new Blob([arr], { type: mime || 'audio/wav' }); | |
| } | |
| async function postTurn(body, headers){ | |
| var url = (window.location.origin || '') + '/ssvoice/turn'; | |
| var res = await fetch(url, { | |
| method: 'POST', | |
| headers: headers || {}, | |
| body: body, | |
| credentials: 'same-origin' | |
| }); | |
| var data = null; | |
| try { data = await res.json(); } catch(e){ data = { ok:false, error:'bad_json', status: res.status }; } | |
| return { http: res.status, data: data }; | |
| } | |
| async function submitBlob(blob){ | |
| if (!S.active) return; | |
| if (S.submitting) { log('submit skipped, busy'); return; } | |
| var now = performance.now(); | |
| if (now - S.lastSubmitAt < CFG.turnCooldownMs) { | |
| log('cooldown'); | |
| return; | |
| } | |
| S.lastSubmitAt = now; | |
| S.submitting = true; | |
| S.turn += 1; | |
| var turnId = String(S.turn); | |
| S.playbackTurn = S.turn; | |
| setStatus('PROCESSING', 'Processingβ¦'); | |
| log('PACKET_CREATED bytes=' + blob.size); | |
| log('PACKET_SENT turn=' + turnId); | |
| try { | |
| var headers = { | |
| 'Content-Type': blob.type || 'application/octet-stream', | |
| 'X-SS-Session': S.sessionId, | |
| 'X-SS-Turn': turnId, | |
| 'X-SS-Opening': '0' | |
| }; | |
| log('REQUEST_START turn=' + turnId + ' bytes=' + blob.size); | |
| var out = await postTurn(blob, headers); | |
| log('REQUEST_RESPONSE status=' + out.http, 'status=', (out.data&&out.data.status), 'rx_ok=', !!(out.data&&out.data.ok)); | |
| // True backend ack only after JSON returns | |
| if (!out.data) { | |
| setStatus('LISTENING', 'Listeningβ¦'); | |
| return; | |
| } | |
| if (out.data.status === 'stale_session') { | |
| log('stale session ignored'); | |
| return; | |
| } | |
| // append transcript to voice box if present | |
| try { | |
| if (out.data.transcript || out.data.reply) { | |
| window.__ssAppendVoiceLine && window.__ssAppendVoiceLine(out.data.transcript, out.data.reply, out.data.action); | |
| } | |
| } catch(e){} | |
| if (out.data.audio_b64 && out.data.turn_id && String(out.data.turn_id) !== String(S.playbackTurn) && String(out.data.turn_id) !== turnId) { | |
| log('stale audio ignored', out.data.turn_id, S.playbackTurn); | |
| } else if (out.data.audio_b64) { | |
| await playAudioB64(out.data.audio_b64, out.data.audio_mime || 'audio/wav', turnId); | |
| } else { | |
| if (S.active) setStatus('LISTENING', out.data.error === 'no_speech' ? 'Could not hear speech β listeningβ¦' : 'Listeningβ¦'); | |
| } | |
| } catch(e) { | |
| log('submit error', e); | |
| if (S.active) setStatus('ERROR', 'Processing error β listeningβ¦'); | |
| setTimeout(function(){ if (S.active) setStatus('LISTENING','Listeningβ¦'); }, 800); | |
| } finally { | |
| S.submitting = false; | |
| } | |
| } | |
| function detachPlayGraph(){ | |
| try { if (S.playSource) S.playSource.disconnect(); } catch(e){} | |
| S.playSource = null; | |
| S.playAnalyser = null; | |
| } | |
| function interruptPlayback(){ | |
| var a = document.getElementById('ss-playback-audio'); | |
| try { | |
| if (a) { a.pause(); a.removeAttribute('src'); a.load(); } | |
| } catch(e){} | |
| detachPlayGraph(); | |
| S.playbackTurn += 1; // invalidate onended | |
| log('PLAYBACK_INTERRUPTED'); | |
| } | |
| async function playAudioB64(b64, mime, turnId){ | |
| if (!S.active) return; | |
| console.log('[SSVoice] AUDIO_RESPONSE_RECEIVED chars=' + (b64||'').length); log('AUDIO_RESPONSE_RECEIVED'); | |
| var a = document.getElementById('ss-playback-audio'); | |
| if (!a) { | |
| a = document.createElement('audio'); | |
| a.id = 'ss-playback-audio'; | |
| a.preload = 'auto'; | |
| document.body.appendChild(a); | |
| } | |
| interruptPlayback(); // stop any previous | |
| // restore turn id after interrupt bump | |
| S.playbackTurn = parseInt(turnId, 10) || S.turn; | |
| var blob = b64ToBlob(b64, mime); | |
| log('AUDIO_RESPONSE bytes=' + blob.size); | |
| var url = URL.createObjectURL(blob); | |
| a.src = url; | |
| log('AUDIO_SOURCE_DETECTED'); | |
| // wire analyser for playback waveform (once per element carefully) | |
| try { | |
| if (S.audioCtx) { | |
| detachPlayGraph(); | |
| // MediaElementSource can only be created once per element per context. | |
| // After End Call + re-accept, __ssMediaSource is deleted by stopSession. | |
| // If it still exists but belongs to a different (closed) context, recreate. | |
| if (a.__ssMediaSource && a.__ssMediaSource.context !== S.audioCtx) { | |
| try { delete a.__ssMediaSource; } catch(ie){} | |
| } | |
| if (!a.__ssMediaSource) { | |
| a.__ssMediaSource = S.audioCtx.createMediaElementSource(a); | |
| } | |
| S.playSource = a.__ssMediaSource; | |
| S.playAnalyser = S.audioCtx.createAnalyser(); | |
| S.playAnalyser.fftSize = 2048; | |
| S.playSource.connect(S.playAnalyser); | |
| S.playAnalyser.connect(S.audioCtx.destination); | |
| } | |
| } catch(e) { | |
| log('play graph note', e); | |
| // fallback: just play without custom graph β clear stale reference | |
| try { delete a.__ssMediaSource; } catch(ie){} | |
| } | |
| var myTurn = S.playbackTurn; | |
| a.onplay = function(){ | |
| if (myTurn !== S.playbackTurn) return; | |
| console.log('[SSVoice] PLAYBACK_START'); log('PLAYBACK_START'); | |
| setStatus('SPEAKING', 'Sherl0ck speakingβ¦'); | |
| }; | |
| a.onended = function(){ | |
| if (myTurn !== S.playbackTurn) return; | |
| console.log('[SSVoice] PLAYBACK_END'); log('PLAYBACK_END'); | |
| try { URL.revokeObjectURL(url); } catch(e){} | |
| if (S.active) setStatus('LISTENING', 'Listeningβ¦'); | |
| }; | |
| a.onerror = function(){ | |
| log('PLAYBACK_ERROR'); | |
| if (S.active) setStatus('LISTENING', 'Listeningβ¦'); | |
| }; | |
| try { | |
| console.log('[SSVoice] PLAY_REQUEST'); log('PLAY_REQUEST'); | |
| await a.play(); | |
| } catch(e) { | |
| log('play() blocked', e); | |
| // try once more after resume | |
| try { | |
| if (S.audioCtx && S.audioCtx.state === 'suspended') await S.audioCtx.resume(); | |
| await a.play(); | |
| } catch(e2) { | |
| log('play failed', e2); | |
| setStatus('ERROR', 'Audio playback unavailable'); | |
| setTimeout(function(){ if (S.active) setStatus('LISTENING','Listeningβ¦'); }, 900); | |
| } | |
| } | |
| } | |
| async function requestOpening(){ | |
| if (S.openingDone) return; | |
| S.submitting = true; | |
| S.turn += 1; | |
| var turnId = String(S.turn); | |
| S.playbackTurn = S.turn; | |
| log('OPENING_REQUEST_START'); setStatus('PROCESSING', 'Connecting Sherl0ckβ¦'); | |
| try { | |
| var headers = { | |
| 'X-SS-Session': S.sessionId, | |
| 'X-SS-Turn': turnId, | |
| 'X-SS-Opening': '1' | |
| }; | |
| var out = await postTurn(new Uint8Array(0), headers); | |
| log('OPENING server', out.http, out.data && out.data.status); | |
| if (out.data && (out.data.reply)) { | |
| window.__ssAppendVoiceLine && window.__ssAppendVoiceLine('', out.data.reply, null); | |
| } | |
| if (out.data && out.data.audio_b64) { | |
| await playAudioB64(out.data.audio_b64, out.data.audio_mime || 'audio/wav', turnId); | |
| } else if (S.active) { | |
| setStatus('LISTENING', 'Listeningβ¦'); | |
| } | |
| } catch(e) { | |
| log('opening fail', e); | |
| if (S.active) setStatus('LISTENING', 'Listeningβ¦'); | |
| } finally { | |
| S.submitting = false; | |
| } | |
| } | |
| async function startSession(sessionId){ | |
| if (S.ending) return; | |
| if (S.active && S.sessionId && sessionId && S.sessionId === sessionId) { | |
| log('startSession noop already active', sessionId); | |
| return; | |
| } | |
| if (S.active) await stopSession(true); | |
| log('START_SESSION', sessionId); | |
| // Always stop ringtone first (browser + gradio stream) | |
| try { window._ssRingStop && window._ssRingStop(); } catch(e){} | |
| S.sessionId = sessionId || (Date.now().toString(36) + Math.random().toString(36).slice(2,8)); | |
| S.turn = 0; | |
| S.active = true; | |
| S.ending = false; | |
| S.speechOn = false; | |
| S.submitting = false; | |
| S.openingDone = false; | |
| S.bargeMs = 0; | |
| if (S.vadTimer) { try { clearTimeout(S.vadTimer); } catch(e){} S.vadTimer = 0; } | |
| ensureCanvas(); | |
| setStatus('ACCEPTING', 'Connecting microphoneβ¦'); | |
| await unlockAudio(); | |
| // Mic may already be primed by gesture-time acceptPrepare | |
| if (!S.stream) { | |
| await startMic(); | |
| } else { | |
| log('MIC_REUSED_FROM_GESTURE'); | |
| } | |
| log('MIC_READY'); | |
| log('VAD_READY'); | |
| setStatus('LISTENING', 'Listeningβ¦'); | |
| log('LISTENING'); | |
| if (S.raf) cancelAnimationFrame(S.raf); | |
| S.raf = requestAnimationFrame(drawWave); | |
| vadTick(); | |
| // Opening must NOT block the Accept gesture / Gradio js= return. | |
| // Fire-and-forget; openingDone gates VAD speech capture until complete. | |
| Promise.resolve() | |
| .then(function(){ return requestOpening(); }) | |
| .then(function(){ S.openingDone = true; log('OPENING_DONE'); }) | |
| .catch(function(e){ | |
| console.log('[SSVoice] OPENING_FAIL', e); | |
| S.openingDone = true; // still allow listening after failure | |
| setStatus('LISTENING', 'Listeningβ¦'); | |
| }); | |
| } | |
| /* Prime mic during the REAL user gesture (before Gradio round-trip). */ | |
| async function acceptPrepare(){ | |
| try { window._ssRingStop && window._ssRingStop(); } catch(e){} | |
| setStatus('ACCEPTING', 'Connecting microphoneβ¦'); | |
| try { | |
| await unlockAudio(); | |
| if (!S.stream) await startMic(); | |
| ensureCanvas(); | |
| if (S.raf) cancelAnimationFrame(S.raf); | |
| S.raf = requestAnimationFrame(drawWave); | |
| log('ACCEPT_PREPARE_OK'); | |
| return true; | |
| } catch(e) { | |
| log('ACCEPT_PREPARE_FAIL', e); | |
| log('MIC_REQUEST_REJECTED name=' + (e.name||'unknown') + ' message=' + (e.message||'unknown')); | |
| setStatus('ERROR', 'Microphone unavailable'); | |
| return false; | |
| } | |
| } | |
| async function stopSession(silent){ | |
| log('END'); | |
| S.ending = true; | |
| S.active = false; | |
| S.speechOn = false; | |
| S.submitting = false; | |
| S.openingDone = false; | |
| try { window._ssRingStop && window._ssRingStop(); } catch(e){} | |
| try { stopRecorder(); } catch(e){} | |
| interruptPlayback(); | |
| stopMicTracks(); | |
| if (S.vadTimer) { try { clearTimeout(S.vadTimer); } catch(e){} S.vadTimer = 0; } | |
| if (S.raf) { try { cancelAnimationFrame(S.raf); } catch(e){} S.raf=0; } | |
| try { | |
| fetch((window.location.origin||'') + '/ssvoice/end', { | |
| method:'POST', | |
| headers: { 'X-SS-Session': S.sessionId || '' }, | |
| credentials:'same-origin' | |
| }).catch(function(){}); | |
| } catch(e){} | |
| // Close AudioContext fully on end to release mic hardware association | |
| try { | |
| if (S.audioCtx) { | |
| var ctx = S.audioCtx; | |
| S.audioCtx = null; | |
| S.playSource = null; | |
| S.playAnalyser = null; | |
| S.micSource = null; | |
| S.micAnalyser = null; | |
| // Clear cached MediaElementSource marker so next call can recreate | |
| var a = document.getElementById('ss-playback-audio'); | |
| if (a) { try { delete a.__ssMediaSource; } catch(e){} } | |
| try { await ctx.close(); } catch(e){} | |
| } | |
| } catch(e){} | |
| setStatus('ENDED', 'Call ended'); | |
| S.sessionId = ''; | |
| S.ending = false; | |
| } | |
| // Public API used by Gradio button callbacks via injected JS | |
| /* Single authoritative Accept path: gesture β mic β /ssvoice/accept β start(session) */ | |
| var _acceptInFlight = false; | |
| async function acceptFromUserGesture(source){ | |
| source = source || 'unknown'; | |
| console.log('[SSVoice] ACCEPT_CLICK'); | |
| console.log('[SSVoice] SSVoice_AVAILABLE=' + (!!window.SSVoice)); | |
| console.log('[SSVoice] ACCEPT_FUNCTION_AVAILABLE=' + (!!window.SSVoice && typeof window.SSVoice.accept === 'function')); | |
| console.log('[SSVoice] NATIVE_ACCEPT_CLICK source=' + source); | |
| if (_acceptInFlight) { console.log('[SSVoice] ACCEPT_IN_FLIGHT ignore'); return S.sessionId || ''; } | |
| if (S.active && S.sessionId) { | |
| console.log('[SSVoice] ALREADY_ACTIVE session=' + S.sessionId); | |
| return S.sessionId; | |
| } | |
| _acceptInFlight = true; | |
| try { | |
| console.log('[SSVoice] USER_GESTURE_START'); | |
| console.log('[SSVoice] RING_STOP_REQUESTED'); | |
| try { if (window._ssRingStop) window._ssRingStop(); } catch(e){} | |
| console.log('[SSVoice] MIC_REQUEST_START'); | |
| var prepOk = await acceptPrepare(); | |
| if (!prepOk) { | |
| console.log('[SSVoice] MIC_REQUEST_FAILED'); | |
| return ''; | |
| } | |
| console.log('[SSVoice] MIC_REQUEST_RESOLVED'); | |
| console.log('[SSVoice] AUDIO_CONTEXT_STATE=' + (S.audioCtx ? S.audioCtx.state : 'null')); | |
| console.log('[SSVoice] MIC_STREAM_TRACKS=' + (S.stream ? S.stream.getTracks().length : 0)); | |
| // Health check β prove share tunnel reaches FastAPI | |
| try { | |
| console.log('[SSVoice] HEALTH_START'); | |
| var hr = await fetch((window.location.origin||'') + '/ssvoice/health', {credentials:'same-origin'}); | |
| console.log('[SSVoice] HEALTH_STATUS=' + hr.status); | |
| var hj = await hr.json().catch(function(){return {};}); | |
| console.log('[SSVoice] HEALTH_OK', JSON.stringify(hj)); | |
| } catch(he) { | |
| console.log('[SSVoice] HEALTH_FAIL', he); | |
| } | |
| // Mint/fetch authoritative session from server | |
| console.log('[SSVoice] ACCEPT_HTTP_START'); | |
| var ar = await fetch((window.location.origin||'') + '/ssvoice/accept', { | |
| method: 'POST', | |
| credentials: 'same-origin', | |
| headers: {'Content-Type': 'application/json'}, | |
| body: '{}' | |
| }); | |
| console.log('[SSVoice] ACCEPT_HTTP_STATUS=' + ar.status); | |
| var aj = await ar.json(); | |
| if (!aj || !aj.ok || !aj.session_id) { | |
| console.log('[SSVoice] ACCEPT_HTTP_FAIL', JSON.stringify(aj)); | |
| setStatus('ERROR', 'Session start failed'); | |
| return ''; | |
| } | |
| console.log('[SSVoice] SESSION_ID=' + aj.session_id); | |
| await startSession(aj.session_id); | |
| console.log('[SSVoice] SESSION_ACTIVE=true'); | |
| return aj.session_id; | |
| } catch(e) { | |
| console.log('[SSVoice] ACCEPT_PATH_FAIL', e); | |
| setStatus('ERROR', 'Accept failed'); | |
| return ''; | |
| } finally { | |
| _acceptInFlight = false; | |
| } | |
| } | |
| window.SSVoice = { | |
| start: startSession, | |
| stop: stopSession, | |
| prepare: acceptPrepare, | |
| accept: acceptFromUserGesture, | |
| isActive: function(){ return !!S.active; }, | |
| state: function(){ return { mode:S.mode, session:S.sessionId, turn:S.turn }; } | |
| }; | |
| // Helper: append lines into Gradio voice transcript textbox if present | |
| window.__ssAppendVoiceLine = function(userT, botT, action){ | |
| try { | |
| var box = document.querySelector('#voice-conv-box textarea, #voice-conv-box input, textarea[data-testid="textbox"]'); | |
| // more robust: find by elem id wrapper | |
| var root = document.getElementById('voice-conv-box'); | |
| var ta = root ? root.querySelector('textarea, input') : null; | |
| if (!ta) { | |
| // fallback any large voice box | |
| var all = document.querySelectorAll('textarea'); | |
| for (var i=0;i<all.length;i++){ | |
| var ph = (all[i].placeholder||''); | |
| if (ph.toLowerCase().indexOf('voice') !== -1 || ph.toLowerCase().indexOf('accept the call') !== -1) { | |
| ta = all[i]; break; | |
| } | |
| } | |
| } | |
| if (!ta) return; | |
| var cur = ta.value || ''; | |
| if (userT) cur += '[You] ' + userT + '\n'; | |
| if (botT) cur += '[Sherl0ck] ' + botT + '\n'; | |
| if (action === 'report_sent') cur += '[System] Report dispatched to all channels\n'; | |
| var setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set; | |
| setter.call(ta, cur); | |
| ta.dispatchEvent(new Event('input', { bubbles:true })); | |
| ta.scrollTop = ta.scrollHeight; | |
| } catch(e){ log('append line fail', e); } | |
| }; | |
| log('CONTROLLER_READY'); | |
| log('VERSION=2.1.0'); | |
| })(); | |
| </script> | |
| """ | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # GLOBAL CALL OVERLAY JS + HTML HELPERS | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _global_call_js(level: str, risk: float, tune_uri: str) -> str: | |
| col = "#FF4500" if level == "CRITICAL" else "#FF8C00" | |
| show = "true" if level in ("CRITICAL", "HIGH") else "false" | |
| sub = f"Risk Score: {round(risk * 100)}%" | |
| return f""" | |
| <script> | |
| (function() {{ | |
| var SHOW={show}, COL='{col}', LEVEL='{level}', RISK='{round(risk*100)}%', SUB='{sub}', TUNE={tune_uri!r}; | |
| window._ssGoToAgentTab = function(cb) {{ | |
| var tabs = document.querySelectorAll('.tab-nav button, button[role="tab"]'); | |
| var clicked = false; | |
| if (tabs.length > 2) {{ tabs[2].click(); clicked = true; }} | |
| if (!clicked) {{ | |
| var all = document.querySelectorAll('button[role="tab"],.tab-nav button,button'); | |
| for (var i=0;i<all.length;i++) {{ | |
| var t=(all[i].textContent||'').replace(/[\\s\\u00a0]+/g,' ').trim().toLowerCase(); | |
| if (t.indexOf('sherl')!==-1||t.indexOf('agent')!==-1) {{ all[i].click(); clicked=true; break; }} | |
| }} | |
| }} | |
| if (cb) setTimeout(cb, 350); | |
| }}; | |
| function _stopAudio() {{ | |
| if (window._ssRingStop) {{ try {{ window._ssRingStop(); }} catch(e) {{}} }} | |
| }} | |
| function _removeOverlay() {{ | |
| if (window._ssObserver) {{ window._ssObserver.disconnect(); window._ssObserver=null; }} | |
| var ph=document.querySelector('#ss-call-card .cc-phone-icon'); if(ph) ph.classList.add('stopped'); | |
| var c=document.getElementById('ss-call-card'); if(c) c.remove(); | |
| var r=document.getElementById('ss-overlay-root'); if(r) r.remove(); | |
| _stopAudio(); | |
| }} | |
| window._ssHandleAccept = function() {{ | |
| console.log('[SSVoice] OVERLAY_ACCEPT_CLICK'); | |
| window._ssCallDismissed=true; | |
| try {{ if (window._ssRingStop) window._ssRingStop(); }} catch(e){{}} | |
| // Authoritative voice init on THIS user gesture β do not rely on .click() | |
| try {{ | |
| if (window.SSVoice && window.SSVoice.accept) {{ | |
| console.log('[SSVoice] FORWARDING_TO_VOICE_ACCEPT'); | |
| window.SSVoice.accept('overlay'); | |
| }} else if (window.SSVoice && window.SSVoice.prepare) {{ | |
| window.SSVoice.prepare(); | |
| }} | |
| }} catch(e){{ console.log('[SSVoice] OVERLAY_ACCEPT_FAIL', e); }} | |
| _removeOverlay(); | |
| // Navigate to agent tab and click Gradio Accept ONLY for UI/session sync. | |
| // Voice already started on this gesture β SSVoice.accept is idempotent. | |
| try {{ | |
| window._ssGoToAgentTab && window._ssGoToAgentTab(function(){{ | |
| setTimeout(function(){{ | |
| var btns=document.querySelectorAll('button'); | |
| for(var j=0;j<btns.length;j++){{ | |
| var tx=((btns[j].textContent||'')+(btns[j].innerText||'')).replace(/[\\s\\u00a0]+/g,' ').trim().toLowerCase(); | |
| if(tx.indexOf('accept')!==-1&&tx.indexOf('call')!==-1&&btns[j].offsetParent!==null){{ | |
| console.log('[SSVoice] UI_ACCEPT_CLICK_AFTER_VOICE'); | |
| btns[j].click(); return; | |
| }} | |
| }} | |
| }}, 200); | |
| }}); | |
| }} catch(e){{}} | |
| }}; | |
| window._ssDismissCall = function() {{ | |
| console.log('[SSVoice] OVERLAY_DISMISS'); | |
| window._ssCallDismissed=true; | |
| try {{ if (window._ssRingStop) window._ssRingStop(); }} catch(e){{}} | |
| _removeOverlay(); | |
| }}; | |
| function _buildCard() {{ | |
| var card=document.createElement('div'); card.id='ss-call-card'; | |
| card.innerHTML='<div class="cc-scan"></div>' | |
| +'<div class="cc-header"><span class="cc-header-label">Incoming Alert Call</span><span class="cc-header-dot"></span></div>' | |
| +'<div class="cc-body">' | |
| +'<div class="cc-phone-icon"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="'+COL+'" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07 19.5 19.5 0 01-6-6 19.79 19.79 0 01-3.07-8.67A2 2 0 014.11 2h3a2 2 0 012 1.72c.127.96.361 1.903.7 2.81a2 2 0 01-.45 2.11L8.09 9.91a16 16 0 006 6l1.27-1.27a2 2 0 012.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0122 16.92z"/></svg></div>' | |
| +'<div style="flex:1;"><div class="cc-info-title">Sherl0ck Agent Calling</div>' | |
| +'<div class="cc-info-sub">'+SUB+'</div>' | |
| +'<div class="cc-risk-badge" style="border-color:'+COL+';color:'+COL+';">'+LEVEL+' \\u00b7 '+RISK+'</div></div>' | |
| +'</div>' | |
| +'<div class="cc-actions"><button class="cc-btn-accept" onclick="window._ssHandleAccept()">Accept</button>' | |
| +'<button class="cc-btn-dismiss" onclick="window._ssDismissCall()">Dismiss</button></div>' | |
| +'<div class="cc-progress"><div class="cc-progress-fill"></div></div>'; | |
| return card; | |
| }} | |
| function _ensureRoot() {{ | |
| var root=document.getElementById('ss-overlay-root'); | |
| if(!root) {{ root=document.createElement('div'); root.id='ss-overlay-root'; document.body.appendChild(root); }} | |
| return root; | |
| }} | |
| function _attachCard() {{ | |
| if(window._ssCallDismissed) return; | |
| if(document.getElementById('ss-call-card')) return; | |
| _ensureRoot().appendChild(_buildCard()); | |
| }} | |
| if(!SHOW) {{ window._ssCallDismissed=false; _removeOverlay(); try{{if(window._ssRingStop)window._ssRingStop();}}catch(e){{}} return; }} | |
| window._ssCallDismissed=false; _removeOverlay(); | |
| // Browser-native ringtone (not Gradio streaming HLS) | |
| try {{ | |
| if (window._ssRingStart) {{ window._ssRingStart(TUNE || ''); }} | |
| else if (TUNE) {{ | |
| var a = document.getElementById('ss-browser-ringtone'); | |
| if (!a) {{ a=document.createElement('audio'); a.id='ss-browser-ringtone'; a.loop=true; document.body.appendChild(a); }} | |
| a.src = TUNE; a.play().catch(function(){{}}); | |
| window._ssRingStop = function(){{ try{{a.pause();a.removeAttribute('src');a.load();}}catch(e){{}} }}; | |
| }} | |
| console.log('[SSVoice] RINGTONE_START_FROM_ALERT'); | |
| }} catch(e) {{ console.log('[SSVoice] RINGTONE_START_FAIL', e); }} | |
| function _isAgentTabActive() {{ | |
| var tabs=document.querySelectorAll('.tab-nav button, button[role="tab"]'); | |
| for(var i=0;i<tabs.length;i++) {{ | |
| var t=(tabs[i].textContent||'').replace(/[\\s\\u00a0]+/g,' ').trim().toLowerCase(); | |
| if((t.indexOf('sherl')!==-1||t.indexOf('agent')!==-1) && (tabs[i].classList.contains('selected')||tabs[i].getAttribute('aria-selected')==='true')) return true; | |
| }} | |
| return false; | |
| }} | |
| function _syncCardVisibility() {{ | |
| var card=document.getElementById('ss-call-card'); | |
| if(card) card.style.display=_isAgentTabActive() ? 'none' : ''; | |
| }} | |
| function _init() {{ | |
| _ensureRoot(); _attachCard(); _syncCardVisibility(); | |
| document.querySelectorAll('.tab-nav button, button[role="tab"]').forEach(function(btn){{ | |
| btn.addEventListener('click', function(){{ setTimeout(_syncCardVisibility, 60); }}); | |
| }}); | |
| if(window._ssObserver) window._ssObserver.disconnect(); | |
| window._ssObserver=new MutationObserver(function() {{ | |
| if(!window._ssCallDismissed&&!document.getElementById('ss-call-card')) _attachCard(); | |
| _syncCardVisibility(); | |
| }}); | |
| window._ssObserver.observe(document.body,{{childList:true,subtree:true,attributes:true,attributeFilter:['class']}}); | |
| }} | |
| if(document.readyState==='loading') document.addEventListener('DOMContentLoaded',_init); | |
| else _init(); | |
| }})(); | |
| </script> | |
| """ | |
| def _stop_call_js() -> str: | |
| return """ | |
| <script> | |
| (function(){ | |
| window._ssCallDismissed=true; | |
| if(window._ssRingStop){try{window._ssRingStop();}catch(e){}} | |
| if(window._ssObserver){window._ssObserver.disconnect();window._ssObserver=null;} | |
| var c=document.getElementById('ss-call-card'); if(c)c.remove(); | |
| var r=document.getElementById('ss-overlay-root'); if(r)r.remove(); | |
| document.querySelectorAll('.agent-call-strip').forEach(function(el){el.style.display='none';}); | |
| })(); | |
| </script> | |
| """ | |
| _tune_check = (CALLER_TUNE_PATH or "").strip() | |
| print(f"[AgentLayer] Caller tune: {'found at ' + _tune_check if _tune_check and os.path.exists(_tune_check) else 'NOT FOUND - synth ring will be used'}") | |
| def _agent_sec(text: str, icon: str = "") -> str: | |
| ic = (f"<span style='flex-shrink:0;'>" | |
| f"{icon.replace('stroke=\"currentColor\"','stroke=\"rgba(255,140,0,.6)\"')}</span>") if icon else "" | |
| return f"<div class='agent-sec-label'>{ic}{text}</div>" | |
| def _call_strip_html(level: str, risk: float, visible: bool) -> str: | |
| if not visible or level == "LOW": return "" | |
| col = "#FF4500" if level == "CRITICAL" else "#FF8C00" | |
| label = "CRITICAL ANOMALY DETECTED" if level == "CRITICAL" else "HIGH RISK ANOMALY" | |
| risk_pct = round(risk * 100) | |
| return ( | |
| f"<div class='agent-call-strip' " | |
| f"onclick='if(window._ssGoToAgentTab){{window._ssGoToAgentTab();}}else{{" | |
| f"var t=document.querySelectorAll(\"button[role=tab],.tab-nav button\");" | |
| f"if(t.length>2)t[2].click();}}'>" | |
| f"<div class='strip-phone'>" | |
| f"<svg width='26' height='26' viewBox='0 0 24 24' fill='none' stroke='{col}' " | |
| f"stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'>" | |
| f"<path d='M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07 19.5 19.5 0 " | |
| f"01-6-6 19.79 19.79 0 01-3.07-8.67A2 2 0 014.11 2h3a2 2 0 012 1.72c.127.96.361 " | |
| f"1.903.7 2.81a2 2 0 01-.45 2.11L8.09 9.91a16 16 0 006 6l1.27-1.27a2 2 0 " | |
| f"012.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0122 16.92z'/></svg>" | |
| f"</div>" | |
| f"<div style='flex:1;'>" | |
| f"<div style='font-family:\"Roboto Mono\",monospace;font-size:.52rem;letter-spacing:.18em;" | |
| f"text-transform:uppercase;color:{col};font-weight:700;'>{label}</div>" | |
| f"<div style='font-family:Charter,Georgia,serif;font-size:.82rem;" | |
| f"color:rgba(255,255,255,.45);margin-top:4px;'>" | |
| f"Incoming call · Risk: " | |
| f"<strong style='color:#fff;font-family:\"Times New Roman\",Times,serif;'>{risk_pct}%</strong>" | |
| f" · <em style='color:{col};font-style:normal;'>Click to open Agent tab</em>" | |
| f"</div></div>" | |
| f"<div style='font-family:\"Roboto Mono\",monospace;font-size:.55rem;font-weight:700;" | |
| f"letter-spacing:.1em;text-transform:uppercase;color:{col};" | |
| f"border:1px solid {col};border-radius:3px;padding:6px 12px;flex-shrink:0;'>" | |
| f"Open Agent →" | |
| f"</div></div>" | |
| ) | |
| def _alert_status_html(level: str, risk: float) -> str: | |
| col = {"CRITICAL": "#FF4500", "HIGH": "#FF8C00", "LOW": "#4CAF50"}.get(level, "#888") | |
| label = {"CRITICAL": "CRITICAL ANOMALY", "HIGH": "HIGH RISK", "LOW": "NO ALERT"}.get(level, level) | |
| card_cls = {"CRITICAL": "critical", "HIGH": "high", "LOW": ""}.get(level, "") | |
| risk_pct = round(risk * 100) | |
| icon_svg = ( | |
| f'<svg width="22" height="22" viewBox="0 0 24 24" fill="none" ' | |
| f'stroke="{col}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">' | |
| + ( | |
| '<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/>' | |
| '<line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>' | |
| if level != "LOW" else | |
| '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>' | |
| ) + '</svg>' | |
| ) | |
| def _chip(lbl, ok): | |
| c, col2 = ("#4CAF50", "rgba(76,175,80,.9)") if ok else ("#444", "rgba(255,255,255,.2)") | |
| return (f'<span class="agent-dep-chip" style="color:{col2};">' | |
| f'<span style="width:5px;height:5px;border-radius:50%;background:{c};display:inline-block;"></span>' | |
| f'{lbl}</span>') | |
| sanim = ' style="animation:status-flash 2s ease-in-out infinite;"' if level == "CRITICAL" else "" | |
| chips = ( | |
| _chip("LFM2.5-Audio", LFM_AUDIO_AVAILABLE) + _chip("Cohere", COHERE_READY) + | |
| _chip("cmd-a-03-2025", COHERE_READY) + _chip("HTTP /ssvoice", True) | |
| ) | |
| return ( | |
| f"<div class='agent-status-card {card_cls}'>" | |
| f"<div style='flex:1;'>" | |
| f"<div style='font-family:\"Roboto Mono\",monospace;font-size:.5rem;letter-spacing:.14em;" | |
| f"text-transform:uppercase;color:rgba(255,255,255,.28);margin-bottom:6px;'>Alert Status</div>" | |
| f"<div style='display:flex;align-items:center;gap:10px;'>{icon_svg}" | |
| f"<div style='font-family:Charter,Georgia,serif;font-size:1.2rem;font-weight:700;" | |
| f"color:{col};'{sanim}>{label}</div></div>" | |
| f"<div class='agent-dep-chips'>{chips}</div>" | |
| f"</div>" | |
| f"<div style='font-size:2.4rem;font-weight:700;color:{col};" | |
| f"font-family:\"Times New Roman\",Times,serif;letter-spacing:-.02em;" | |
| f"{('animation:status-flash 2s ease-in-out infinite;' if level=='CRITICAL' else '')}'>" | |
| f"{risk_pct}%</div></div>" | |
| ) | |
| def _clean_llm_markdown(text: str) -> str: | |
| text = text.replace('\r\n', '\n').replace('\r', '\n') | |
| cleaned = [] | |
| for line in text.split('\n'): | |
| m = re.match(r'^#{1,6}\s+(.+)$', line.strip()) | |
| if m: cleaned.append(f'**{m.group(1)}**'); continue | |
| s = re.sub(r'^[\s]*[-\*]\s+(?!\*)', '', line) | |
| if s != line: cleaned.append(s); continue | |
| s2 = re.sub(r'^[\s]*[\u2022\u00b7]\s*', '', line) | |
| if s2 != line: cleaned.append(s2); continue | |
| cleaned.append(line) | |
| return re.sub(r'\n{3,}', '\n\n', '\n'.join(cleaned)).strip() | |
| _SHERL0CK_LABEL_HTML = '<span class="brand-name">Sherl<span class="brand-zero">0</span>ck</span>' | |
| _CHAT_SCROLL_OBSERVER_JS = """ | |
| <script> | |
| (function() { | |
| var _lastScrollHeight = -1; | |
| function _tick() { | |
| var box = document.getElementById('ss-chat-box'); | |
| if (box && box.scrollHeight !== _lastScrollHeight) { | |
| box.scrollTop = box.scrollHeight; | |
| _lastScrollHeight = box.scrollHeight; | |
| } | |
| requestAnimationFrame(_tick); | |
| } | |
| if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function() { requestAnimationFrame(_tick); }); | |
| else requestAnimationFrame(_tick); | |
| })(); | |
| </script> | |
| """ | |
| def _render_bubble_content(raw: str) -> str: | |
| raw = raw.replace("&", "&").replace("<", "<").replace(">", ">") | |
| raw = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', raw) | |
| raw = re.sub(r'(\d+\.?\d*%?)', r"<span style='font-family:\"Times New Roman\",Times,serif;'>\1</span>", raw) | |
| parts = [] | |
| for para in re.split(r'\n\n+', raw): | |
| para = para.strip() | |
| if not para: continue | |
| lines = [l.strip() for l in para.split('\n') if l.strip()] | |
| if lines and all(re.match(r'^\d+[\.\)]\s+', l) for l in lines): | |
| lh = '' | |
| for l in lines: | |
| m2 = re.match(r'^(\d+)[\.\)]\s+(.+)$', l) | |
| if m2: | |
| lh += (f'<div class="chat-li"><span class="li-num">{m2.group(1)}.</span>' | |
| f'<span>{m2.group(2)}</span></div>') | |
| parts.append(lh) | |
| else: | |
| parts.append(f'<div class="chat-para">{para.replace(chr(10),"<br/>")}</div>') | |
| return ''.join(parts) or f'<div class="chat-para">{raw}</div>' | |
| def _render_chat_html(history: list) -> str: | |
| if not history: | |
| return ('<div class="chat-display" id="ss-chat-box">' | |
| '<div class="msg-empty">Run an analysis then click <strong>Start Chat</strong>.</div></div>') | |
| parts = ['<div class="chat-display" id="ss-chat-box">'] | |
| for m in history: | |
| role, content = m.get("role", "user"), m.get("content", "") | |
| if role == "assistant": | |
| rendered = _render_bubble_content(_clean_llm_markdown(content)) | |
| parts.append( | |
| '<div class="msg-agent">' | |
| f'<div class="msg-lbl"><span class="lbl-dot"></span>{_SHERL0CK_LABEL_HTML}</div>' | |
| f'<div class="msg-bubble">{rendered}</div></div>' | |
| ) | |
| elif role == "user": | |
| safe = content.replace("&","&").replace("<","<").replace(">",">").replace("\n","<br/>") | |
| parts.append( | |
| '<div class="msg-you"><div class="msg-lbl">You<span class="lbl-dot"></span></div>' | |
| f'<div class="msg-bubble">{safe}</div></div>' | |
| ) | |
| else: | |
| safe = content.replace("&","&").replace("<","<").replace(">",">") | |
| parts.append(f'<div class="msg-system">{safe}</div>') | |
| parts.append('</div>') | |
| return ''.join(parts) | |
| def _analyze_gpu_duration(video=None, include_llava=False): | |
| """Dynamic ZeroGPU reservation: Stage 8 (LLaVA) adds minutes of VLM time.""" | |
| return 420 if include_llava else 240 | |
| def process_video_with_agent(video, include_llava=False): | |
| empty = (no_result_html(), None, None, None, | |
| _alert_status_html("LOW", 0.0), "", "", gr.update(visible=False)) | |
| if video is None: | |
| return empty | |
| try: | |
| raw = run_pipeline(video, enable_llava=bool(include_llava)) | |
| except Exception: | |
| tb = traceback.format_exc() | |
| print(f"[AgentLayer] Pipeline error:\n{tb}") | |
| return (f"<div style='color:#FF4500;font-family:\"Roboto Mono\",monospace;" | |
| f"font-size:.8rem;padding:20px;background:#050505;'>{tb}</div>", | |
| None, None, None, _alert_status_html("LOW", 0.0), "", "", gr.update(visible=False)) | |
| r = raw["risk_score"] | |
| report_html = build_report_html(raw["report"], r, raw["risk_label"], | |
| raw["status"], graph_path=raw.get("graph_path")) | |
| from PIL import Image as _PIL | |
| fi = _PIL.fromarray(raw["top_frame"]) if raw.get("top_frame") is not None else None | |
| jd_str = json.dumps(raw["json_data"], indent=2, default=str) | |
| pdf_out = raw.get("pdf_path") | |
| payload = build_alert_payload(raw) | |
| level = determine_alert_level(r) | |
| _AGENT_STATE.update({"payload": payload, "level": level, "voice_live": False, "text_live": False}) | |
| print(f"[AgentLayer] State updated: level={level} risk={r:.3f}") | |
| if level == "CRITICAL": | |
| print("[AgentLayer] CRITICAL - auto-dispatching.") | |
| dispatch_all_channels(payload, level) | |
| tune = _caller_tune_uri() | |
| status_html = _alert_status_html(level, r) | |
| strip_html = _call_strip_html(level, r, visible=(level in ("CRITICAL", "HIGH"))) | |
| banner_js = _global_call_js(level, r, tune) | |
| if level in ("CRITICAL", "HIGH"): | |
| def _prewarm(): | |
| try: | |
| _load_lfm_audio() | |
| except Exception: | |
| pass | |
| _tts_executor.submit(_prewarm) | |
| return (report_html, fi, jd_str, pdf_out, | |
| status_html, strip_html, banner_js, | |
| gr.update(visible=(level in ("CRITICAL", "HIGH")))) | |
| def process_video_with_agent_for_agent_tab(video, include_llava=False): | |
| result = list(process_video_with_agent(video, include_llava)) | |
| result[5] = "" | |
| return tuple(result) | |
| def accept_call(): | |
| """Server-side Accept: mint/reuse session, stop ring, show voice UI. | |
| CRITICAL: Browser microphone + SSVoice.start MUST already have been started | |
| from the trusted user gesture via SSVoice.accept() (Gradio js= / overlay). | |
| Do NOT inject <script>SSVoice.start()</script> here β Gradio HTML updates | |
| often do not re-execute scripts, which was the live failure mode. | |
| """ | |
| global _voice_active_payload | |
| payload = _AGENT_STATE.get("payload", {}) | |
| level = _AGENT_STATE.get("level", "LOW") | |
| # Prefer session already minted by browser POST /ssvoice/accept | |
| sid = _AGENT_STATE.get("voice_session_id") or "" | |
| if not sid: | |
| sid = uuid.uuid4().hex | |
| _AGENT_STATE["voice_session_id"] = sid | |
| _AGENT_STATE["voice_turn"] = 0 | |
| _AGENT_STATE["voice_live"] = True | |
| _AGENT_STATE["ring_stopped"] = True | |
| _voice_active_payload = payload | |
| print(f"[AgentLayer] accept_call session={sid} level={level} " | |
| f"(browser should already own this session)") | |
| # UI only β no boot script that starts voice | |
| return ( | |
| "Call connected. Speak naturally β Sherl0ck is listening.\n", | |
| gr.update(visible=False, interactive=False, value="Accept Call"), | |
| gr.update(visible=True), | |
| gr.update(visible=True), | |
| _stop_call_js(), | |
| "", # ringtone placeholder (browser-native ring already stopped) | |
| ) | |
| def end_call(conv: str): | |
| _AGENT_STATE["voice_live"] = False | |
| sid = _AGENT_STATE.get("voice_session_id", "") | |
| _AGENT_STATE["voice_session_id"] = "" | |
| bye = "Sherlock signing off Investigation session ended Stay vigilant and safe" | |
| updated = (conv or "") + f"[Sherl0ck] {bye}\n" | |
| end_js = f""" | |
| <script> | |
| (function(){{ | |
| try {{ if (window._ssRingStop) window._ssRingStop(); }} catch(e){{}} | |
| try {{ if (window.SSVoice) window.SSVoice.stop(); }} catch(e){{}} | |
| }})(); | |
| </script> | |
| """ | |
| print(f"[AgentLayer] end_call session={sid[:8] if sid else '-'}") | |
| return ( | |
| updated, | |
| gr.update(visible=True, interactive=True, value="Accept Call"), | |
| gr.update(visible=False), | |
| gr.update(visible=False), | |
| end_js, | |
| ) | |
| def init_text_chat(): | |
| payload = _AGENT_STATE.get("payload", {}) | |
| level = _AGENT_STATE.get("level", "LOW") | |
| jd = payload.get("json_data") or {} | |
| print(f"[AgentLayer][Text] init_text_chat: payload_keys={list(payload.keys())} jd_keys={list(jd.keys())[:8]}") | |
| if not payload or not _has_pipeline_data(jd): | |
| return _render_chat_html([{"role": "system", | |
| "content": "No analysis loaded. Run the pipeline on the Analyse tab first."}]) | |
| _AGENT_STATE["text_live"] = True | |
| text_agent_init(payload, level) | |
| return _render_chat_html(_text_conversation_history) | |
| def manual_dispatch(): | |
| payload = _AGENT_STATE.get("payload", {}) | |
| level = _AGENT_STATE.get("level", "LOW") | |
| if not payload: | |
| return "No analysis loaded Run pipeline on the Analyse tab first" | |
| results = dispatch_all_channels(payload, level) | |
| ts = datetime.now(timezone.utc).strftime("%H:%M UTC") | |
| lines = [f"Dispatch [{level}] {ts}"] | |
| lines += [f" {ch.capitalize():<12s} {'OK' if ok else 'FAIL'}" for ch, ok in results.items()] | |
| return "\n".join(lines) | |
| def _refresh_agent_ui(): | |
| payload = _AGENT_STATE.get("payload", {}) | |
| level = _AGENT_STATE.get("level", "LOW") | |
| risk = float(payload.get("risk", 0.0)) | |
| tune = _caller_tune_uri() | |
| incoming = level in ("CRITICAL", "HIGH") | |
| return ( | |
| _alert_status_html(level, risk), | |
| "", | |
| _global_call_js(level, risk, tune), | |
| gr.update(visible=incoming, interactive=incoming, value="Accept Call"), | |
| ) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # GRADIO 5 RUNTIME THEME + OVERRIDES (Hugging Face Space port) | |
| # The notebook CSS was written against the Gradio 4 DOM (.tab-nav, .gr-button). | |
| # The Space runs Gradio 5.49.1, whose tabs are button[role="tab"] with a | |
| # .selected class, whose buttons are .primary/.secondary and whose checkbox is | |
| # a styled input[type="checkbox"]. The theme below pins every Gradio design | |
| # token to the notebook palette (pitch black / white / orange-saffron) and the | |
| # CSS afterwards forces the remaining runtime surfaces. Status colors used | |
| # inside analysis results are untouched. | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| SS_THEME = ( | |
| gr.themes.Base( | |
| primary_hue=gr.themes.colors.orange, | |
| secondary_hue=gr.themes.colors.neutral, | |
| neutral_hue=gr.themes.colors.neutral, | |
| font=[gr.themes.GoogleFont("Roboto Mono"), "ui-monospace", "SFMono-Regular", "monospace"], | |
| font_mono=[gr.themes.GoogleFont("Roboto Mono"), "ui-monospace", "SFMono-Regular", "monospace"], | |
| ).set( | |
| body_background_fill="#000000", | |
| body_background_fill_dark="#000000", | |
| body_text_color="#FFFFFF", | |
| body_text_color_dark="#FFFFFF", | |
| body_text_color_subdued="#888888", | |
| body_text_color_subdued_dark="#888888", | |
| block_background_fill="#000000", | |
| block_background_fill_dark="#000000", | |
| block_border_color="#1e1e1e", | |
| block_border_color_dark="#1e1e1e", | |
| block_border_width="1px", | |
| block_label_background_fill="#000000", | |
| block_label_background_fill_dark="#000000", | |
| block_label_text_color="#cccccc", | |
| block_label_text_color_dark="#cccccc", | |
| block_title_text_color="#f2f2f2", | |
| block_title_text_color_dark="#f2f2f2", | |
| panel_background_fill="#000000", | |
| panel_background_fill_dark="#000000", | |
| panel_border_color="#1e1e1e", | |
| panel_border_color_dark="#1e1e1e", | |
| border_color_primary="#1e1e1e", | |
| border_color_primary_dark="#1e1e1e", | |
| border_color_accent="#FF8C00", | |
| border_color_accent_dark="#FF8C00", | |
| input_background_fill="#0d0d0d", | |
| input_background_fill_dark="#0d0d0d", | |
| input_border_color="#2a2a2a", | |
| input_border_color_dark="#2a2a2a", | |
| input_border_color_focus="#FF8C00", | |
| input_border_color_focus_dark="#FF8C00", | |
| input_placeholder_color="#555555", | |
| input_placeholder_color_dark="#555555", | |
| checkbox_background_color="#0d0d0d", | |
| checkbox_background_color_dark="#0d0d0d", | |
| checkbox_background_color_selected="#FF8C00", | |
| checkbox_background_color_selected_dark="#FF8C00", | |
| checkbox_border_color="#2a2a2a", | |
| checkbox_border_color_dark="#2a2a2a", | |
| checkbox_border_color_selected="#FF8C00", | |
| checkbox_border_color_selected_dark="#FF8C00", | |
| checkbox_label_text_color="#f0f0f0", | |
| checkbox_label_text_color_dark="#f0f0f0", | |
| button_primary_background_fill="#FF8C00", | |
| button_primary_background_fill_dark="#FF8C00", | |
| button_primary_background_fill_hover="#ffa033", | |
| button_primary_background_fill_hover_dark="#ffa033", | |
| button_primary_text_color="#000000", | |
| button_primary_text_color_dark="#000000", | |
| button_primary_border_color="#FF8C00", | |
| button_primary_border_color_dark="#FF8C00", | |
| button_secondary_background_fill="#ffffff", | |
| button_secondary_background_fill_dark="#ffffff", | |
| button_secondary_background_fill_hover="#FF8C00", | |
| button_secondary_background_fill_hover_dark="#FF8C00", | |
| button_secondary_text_color="#000000", | |
| button_secondary_text_color_dark="#000000", | |
| button_secondary_border_color="#ffffff", | |
| button_secondary_border_color_dark="#ffffff", | |
| table_border_color="#1e1e1e", | |
| table_border_color_dark="#1e1e1e", | |
| table_even_background_fill="#050505", | |
| table_even_background_fill_dark="#050505", | |
| table_odd_background_fill="#000000", | |
| table_odd_background_fill_dark="#000000", | |
| loader_color="#FF8C00", | |
| loader_color_dark="#FF8C00", | |
| color_accent_soft="rgba(255,140,0,0.15)", | |
| link_text_color="#FF8C00", | |
| link_text_color_dark="#FF8C00", | |
| link_text_color_visited="#FF8C00", | |
| link_text_color_visited_dark="#FF8C00", | |
| ) | |
| ) | |
| GRADIO5_RUNTIME_CSS = """ | |
| /* ββββββββββ Gradio 5 runtime overrides β notebook palette enforcement ββββββββββ */ | |
| html, body { background:#000000 !important; } | |
| .gradio-container, .gradio-container .contain, .gradio-container main { | |
| background:#000000 !important; color:#ffffff !important; | |
| } | |
| .gradio-container a { color:#FF8C00 !important; } | |
| /* Tabs β Gradio 5 renders button[role=tab] + .selected (no .tab-nav) */ | |
| .gradio-container [role="tablist"], .gradio-container .tab-nav { | |
| background:#000000 !important; border-bottom:1px solid #1e1e1e !important; | |
| gap:0 !important; padding:0 24px !important; box-shadow:none !important; | |
| } | |
| .gradio-container button[role="tab"], .gradio-container .tab-nav button { | |
| font-family:'Roboto Mono',ui-monospace,monospace !important; font-size:.72rem !important; | |
| letter-spacing:.13em !important; color:#999999 !important; background:transparent !important; | |
| border:none !important; border-bottom:2px solid transparent !important; | |
| padding:14px 0 !important; margin-right:26px !important; | |
| text-transform:uppercase !important; font-weight:600 !important; white-space:nowrap !important; | |
| box-shadow:none !important; border-radius:0 !important; | |
| } | |
| .gradio-container button[role="tab"]:hover, .gradio-container .tab-nav button:hover { color:#dddddd !important; } | |
| .gradio-container button[role="tab"].selected, | |
| .gradio-container button[role="tab"][aria-selected="true"], | |
| .gradio-container .tab-nav button.selected { | |
| color:#ffffff !important; background:transparent !important; | |
| border-bottom:2px solid #FF8C00 !important; font-weight:700 !important; | |
| } | |
| /* Blocks / panels / forms β never gray */ | |
| .gradio-container .block, .gradio-container .form, .gradio-container .panel, | |
| .gradio-container fieldset, .gradio-container .gap, .gradio-container section { | |
| background:#000000 !important; box-shadow:none !important; | |
| } | |
| .gradio-container .block { border-color:#1e1e1e !important; } | |
| .gradio-container .label-wrap, .gradio-container .label-wrap span, | |
| .gradio-container span[data-testid="block-info"], .gradio-container .label-text { | |
| color:#cccccc !important; background:transparent !important; | |
| font-family:'Roboto Mono',ui-monospace,monospace !important; letter-spacing:.08em !important; | |
| } | |
| .gradio-container .block-label, .gradio-container .block-title { color:#cccccc !important; background:#000000 !important; } | |
| /* Inputs */ | |
| .gradio-container input:not([type="checkbox"]):not([type="radio"]), | |
| .gradio-container textarea, .gradio-container select { | |
| background:#0d0d0d !important; color:#f0f0f0 !important; | |
| border:1px solid #2a2a2a !important; border-radius:3px !important; | |
| } | |
| .gradio-container input:not([type="checkbox"]):focus, | |
| .gradio-container textarea:focus, .gradio-container select:focus { | |
| border-color:#FF8C00 !important; box-shadow:0 0 0 1px #FF8C00 !important; outline:none !important; | |
| } | |
| .gradio-container input::placeholder, .gradio-container textarea::placeholder { color:#555555 !important; } | |
| /* Checkbox β orange accent, never blue (Gradio 5 styles the raw input) */ | |
| .gradio-container input[type="checkbox"] { | |
| background:#0d0d0d !important; border:1px solid #2a2a2a !important; accent-color:#FF8C00 !important; | |
| } | |
| .gradio-container input[type="checkbox"]:checked { | |
| background:#FF8C00 !important; border-color:#FF8C00 !important; | |
| } | |
| .gradio-container input[type="checkbox"]:focus, .gradio-container input[type="checkbox"]:focus-visible { | |
| outline:1px solid #FF8C00 !important; box-shadow:0 0 0 1px #FF8C00 !important; | |
| } | |
| /* Upload / file surfaces β never gray */ | |
| .gradio-container .wrap.default, .gradio-container .wrap.default:hover, | |
| .gradio-container .wrap-inner, .gradio-container .upload-container, | |
| .gradio-container .file-preview, .gradio-container .file-preview > div, | |
| .gradio-container [data-testid="file-preview"] { | |
| background:#060606 !important; color:#888888 !important; | |
| } | |
| .gradio-container .wrap.default { border:1px dashed #2a2a2a !important; } | |
| .gradio-container .wrap.default:hover, .gradio-container .wrap-inner:hover { border-color:#FF8C00 !important; } | |
| .gradio-container video { background:#000000 !important; } | |
| /* Buttons β Gradio 5 uses .primary / .secondary */ | |
| .gradio-container button.primary { | |
| background:#FF8C00 !important; color:#000000 !important; border-color:#FF8C00 !important; | |
| } | |
| .gradio-container button.primary:hover { background:#ffa033 !important; border-color:#ffa033 !important; } | |
| .gradio-container button.secondary { | |
| background:#ffffff !important; color:#000000 !important; border:1px solid #ffffff !important; | |
| } | |
| .gradio-container button.secondary:hover { background:#FF8C00 !important; border-color:#FF8C00 !important; color:#000000 !important; } | |
| .gradio-container button:focus, .gradio-container button:focus-visible { | |
| outline:1px solid #FF8C00 !important; box-shadow:none !important; | |
| } | |
| /* Status / progress / misc */ | |
| .gradio-container .progress-text, .gradio-container .progress-level-inner { color:#FF8C00 !important; } | |
| .gradio-container .progress-level > div { background:#FF8C00 !important; } | |
| .gradio-container .meta-text, .gradio-container .meta-text-center { color:#666666 !important; background:#000000 !important; } | |
| .gradio-container .prose, .gradio-container .prose p, .gradio-container .prose li { color:#dddddd !important; } | |
| .gradio-container ::selection { background:rgba(255,140,0,.35) !important; } | |
| .gradio-container *:focus-visible { outline:1px solid #FF8C00 !important; } | |
| .gradio-container .toast-body { background:#0d0d0d !important; color:#f0f0f0 !important; border:1px solid #2a2a2a !important; } | |
| footer { display:none !important; } | |
| """ | |
| AGENT_CSS = AGENT_CSS + GRADIO5_RUNTIME_CSS | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # BUILD DEMO β Agent-enabled app | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| _T_HOME = "\u00a0\u00a0\u00a0 Home \u00a0\u00a0\u00a0" | |
| _T_ANALYSE = "\u00a0\u00a0 Analyse \u00a0\u00a0" | |
| _T_AGENT = "\u00a0 Sherl\u09e6ck Agent \u00a0" | |
| _T_CONTACT = "\u00a0\u00a0 Contact \u00a0\u00a0" | |
| # Fix tab label to match original branding | |
| _T_AGENT = "\u00a0 Sherl\u09e6ck Agent \u00a0".replace("\u09e6", "0") | |
| # Use proper zero in brand (original used mathematical/special zero sometimes) | |
| _T_AGENT = "\u00a0 Sherl\u0030ck Agent \u00a0" | |
| with gr.Blocks(title="CrimeSceneSolver", css=AGENT_CSS, theme=SS_THEME) as demo_v2: | |
| gr.HTML(NAV_HTML) | |
| gr.HTML(SSVOICE_CONTROLLER_HTML) | |
| gr.HTML('<div id="ss-debug-log"></div>') | |
| gr.HTML(_CHAT_SCROLL_OBSERVER_JS) | |
| # Browser-only ringtone β avoid Gradio StaticAudio/HLS (share-link breakage) | |
| ringtone_out = gr.HTML(value="", visible=False, elem_id="ss-ringtone-placeholder") | |
| with gr.Tabs(): | |
| # ββ HOME ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| with gr.TabItem(_T_HOME): | |
| gr.HTML(HOME_HTML) | |
| gr.HTML(FOOTER_HTML) | |
| # ββ ANALYSE ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| with gr.TabItem(_T_ANALYSE): | |
| _analyse_strip_tmp = gr.HTML("") | |
| _analyse_global_js = gr.HTML("") | |
| gr.HTML(""" | |
| <div style="max-width:780px;margin:0 auto;padding:48px 28px 0;"> | |
| <div style="font-family:'Roboto Mono',monospace;font-size:.62rem;letter-spacing:.16em; | |
| color:#FF8C00;text-transform:uppercase;margin-bottom:12px;font-weight:600;"> | |
| Forensic Analysis</div> | |
| <h1 style="font-family:Charter,Georgia,serif;font-size:3rem;font-weight:400; | |
| color:#fff;margin-bottom:8px;">Case File Analysis</h1> | |
| <p style="font-family:Charter,Georgia,serif;font-size:.88rem;color:#555;margin-bottom:28px;"> | |
| Upload CCTV footage to run the full nine-stage forensic pipeline. | |
| </p> | |
| <hr class="rule"> | |
| </div>""") | |
| with gr.Column(): | |
| gr.HTML('<div style="max-width:780px;margin:0 auto;padding:0 28px;">') | |
| gr.HTML(sec_label("Upload Footage")) | |
| vid_in2 = gr.Video(label="", elem_id="vid-up", sources=["upload"], | |
| show_label=False, height=175) | |
| gr.HTML('<div style="height:14px;"></div>') | |
| llava_chk = gr.Checkbox( | |
| value=False, | |
| label="Enable LLaVA contextual explanation (Stage 8)", | |
| info="Adds the final multimodal narrative stage and may take substantially longer.", | |
| elem_id="llava-chk", | |
| ) | |
| gr.HTML('<div style="height:14px;"></div>') | |
| gr.HTML('<div style="display:flex;justify-content:flex-start;">') | |
| ana_btn2 = gr.Button("Analyse Footage", elem_id="ana-btn", elem_classes="gr-button") | |
| gr.HTML('</div>') | |
| spinner2 = gr.HTML("") | |
| gr.HTML('<div style="height:28px;"></div>') | |
| gr.HTML(sec_label("Alert Status")) | |
| alert_status_out2 = gr.HTML(value=_alert_status_html("LOW", 0.0)) | |
| gr.HTML('<div style="height:18px;"></div>') | |
| gr.HTML(sec_label("Risk Assessment & Report")) | |
| report_out2 = gr.HTML( | |
| value=("<div style='text-align:center;padding:60px 0;" | |
| "font-family:Charter,Georgia,serif;font-size:.9rem;color:#2a2a2a;'>" | |
| "Upload footage and press Analyse.</div>"), | |
| elem_id="report-html") | |
| gr.HTML('<div style="height:20px;"></div>') | |
| gr.HTML(sec_label("Peak Anomaly Frame")) | |
| frame_out2 = gr.Image(label="", elem_id="kf-img", show_label=False) | |
| gr.HTML('<div style="height:20px;"></div>') | |
| gr.HTML(sec_label("Download Forensic PDF Report")) | |
| pdf_out2 = gr.File(label="", elem_id="pdf-out", show_label=False, | |
| file_types=[".pdf"], interactive=False) | |
| gr.HTML('<div style="height:20px;"></div>') | |
| gr.HTML(sec_label("Structured JSON Output")) | |
| json_out2 = gr.Textbox(label="", elem_id="json-out", show_label=False, | |
| lines=20, placeholder="JSON output...") | |
| gr.HTML('</div>') | |
| def _show_s(): return gr.update(value=spinner_html()) | |
| def _hide_s(): return gr.update(value="") | |
| gr.HTML(FOOTER_HTML) | |
| # ββ SHERL0CK AGENT ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| with gr.TabItem(_T_AGENT): | |
| gr.HTML(""" | |
| <div style="max-width:860px;margin:0 auto;padding:44px 28px 0;"> | |
| <div style="font-family:'Roboto Mono',monospace;font-size:.52rem;letter-spacing:.2em; | |
| color:#FF8C00;text-transform:uppercase;margin-bottom:10px;font-weight:700; | |
| display:flex;align-items:center;gap:8px;"> | |
| <span style="width:3px;height:12px;background:#FF8C00;border-radius:2px;display:inline-block;"></span> | |
| Agentic Escalation Layer | |
| </div> | |
| <h2 style="font-family: Charter, Georgia, serif; font-size: 3rem; font-weight: 400; color: #fff; margin-bottom: 8px;"> | |
| Sherl<span style="font-family: 'Times New Roman', Times, serif; font-weight: 400;">0</span>ck | |
| <span style="font-size: 1rem; font-weight: 400; color: rgba(255,255,255,0.7); margin-left: 10px;">Agent</span> | |
| </h2> | |
| <p style="font-family:Charter,Georgia,serif;font-size:.88rem;color:#555;margin-bottom:0; | |
| max-width:560px;line-height:1.7;"> | |
| Run an analysis on the Analyse tab first. After Accept Call, speak naturally β | |
| no push-to-talk. Text chat remains independent. | |
| </p> | |
| <hr class="rule"> | |
| </div>""") | |
| with gr.Column(): | |
| gr.HTML('<div class="agent-page">') | |
| gr.HTML(_agent_sec("System Status", ICON_SHIELD)) | |
| agent_alert_html = gr.HTML(value=_alert_status_html("LOW", 0.0)) | |
| gr.HTML('<div style="height:8px;"></div>') | |
| call_strip_html = gr.HTML(value="") | |
| agent_global_js = gr.HTML("") | |
| gr.HTML(_agent_sec("Evidence Dispatch", ICON_DISPATCH)) | |
| with gr.Row(equal_height=True): | |
| dispatch_btn = gr.Button("Send Report to All Channels", | |
| elem_classes=["agent-btn", "btn-dispatch"]) | |
| dispatch_status = gr.Textbox(label="", elem_id="dispatch-status", show_label=False, | |
| lines=3, placeholder="Dispatch log...", interactive=False) | |
| with gr.Row(equal_height=False, elem_classes=["agent-dual-panel"]): | |
| # TEXT CHAT COLUMN | |
| with gr.Column(elem_classes=["agent-panel", "text-panel"]): | |
| gr.HTML(""" | |
| <div class='panel-header text-header'> | |
| Text Chat with Sherl0ck | |
| <span style='font-family:"Roboto Mono",monospace;font-size:.4rem; | |
| letter-spacing:.08em;text-transform:uppercase; | |
| color:rgba(100,149,237,.4);margin-left:auto;'> | |
| Full depth | |
| </span> | |
| </div> | |
| <div class='panel-desc'> | |
| Ask Sherl0ck anything about the analysis. Say | |
| <em>send report</em> to dispatch evidence to all channels. | |
| </div> | |
| """) | |
| text_chat_display = gr.HTML( | |
| value=('<div class="chat-display" id="ss-chat-box">' | |
| '<div class="msg-empty">' | |
| 'Run an analysis then click <strong>Start Chat</strong>.' | |
| '</div></div>'), | |
| elem_id="text-chat-display", | |
| visible=True | |
| ) | |
| start_chat_btn = gr.Button("Start Chat", elem_classes=["agent-btn", "btn-start"]) | |
| with gr.Row(equal_height=False, elem_id="chat-input-row"): | |
| text_in = gr.Textbox( | |
| label="",show_label=False,elem_id="text-chat-input", | |
| placeholder="Type your question for Sherl0ck...", | |
| lines=1,max_lines=1,submit_btn=False,scale=4 | |
| ) | |
| send_text_btn = gr.Button("Send", elem_id="send-text-btn", elem_classes=["agent-btn", "btn-send"], scale=1) | |
| # VOICE CALL COLUMN | |
| with gr.Column(elem_classes=["agent-panel", "voice-panel"]): | |
| gr.HTML(""" | |
| <div class='panel-header voice-header'> | |
| Voice Call with Sherl0ck | |
| <span class='experimental-badge'>LFM2.5-Audio</span> | |
| <span style='font-family:"Roboto Mono",monospace;font-size:.4rem; | |
| letter-spacing:.08em;text-transform:uppercase; | |
| color:rgba(76,175,80,.35);margin-left:auto;'> | |
| Hands-free VAD | |
| </span> | |
| </div> | |
| <div class='panel-desc'> | |
| Accept the call once. Microphone stays live with automatic speech | |
| detection, barge-in, and continuous turns. Only End Call stops it. | |
| </div> | |
| """) | |
| with gr.Row(equal_height=True): | |
| accept_btn = gr.Button("Accept Call", elem_classes=["agent-btn", "btn-accept"], | |
| visible=False, scale=1) | |
| end_btn = gr.Button("End Call", elem_classes=["agent-btn", "btn-end"], | |
| visible=False, scale=1) | |
| voice_conv_box = gr.Textbox( | |
| label="", elem_id="voice-conv-box", show_label=False, lines=8, | |
| placeholder="Accept the call to begin. Voice transcript appears here.", | |
| interactive=False) | |
| with gr.Column(visible=False) as voice_controls_col: | |
| gr.HTML(""" | |
| <div class="live-indicator"> | |
| <span class="live-dot"></span> | |
| <span class="live-label">Sherl0ck - Call Active</span> | |
| </div> | |
| <div id="ss-voice-ui"> | |
| <canvas id="ss-wave-canvas" width="640" height="72"></canvas> | |
| <div id="ss-voice-status-line" class="mode-listening">Waitingβ¦</div> | |
| </div> | |
| """) | |
| gr.HTML('</div>') # agent-page | |
| _SCROLL_CHAT_JS = """ | |
| () => { | |
| let box = document.getElementById("ss-chat-box"); | |
| if (box) { box.scrollTop = box.scrollHeight; } | |
| } | |
| """ | |
| start_chat_btn.click(fn=init_text_chat, inputs=None, outputs=[text_chat_display], | |
| scroll_to_output=False | |
| ).then(fn=None, inputs=None, outputs=None, js=_SCROLL_CHAT_JS) | |
| send_text_btn.click( | |
| fn=handle_text_input,inputs=[text_in],outputs=[text_chat_display, text_in], | |
| scroll_to_output=False | |
| ).then(fn=None, inputs=None, outputs=None, js=_SCROLL_CHAT_JS) | |
| text_in.submit( | |
| fn=handle_text_input,inputs=[text_in],outputs=[text_chat_display, text_in], | |
| scroll_to_output=False | |
| ).then(fn=None, inputs=None, outputs=None, js=_SCROLL_CHAT_JS) | |
| dispatch_btn.click(fn=manual_dispatch, inputs=None, outputs=[dispatch_status]) | |
| gr.HTML(FOOTER_HTML) | |
| # ββ CONTACT βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| with gr.TabItem(_T_CONTACT): | |
| gr.HTML(""" | |
| <div style="max-width:600px;margin:0 auto;padding:52px 28px 0;"> | |
| <div style="font-family:'Roboto Mono',monospace;font-size:.62rem;letter-spacing:.16em; | |
| color:#FF8C00;text-transform:uppercase;margin-bottom:12px;font-weight:600;"> | |
| Get In Touch</div> | |
| <h2 style="font-family:Charter,Georgia,serif;font-size:3.4rem;font-weight:400; | |
| color:#fff;line-height:1.1;margin-bottom:20px;">Contact Us</h2> | |
| <p style="font-family:Charter,Georgia,serif;font-size:.88rem;color:#555;margin-bottom:28px;"> | |
| Reach out with questions, partnership inquiries, or case-specific requests. | |
| </p> | |
| </div>""") | |
| with gr.Column(): | |
| gr.HTML('<div style="max-width:600px;margin:0 auto;padding:0 28px 48px;">') | |
| gr.HTML('<div class="contact-card">') | |
| ct_n2 = gr.Textbox(label="Name", placeholder="Your full name") | |
| gr.HTML('<div style="height:12px;"></div>') | |
| ct_e2 = gr.Textbox(label="Email", placeholder="your@email.com") | |
| gr.HTML('<div style="height:12px;"></div>') | |
| ct_p2 = gr.Textbox(label="Phone Number", placeholder="+91 XXXXXXXXXX") | |
| gr.HTML('<div style="height:12px;"></div>') | |
| ct_m2 = gr.Textbox(label="Message", placeholder="Your message...", lines=5) | |
| gr.HTML('<div style="height:18px;"></div>') | |
| gr.HTML('<div style="display:flex;justify-content:flex-start;">') | |
| ct_b2 = gr.Button("Send Message", elem_classes="gr-button", elem_id="send-btn") | |
| gr.HTML('</div>') | |
| ct_r2 = gr.HTML("") | |
| gr.HTML(""" | |
| <div class="contact-info-block"> | |
| <div style="font-family:'Roboto Mono',monospace;font-size:.6rem;letter-spacing:.14em; | |
| text-transform:uppercase;color:#FF8C00;margin-bottom:14px;font-weight:600;"> | |
| Direct Contact</div> | |
| <div style="display:flex;flex-direction:column;gap:12px;"> | |
| <a href="mailto:anumulasamarth@gmail.com" | |
| style="font-family:Charter,Times New Roman,serif;font-size:.86rem;color:#bbb;text-decoration:none;" | |
| onmouseover="this.style.color='#FF8C00'" onmouseout="this.style.color='#bbb'"> | |
| anumulasamarth@gmail.com | |
| </a> | |
| </div> | |
| </div>""") | |
| gr.HTML(""" | |
| <style> | |
| #send-btn, #send-btn button { | |
| width:auto !important; max-width:200px !important; | |
| min-height:44px !important; height:44px !important; padding:0 28px !important; | |
| } | |
| </style>""") | |
| gr.HTML('</div></div>') | |
| ct_b2.click(handle_contact, [ct_n2, ct_e2, ct_p2, ct_m2], ct_r2) | |
| gr.HTML(FOOTER_HTML) | |
| # Wire analyse pipeline | |
| def _maybe_ring(): | |
| """Ringtone is browser-native via _global_call_js; keep Gradio output clear.""" | |
| level = _AGENT_STATE.get("level", "LOW") | |
| if level in ("CRITICAL", "HIGH"): | |
| _AGENT_STATE["ring_stopped"] = False | |
| print("[Ringtone] browser-native ring armed (no Gradio stream)") | |
| return "" | |
| _pipe_event = ( | |
| ana_btn2.click(fn=_show_s, inputs=None, outputs=spinner2, queue=True | |
| ).then(fn=process_video_with_agent, inputs=[vid_in2, llava_chk], | |
| outputs=[report_out2, frame_out2, json_out2, pdf_out2, | |
| alert_status_out2, _analyse_strip_tmp, | |
| _analyse_global_js, accept_btn] | |
| ).then(fn=_hide_s, inputs=None, outputs=spinner2, queue=True) | |
| ) | |
| _pipe_event.then(fn=_refresh_agent_ui, inputs=None, | |
| outputs=[agent_alert_html, call_strip_html, agent_global_js, accept_btn]) | |
| _ring_event = _pipe_event.then(fn=_maybe_ring, inputs=None, outputs=ringtone_out) | |
| _ACCEPT_JS = """ | |
| async () => { | |
| console.log('[SSVoice] GRADIO_ACCEPT_JS'); | |
| try { | |
| if (window.SSVoice && window.SSVoice.accept) { | |
| await window.SSVoice.accept('gradio-btn'); | |
| } else if (window.SSVoice && window.SSVoice.prepare) { | |
| await window.SSVoice.prepare(); | |
| } | |
| } catch (e) { | |
| console.log('[SSVoice] GRADIO_ACCEPT_JS_FAIL', e); | |
| } | |
| return []; | |
| } | |
| """ | |
| accept_btn.click( | |
| fn=accept_call, inputs=None, | |
| outputs=[voice_conv_box, accept_btn, end_btn, | |
| voice_controls_col, agent_global_js, ringtone_out], | |
| js=_ACCEPT_JS, | |
| cancels=[_ring_event]) | |
| end_btn.click(fn=end_call, inputs=[voice_conv_box], | |
| outputs=[voice_conv_box, accept_btn, end_btn, | |
| voice_controls_col, agent_global_js]) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # ZERO-GPU ASSET PREFETCH | |
| # Background warm-up of the HF cache (mirrors the notebook's startup downloads | |
| # without blocking Space startup). Every download is also resolved lazily on | |
| # first use, so a missed prefetch never breaks an analysis. | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _prefetch_assets(): | |
| for key in ("AE", "TS_BINARY", "TS_MULTI", "YOLO", "RL_AGENT", "AAE", "LLAVA"): | |
| try: | |
| Config.PATHS[key] | |
| except Exception as e: | |
| print(f"[Prefetch] {key} failed: {e}") | |
| try: | |
| from huggingface_hub import snapshot_download as _snap | |
| _snap("LiquidAI/LFM2.5-Audio-1.5B") | |
| except Exception as e: | |
| print(f"[Prefetch] LFM2.5-Audio failed: {e}") | |
| print("[Prefetch] SceneSolver asset cache warm.") | |
| if os.environ.get("SCENESOLVER_NO_PREFETCH", "").strip() not in ("1", "true", "True"): | |
| threading.Thread(target=_prefetch_assets, daemon=True, name="AssetPrefetch").start() | |
| else: | |
| print("[Prefetch] Skipped (SCENESOLVER_NO_PREFETCH set - assets resolve lazily).") | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # LAUNCH β Hugging Face Space entry point (Gradio SDK, ZeroGPU hardware) | |
| # `demo` is the conventional Blocks name Spaces look for. No share=True, no | |
| # server_port β the Spaces runtime configures the public URL and port. | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| demo = demo_v2 | |
| print(f"[AgentLayer] Gradio version: {getattr(gr, '__version__', '?')}") | |
| demo.queue(default_concurrency_limit=1) | |
| _launched_app = None | |
| try: | |
| _launched_app, _local_url, _share_url = demo.launch(prevent_thread_lock=True) | |
| except Exception: | |
| traceback.print_exc() | |
| demo.launch() # blocking fallback (voice HTTP routes unavailable) | |
| if _launched_app is not None: | |
| try: | |
| _register_ssvoice_routes(_launched_app) | |
| print("[AgentLayer] /ssvoice routes attached to the live Gradio FastAPI app") | |
| except Exception as e: | |
| print(f"[AgentLayer] Voice route registration failed: {e}") | |
| traceback.print_exc() | |
| print("[AgentLayer] demo_v2 is live with continuous HTTP voice transportβ¦") | |
| try: | |
| demo.block_thread() | |
| except (KeyboardInterrupt, OSError): | |
| pass | |