from __future__ import annotations import html import os import random import threading import time from difflib import SequenceMatcher from pathlib import Path import gradio as gr from engine import TurnRequest, build_engine from robot import ReachyBridgeRobot, render_avatar_html APP_TITLE = "Reachy Bridge" STATIC_DIR = Path(__file__).parent / "static" AVATAR_DOC = (STATIC_DIR / "avatar.html").read_text(encoding="utf-8") ENGINE_LABEL = os.getenv("ENGINE", "seamless").lower() ROBOT_CONNECTED = os.getenv("ROBOT_CONNECTED", "0") == "1" PAIR_OPTIONS = [ "Hindi -> English", "English -> Hindi", "Spanish -> English", "English -> Spanish", "German -> English", "English -> German", "Chinese -> English", "English -> Chinese", ] TEXT_EXAMPLES = [ ["नमस्ते, आपसे मिलकर खुशी हुई।", "Hindi -> English"], ["Hola, me alegra mucho verte hoy.", "Spanish -> English"], ["Guten Morgen, schön dich zu sehen.", "German -> English"], ["Hello, welcome to our home — please come in.", "English -> Hindi"], ] SETUP_VIRTUAL = "Virtual Reachy (this browser)" SETUP_PHYSICAL = "I have a Reachy Mini" # --------------------------------------------------------------------------- # Live Conversation (robot-only) — jolly female persona copy. The persona only # ever decorates the wrapper (status copy + spoken greeting/sign-off + motion); # the translated sentence itself is always spoken verbatim from the engine. # --------------------------------------------------------------------------- CONV_LISTEN = ["I'm all ears — go ahead!", "Listening closely… say the word."] CONV_THINK = ["Brain whirring… finding the perfect words.", "One sec — translating, no funny business."] CONV_SPEAK = ["Here it comes, straight from me to them!", "Passing the message along — word for word."] CONV_STOP = ["Mic off, antennas down. That was fun!", "All done here — tap Start whenever you're ready."] CONV_ERR = ["Oops — my circuits tripped. Try that again?", "Hiccup on my end! Give it another go."] CONV_GREETING = ("Hi there! I'm Reachy, your friendly interpreter. " "Say anything and I'll carry it across — let's connect!") CONV_SIGNOFF = ("That was lovely chatting through you both! " "Reachy signing off — come back anytime!") CONV_CSS = """ """ TUTOR_PHRASES = { "Hindi": [ {"practice": "नमस्ते, आपसे मिलकर खुशी हुई।", "guide": "Namaste, aapse milkar khushi hui.", "meaning": "Hello, nice to meet you."}, {"practice": "आपका दिन शुभ हो।", "guide": "Aapka din shubh ho.", "meaning": "Have a good day."}, {"practice": "कृपया धीरे बोलिए।", "guide": "Kripya dheere boliye.", "meaning": "Please speak slowly."}, {"practice": "बहुत बहुत धन्यवाद।", "guide": "Bahut bahut dhanyavaad.", "meaning": "Thank you very much."}, ], "Spanish": [ {"practice": "Hola, me alegra verte.", "guide": "OH-lah, meh ah-LEH-grah VER-teh.", "meaning": "Hello, nice to see you."}, {"practice": "¿Cómo estás hoy?", "guide": "KOH-moh ehs-TAHS oy.", "meaning": "How are you today?"}, {"practice": "Por favor, habla despacio.", "guide": "por fah-VOR, AH-blah dehs-PAH-syoh.", "meaning": "Please speak slowly."}, {"practice": "Muchas gracias por tu ayuda.", "guide": "MOO-chahs GRAH-syahs por too ah-YOO-dah.", "meaning": "Thank you very much for your help."}, ], "German": [ {"practice": "Hallo, schön dich zu sehen.", "guide": "HAH-loh, shurn dikh tsoo ZAY-en.", "meaning": "Hello, nice to see you."}, {"practice": "Wie geht es dir heute?", "guide": "vee gayt es deer HOY-tuh.", "meaning": "How are you today?"}, {"practice": "Bitte sprich langsam.", "guide": "BIT-tuh shprikh LAHNG-zahm.", "meaning": "Please speak slowly."}, {"practice": "Vielen Dank für deine Hilfe.", "guide": "FEE-len dahnk fyoor DYE-nuh HIL-fuh.", "meaning": "Thank you for your help."}, ], "Chinese": [ {"practice": "你好,很高兴见到你。", "guide": "Ni hao, hen gaoxing jiandao ni.", "meaning": "Hello, nice to meet you."}, {"practice": "你今天好吗?", "guide": "Ni jintian hao ma?", "meaning": "How are you today?"}, {"practice": "请说慢一点。", "guide": "Qing shuo man yidian.", "meaning": "Please speak slowly."}, {"practice": "非常感谢你的帮助。", "guide": "Feichang ganxie ni de bangzhu.", "meaning": "Thank you very much for your help."}, ], "English": [ {"practice": "Hello, nice to meet you.", "guide": "Say it warmly.", "meaning": "Hello, nice to meet you."}, {"practice": "How are you today?", "guide": "Friendly, rising tone.", "meaning": "How are you today?"}, {"practice": "Please speak slowly.", "guide": "Clear and calm.", "meaning": "Please speak slowly."}, {"practice": "Thank you very much for your help.", "guide": "Sincere tone.", "meaning": "Thank you very much for your help."}, ], } # --------------------------------------------------------------------------- # Head: fonts + the bridge that drives the 3D avatar from app state # --------------------------------------------------------------------------- HEAD_HTML = """ """ FORCE_DARK_JS = """() => { const u = new URL(window.location); if (u.searchParams.get('__theme') !== 'dark') { u.searchParams.set('__theme', 'dark'); window.location.replace(u.href); } }""" CUSTOM_CSS = """ :root { --accent: #818cf8; --accent-2: #a78bfa; --ok: #34d399; --ink: #eef1f7; --muted: #98a2b8; --faint: #6c768f; --line: rgba(255,255,255,0.09); --surface: #161d2e; --grad: linear-gradient(135deg, #6366f1, #8b5cf6); --font: "Sora", system-ui, -apple-system, sans-serif; } .gradio-container { max-width: 1400px !important; font-family: var(--font); } footer { display: none !important; } * { font-family: var(--font); } /* ---- App bar ---- */ .appbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; padding: 16px 22px; border-radius: 16px; margin-bottom: 6px; background: linear-gradient(120deg, rgba(99,102,241,0.16), rgba(139,92,246,0.10) 70%, transparent); border: 1px solid var(--line); } .appbar-brand { display: flex; align-items: center; gap: 13px; } .appbar-brand .dot { width: 10px; height: 10px; border-radius: 50%; background: var(--ok); box-shadow: 0 0 0 4px rgba(52,211,153,0.18); } .appbar-brand .bt { font-size: 1.5rem; font-weight: 700; letter-spacing: -.02em; line-height: 1; color: var(--ink); } .appbar-brand .bt b { background: var(--grad); -webkit-background-clip: text; background-clip: text; color: transparent; } .appbar-brand .bs { font-size: .72rem; letter-spacing: .14em; text-transform: uppercase; color: var(--accent); font-weight: 600; margin-top: 3px; } .appbar-badges { display: flex; gap: 8px; flex-wrap: wrap; } .badge { font-size: .76rem; font-weight: 600; padding: 7px 12px; border-radius: 8px; border: 1px solid var(--line); color: var(--muted); background: rgba(255,255,255,0.03); } .badge.ok { color: var(--ok); border-color: rgba(52,211,153,0.32); background: rgba(52,211,153,0.10); } .badge.accent { color: var(--accent); border-color: rgba(129,140,248,0.35); background: rgba(129,140,248,0.12); } /* ---- 3D avatar frame (fixed, no cheap scroll) ---- */ .avatar-frame { border-radius: 18px; overflow: hidden; border: 1px solid var(--line); background: #0b0f1a; box-shadow: 0 16px 40px rgba(0,0,0,0.35); } .reachy-3d-frame { width: 100%; height: 460px; border: none; display: block; overflow: hidden; } .avatar-caption { margin-top: 10px; } .reachy-state-signal { padding: 12px 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--surface); display: flex; align-items: center; justify-content: space-between; gap: 10px; } .reachy-state-head { display: flex; align-items: center; gap: 8px; } .reachy-state-badge { font-size: .68rem; letter-spacing: .04em; text-transform: uppercase; padding: 4px 9px; border-radius: 7px; color: var(--accent); background: rgba(129,140,248,0.14); font-weight: 600; } .reachy-state-conn { font-size: .66rem; letter-spacing: .04em; text-transform: uppercase; color: var(--faint); } .reachy-state-copy strong { font-size: .95rem; color: var(--ink); font-weight: 600; } .reachy-state-copy span { display: none; } /* ---- Big translate button ---- */ #translate-now button { background: var(--grad) !important; color: #fff !important; border: none !important; font-size: 1.35rem !important; font-weight: 700 !important; letter-spacing: .01em !important; padding: 20px 24px !important; border-radius: 16px !important; min-height: 70px !important; box-shadow: 0 12px 30px rgba(99,102,241,0.38) !important; transition: transform .12s ease !important; } #translate-now button:hover { transform: translateY(-2px) !important; } #translate-now button:active { transform: translateY(1px) !important; } /* ---- Result block (rich, not a one-liner) ---- */ .result-block { border: 1px solid var(--line); border-radius: 16px; padding: 20px 22px; background: var(--surface); } .result-route { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; } .rchip { font-size: .8rem; font-weight: 600; padding: 5px 11px; border-radius: 8px; background: rgba(255,255,255,0.05); color: var(--muted); border: 1px solid var(--line); } .rchip.rchip-accent { color: var(--accent); background: rgba(129,140,248,0.14); border-color: rgba(129,140,248,0.32); } .rarrow { color: var(--faint); } .result-src-label, .result-tr-label { font-size: .7rem; letter-spacing: .1em; text-transform: uppercase; color: var(--faint); font-weight: 600; margin-bottom: 5px; } .result-src { font-size: 1.1rem; color: var(--muted); font-weight: 500; margin-bottom: 16px; line-height: 1.45; } .result-tr { font-size: clamp(1.6rem, 3vw, 2.4rem); color: var(--ink); font-weight: 700; line-height: 1.3; letter-spacing: -.01em; } /* ---- Session history ---- */ .hist { display: grid; gap: 8px; } .hist.empty { color: var(--faint); font-size: .9rem; padding: 14px; border: 1px dashed var(--line); border-radius: 12px; text-align: center; } .hrow { padding: 11px 14px; border-radius: 11px; background: rgba(255,255,255,0.03); border: 1px solid var(--line); } .hrow .hsrc { color: var(--muted); font-size: .88rem; } .hrow .htr { color: var(--ink); font-size: 1rem; font-weight: 600; margin-top: 2px; } .hrow .hmeta { color: var(--faint); font-size: .68rem; letter-spacing: .06em; text-transform: uppercase; margin-top: 5px; } /* ---- Status + small panels ---- */ .statusbar { padding: 12px 16px; border-radius: 12px; border: 1px solid var(--line); background: var(--surface); } .statusbar strong { display: block; font-size: .72rem; letter-spacing: .08em; text-transform: uppercase; color: var(--accent); margin-bottom: 3px; font-weight: 600; } .statusbar span { color: var(--ink); } .statusbar span.ok { color: var(--ok); } .statusbar span.warn { color: #fbbf24; } .section-title { font-size: .76rem; letter-spacing: .1em; text-transform: uppercase; color: var(--faint); font-weight: 600; margin: 4px 0 8px; } /* ---- Runtime + setup (Robot tab) ---- */ .runtime-grid { display: grid; gap: 8px; } .rt-row { display: flex; justify-content: space-between; gap: 12px; padding: 12px 15px; border-radius: 11px; background: var(--surface); border: 1px solid var(--line); } .rt-row .k { font-size: .74rem; letter-spacing: .04em; text-transform: uppercase; color: var(--faint); font-weight: 500; } .rt-row .v { font-weight: 600; color: var(--ink); } .rt-row .v.ok { color: var(--ok); } .note { color: var(--muted); font-size: .86rem; line-height: 1.5; margin-top: 8px; } .setup-compare { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } .setup-col { border: 1px solid var(--line); border-radius: 12px; padding: 14px; background: var(--surface); } .setup-col h3 { margin: 0 0 8px; font-size: .76rem; letter-spacing: .04em; text-transform: uppercase; font-weight: 600; } .setup-col.virtual h3 { color: var(--ok); } .setup-col.physical h3 { color: var(--accent-2); } .setup-col ul { margin: 0; padding-left: 18px; color: var(--muted); font-size: .88rem; line-height: 1.6; } .reco { margin-top: 10px; padding: 12px 14px; border-radius: 11px; background: rgba(129,140,248,0.12); border: 1px solid rgba(129,140,248,0.30); color: var(--ink); font-size: .9rem; } .codeblock { display: block; white-space: pre-wrap; word-break: break-word; font-family: ui-monospace, "Cascadia Code", monospace; font-size: .82rem; color: var(--accent); background: #0b0f1a; border: 1px solid var(--line); border-radius: 11px; padding: 13px 15px; margin-top: 8px; } @media (max-width: 920px) { .setup-compare { grid-template-columns: 1fr; } .reachy-3d-frame { height: 360px; } } /* ===================================================================== Cyberpunk-studio layer (purely cosmetic — overrides above, no markup or wiring changes). Reliable techniques only: glows, glass, pseudo accents. Degrades cleanly if backdrop-filter is unsupported. ===================================================================== */ :root { --neon: #22e3ff; --jp: "Noto Sans JP", var(--font); } /* ambient depth field behind the app + faint scanline grain on top */ .gradio-container { position: relative; } .gradio-container::before { content: ""; position: fixed; inset: 0; z-index: -1; pointer-events: none; background: radial-gradient(60% 50% at 18% 8%, rgba(99,102,241,0.18), transparent 60%), radial-gradient(55% 50% at 88% 92%, rgba(167,139,250,0.14), transparent 60%), radial-gradient(40% 40% at 50% 50%, rgba(34,227,255,0.05), transparent 70%); } .gradio-container::after { content: ""; position: fixed; inset: 0; z-index: 1; pointer-events: none; opacity: 0.045; background: repeating-linear-gradient(180deg, rgba(255,255,255,0.9) 0 1px, transparent 1px 3px); mix-blend-mode: overlay; } /* glass + living neon glow on the hero panels */ .appbar, .avatar-frame, .result-block, .statusbar, .reachy-state-signal, .rt-row, .setup-col, .reco, .hrow { backdrop-filter: blur(7px); -webkit-backdrop-filter: blur(7px); } .appbar { position: relative; overflow: hidden; background: linear-gradient(120deg, rgba(99,102,241,0.20), rgba(139,92,246,0.12) 70%, transparent), rgba(13,17,28,0.55); border-color: rgba(129,140,248,0.28); animation: glowpulse 5.5s ease-in-out infinite; } .appbar-brand, .appbar-badges { position: relative; z-index: 1; } .appbar::after { /* faint kanji watermark — decorative, family-friendly */ content: "通訳"; position: absolute; right: 20px; top: 50%; transform: translateY(-50%); font-family: var(--jp); font-weight: 700; font-size: 3.4rem; line-height: 1; color: rgba(129,140,248,0.07); letter-spacing: .1em; z-index: 0; pointer-events: none; } @keyframes glowpulse { 0%, 100% { box-shadow: 0 0 0 1px rgba(129,140,248,0.20), 0 14px 36px rgba(0,0,0,0.35), 0 0 26px rgba(99,102,241,0.10); } 50% { box-shadow: 0 0 0 1px rgba(34,227,255,0.34), 0 14px 36px rgba(0,0,0,0.35), 0 0 36px rgba(99,102,241,0.20); } } /* avatar stage: neon edge + animated corner brackets */ .avatar-frame { position: relative; border-color: rgba(34,227,255,0.22); box-shadow: 0 16px 44px rgba(0,0,0,0.45), 0 0 30px rgba(34,227,255,0.08); } .avatar-frame::before, .avatar-frame::after { content: ""; position: absolute; width: 26px; height: 26px; pointer-events: none; z-index: 3; border: 2px solid var(--neon); opacity: 0.7; filter: drop-shadow(0 0 6px rgba(34,227,255,0.6)); animation: bracketpulse 3.2s ease-in-out infinite; } .avatar-frame::before { top: 10px; left: 10px; border-right: 0; border-bottom: 0; border-radius: 6px 0 0 0; } .avatar-frame::after { bottom: 10px; right: 10px; border-left: 0; border-top: 0; border-radius: 0 0 6px 0; } @keyframes bracketpulse { 0%,100% { opacity: 0.35; } 50% { opacity: 0.85; } } /* mono "terminal" accents on labels */ .section-title::before, .result-src-label::before, .result-tr-label::before { content: "// "; color: var(--accent); opacity: 0.8; } .statusbar strong::before { content: "▍ "; color: var(--neon); } /* result translation gets a soft neon read-out glow */ .result-tr { text-shadow: 0 0 22px rgba(129,140,248,0.22); } .result-block { border-color: rgba(129,140,248,0.20); background: rgba(22,29,46,0.72); } /* translate button: brighter, with a moving sheen */ #translate-now button { position: relative; overflow: hidden; text-transform: uppercase; letter-spacing: .06em !important; box-shadow: 0 12px 30px rgba(99,102,241,0.42), 0 0 28px rgba(34,227,255,0.18) !important; } #translate-now button::after { content: ""; position: absolute; top: 0; left: -60%; width: 50%; height: 100%; pointer-events: none; background: linear-gradient(100deg, transparent, rgba(255,255,255,0.35), transparent); transform: skewX(-18deg); animation: sheen 4.5s ease-in-out infinite; } @keyframes sheen { 0% { left: -60%; } 55%, 100% { left: 140%; } } /* neon active tab (Gradio 5.x uses .tab-container; .tab-nav kept for other versions) */ .tab-container button, .tab-nav button { position: relative; } .tab-container button.selected, .tab-nav button.selected { color: var(--neon) !important; text-shadow: 0 0 12px rgba(34,227,255,0.45); } .tab-container button.selected::after, .tab-nav button.selected::after { content: ""; position: absolute; left: 10%; right: 10%; bottom: 0; height: 2px; background: linear-gradient(90deg, transparent, var(--neon), transparent); box-shadow: 0 0 10px rgba(34,227,255,0.7); } @media (max-width: 920px) { .appbar::after { display: none; } } """ # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def parse_pair(pair_label: str) -> tuple[str, str]: source, target = [part.strip() for part in pair_label.split("->", maxsplit=1)] return source, target def friendly_engine_label(engine_name: str) -> str: n = (engine_name or "").lower() if "seamless" in n: return "SeamlessM4T" if "local-nmt" in n or n == "nllb": return "Local NMT (NLLB)" if "qwen" in n or n == "cascade": return "Qwen" if "omni" in n: return "MiniCPM-o" return n.upper() if n else "Engine" def compute_mode_label() -> str: return "GPU interpreter (SeamlessM4T)" if engine.seamless is not None else "On-device (CPU fallback)" def robot_mode_label(setup_mode: str | None) -> str: if ROBOT_CONNECTED: return robot.connection_report() if setup_mode == SETUP_PHYSICAL: return "Physical selected — run locally to actuate" return "Virtual only (browser)" def render_app_bar() -> str: eng = friendly_engine_label(engine.active_label) return f"""
Reachy Bridge
Live voice interpreter
{html.escape(eng)} No token needed Speaks the translation aloud
""" def render_stage() -> str: doc = html.escape(AVATAR_DOC, quote=True) return f"""
""" def render_status(title: str, body: str, tone: str = "") -> str: return f'
{html.escape(title)}{html.escape(body)}
' def render_result(source_lang: str, target_lang: str, source_text: str, translated: str) -> str: return f"""
{html.escape(source_lang)} {html.escape(target_lang)}
Heard
{html.escape(source_text or "—")}
Translation
{html.escape(translated or "—")}
""" def render_placeholder() -> str: return """
Translation
Talk or type, then Reachy interprets and speaks it aloud.
""" def render_history(items: list[dict]) -> str: if not items: return '
Your translated turns will appear here.
' rows = "".join( f'
{html.escape(i["source"])}
' f'
{html.escape(i["translation"])}
' f'
{html.escape(i["src"])} → {html.escape(i["tgt"])}
' for i in reversed(items[-12:]) ) return f'
{rows}
' def render_runtime(engine_name: str, output_mode: str, setup_mode: str | None) -> str: return f"""
Engine{html.escape(friendly_engine_label(engine_name))}
Compute{html.escape(compute_mode_label())}
Robot{html.escape(robot_mode_label(setup_mode))}
Output{html.escape(output_mode)}
Active path for the current turn. No token or key required.
""" def render_setup_compare() -> str: return """

Virtual Reachy

Physical Reachy Mini

Recommended: Virtual — instant in this browser. Physical adds motion + robot voice when you run locally with your robot.
""" def render_local_guide() -> str: cmd = ( '# Start the Reachy Mini daemon, then run the app pointed at your robot:\n' '$env:ENGINE="seamless"; $env:ROBOT_CONNECTED="1"; python app.py' ) return f"""
On the hosted Space everything stays virtual (it can't reach your robot). To unlock motion + robot voice, run locally next to your Reachy Mini:
{html.escape(cmd)}
Then use Test robot below to confirm the antennas and head move.
""" def pick_practice_language(pair_label: str) -> str: source_lang, target_lang = parse_pair(pair_label) return source_lang if source_lang != "English" else target_lang def normalize_for_match(text: str) -> str: keep = [c for c in text.lower() if c.isalnum() or c.isspace()] return " ".join("".join(keep).split()) def build_tutor_audio(text: str, practice_language: str) -> str | None: if practice_language != "English": return None cascade_engine = getattr(engine, "cascade", None) if cascade_engine is None: return None try: return cascade_engine._synthesize_english(text) except Exception: return None # --------------------------------------------------------------------------- # Interpret turn (text or audio) — outputs: # status, result, audio, avatar_signal, history_html, history_state # --------------------------------------------------------------------------- def do_turn(audio_path, text_input, active_input, pair_label, history): source_lang, target_lang = parse_pair(pair_label) turn_hint = "right" if source_lang == "English" else "left" use_audio = active_input == "audio" has_text = bool(text_input and text_input.strip()) has_audio = bool(audio_path) history = history or [] NO = gr.update() # leave a component unchanged if use_audio and not has_audio: yield ( render_status("Record first", "Record your voice on the Speak tab, then press Translate."), render_placeholder(), None, render_avatar_html(robot.idle_snapshot()), render_history(history), history, NO, NO, ) return if not use_audio and not has_text: yield ( render_status("Type first", "Type a sentence on the Type tab, then press Translate."), render_placeholder(), None, render_avatar_html(robot.idle_snapshot()), render_history(history), history, NO, NO, ) return yield ( render_status("Interpreting", "Listening and composing the translation. First run loads the model (~30s on CPU); the GPU Space is fast."), NO, None, render_avatar_html(robot.thinking_snapshot()), render_history(history), history, NO, NO, ) time.sleep(0.2) try: if use_audio: result = engine.process_turn(TurnRequest(audio_path=audio_path, source_lang=source_lang, target_lang=target_lang)) else: result = engine.translate_text(source_lang, target_lang, text_input.strip()) except Exception as exc: yield ( render_status("Engine setup needed", str(exc), "warn"), render_placeholder(), None, render_avatar_html(robot.encourage_snapshot("Check the logs, then try again.")), render_history(history), history, NO, NO, ) return avatar = robot.perform_translation( text=result.translated_text, audio_path=result.output_audio_path, speech_supported=result.speech_supported, turn_hint=turn_hint, ) new_history = history + [{ "src": source_lang, "tgt": target_lang, "source": result.source_text, "translation": result.translated_text, }] if result.raw_response.get("fallback_from") == "seamless": status_body = "Seamless was busy — used the on-device fallback." tone = "warn" elif result.speech_supported: status_body = "Reachy spoke this translation aloud." tone = "ok" else: status_body = "Translation ready (shown on screen)." tone = "ok" # clear the inputs so the next turn starts fresh (no stale re-translation) yield ( render_status("Done", status_body, tone), render_result(source_lang, target_lang, result.source_text, result.translated_text), result.output_audio_path, render_avatar_html(avatar), render_history(new_history), new_history, gr.update(value=""), # clear text box gr.update(value=None), # clear mic recording ) def do_turn_mic(audio_path, pair_label, history): yield from do_turn(audio_path, "", "audio", pair_label, history) def reset_session(): return ( render_status("Reachy is ready", "Pick a route, then talk or type to start."), render_placeholder(), None, render_avatar_html(robot.idle_snapshot()), render_history([]), [], gr.update(value=""), gr.update(value=None), ) # --------------------------------------------------------------------------- # Tutor turn # --------------------------------------------------------------------------- def tutor_audio_path(lang, idx): """Reliable pre-generated tutor audio (static file, NO GPU/engine dependency).""" p = STATIC_DIR / "tutor" / f"{lang}_{idx}.mp3" return str(p) if p.exists() else None def tutor_prompt(pair_label, idx): """Advance to the NEXT practice phrase (rotates), and speak it if the GPU is free.""" lang = pick_practice_language(pair_label) phrases = TUTOR_PHRASES[lang] new_idx = ((idx if idx is not None else -1) + 1) % len(phrases) phrase = phrases[new_idx] audio = tutor_audio_path(lang, new_idx) listen = ( "Listen to Reachy, then record yourself saying it." if audio else "Read it aloud, then record yourself." ) return ( render_status( f"Phrase {new_idx + 1} of {len(phrases)} · {lang}", f"{phrase['practice']} · {phrase['guide']} — {listen}", ), render_result(lang, "Meaning", phrase["practice"], phrase["meaning"]), audio, new_idx, ) def tutor_check(audio_path, pair_label, idx): """Score the user's attempt against the CURRENT phrase (direct match, no extra GPU).""" lang = pick_practice_language(pair_label) phrases = TUTOR_PHRASES[lang] phrase = phrases[(idx if idx is not None else 0) % len(phrases)] if not audio_path: return ( render_status("Record first", "Record yourself saying the phrase, then press Check."), gr.update(), gr.update(), idx, ) try: heard, _ = engine.transcribe_only(audio_path, lang) except Exception as exc: return render_status("Tutor needs the speech model", str(exc), "warn"), gr.update(), gr.update(), idx sim = SequenceMatcher(None, normalize_for_match(heard), normalize_for_match(phrase["practice"])).ratio() success = sim >= 0.6 fb = "Great pronunciation!" if success else "Good try — listen again and match the sounds, then check again." return ( render_status( "Tutor result" + (" ✓" if success else ""), f"{fb} · match {sim:.0%}", "ok" if success else "warn", ), render_result(lang, "Your attempt", phrase["practice"], heard or "(nothing heard — try again)"), gr.update(), # keep the phrase audio; don't re-synthesize (saves GPU) idx, ) def run_self_test(): ok, message = robot.self_test() return render_status("Robot test", message, "ok" if ok else "warn") def run_system_check(): t0 = time.time() try: result = engine.translate_text("English", "Spanish", "Hello, this is a quick system check.", prefer_voice_output=True) dt = time.time() - t0 voice = "voice produced" if result.output_audio_path else "text only" body = f'{friendly_engine_label(result.engine_used)} · {voice} · {dt:.1f}s — "{result.translated_text}"' return render_status("System check", body, "ok") except Exception as exc: return render_status("System check", str(exc), "warn") def on_setup_change(setup_mode): physical = setup_mode == SETUP_PHYSICAL # Selecting physical attempts a real connection + self-test when armed # (ROBOT_CONNECTED=1). self_test() short-circuits to a "robot mode off" # message with no SDK import or motion when not armed, so this is safe on # the hosted Space and locally without the env var. return ( gr.update(visible=physical), render_runtime(engine.active_label, "Idle", setup_mode), run_self_test() if physical else "", ) # --------------------------------------------------------------------------- # Live Conversation (robot-only) — thread-safe buffer + worker + UI handlers. # A background worker drives the robot mic loop and mutates a module-global, # lock-protected buffer; a gr.Timer polls it to refresh the UI. (gr.State is # per-session and invisible to threads, so it cannot be used here.) # --------------------------------------------------------------------------- _CONV_LOCK = threading.Lock() _CONV_STATE = { "running": False, "avatar": "idle", "title": "Live Conversation", "body": "Connect your Reachy Mini to begin.", "turns": [], } def _conv_update(**kw): turns = kw.pop("turns", None) with _CONV_LOCK: _CONV_STATE.update(kw) if turns is not None: _CONV_STATE["turns"] = turns def _conv_add_turn(source_lang, target_lang, source_text, translation): with _CONV_LOCK: _CONV_STATE["turns"] = (_CONV_STATE["turns"] + [{ "src": source_lang, "tgt": target_lang, "source": source_text, "translation": translation, }])[-8:] def _conv_snapshot(): with _CONV_LOCK: return dict(_CONV_STATE), list(_CONV_STATE["turns"]) def _interruptible_pause(stop_event, seconds): end = time.time() + seconds while time.time() < end and not stop_event.is_set(): time.sleep(0.05) def _conversation_worker(stop_event, source_lang, target_lang): """Robot-mic hands-free loop: listen -> interpret (fixed pair) -> speak (female) -> repeat.""" _conv_update(running=True, avatar="speaking", title="Reachy says hi", body="Reachy is saying hello…") try: engine.warm_female_voice(target_lang) except Exception: pass try: robot.speak_chatter(engine.synthesize_female(CONV_GREETING, "English"), stop_event) except Exception: pass while not stop_event.is_set(): _conv_update(avatar="listening", title="Listening", body=random.choice(CONV_LISTEN)) audio_path, doa = robot.capture_utterance(stop_event) if stop_event.is_set(): break if not audio_path: continue _conv_update(avatar="thinking", title="Interpreting", body=random.choice(CONV_THINK)) try: result = engine.process_turn(TurnRequest( audio_path=audio_path, source_lang=source_lang, target_lang=target_lang, prefer_voice_output=False)) robot_audio = engine.synthesize_female(result.translated_text, target_lang) except Exception as exc: _conv_update(avatar="encourage", title="Hiccup", body=f"{random.choice(CONV_ERR)} ({exc})") _interruptible_pause(stop_event, 1.4) # let the hiccup caption stay visible continue _conv_add_turn(source_lang, target_lang, result.source_text, result.translated_text) _conv_update(avatar="speaking", title="Reachy speaks", body=random.choice(CONV_SPEAK)) robot.perform_conversation_turn(robot_audio, doa, stop_event) # Terminal copy is written by conversation_stop() (single writer) after the # worker has joined — avoids a last-writer-wins race on the final caption. class ConversationSession: """Owns the worker thread for one Live Conversation run. A single module-global instance backs the (single-user, on-the-robot) app; start/stop are serialized.""" def __init__(self): self._thread = None self._stop = None self._lock = threading.Lock() def is_running(self): with self._lock: return self._thread is not None and self._thread.is_alive() def start(self, source_lang, target_lang): with self._lock: self._stop_locked() if not robot.start_conversation(): return False self._stop = threading.Event() self._thread = threading.Thread( target=_conversation_worker, args=(self._stop, source_lang, target_lang), daemon=True) self._thread.start() return True def stop(self): with self._lock: self._stop_locked() def _stop_locked(self): if self._stop is not None: self._stop.set() thread = self._thread if thread is not None and thread.is_alive(): thread.join(timeout=8.0) try: robot.stop_conversation() except Exception: pass self._thread = None self._stop = None def render_conversation_avatar(state): snap = robot.snapshot(state["avatar"], state["title"], state["body"], "Live Convo") return render_avatar_html(snap) def render_conversation_captions(turns): if not turns: return ('
Your bilingual captions appear ' 'here. Press Start and talk to Reachy.
') prev = turns[:-1][-4:] latest = turns[-1] small = "".join( f'
{html.escape(t["src"])} ' f'{html.escape(t["source"])} ' f'{html.escape(t["tgt"])} {html.escape(t["translation"])}
' for t in prev ) big = ( '
' f'
{html.escape(latest["src"])}' f'{html.escape(latest["source"])}
' f'
{html.escape(latest["tgt"])}' f'{html.escape(latest["translation"])}
' '
' ) return f'
{small}
{big}
' def render_conversation_gate(): return render_status( "Robot needed for Live Conversation", "This hands-free mode runs only with a connected Reachy Mini, on the robot's own machine. " 'Start the daemon, run `$env:ROBOT_CONNECTED="1"; python app.py`, then pick ' "“I have a Reachy Mini” in Robot & Setup.", "warn", ) def conversation_tick(): state, turns = _conv_snapshot() return render_conversation_avatar(state), render_conversation_captions(turns) def conversation_start(pair_label): if not ROBOT_CONNECTED: _conv_update(running=False, avatar="idle", title="Robot needed", body="Run locally next to your Reachy Mini.") state, turns = _conv_snapshot() return (render_conversation_gate(), render_conversation_captions(turns), gr.Timer(active=False)) if conversation_session.is_running(): # one conversation at a time (single robot) state, turns = _conv_snapshot() return (render_conversation_avatar(state), render_conversation_captions(turns), gr.Timer(active=True)) source_lang, target_lang = parse_pair(pair_label) _conv_update(turns=[]) if not conversation_session.start(source_lang, target_lang): _conv_update(running=False, avatar="encourage", title="Couldn't reach Reachy", body=robot.connection_report()) state, turns = _conv_snapshot() return (render_conversation_avatar(state), render_conversation_captions(turns), gr.Timer(active=False)) state, turns = _conv_snapshot() return (render_conversation_avatar(state), render_conversation_captions(turns), gr.Timer(active=True)) def conversation_stop(): conversation_session.stop() _conv_update(running=False, avatar="idle", title="Conversation ended", body=random.choice(CONV_STOP)) state, turns = _conv_snapshot() return (render_conversation_avatar(state), render_conversation_captions(turns), gr.Timer(active=False)) # --------------------------------------------------------------------------- # Build app # --------------------------------------------------------------------------- robot = ReachyBridgeRobot() engine = build_engine() conversation_session = ConversationSession() def _warmup() -> None: try: if engine.seamless is None: engine.cascade.warm() except Exception: pass threading.Thread(target=_warmup, daemon=True).start() THEME = gr.themes.Soft(primary_hue="indigo", secondary_hue="purple", neutral_hue="slate") with gr.Blocks(theme=THEME, css=CUSTOM_CSS, head=HEAD_HTML, js=FORCE_DARK_JS, title=APP_TITLE) as demo: history_state = gr.State([]) active_input = gr.State("text") tutor_idx = gr.State(-1) gr.HTML(render_app_bar()) with gr.Tabs(): # ---------------- Interpret ---------------- with gr.Tab("Interpret"): with gr.Row(equal_height=False): with gr.Column(scale=5): gr.HTML(render_stage()) avatar_html = gr.HTML(render_avatar_html(robot.idle_snapshot()), elem_classes="avatar-caption") with gr.Column(scale=7): pair = gr.Dropdown(choices=PAIR_OPTIONS, value="Hindi -> English", label="Language route") with gr.Tabs(): with gr.Tab("Type") as type_tab: text_input = gr.Textbox(placeholder="Type a sentence to interpret...", label=None, lines=2) gr.Examples(examples=TEXT_EXAMPLES, inputs=[text_input, pair], label="Try an example") with gr.Tab("Speak") as speak_tab: audio_input = gr.Audio(sources=["microphone"], type="filepath", format="wav", label="Record, then stop — Reachy translates automatically") translate_btn = gr.Button("🔊 Translate Now", elem_id="translate-now", variant="primary") status_html = gr.HTML(render_status("Reachy is ready", "Pick a route, then talk or type to start.")) result_html = gr.HTML(render_placeholder()) browser_audio = gr.Audio(label="Reachy voice output", autoplay=True, interactive=False) with gr.Accordion("Session history", open=False): history_html = gr.HTML(render_history([])) clear_btn = gr.Button("Clear session", size="sm", variant="secondary") # ---------------- Tutor ---------------- with gr.Tab("Tutor"): gr.HTML('
Practice a phrase and get feedback
') with gr.Row(): tutor_pair = gr.Dropdown(choices=PAIR_OPTIONS, value="Hindi -> English", label="Language route") with gr.Row(): prompt_btn = gr.Button("Give me a phrase", variant="secondary") tutor_status = gr.HTML(render_status("Tutor", "Pick a route and get a phrase to practice.")) tutor_result = gr.HTML(render_placeholder()) tutor_prompt_audio = gr.Audio(label="Reachy's voice", autoplay=True, interactive=False) tutor_audio = gr.Audio(sources=["microphone"], type="filepath", format="wav", label="Record your attempt") check_btn = gr.Button("Check my pronunciation", variant="primary") # ---------------- Live Conversation (robot only) ---------------- with gr.Tab("Live Conversation"): gr.HTML(CONV_CSS) gr.HTML('
Hands-free interpreter — needs a Reachy Mini
') with gr.Row(): convo_pair = gr.Dropdown(choices=PAIR_OPTIONS, value=PAIR_OPTIONS[0], label="Conversation pair", scale=3) convo_start_btn = gr.Button("Let's go!", variant="primary", scale=1) convo_stop_btn = gr.Button("Take a break", variant="secondary", scale=1) convo_card = gr.HTML(render_conversation_avatar({ "avatar": "idle", "title": "Live Conversation", "body": ("Press “Let's go!” and start talking." if ROBOT_CONNECTED else "Run locally with a Reachy Mini to use hands-free mode."), })) convo_captions = gr.HTML(render_conversation_captions([])) convo_timer = gr.Timer(0.5, active=False) # ---------------- Robot & Setup ---------------- with gr.Tab("Robot & Setup"): gr.HTML('
Your Reachy
') setup = gr.Radio(choices=[SETUP_VIRTUAL, SETUP_PHYSICAL], value=SETUP_VIRTUAL, label="Reachy mode") gr.HTML(render_setup_compare()) with gr.Column(visible=False) as local_guide_group: gr.HTML(render_local_guide()) test_robot = gr.Button("Test robot", variant="secondary") test_output = gr.HTML() gr.HTML('
Runtime
') runtime_html = gr.HTML(render_runtime(engine.active_label, "Idle", SETUP_VIRTUAL)) system_check_btn = gr.Button("Run system check", variant="secondary") system_check_out = gr.HTML() # ---------------- About ---------------- with gr.Tab("About"): gr.Markdown( """ ### Reachy Bridge — live voice interpreter for Reachy Mini Speak or type in one language; Reachy **speaks the translation** in another, and a 3D Reachy Mini reacts. **Zero setup — no token, no API key.** Every model is public and downloads automatically. **Engine:** SeamlessM4T v2 on ZeroGPU (one model: hears speech, speaks the translation). If the GPU is busy it falls back to an on‑device, token‑free path (Whisper + NLLB + Piper). Optional Qwen phrasing only if you set `HF_TOKEN`. **Built for families** — a grandparent speaks Hindi, Spanish, German, or Chinese, and the next listener hears it in their language. """ ) # ---- wiring ---- interpret_outputs = [status_html, result_html, browser_audio, avatar_html, history_html, history_state, text_input, audio_input] translate_btn.click(do_turn, [audio_input, text_input, active_input, pair, history_state], interpret_outputs) audio_input.stop_recording(do_turn_mic, [audio_input, pair, history_state], interpret_outputs) clear_btn.click(reset_session, outputs=interpret_outputs) type_tab.select(lambda: "text", None, active_input) speak_tab.select(lambda: "audio", None, active_input) tutor_outputs = [tutor_status, tutor_result, tutor_prompt_audio, tutor_idx] prompt_btn.click(tutor_prompt, [tutor_pair, tutor_idx], tutor_outputs) check_btn.click(tutor_check, [tutor_audio, tutor_pair, tutor_idx], tutor_outputs) setup.change(on_setup_change, [setup], [local_guide_group, runtime_html, test_output]) test_robot.click(run_self_test, outputs=[test_output]) system_check_btn.click(run_system_check, outputs=[system_check_out]) convo_start_btn.click(conversation_start, [convo_pair], [convo_card, convo_captions, convo_timer], concurrency_limit=1) convo_stop_btn.click(conversation_stop, None, [convo_card, convo_captions, convo_timer], concurrency_limit=1) convo_timer.tick(conversation_tick, None, [convo_card, convo_captions], show_progress="hidden") if __name__ == "__main__": on_space = bool(os.getenv("SPACE_ID")) demo.queue().launch( server_name="0.0.0.0" if on_space else os.getenv("GRADIO_SERVER_NAME", "127.0.0.1"), allowed_paths=[str(STATIC_DIR)], )