| from __future__ import annotations |
|
|
| import html |
| import math |
| import os |
| import threading |
| import time |
| import uuid |
| import wave |
| from dataclasses import dataclass |
| from pathlib import Path |
|
|
| import numpy as np |
|
|
|
|
| |
| CONV_AUDIO_DIR = Path(os.getenv("REACHY_BRIDGE_CACHE", "runtime_cache")) / "conv" |
|
|
|
|
| AVATAR_STATES = { |
| "idle", |
| "listening", |
| "thinking", |
| "speaking", |
| "nodding", |
| "text_only", |
| "success", |
| "encourage", |
| } |
|
|
|
|
| @dataclass(slots=True) |
| class AvatarSnapshot: |
| state: str |
| title: str |
| detail: str |
| badge: str |
| connected: bool |
| connection_label: str |
|
|
|
|
| class ReachyBridgeRobot: |
| """Drives a physical Reachy Mini when connected; otherwise a no-op (virtual). |
| |
| The hardware calls target the official `reachy_mini` SDK (see |
| https://huggingface.co/docs/reachy_mini/SDK/python-sdk): |
| * connect: `with ReachyMini(connection_mode=...) as mini` |
| * move: `mini.goto_target(head=create_head_pose(...), antennas=..., body_yaw=..., method="minjerk")` |
| * speak: `mini.media.start_playing()` then `mini.media.push_audio_sample(samples)` |
| |
| Every hardware call is feature-detected so an older/newer SDK degrades to the |
| virtual experience with a readable status message instead of crashing. |
| """ |
|
|
| def __init__(self, connected: bool | None = None) -> None: |
| if connected is None: |
| connected = os.getenv("ROBOT_CONNECTED", "0") == "1" |
| self.connected_requested = connected |
| self.connection_mode = os.getenv("REACHY_CONNECTION_MODE") |
| self.media_backend = os.getenv("REACHY_MEDIA_BACKEND", "default") |
| self._mini = None |
| self._create_head_pose = None |
| self._sdk_error: str | None = None |
| self._hardware_ready = False |
| self._playback_ready = False |
| |
| self._motors_enabled = False |
| self._hw_lock = threading.RLock() |
| self._speaking = threading.Event() |
| self._idle_thread: threading.Thread | None = None |
| self._idle_stop: threading.Event | None = None |
| self.vad_energy = float(os.getenv("CONV_VAD_ENERGY", "0.012")) |
| |
|
|
| |
| |
| |
| def snapshot(self, state: str, title: str, detail: str, badge: str) -> AvatarSnapshot: |
| normalized = state if state in AVATAR_STATES else "idle" |
| return AvatarSnapshot( |
| state=normalized, |
| title=title, |
| detail=detail, |
| badge=badge, |
| connected=self._hardware_ready, |
| connection_label=self._connection_label(), |
| ) |
|
|
| def idle_snapshot(self) -> AvatarSnapshot: |
| return self.snapshot("idle", "Reachy is ready", "Pick a language route, then talk or type to start.", "Standby") |
|
|
| def listening_snapshot(self, pair_label: str) -> AvatarSnapshot: |
| return self.snapshot("listening", "Listening", f"Capturing the speaker for {pair_label}.", "Input Live") |
|
|
| def thinking_snapshot(self) -> AvatarSnapshot: |
| return self.snapshot("thinking", "Interpreting", "Understanding speech and composing the translation.", "Processing") |
|
|
| def speaking_snapshot(self, text: str) -> AvatarSnapshot: |
| return self.snapshot("speaking", "Reachy speaks the translation", text, "Voice Output") |
|
|
| def nodding_snapshot(self, text: str) -> AvatarSnapshot: |
| return self.snapshot("nodding", "Reachy confirms", text, "Motion Cue") |
|
|
| def text_only_snapshot(self, text: str) -> AvatarSnapshot: |
| return self.snapshot("text_only", "Translation ready", text, "Screen Output") |
|
|
| def success_snapshot(self, text: str) -> AvatarSnapshot: |
| return self.snapshot("success", "Great job", text, "Tutor Win") |
|
|
| def encourage_snapshot(self, text: str) -> AvatarSnapshot: |
| return self.snapshot("encourage", "Try once more", text, "Coach Mode") |
|
|
| |
| |
| |
| def perform_translation( |
| self, |
| text: str, |
| audio_path: str | None, |
| speech_supported: bool, |
| turn_hint: str = "center", |
| ) -> AvatarSnapshot: |
| snapshot = self.speaking_snapshot(text) if speech_supported else self.text_only_snapshot(text) |
| if not self._ensure_connected(): |
| return snapshot |
| try: |
| self._turn_to_speaker(turn_hint) |
| if speech_supported and audio_path: |
| self._play_audio(Path(audio_path)) |
| self._nod_once(turn_hint) |
| except Exception as exc: |
| self._sdk_error = str(exc) |
| return snapshot |
|
|
| def perform_tutor_feedback(self, success: bool, audio_path: str | None, text: str) -> AvatarSnapshot: |
| snapshot = self.success_snapshot(text) if success else self.encourage_snapshot(text) |
| if not self._ensure_connected(): |
| return snapshot |
| try: |
| if audio_path: |
| self._play_audio(Path(audio_path)) |
| self._nod_once("center") |
| except Exception as exc: |
| self._sdk_error = str(exc) |
| return snapshot |
|
|
| def self_test(self) -> tuple[bool, str]: |
| """One-click hardware check: connect, wiggle antennas, recenter. Safe + small.""" |
| if not self.connected_requested: |
| return False, "Robot mode is off (virtual). Set ROBOT_CONNECTED=1 and run locally with your Reachy Mini." |
| if not self._ensure_connected(): |
| return False, self._sdk_error or "Could not reach a Reachy Mini. Is the daemon running?" |
| try: |
| self._move(antennas_deg=[35, 35], duration=0.5) |
| self._move(antennas_deg=[-10, -10], duration=0.5) |
| self._move(head_kwargs={"yaw": 0, "pitch": 0}, antennas_deg=[0, 0], duration=0.5) |
| return True, "Reachy Mini responded: antennas + head moved. Hardware looks good." |
| except Exception as exc: |
| self._sdk_error = str(exc) |
| return False, f"Connected, but a motion call failed: {exc}" |
|
|
| def connection_report(self) -> str: |
| if not self.connected_requested: |
| return "Virtual only (browser)" |
| if self._hardware_ready: |
| return "Reachy Mini linked" |
| if self._sdk_error: |
| return f"Robot requested, fallback active ({self._sdk_error})" |
| return "Robot requested (not yet linked)" |
|
|
| |
| |
| |
| def _connection_label(self) -> str: |
| if self.connected_requested and self._hardware_ready: |
| return "Robot Linked" |
| if self.connected_requested: |
| return "Robot Fallback" |
| return "Virtual Reachy" |
|
|
| def _ensure_connected(self) -> bool: |
| if not self.connected_requested: |
| return False |
| if self._hardware_ready and self._mini is not None: |
| return True |
|
|
| try: |
| from reachy_mini import ReachyMini |
| from reachy_mini.utils import create_head_pose |
| except ImportError as exc: |
| self._sdk_error = f"reachy_mini SDK not installed: {exc}" |
| return False |
|
|
| try: |
| self._create_head_pose = create_head_pose |
| kwargs = {"media_backend": self.media_backend} |
| if self.connection_mode: |
| kwargs["connection_mode"] = self.connection_mode |
| |
| self._mini = self._construct_mini(ReachyMini, kwargs) |
| if hasattr(self._mini, "__enter__"): |
| self._mini.__enter__() |
| self._enable_motors() |
| self._init_playback() |
| self._hardware_ready = True |
| return True |
| except Exception as exc: |
| self._mini = None |
| self._hardware_ready = False |
| self._sdk_error = str(exc) |
| return False |
|
|
| @staticmethod |
| def _construct_mini(reachy_cls, kwargs: dict): |
| |
| try: |
| return reachy_cls(**kwargs) |
| except TypeError: |
| slim = {k: v for k, v in kwargs.items() if k == "connection_mode"} |
| try: |
| return reachy_cls(**slim) |
| except TypeError: |
| return reachy_cls() |
|
|
| def _init_playback(self) -> None: |
| media = getattr(self._mini, "media", None) |
| if media is not None and hasattr(media, "start_playing"): |
| try: |
| media.start_playing() |
| self._playback_ready = True |
| except Exception as exc: |
| self._sdk_error = f"Robot speaker unavailable: {exc}" |
|
|
| def _enable_motors(self) -> None: |
| |
| |
| mini = self._mini |
| if mini is None or self._motors_enabled or not hasattr(mini, "enable_motors"): |
| return |
| try: |
| mini.enable_motors() |
| self._motors_enabled = True |
| except Exception as exc: |
| self._sdk_error = f"enable_motors failed: {exc}" |
|
|
| def _move( |
| self, |
| head_kwargs: dict | None = None, |
| antennas_deg: list[float] | None = None, |
| body_yaw_deg: float | None = None, |
| duration: float = 0.6, |
| ) -> None: |
| if self._mini is None or not hasattr(self._mini, "goto_target"): |
| return |
| target: dict = {"duration": duration} |
| if head_kwargs is not None and self._create_head_pose is not None: |
| target["head"] = self._create_head_pose(degrees=True, mm=True, **head_kwargs) |
| if antennas_deg is not None: |
| target["antennas"] = np.deg2rad(np.asarray(antennas_deg, dtype=float)) |
| if body_yaw_deg is not None: |
| target["body_yaw"] = float(np.deg2rad(body_yaw_deg)) |
| with self._hw_lock: |
| try: |
| self._mini.goto_target(method="minjerk", **target) |
| except TypeError: |
| target.pop("method", None) |
| self._mini.goto_target(**target) |
|
|
| def _turn_to_speaker(self, turn_hint: str) -> None: |
| yaw_lookup = {"left": 22, "right": -22, "center": 0} |
| body_yaw = yaw_lookup.get(turn_hint, 0) |
| self._move( |
| head_kwargs={"yaw": body_yaw * 0.4, "pitch": -4}, |
| antennas_deg=[12, -12], |
| body_yaw_deg=body_yaw, |
| duration=0.8, |
| ) |
|
|
| def _nod_once(self, turn_hint: str) -> None: |
| yaw_lookup = {"left": 9, "right": -9, "center": 0} |
| yaw = yaw_lookup.get(turn_hint, 0) |
| self._move(head_kwargs={"yaw": yaw, "pitch": 10}, antennas_deg=[8, -8], duration=0.35) |
| time.sleep(0.2) |
| self._move(head_kwargs={"yaw": yaw, "pitch": -6}, antennas_deg=[4, -4], duration=0.35) |
| time.sleep(0.2) |
|
|
| def _play_audio(self, audio_path: Path, expressive: bool = False, stop_event=None) -> None: |
| media = getattr(self._mini, "media", None) |
| if media is None or not hasattr(media, "push_audio_sample"): |
| raise RuntimeError("This Reachy Mini SDK build has no media.push_audio_sample for speaker output.") |
| if not self._playback_ready: |
| self._init_playback() |
|
|
| samples, source_rate = _read_wav_mono_float32(audio_path) |
| target_rate = source_rate |
| if hasattr(media, "get_output_audio_samplerate"): |
| try: |
| target_rate = int(media.get_output_audio_samplerate()) |
| except Exception: |
| target_rate = source_rate |
| if target_rate != source_rate: |
| samples = _resample(samples, source_rate, target_rate) |
|
|
| |
| frame = samples.reshape(-1, 1).astype(np.float32) |
| with self._hw_lock: |
| media.push_audio_sample(frame) |
| |
| clip = min(15.0, len(samples) / max(1, target_rate)) |
| if expressive: |
| self._talk_motion(clip, stop_event) |
| else: |
| self._sleep_interruptible(clip, stop_event) |
|
|
| |
| |
| |
| |
| def conversation_ready(self) -> bool: |
| if not self._ensure_connected(): |
| return False |
| media = getattr(self._mini, "media", None) |
| return media is not None and hasattr(media, "get_audio_sample") |
|
|
| def start_conversation(self) -> bool: |
| if not self._ensure_connected(): |
| return False |
| media = getattr(self._mini, "media", None) |
| if media is None or not hasattr(media, "get_audio_sample"): |
| self._sdk_error = "This Reachy Mini SDK build has no microphone capture (media.get_audio_sample)." |
| return False |
| try: |
| if hasattr(media, "start_recording"): |
| media.start_recording() |
| except Exception as exc: |
| self._sdk_error = f"Could not start the robot microphone: {exc}" |
| return False |
| self._init_playback() |
| self.start_idle_breathing() |
| return True |
|
|
| def stop_conversation(self) -> None: |
| self.stop_idle_breathing() |
| media = getattr(self._mini, "media", None) |
| try: |
| if media is not None and hasattr(media, "stop_recording"): |
| media.stop_recording() |
| except Exception: |
| pass |
| try: |
| if media is not None and hasattr(media, "stop_playing"): |
| media.stop_playing() |
| self._playback_ready = False |
| except Exception: |
| pass |
| try: |
| self._move(head_kwargs={"yaw": 0, "pitch": 0}, antennas_deg=[0, 0], body_yaw_deg=0, duration=0.5) |
| except Exception: |
| pass |
|
|
| def start_idle_breathing(self) -> None: |
| if self._mini is None or not hasattr(self._mini, "set_target"): |
| return |
| if self._idle_thread is not None and self._idle_thread.is_alive(): |
| return |
| self._idle_stop = threading.Event() |
| self._idle_thread = threading.Thread(target=self._idle_loop, daemon=True) |
| self._idle_thread.start() |
|
|
| def stop_idle_breathing(self) -> None: |
| if self._idle_stop is not None: |
| self._idle_stop.set() |
| thread = self._idle_thread |
| if thread is not None and thread.is_alive(): |
| thread.join(timeout=1.0) |
| self._idle_thread = None |
| self._idle_stop = None |
|
|
| def _idle_loop(self) -> None: |
| """Subtle 'breathing' via instant set_target; pauses while the robot talks.""" |
| t0 = time.time() |
| stop = self._idle_stop |
| while stop is not None and not stop.is_set(): |
| if self._speaking.is_set(): |
| time.sleep(0.06) |
| continue |
| t = time.time() - t0 |
| yaw = 2.0 * math.sin(2 * math.pi * 0.13 * t) |
| pitch = 3.0 * math.sin(2 * math.pi * 0.25 * t) |
| sway = 5.0 * math.sin(2 * math.pi * 0.5 * t) |
| try: |
| self._set_target(head_kwargs={"yaw": yaw, "pitch": pitch}, |
| antennas_deg=[sway, -sway], body_yaw_deg=yaw * 0.3) |
| except Exception: |
| break |
| time.sleep(0.05) |
|
|
| def capture_utterance(self, stop_event, *, max_seconds: float = 12.0, |
| silence_ms: int = 800, listen_timeout: float = 30.0): |
| """Listen on the robot mic until a full utterance ends. Returns (wav_path|None, doa).""" |
| media = getattr(self._mini, "media", None) |
| if media is None or not hasattr(media, "get_audio_sample"): |
| return None, math.pi / 2 |
|
|
| sample_rate = 16000 |
| if hasattr(media, "get_input_audio_samplerate"): |
| try: |
| sample_rate = int(media.get_input_audio_samplerate()) or 16000 |
| except Exception: |
| sample_rate = 16000 |
| if sample_rate <= 0: |
| sample_rate = 16000 |
|
|
| frames: list[np.ndarray] = [] |
| started = False |
| silence_s = 0.0 |
| spoken_s = 0.0 |
| waited_s = 0.0 |
| last_doa = math.pi / 2 |
| silence_limit = silence_ms / 1000.0 |
|
|
| while not stop_event.is_set(): |
| if self._speaking.is_set(): |
| time.sleep(0.02) |
| continue |
| with self._hw_lock: |
| chunk = media.get_audio_sample() |
| if chunk is None: |
| time.sleep(0.02) |
| if not started: |
| waited_s += 0.02 |
| if waited_s >= listen_timeout: |
| return None, last_doa |
| continue |
| chunk = np.asarray(chunk, dtype=np.float32) |
| mono = chunk.mean(axis=1) if chunk.ndim > 1 else chunk |
| seg = len(mono) / sample_rate |
|
|
| speech = None |
| if hasattr(media, "get_DoA"): |
| with self._hw_lock: |
| doa = media.get_DoA() |
| if doa is not None: |
| try: |
| if isinstance(doa, (tuple, list)) and len(doa) >= 2: |
| last_doa = float(doa[0]) |
| speech = bool(doa[1]) |
| elif isinstance(doa, (int, float)): |
| last_doa = float(doa) |
| else: |
| self._sdk_error = f"Unexpected get_DoA() shape: {type(doa)}" |
| except Exception as exc: |
| self._sdk_error = f"get_DoA parse failed: {exc}" |
| if speech is None: |
| rms = float(np.sqrt(np.mean(np.square(mono)))) if mono.size else 0.0 |
| speech = rms > self.vad_energy |
|
|
| if speech: |
| started = True |
| frames.append(mono) |
| silence_s = 0.0 |
| spoken_s += seg |
| elif started: |
| frames.append(mono) |
| silence_s += seg |
| spoken_s += seg |
| if silence_s >= silence_limit: |
| break |
| if started and spoken_s >= max_seconds: |
| break |
|
|
| if stop_event.is_set() or not frames: |
| return None, last_doa |
| audio = np.concatenate(frames).astype(np.float32) |
| if audio.size < int(0.3 * sample_rate): |
| return None, last_doa |
| return _write_conversation_wav(audio, sample_rate), last_doa |
|
|
| def look_at_doa(self, doa: float) -> None: |
| |
| |
| |
| frac = (math.pi / 2 - float(doa)) / (math.pi / 2) |
| frac = max(-1.0, min(1.0, frac)) |
| body_yaw = frac * 18.0 |
| |
| antennas = [16, 4] if frac >= 0 else [4, 16] |
| self._set_target(head_kwargs={"yaw": body_yaw * 0.4, "pitch": -3}, |
| antennas_deg=antennas, body_yaw_deg=body_yaw) |
|
|
| def perform_conversation_turn(self, audio_path, doa: float, stop_event=None) -> None: |
| if stop_event is not None and stop_event.is_set(): |
| return |
| self._speaking.set() |
| try: |
| self.look_at_doa(doa) |
| if audio_path and not (stop_event is not None and stop_event.is_set()): |
| self._play_audio(Path(audio_path), expressive=True, stop_event=stop_event) |
| if not (stop_event is not None and stop_event.is_set()): |
| self._excited_landed(stop_event) |
| except Exception as exc: |
| self._sdk_error = str(exc) |
| finally: |
| self._speaking.clear() |
|
|
| def speak_chatter(self, audio_path, stop_event=None) -> None: |
| """Play a NON-translation interjection (greeting/sign-off) on the robot speaker.""" |
| if not audio_path: |
| return |
| if stop_event is not None and stop_event.is_set(): |
| return |
| self._speaking.set() |
| try: |
| self._play_audio(Path(audio_path), expressive=True, stop_event=stop_event) |
| except Exception as exc: |
| self._sdk_error = str(exc) |
| finally: |
| self._speaking.clear() |
|
|
| def _excited_landed(self, stop_event=None) -> None: |
| |
| self._set_target(head_kwargs={"yaw": 0, "pitch": 6}, antennas_deg=[30, 30], body_yaw_deg=8) |
| self._sleep_interruptible(0.2, stop_event) |
| self._set_target(head_kwargs={"yaw": 0, "pitch": 0}, antennas_deg=[0, 0], body_yaw_deg=0) |
|
|
| def _talk_motion(self, duration: float, stop_event=None) -> None: |
| """Small head bobs synced to playback length (instant set_target loop).""" |
| t0 = time.time() |
| while time.time() - t0 < duration: |
| if stop_event is not None and stop_event.is_set(): |
| return |
| t = time.time() - t0 |
| bob = 6.0 * max(0.0, math.sin(t * 9.0)) |
| try: |
| self._set_target(head_kwargs={"yaw": 3.0 * math.sin(t * 2.0), "pitch": 2.0 - bob * 0.4}, |
| antennas_deg=[6, 6]) |
| except Exception: |
| self._sleep_interruptible(max(0.0, duration - (time.time() - t0)), stop_event) |
| return |
| time.sleep(0.04) |
|
|
| @staticmethod |
| def _sleep_interruptible(duration: float, stop_event=None) -> None: |
| if stop_event is None: |
| time.sleep(max(0.0, duration)) |
| return |
| end = time.time() + max(0.0, duration) |
| while time.time() < end and not stop_event.is_set(): |
| time.sleep(0.05) |
|
|
| def _set_target(self, head_kwargs: dict | None = None, antennas_deg: list[float] | None = None, |
| body_yaw_deg: float | None = None) -> None: |
| mini = self._mini |
| if mini is None or not hasattr(mini, "set_target"): |
| return |
| kwargs: dict = {} |
| |
| if head_kwargs is not None and self._create_head_pose is not None: |
| kwargs["head"] = self._create_head_pose(degrees=True, mm=True, **head_kwargs) |
| if antennas_deg is not None: |
| kwargs["antennas"] = np.deg2rad(np.asarray(antennas_deg, dtype=float)) |
| if body_yaw_deg is not None: |
| kwargs["body_yaw"] = float(np.deg2rad(body_yaw_deg)) |
| if not kwargs: |
| return |
| try: |
| with self._hw_lock: |
| mini.set_target(**kwargs) |
| except Exception: |
| pass |
|
|
|
|
| def _read_wav_mono_float32(audio_path: Path) -> tuple[np.ndarray, int]: |
| with wave.open(str(audio_path), "rb") as wav_file: |
| channels = wav_file.getnchannels() |
| sample_width = wav_file.getsampwidth() |
| frame_rate = wav_file.getframerate() |
| raw = wav_file.readframes(wav_file.getnframes()) |
| if sample_width == 2: |
| audio = np.frombuffer(raw, dtype="<i2").astype(np.float32) / 32768.0 |
| elif sample_width == 4: |
| audio = np.frombuffer(raw, dtype="<i4").astype(np.float32) / 2147483648.0 |
| else: |
| raise RuntimeError(f"Unsupported WAV sample width: {sample_width}") |
| if channels > 1: |
| audio = audio.reshape(-1, channels).mean(axis=1) |
| return np.clip(audio, -1.0, 1.0).astype(np.float32), frame_rate |
|
|
|
|
| def _resample(audio: np.ndarray, source_rate: int, target_rate: int) -> np.ndarray: |
| if source_rate == target_rate or audio.size == 0: |
| return audio.astype(np.float32) |
| duration = audio.shape[0] / source_rate |
| target_size = max(1, int(round(duration * target_rate))) |
| source_positions = np.linspace(0.0, duration, num=audio.shape[0], endpoint=False) |
| target_positions = np.linspace(0.0, duration, num=target_size, endpoint=False) |
| return np.interp(target_positions, source_positions, audio).astype(np.float32) |
|
|
|
|
| def _write_conversation_wav(audio: np.ndarray, sample_rate: int) -> str: |
| CONV_AUDIO_DIR.mkdir(parents=True, exist_ok=True) |
| output_path = CONV_AUDIO_DIR / f"utt_{uuid.uuid4().hex}.wav" |
| pcm = (np.clip(audio, -1.0, 1.0) * 32767.0).astype("<i2") |
| with wave.open(str(output_path), "wb") as wav_file: |
| wav_file.setnchannels(1) |
| wav_file.setsampwidth(2) |
| wav_file.setframerate(sample_rate) |
| wav_file.writeframes(pcm.tobytes()) |
| return str(output_path) |
|
|
|
|
| def render_avatar_html(snapshot: AvatarSnapshot) -> str: |
| """Emit a small state signal the 3D avatar stage reacts to (no scene reload).""" |
| state = html.escape(snapshot.state) |
| title = html.escape(snapshot.title) |
| detail = html.escape(snapshot.detail) |
| badge = html.escape(snapshot.badge) |
| connection = html.escape(snapshot.connection_label) |
| return f""" |
| <div id="reachy-state-signal" class="reachy-state-signal" data-avatar-state="{state}" data-connection="{connection}"> |
| <div class="reachy-state-head"> |
| <span class="reachy-state-badge">{badge}</span> |
| <span class="reachy-state-conn">{connection}</span> |
| </div> |
| <div class="reachy-state-copy"> |
| <strong>{title}</strong> |
| <span>{detail}</span> |
| </div> |
| </div> |
| """ |
|
|