| 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" |
|
|
| |
| |
| |
| |
| |
| 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 = """ |
| <style> |
| .conv-stage { display:flex; flex-direction:column; gap:10px; padding:14px; border-radius:16px; |
| background:linear-gradient(120deg, rgba(99,102,241,0.10), rgba(139,92,246,0.06)); |
| border:1px solid rgba(255,255,255,0.09); min-height:120px; } |
| .conv-empty { color:#98a2b8; text-align:center; padding:26px; font-size:0.98rem; } |
| .conv-prevwrap { display:flex; flex-direction:column; gap:4px; opacity:0.66; } |
| .conv-prev { font-size:0.9rem; color:#cdd5e6; } |
| .conv-prev .cl { color:#a78bfa; font-weight:600; } |
| .conv-prev .ca { color:#6c768f; } |
| .conv-big { display:flex; flex-direction:column; gap:8px; padding:12px; border-radius:12px; |
| background:rgba(8,11,20,0.45); border:1px solid rgba(255,255,255,0.08); } |
| .conv-line { font-size:1.5rem; line-height:1.3; color:#eef1f7; } |
| .conv-line .lbl { display:inline-block; min-width:64px; font-size:0.7rem; letter-spacing:.12em; |
| text-transform:uppercase; color:#818cf8; vertical-align:middle; margin-right:10px; } |
| .conv-line.tgt { color:#6ee7b7; font-weight:600; } |
| </style> |
| """ |
|
|
| 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_HTML = """ |
| <link rel="preconnect" href="https://fonts.googleapis.com"> |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> |
| <link href="https://fonts.googleapis.com/css2?family=Sora:wght@400;500;600;700;800&family=Noto+Sans+JP:wght@400;500;700&display=swap" rel="stylesheet"> |
| <script> |
| (function () { |
| function boot() { |
| var frame = document.getElementById('reachy-3d-frame'); |
| if (!frame) { return setTimeout(boot, 300); } |
| var last = null; |
| |
| // Whole-page pointer -> Reachy watches the mouse (throttled via rAF). |
| var queued = false, lastEv = null; |
| window.addEventListener('mousemove', function (ev) { |
| lastEv = ev; |
| if (queued) return; |
| queued = true; |
| requestAnimationFrame(function () { |
| queued = false; |
| var r = frame.getBoundingClientRect(); |
| if (!r.width || !r.height) return; |
| var x = (lastEv.clientX - (r.left + r.width / 2)) / (r.width / 2); |
| var y = (lastEv.clientY - (r.top + r.height / 2)) / (r.height / 2); |
| try { frame.contentWindow.postMessage({ type: 'reachy-pointer', x: x, y: y }, '*'); } catch (e) {} |
| }); |
| }, { passive: true }); |
| |
| function pump() { |
| var sig = document.getElementById('reachy-state-signal'); |
| if (!sig) return; |
| var st = sig.getAttribute('data-avatar-state'); |
| if (st && st !== last) { |
| last = st; |
| try { frame.contentWindow.postMessage({ type: 'reachy-state', state: st }, '*'); } catch (e) {} |
| } |
| } |
| var obs = new MutationObserver(pump); |
| obs.observe(document.body, { subtree: true, childList: true, attributes: true, attributeFilter: ['data-avatar-state'] }); |
| setInterval(pump, 700); |
| frame.addEventListener('load', function () { setTimeout(pump, 400); }); |
| setTimeout(pump, 900); |
| } |
| if (document.readyState === 'loading') { window.addEventListener('DOMContentLoaded', boot); } |
| else { boot(); } |
| })(); |
| </script> |
| """ |
|
|
| 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; } } |
| """ |
|
|
|
|
| |
| |
| |
| 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""" |
| <div class="appbar"> |
| <div class="appbar-brand"> |
| <span class="dot"></span> |
| <div><div class="bt">Reachy <b>Bridge</b></div><div class="bs">Live voice interpreter</div></div> |
| </div> |
| <div class="appbar-badges"> |
| <span class="badge accent">{html.escape(eng)}</span> |
| <span class="badge ok">No token needed</span> |
| <span class="badge">Speaks the translation aloud</span> |
| </div> |
| </div> |
| """ |
|
|
|
|
| def render_stage() -> str: |
| doc = html.escape(AVATAR_DOC, quote=True) |
| return f""" |
| <div class="avatar-frame"> |
| <iframe id="reachy-3d-frame" class="reachy-3d-frame" title="Reachy Mini 3D" scrolling="no" srcdoc="{doc}"></iframe> |
| </div> |
| """ |
|
|
|
|
| def render_status(title: str, body: str, tone: str = "") -> str: |
| return f'<div class="statusbar"><strong>{html.escape(title)}</strong><span class="{tone}">{html.escape(body)}</span></div>' |
|
|
|
|
| def render_result(source_lang: str, target_lang: str, source_text: str, translated: str) -> str: |
| return f""" |
| <div class="result-block"> |
| <div class="result-route"> |
| <span class="rchip">{html.escape(source_lang)}</span><span class="rarrow">→</span> |
| <span class="rchip rchip-accent">{html.escape(target_lang)}</span> |
| </div> |
| <div class="result-src-label">Heard</div> |
| <div class="result-src">{html.escape(source_text or "—")}</div> |
| <div class="result-tr-label">Translation</div> |
| <div class="result-tr">{html.escape(translated or "—")}</div> |
| </div> |
| """ |
|
|
|
|
| def render_placeholder() -> str: |
| return """ |
| <div class="result-block"> |
| <div class="result-tr-label">Translation</div> |
| <div class="result-tr">Talk or type, then Reachy interprets and speaks it aloud.</div> |
| </div> |
| """ |
|
|
|
|
| def render_history(items: list[dict]) -> str: |
| if not items: |
| return '<div class="hist empty">Your translated turns will appear here.</div>' |
| rows = "".join( |
| f'<div class="hrow"><div class="hsrc">{html.escape(i["source"])}</div>' |
| f'<div class="htr">{html.escape(i["translation"])}</div>' |
| f'<div class="hmeta">{html.escape(i["src"])} → {html.escape(i["tgt"])}</div></div>' |
| for i in reversed(items[-12:]) |
| ) |
| return f'<div class="hist">{rows}</div>' |
|
|
|
|
| def render_runtime(engine_name: str, output_mode: str, setup_mode: str | None) -> str: |
| return f""" |
| <div class="runtime-grid"> |
| <div class="rt-row"><span class="k">Engine</span><span class="v ok">{html.escape(friendly_engine_label(engine_name))}</span></div> |
| <div class="rt-row"><span class="k">Compute</span><span class="v">{html.escape(compute_mode_label())}</span></div> |
| <div class="rt-row"><span class="k">Robot</span><span class="v">{html.escape(robot_mode_label(setup_mode))}</span></div> |
| <div class="rt-row"><span class="k">Output</span><span class="v">{html.escape(output_mode)}</span></div> |
| </div> |
| <div class="note">Active path for the current turn. No token or key required.</div> |
| """ |
|
|
|
|
| def render_setup_compare() -> str: |
| return """ |
| <div class="setup-compare"> |
| <div class="setup-col virtual"><h3>Virtual Reachy</h3> |
| <ul><li>3D Reachy on screen</li><li>Speaks the translation in your browser</li><li>Works instantly for everyone</li></ul></div> |
| <div class="setup-col physical"><h3>Physical Reachy Mini</h3> |
| <ul><li>Turns its body to the speaker</li><li>Plays the voice on the robot speaker</li><li>Nods to confirm</li></ul></div> |
| </div> |
| <div class="reco">Recommended: <b>Virtual</b> — instant in this browser. Physical adds motion + robot voice when you run locally with your robot.</div> |
| """ |
|
|
|
|
| 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""" |
| <div class="note">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:</div> |
| <code class="codeblock">{html.escape(cmd)}</code> |
| <div class="note">Then use <b>Test robot</b> below to confirm the antennas and head move.</div> |
| """ |
|
|
|
|
| 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 |
|
|
|
|
| |
| |
| |
| |
| 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() |
|
|
| 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" |
| |
| 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=""), |
| gr.update(value=None), |
| ) |
|
|
|
|
| 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), |
| ) |
|
|
|
|
| |
| |
| |
| 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(), |
| 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 |
| |
| |
| |
| |
| return ( |
| gr.update(visible=physical), |
| render_runtime(engine.active_label, "Idle", setup_mode), |
| run_self_test() if physical else "", |
| ) |
|
|
|
|
| |
| |
| |
| |
| |
| |
| _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) |
| 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) |
|
|
| |
| |
|
|
|
|
| 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 ('<div class="conv-stage"><div class="conv-empty">Your bilingual captions appear ' |
| 'here. Press Start and talk to Reachy.</div></div>') |
| prev = turns[:-1][-4:] |
| latest = turns[-1] |
| small = "".join( |
| f'<div class="conv-prev"><span class="cl">{html.escape(t["src"])}</span> ' |
| f'{html.escape(t["source"])} <span class="ca">→</span> ' |
| f'<span class="cl">{html.escape(t["tgt"])}</span> {html.escape(t["translation"])}</div>' |
| for t in prev |
| ) |
| big = ( |
| '<div class="conv-big">' |
| f'<div class="conv-line src"><span class="lbl">{html.escape(latest["src"])}</span>' |
| f'{html.escape(latest["source"])}</div>' |
| f'<div class="conv-line tgt"><span class="lbl">{html.escape(latest["tgt"])}</span>' |
| f'{html.escape(latest["translation"])}</div>' |
| '</div>' |
| ) |
| return f'<div class="conv-stage"><div class="conv-prevwrap">{small}</div>{big}</div>' |
|
|
|
|
| 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(): |
| 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)) |
|
|
|
|
| |
| |
| |
| 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(): |
| |
| 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") |
|
|
| |
| with gr.Tab("Tutor"): |
| gr.HTML('<div class="section-title">Practice a phrase and get feedback</div>') |
| 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") |
|
|
| |
| with gr.Tab("Live Conversation"): |
| gr.HTML(CONV_CSS) |
| gr.HTML('<div class="section-title">Hands-free interpreter — needs a Reachy Mini</div>') |
| 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) |
|
|
| |
| with gr.Tab("Robot & Setup"): |
| gr.HTML('<div class="section-title">Your Reachy</div>') |
| 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('<div class="section-title">Runtime</div>') |
| 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() |
|
|
| |
| 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. |
| """ |
| ) |
|
|
| |
| 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)], |
| ) |
|
|