amitlal's picture
Live Conversation + Backyard AI submission: tags, demo videos, social link
1188e4b verified
Raw
History Blame Contribute Delete
49.9 kB
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 = """
<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: fonts + the bridge that drives the 3D avatar from app state
# ---------------------------------------------------------------------------
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; } }
"""
# ---------------------------------------------------------------------------
# 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"""
<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
# ---------------------------------------------------------------------------
# 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 ('<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(): # 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('<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")
# ---------------- Live Conversation (robot only) ----------------
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)
# ---------------- Robot & Setup ----------------
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()
# ---------------- 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)],
)